原創:扣釘日記(微信公眾號ID:codelogs),歡迎分享,轉載請保留出處。
繼上次我們JVM停頓十幾秒的問題解決後,我們系統終於穩定了,再也不會無故重啟了!
這是之前的文章:耗時幾個月,終於找到了JVM停頓十幾秒的原因
但有點奇怪的是,每隔一段時間,我們服務介面就會有一小波499超時,經過檢視gc紀錄檔,又發現JVM停頓了好幾秒!
有了上次JVM停頓排查經驗後,我馬上就檢查了gc紀錄檔與safepoint紀錄檔,發現如下紀錄檔:
$ cat gc-*.log | awk '/application threads were stopped/ && $(NF-6)>1'|tail
2022-05-08T16:40:53.886+0800: 78328.993: Total time for which application threads were stopped: 9.4917471 seconds, Stopping threads took: 9.3473059 seconds
2022-05-08T17:40:32.574+0800: 81907.681: Total time for which application threads were stopped: 3.9786219 seconds, Stopping threads took: 3.9038683 seconds
2022-05-08T17:41:00.063+0800: 81935.170: Total time for which application threads were stopped: 1.2607608 seconds, Stopping threads took: 1.1258499 seconds
$ cat safepoint.log | awk '/vmop/{title=$0;getline;if($(NF-2)+$(NF-4)>1000){print title;print $0}}'
vmop [threads: total initially_running wait_to_block] [time: spin block sync cleanup vmop] page_trap_count
78319.500: G1IncCollectionPause [ 428 0 2 ] [ 0 9347 9347 7 137 ] 0
vmop [threads: total initially_running wait_to_block] [time: spin block sync cleanup vmop] page_trap_count
81903.703: G1IncCollectionPause [ 428 0 4 ] [ 0 3903 3903 14 60 ] 0
vmop [threads: total initially_running wait_to_block] [time: spin block sync cleanup vmop] page_trap_count
81933.906: G1IncCollectionPause [ 442 0 1 ] [ 0 1125 1125 8 126 ] 0
從紀錄檔上可以看到,JVM停頓也是由safepoint導致的,而safepoint耗時主要在block階段!
通過新增JVM引數-XX:+SafepointTimeout -XX:SafepointTimeoutDelay=1000
後,可列印出哪些執行緒超過1000ms
沒有到達safepoint,如下:
可以看到都是一些http或grpc的worker執行緒沒走到safepoint,但為啥沒到達safepoint,看不出關鍵,只好又去網上搜尋了。
在上面的safepoint紀錄檔中,spin與block都是等待執行緒進入safepoint的耗時,而vmop就是需要在安全點執行的JVM操作耗時,遺憾的是,網上講safepoint的文章雖多,但基本沒有將block階段與spin階段區別講清楚的!
沒辦法,只能去看看JVM內部safepoint的實現程式碼了,在閱讀了safepoint.cpp後,對spin與block的區別也大致有點理解了,如下:
thread_in_Java
、thread_in_vm
、thread_in_native
。thread_in_native
,jvm會認為它已經在安全區域(Safe Region),故不需要等待其到達safepoint,當它從thread_in_native
狀態返回時,會自行掛起。thread_in_Java
,這種執行緒jvm需要等待它執行到safepoint後,將其掛起或自行掛起。thread_in_vm
,比如執行緒執行System.arraycopy
,由於jvm內部並沒有放置safepoint,jvm必須等待其轉換到thread_in_native
或thread_in_Java
才能將其掛起或自行掛起。而spin階段實際在做兩件事情,一是將thread_in_native
狀態的執行緒刨除掉,這並不會太耗時,二是輪詢各執行緒狀態,等待thread_in_Java
狀態的執行緒變為其它狀態(如走到了safepoint),這也是為什麼counted loop
這種程式碼會導致spin階段耗時高,因為它是thread_in_Java
狀態的。
而block階段實際就是在等待thread_in_vm
狀態的執行緒走到safepoint,與spin不同的是,safepoint執行緒將自己掛起,以等待最後一個thread_in_vm
執行緒到達safepoint後將其喚醒。
如果看完我的描述,還是無法理解,強烈建議大家自己去閱讀下safepoint原始碼,要看懂大概脈絡還是不難的,而網上文章用來了解一些基礎知識即可,不必費力看太多。
safepoint原始碼:http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/818b1963f7a2/src/share/vm/runtime/safepoint.cpp
主要方法:SafepointSynchronize::begin
, SafepointSynchronize::block
,SafepointSynchronize::end
回到之前遇到的問題,我們是block階段耗時長,這是在等待thread_in_vm
狀態的執行緒到達safepoint,而執行緒處於thread_in_vm
狀態則說明執行緒在執行JVM內部程式碼。
難道我們什麼程式碼用法,導致執行緒在jvm內部執行耗時過長?特別是在jvm社群找到一個提議,即建議在System.arraycopy
中新增safepoint,讓我也有點懷疑它了,但如何證明呢?
提議連結:https://bugs.openjdk.org/browse/JDK-8233300。
經過一段時間瞭解,發現目前分析safepoint主流工具如下:
async-profiler提供了--ttsp
的選項,用來分析safepoint事件,如下:
# 下載async-profiler
$ wget https://github.com/jvm-profiling-tools/async-profiler/releases/download/v2.8/async-profiler-2.8-linux-x64.tar.gz && tar xvf async* && cd async*
# 啟動async-profiler採集safepoint時的執行緒棧
$ ./profiler.sh start -e wall -t -o collapsed -f /tmp/tts.collased --ttsp jps
# 發現safepoint問題產生後,停止採集並匯出執行緒棧
$ ./profiler.sh stop -e wall -t -o collapsed -f /tmp/tts.collased --ttsp jps
# 執行緒棧轉換為火焰圖工具
$ wget https://github.com/jvm-profiling-tools/async-profiler/releases/download/v2.8/converter.jar
$ java -cp converter.jar FlameGraph /tmp/tts.collapsed tts.html
最開始,抓到的火焰圖是這樣的,如下:
由於我使用的是-e wall
選項,這會把等待狀態的執行緒棧也抓取下來,而safepoint發生時,大多數執行緒都會等待,所以火焰圖中包含了太多無效資訊。
於是,我調整為使用--all-user
選項,這會只抓取在CPU上跑著的執行緒棧,同時將--ttsp
調整為--begin SafepointSynchronize::print_safepoint_timeout --end RuntimeService::record_safepoint_synchronized
,以使得async-profiler僅在發生超時safepoint時才採集執行緒棧,因為safepoint超時的時候會呼叫SafepointSynchronize::print_safepoint_timeout
方法列印上面介紹過的超時未到safepoint執行緒的紀錄檔。
調整後,抓到的火焰圖是這樣的,如下:
發現沒有到達safepoint的執行緒在執行getLoadAverage
方法,這是java整合的prometheus監控元件,用來獲取機器負載的,這能有什麼問題?
我又發現,最後一個到達safepoint的執行緒會呼叫Monitor::notify_all
喚醒safepoint協調執行緒,那使用-e Monitor::notify_all
抓取的執行緒棧會更加準確,如下:
如上,最後一個到達safepoint的執行緒,確實就在執行getLoadAverage
方法,可這個方法能有什麼問題呢?我用strace看了一下,這個方法也就是從/proc/loadavg
偽檔案中讀取負載資訊而已。
問題一直沒有排查出來,直到有一天,我突然發現,當一臺容器上的jvm出現safepoint超時問題後,會不固定的每隔幾小時出現一次,而同時間裡,不出現問題的容器則穩得一批!
很顯然,這個問題大概率和底層宿主機有關,我懷疑是部署在同一宿主機上的其它容器搶佔了cpu導致,但在我詢問運維宿主機情況時,運維一直說宿主機正常,也不知道他們是否認真看了!
又過了很久,有一次和隔壁組同事聊天,發現他們也遇到了超時問題,說是運維為了降機器成本,在宿主機上部署的容器越來越多!
再次出現問題後,我直接找運維要了宿主機的監控,我要自己確認,如下:
可以發現宿主機負載在11點到12點之間,多次飆升到100以上,而我們JVM發生暫停的時間與之基本吻合。
至此,問題原因已經找到,執行緒到不了safepoint,是因為它得不到CPU啊,和thread_in_vm
狀態無關,和getLoadAverage
也無關,他們只是湊巧或執行頻率較高而已,得不到CPU資源時,執行緒能停在任何位置上!
可是我有一個想法,如果運維死活說宿主機沒有問題,不給監控,那在容器中的我們,是否能有證據證明問題在宿主機呢?
於是,我又嘗試在容器內找證據了!
在Linux中可以無形拖慢執行緒執行速度的地方,主要有2點:
direct reclaim可以通過cat /proc/vmstat|grep -E "pageoutrun|allocstall"
來測量,其中allocstall就是direct reclaim發生的次數。
而執行緒排程延遲可以通過觀測/proc/<pid>/task/<tid>/schedstat
來測量,如下:
$ cat /proc/1/task/1/schedstat
55363216 1157776 75
解釋如下:
而由於我需要分析整個程序,上述資訊是單個執行緒的,於是我寫了一個指令碼,彙總了各個執行緒的排程資料,以採集程序排程延遲資訊,執行效果如下:
$ python -u <(curl -sS https://gitee.com/fmer/shell/raw/master/diagnosis/pidsched.py) `pgrep -n java`
2022-06-11T15:13:47 pid:1 total:1016.941ms idle:0.000ms oncpu:( 1003.000ms max:51.000ms cs:105 tid:23004 ) sched_delay:( 120.000ms max:18.000ms cs:36 tid:217 )
2022-06-11T15:13:48 pid:1 total:1017.327ms idle:415.327ms oncpu:( 596.000ms max:54.000ms cs:89 tid:215 ) sched_delay:( 6.000ms max:0.000ms cs:255 tid:153 )
2022-06-11T15:13:49 pid:1 total:1017.054ms idle:223.054ms oncpu:( 786.000ms max:46.000ms cs:117 tid:14917 ) sched_delay:( 8.000ms max:0.000ms cs:160 tid:63 )
2022-06-11T15:13:50 pid:1 total:1016.791ms idle:232.791ms oncpu:( 767.000ms max:75.000ms cs:120 tid:9290 ) sched_delay:( 17.000ms max:5.000ms cs:290 tid:153 )
可以發現,正常情況下,排程延遲在10ms以下。
等到再次發生超時safepoint問題時,我檢查了相關紀錄檔,如下:
我發現,在問題發生時,oncpu與sched_delay都是0,即執行緒即不在CPU上,也不在CPU佇列上,也就是說執行緒根本沒有被排程!它要麼在睡眠,要麼被限制排程!
聯想到我們JVM是在容器中執行,而容器會通過cgroup機制限制程序的CPU使用量,經過一番瞭解,我發現在容器中,可以通過/sys/fs/cgroup/cpu,cpuacct/cpu.stat
來了解程序被限制的情況,如下:
# cgroup週期的時間長度,一個週期是100ms
$ cat /sys/fs/cgroup/cpu,cpuacct/cpu.cfs_period_us
100000
# 容器分配的時間配額,由於我們是4核容器,所以這裡是400ms
$ cat /sys/fs/cgroup/cpu,cpuacct/cpu.cfs_quota_us
400000
$ cat /sys/fs/cgroup/cpu,cpuacct/cpu.stat
nr_periods 3216521
nr_throttled 1131
throttled_time 166214531184
cpu.stat解釋如下:
於是,我寫了一個小指令碼來採集這個資料,如下:
$ nohup bash -c 'while sleep 1;do echo `date +%FT%T` `cat /sys/fs/cgroup/cpu,cpuacct/cpu.stat`;done' cpustat > cpustat.log &
再等到safepoint超時問題發生時,gc紀錄檔如下:
$ ps h -o pid --sort=-pmem -C java|head -n1|xargs -i ls -l /proc/{}/fd|awk '/gc-.*.log/{print $NF}'|xargs cat|awk '/application threads were stopped/ && $(NF-6)>1'|tail
2022-06-10T14:00:45.334+0800: 192736.429: Total time for which application threads were stopped: 1.1018709 seconds, Stopping threads took: 1.0070313 seconds
2022-06-10T14:11:12.449+0800: 193363.544: Total time for which application threads were stopped: 1.0257833 seconds, Stopping threads took: 0.9586368 seconds
cpustat.log如下:
cat cpustat.log |awk '{if(!pre)pre=$NF;delta=($NF-pre)/1000000;print delta,$0;pre=$NF}'|less
可以發現,在JVM停頓發生的時間點,容器被限制排程多次,總共限制的時間超3000ms!
在找到問題後,我通過cgroup與jvm stw關鍵字在google上搜尋,發現在k8s中,container_cpu_cfs_throttled_seconds_total
指標也代表了容器CPU被限制的時間,於是我立馬將運維的監控面板改了改,如下:
可見時間點也基本吻合,只是這個數值偏小很多,有知道原因的可以告知下。
此外我也搜尋到了問題類似的文章:https://heapdump.cn/article/1930426 ,可見很多時候,遇到的問題,別人早就遇到過並分享了,關鍵是這種文章被大量低質量文章給淹沒了,沒找到問題前,你根本搜尋不到!
哎,分享傳播了知識,同時也阻礙了知識傳播!
排查這個問題的過程中,學到了不少新知識與新方法,總結如下:
/proc/<pid>/task/<tid>/schedstat
測量程序排程延遲。/sys/fs/cgroup/cpu,cpuacct/cpu.stat
測量容器CPU受限情況。耗時幾個月,終於找到了JVM停頓十幾秒的原因
密碼學入門
神祕的backlog引數與TCP連線佇列
mysql的timestamp會存在時區問題?
真正理解可重複讀事務隔離級別
字元編碼解惑