InetAddress.getLocalHost() 執行很慢?
背景介紹
某次在 SpringBoot 2.2.0 項目的一個配置類中引入了這麼一行代碼:
InetAddress.getLocalHost().getHostAddress()
導致項目啟動明顯變慢。同時報出了相關的警告信息:
2022-10-03 23:32:01.806 [TID: N/A] WARN [main] o.s.b.StartupInfoLogger – InetAddress.getLocalHost().getHostName() took 5007 milliseconds to respond. Please verify your network configuration (macOS machines may need to add entries to /etc/hosts).
根據報警信息可知,只要獲取主機信息的耗時超過了閾值HOST_NAME_RESOLVE_THRESHOLD=200ms,就會提示這個信息。很明顯,我們的耗時已經超過5s。同時,如果為 Mac 系統,還會貼心地提示在/etc/hosts文件中配置本地dns。
我們看看目前hosts文件中的配置:
127.0.0.1 localhost
255.255.255.255 broadcasthost
::1 localhost
根據網上各種文章的提示,我們將主機名追加進去,變成這樣:
127.0.0.1 localhost xiaoxi666s-MacBook-Pro.local
255.255.255.255 broadcasthost
::1 localhost
其中,xiaoxi666s-MacBook-Pro.local 就是我的主機名。
註:更改hosts文件內容後,可使用命令
sudo killall -HUP mDNSResponder
刷新dns,無需重啟電腦。
再次啟動 SpringBoot 程序,我們發現警告信息消失了,也就意味着主機信息獲取的耗時不會超過200ms。
那麼問題來了,這背後究竟是什麼機制,讓我們一探究竟。
使用Wireshark抓包看看
由於我們要獲取自己的主機信息,這裡走的是本地迴環網絡,因此選中Loopback網絡接口:
先把hosts改回去,抓一下hosts文件改動前的網絡包:
按照時間順序,可以將抓到的網絡包分為三段,每段中又可以分為Ipv4和Ipv6兩種地址的請求。
其中用到的協議是 mdns,也即多播dns(Multicast DNS),它主要實現了在沒有傳統 dns 服務器的情況下使局域網內的主機實現相互發現和通信,使用的端口為 5353,遵從 dns 協議。隨便點開一個請求查看詳情便可以得到驗證:
另外,網絡包中的目標ip 224.0.0.251是 Mac 的官方 mdns 查詢地址,詳情可參見//github.com/apple-oss-distributions/mDNSResponder/tree/mDNSResponder-1096.100.3
實際多次測試發現,主機信息都在第三次發送網絡包後返回(阻塞在 InetAddress.getLocalHost() 方法上。參見下圖,阻塞在第18行,5秒後才跳到第19行)。從上圖的時間線看,約在8秒時返回,整體耗時與上面報出的 5007ms 吻合。再仔細觀察網絡包,看起來是連續發了三次請求。第一次在 3.1s 時發出,第二次在 4.1s 時發出,第三次在 7.1s 時發出,重試間隔分別為 1s 和 3s,看起來像是一種指數退避的重試。當然,8秒左右時返回結果,就對應第一次請求,剩下兩次請求的結果被忽略了。
我們再看看hosts中添加主機信息後,對應的網絡包:
啊噢,這次沒有抓到任何相關的網絡包,猜測直接讀取了hosts文件拿到了主機名,根本沒走網絡。
那麼,這段獲取主機信息的程序究竟是怎麼運作的呢,hosts文件中沒有添加主機名時,時間都耗在了哪裡?
看看對應的源碼
源碼比較好找,參見下圖:
我們再次把hosts中的主機名去掉,並使用 Arthas 工具的 trace
命令看看鏈路耗時:
提示:如果抓包時出現 No class or method is affected 的報錯,可查看對應的日誌文件進行排查,見下圖:
可知需要提升下權限,執行命令 options unsafe true
後,再嘗試使用 trace
命令即可。
但好巧不巧,居然抓不到調用鏈?那我們試試用 Arthas 的 profiler
命令生成一下火焰圖吧:
可以看到很多編譯相關的,我們忽略之,只把主機信息獲取的那部分放大:
哦吼,時間基本都耗在了 InetAddress.getAddressesFromNameService 這行代碼:
往下追溯,可知時間基本耗在了 nameService.lookupAllHostAddr:
再往下就到了native方法:
於是我們到 jdk 源碼中看看(我用的 jdk8):
接下來需要找 getaddrinfo 的實現,由於不知道具體的實現源碼在哪裡,於是我們在網上找一下 Linux 系統的源碼作為參考,參見://codebrowser.dev/glibc/glibc/sysdeps/posix/getaddrinfo.c.html#getaddrinfo
內部的具體實現基本都是和操作系統交互,我們簡單瞄幾眼就行。另外,在 getaddrinfo 源碼中沒有找到火焰圖給出的調用鏈,我們暫時不再深入。
目前,我們知道了方法 getaddrinfo 會被調用,因此簡單寫段 c 程序復現一下:
#include<sys/time.h> #include <iostream> #include <string.h> #include <sys/types.h> #include <sys/socket.h> #include <netdb.h> #include <netinet/in.h> #include <arpa/inet.h> using namespace std; int main(){ char* hostname = "xiaoxi666s-MacBook-Pro.local"; addrinfo hints, *res; in_addr addr; int err; struct timeval start, end; gettimeofday(&start, NULL); memset(&hints, 0, sizeof(addrinfo)); hints.ai_socktype = SOCK_STREAM; hints.ai_family = AF_INET; if((err = getaddrinfo(hostname, NULL, &hints, &res)) != 0){ // 打印耗時(異常情況) gettimeofday(&end, NULL); printf("times=%d\n", end.tv_usec - start.tv_usec); printf("error %d : %s\n", err, gai_strerror(err)); return 1; } // 打印耗時(正常情況) gettimeofday(&end, NULL); printf("times=%d\n", end.tv_usec - start.tv_usec); addr.s_addr = ((sockaddr_in*)(res->ai_addr))->sin_addr.s_addr; printf("ip addresss: %s\n", inet_ntoa(addr)); freeaddrinfo(res); return 0; }
其中的 hostname 即為主機名 xiaoxi666s-MacBook-Pro.local,我們在 Java 項目中調試時也可以看到,上面的程序中直接將其寫死。
運行程序,對比下 hosts 文件中 沒有添加主機名 和 添加主機名後的輸出結果:
# hosts 文件中沒有添加主機名
times=6431
error 8 : nodename nor servname provided, or not known
# hosts 文件中添加主機名
times=1789
ip addresss: 127.0.0.1
可以看到,當 hosts 文件中沒有添加主機名時,根本找不到對應的網絡地址(因為 dns 中也沒有解析到),添加之後就能返回對應的 ip 127.0.0.1 了。
這裡有幾個地方需要注意:
-
即使 hosts 文件中添加主機名,標準 Linux 的 getaddrinfo 方法執行時,也會有接近兩秒的耗時,但我們在 Java 代碼中運行時卻只有幾十毫秒;
-
前文我們使用 Wireshark 抓包時提到,mdns 查詢時存在重試機制,但標準 Linux 的 getaddrinfo 方法中沒有看到對應的代碼;
-
前面提到的5秒返回結果,其實不是返回結果,而是超時了。但標準 Linux 的 getaddrinfo 方法中沒有看到對應的超時控制代碼;
因此,我們可以大膽猜測 MaxOS 系統對標準 Linux 代碼進行了修改,加了本地緩存、重試、超時等機制。
接着上面的第3點,回到 Java 項目調試一下,看看為什麼超時了還能返回結果。
當 hosts 文件中沒有添加主機名時,會返回本機所有的 ip 地址:
當 hosts 文件中添加主機名後,只會返回配置的 127.0.01 的 ip 地址:
其中,當 hosts 文件中沒有添加主機名時,getaddrinfo 調用返回錯誤碼,此時 jdk 會轉而調用 lookupIfLocalhost 方法,它內部調用了操作系統的 getifaddrs 方法,以獲取本機所有 ip 地址:
對應的源碼可以參考//codebrowser.dev/glibc/glibc/sysdeps/unix/sysv/linux/ifaddrs.c.html。
總結
本文以 Java 中獲取主機名慢的場景為契機,使用多種技術手段研究背後的原理,包括使用 Wireshark 抓包,使用 Arthas 工具定位到性能瓶頸,再轉到 jdk 中查看對應的 native 方法實現,由於沒找到最底層調用鏈路源碼,轉而參照標準Linux的相關源碼,簡單復現了上述場景。
進一步地,由於沒找到最底層調用鏈路源碼,我們根據現象猜測的本地緩存、重試、超時等機制沒有得到驗證,有興趣的同學可以進一步研究探索。