從一次線上故障思考 Java 問題定位思路

問題出現:現網CPU飆高,Full GC告警


CGI 服務發佈到現網後,現網機器出現了Full GC告警,同時CPU飆高99%。在優先恢復現網服務正常後,開始着手定位Full GC的問題。在現場只能夠抓到四個GC線程佔用了很高的CPU,無法抓到引發Full GC的線程。查看了服務故障期間的錯誤日誌,發現更多的是由於Full GC引起的問題服務異常日誌,無法確定Full GC的根源。爲了查找問題的根源,只能從發佈本身入手去查問題,發現一次bugfix的提交,有可能觸發一個死循環邏輯:

for(int i = 1 ;i <= totalPage ;i++) {
 String path = path_prefix + "?cmd=txt_preview&page=" + String.valueOf(i) + "&sign=" + fileSignature;
 url_list.add(path);
}

循環中的參數 totalPage 爲 long 類型,由一個外部參數進行賦值。當外部參數非常大,超過 int 的最大值時,i遞增到int的最大值後,i++ 會發生翻轉,變成一個負數,從而使 for 會進入死循環。利用下面這段代碼可以試驗:

public static void main(String[] args{
   long totalPage = Long.MAX_VALUE;
   for(int i = 0 ;i<totalPage;i++){
       if(i<0){
           System.out.println(i);
       }
   }
}

通過日誌,發現外部確實傳遞了一個非常大的參數:


640?wx_fmt=png


確認了當命中邏輯的時候,會進入一個死循環。在循環中不斷進行字符串的拼接與list的Add操作,很快就會耗盡JVM堆內存導致Full GC。經過測算,實際上並不需要死循環,只要是一個比較大的循環,就能夠引發Full GC。對totlePage的大小做了限定後,發佈了新版本,沒有再出現Full GC的問題。


現場還原:重現問題,探索定位思路


回顧排查問題的過程並不高效,最開始懷疑過是否是打包有問題或使用的jdk版本不對,花了較多的時間確認打包問題。另一方面,發佈帶出的代碼較多,通過重複review代碼無法很快鎖定問題。爲了探索一種更有效的問題定位方法,我將有問題的代碼重新部署到機器上,手動構造請求觸發bug,探索定位此類問題的通用思路。


如何確定bug可以導致CPU飆升?爲何會引發OOM?


1) 在 Java 服務上開啓 JMX,在本地使用 VisualVm 來查看 Java 服務在運行過程中的內存、GC、線程等信息。VisualVM 是 Sun 的一個 OpenJDK 項目,它是集成了多個 JDK 命令工具的一個可視化工具,它主要用來監控 JVM 的運行情況,可以用它來查看和瀏覽 Heap Dump、Thread Dump、內存對象實例情況、GC 執行情況、CPU 消耗以及類的裝載情況,也可以使用它來創建必要信息的日誌。


640?wx_fmt=jpeg


可以看到邏輯被命中的時候,CPU確實是升到100%的,此時也發生了Full GC告警。嘗試着多發了幾次請求,服務直接就掛掉了。這裏有個問題是:不是已經Full GC了嗎,爲什麼還會發生OOM?實際上,雖然JVM已經開始回收內存,但是由於對象被引用,這些內存是回收不掉的。從GC日誌可以看到回收的情況:


640?wx_fmt=jpeg


從GC日誌中可以看到,新生代的Eden區域與老年代都已經被佔滿。如果新生代放不下對象的時候,object會直接被放到老年代中。除了GC日誌,也可以使用jstat命令來堆Java堆內存的使用情況進行統計展示:


jstat -gcutil 12309 1000 10


1000爲統計的間隔,單位爲毫秒,10爲統計的次數,輸出如下:


640?wx_fmt=png


從輸出中同樣可以看到E(Eden)區與O(Old)區都已經被佔滿了。其他幾個輸出項的含義如下:


  • YGC: 從啓動到採樣時Young Generation GC的次數

  • YGCT: 從啓動到採樣時Young Generation GC所用的時間 (s).

  • FGC: 從啓動到採樣時Old Generation GC的次數.

  • FGCT: 從啓動到採樣時Old Generation GC所用的時間 (s).

  • GCT: 從啓動到採樣時GC所用的總時間 (s).


可以看到JVM一直在嘗試回收老年代,但是一直沒能將內存回收回來。


如何獲取佔用CPU最高的線程id?


2)可以登上機器,確認下是什麼線程使 CPU 飆高。先ps查看 Java 進程的 PID:


640?wx_fmt=png


拿到進程 pid 後,可以使用 top 命令,來看是什麼線程佔用了 CPU。


top -p 12309 -H


-p 用於指定進程,-H 用於獲取每個線程的信息,從 top 輸出的內容,可以看到有四個線程佔用了非常高的 CPU:


640?wx_fmt=png


到這裏可以拿到12313、12312、12311、12314這四個線程id。爲了確定這些是什麼線程,需要使用 jstack 命令來查看這幾個是什麼線程。


高佔用CPU的是什麼線程?


3) jstack 是 Java 虛擬機自帶的一種堆棧跟蹤工具,用於打印出給定的 Java 進程 ID 或 core file 或遠程調試服務的 Java 堆棧信息。使用下面命令,將 Java 進程的堆棧信息打印到文件中:


jstack -l 12309 > stack.log


在線程堆棧信息中,線程 id 是使用十六進制來表示的。將上面四個四個線程 id 轉換爲16進制,分別是0X3019、0X3018、0×3017、0x301A。在 stack.log 中可以找到這幾個線程:


640?wx_fmt=png


到這裏可以確定的是,死循環引發了Full GC,四個GC線程一直嘗試着回收內存,這四個線程將CPU佔滿。


是哪些對象佔用了內存?


4)Full GC、OOM、CPU 被佔滿的問題都得到了解答。那麼再次遇到類似的線上問題時,如何確定或者縮小問題範圍,找到導致問題的代碼呢?這時候需要進一步觀察的是 Java 堆內存的信息,查看是什麼對象佔用了內存。可以使用上文提到的 VisualVM 來生成 headdump 文件:


640?wx_fmt=jpeg


也可以在機器上使用 jmap 命令來生成 head dump 文件。


jmap -dump:live,format=b,file=headInfo.hprof 12309


live 這個參數表示我們需要抓取的是目前在生命週期內的內存對象,也就是說 GC 收不走的對象,在這種場景下,我們需要的就是這些內存的信息。生成了 hprof 文件後,可以拉回到本地,使用 VisualVM 來打開它進行分析。打開後可以看到:


640?wx_fmt=png


從信息中可以看到,字符串 char[] 在佔了內存的73%,因此可以確定的是內存泄漏與字符串有關。通常生成的 headdump 文件會很大,也可以使用下面的命令,來查看佔用內存最多的類型:


jmap -histo 12309 > heap.log


輸出內容如下:


640?wx_fmt=jpeg


能否對堆內對象進行查詢?


5) 到這裏突然有個想法,如果能夠分析出相似度高的字符串,那麼有比較大的可能是這些字符串存在泄漏,從而可以縮小問題代碼的範圍。確實是有這麼一種工具來對堆內的對象進行分析,也就是OQL(Object Query Language),在VisualVM中可以對headdump文件執行對象查詢,下面是一個示例,查找包含內容最多的List:


select map(top(heap.objects('java.util.ArrayList'), 'rhs.size - lhs.size'5),"toHtml(it)+'='+it.size")


查詢結果如下:


640?wx_fmt=jpeg


如何查找到相似度最高的字符串,還在繼續學習研究中。

640?wx_fmt=jpeg

一些疑問與總結


1)爲什麼無法抓到引發 Full GC 的線程?一個猜測是線程拋出 OOM 異常之後就被終止了,線程只存活了很短的時間。


2)爲什麼對 Eden 區回收後存活的對象,不會被拷貝到 survivor 區?從上面的 GC 日誌可以看到,BeforeGC 與 AfterGC,新生代中的兩個survivor 區(也就是from\to)一直都是0%,這裏猜想可能是 survivor 區太小,沒有足夠的空間存放從 Eden 區拷貝拷貝過來的對象。同時老年代也沒有足夠的空間(已經99%了),因此 JVM 的 GC 基本沒有什麼有效的回收操作。


3)重現問題時,在日誌裏發現了一個 OOM 的錯誤信息:


java.lang.OutOfMemoryErrorGC overhead limit exceeded


這種情況發生的原因是, 程序基本上耗盡了所有的可用內存, GC 也清理不了。JVM 執行垃圾收集的時間比例太大, 有效的運算量太小。默認情況下, 如果GC花費的時間超過 98%, 並且 GC 回收的內存少於 2%, JVM 就會拋出這個錯誤。從這裏也可以看到 GC 線程一直在嘗試回收內存,但是回收效果實在太差,也就是第二點提到的。


4)當時在線上環境出現問題時,看到很多 log4 j的錯誤日誌信息,是什麼原因?猜測大概是寫日誌的 I/O 操作要經過內存,而內存已經被使用光,無法進行寫操作所導致。這些問題都可以進一步研究。


對於一般的 OOM 問題,通過這幾個方面的思考,大致可以鎖定問題所在,或是縮小問題可能發生的範圍。例如對某些特定類型的內存泄漏來說,到這一步已經可以分析出是什麼類型導致內存泄漏。而對本案例來說,根據排查結果可以優先考慮的是字符串的泄露,代碼 review 中查看是否有操作字符串的地方,而不會將問題的優先級鎖定在打包問題上。


擴展閱讀

Java面試題66:如果查詢和定位慢查詢

排查Java線上服務故障的方法和實例分析

Spring4+Spring MVC+MyBatis整合思路


來源:https://www.cnblogs.com/QG-whz/p/9647614.html

文章來源網絡,版權歸作者本人所有,如侵犯到原作者權益,請與我們聯繫刪除