讀了啥 周志明的深入理解Java虛擬機中的調優案例。 第一個案例 背景 一個網站部署在JVM上,而Java堆大小固定在了12G,但是總會出現長時間無法響應的情況。 使用了吞吐量優先收集器:可能是Parallel Scavenge和Parallel Old收集器。 問題 網站直接從磁碟拷貝文檔到堆記憶體 ...
讀了啥
周志明的深入理解Java虛擬機中的調優案例。
第一個案例
背景
一個網站部署在JVM上,而Java堆大小固定在了12G,但是總會出現長時間無法響應的情況。
使用了吞吐量優先收集器:可能是Parallel Scavenge和Parallel Old收集器。
問題
網站直接從磁碟拷貝文檔到堆記憶體中,文檔過大導致進入老年代,頻繁操作很快占滿Java堆,導致Full GC被觸發。
網站以前部署在小記憶體的機器上,反而Full GC造成的停頓不明顯了。所以,如今機器升級意義也不大。
經驗
老年代的占用值得關註,不然Full GC會造成延遲。最起碼程式中的絕大多數對象生存時間不能太長。
因為64位JVM使用到了壓縮指針(像32位JVM一樣管理記憶體,避免記憶體濫用,但需要額外的計算去處理指針)、緩存行對齊(緩存行就是構成電腦高速緩存的基本單位,JVM需要額外計算進行對齊)等原因,性能表現並沒有預期中那樣強於32位JVM。
大記憶體進行快照轉儲難度很大——因為沒地方放,要麼使用JMC(Java任務管理器)監控運行時的記憶體使用狀態。
或者不用大記憶體,改成同時起用多個JVM實例,每個實例擁有小份實際記憶體。並打散上游來的請求,比較平均地分配給每個JVM實例上的應用。在這種情況下,可以用ID標明用戶請求,讓均衡器進行路由,使同個用戶的不同請求固定地在同一臺實例上的應用上處理,也就是所謂集群的親合性。但是,這種情況可能導致共用資源的競爭,如磁碟、資源池等。同時,每個實例使用單獨的緩存也會浪費資源,可以用一個共用緩存池(最典型的就是Redis這種中間件了)來代替。
如何解決
一:改用垃圾收集器。現面世的收集器中有以控制延遲為目標的,如Shenandoah、ZGC等。
二:沿用老的收集器,但改造程式。使其不會快速占滿空間,然後在特定時間點統一Full GC。
在案例中,最終用多個JVM實例,並改用CMS收集器(分多階段,儘量與應用程式併發執行,標記後回收)進行回收。
第二個案例
背景
是一個親合式集群(前文提到過),為了共用數據使用了JBoss(一個開源工具,用於構建分散式的緩存,當然緩存彼此的數據是保持一致的。有點Redis的意思)。
但是總報OOM,且程式沒有發生過頻繁更新。
問題
記憶體中存在大量類型為NAKACK的對象。
JGroups(被JBoss使用,是一個用於模擬分組廣播來實現群組通信的開源工具)有個叫NAKACK的類,它用於記錄協議棧,並實現了up()和down()方法用於表示每層協議在數據包接受和發送時產生的作用。
該工具必須保證數據包是有序的,但數據包一定存在失敗重傳的可能。在保證成功前,數據包會一直放在記憶體里。
應用需要控制用戶在某時段只能在一臺機器上登錄(也就是親合性),所以應用中有一個過濾器,它會向所有集群發送"必要信息"以實現這個功能,每來一個請求就這樣做一次。
可想而知,請求越多,必要的信息也就越多。而底層的JGroups又將這些信息存到記憶體里。在網路條件變差時,信息就會堆積,類型NAKACK的對象就越來越多了。
直到OOM。
經驗
使用參數HeapDumpOnOutOfMemoryError在JVM上,這樣如果再發生OOM,會自動生成轉儲文件。
JBoss適合讀操作頻繁的工作,但不太適合寫操作頻繁的場合。
如何解決
這種情況要麼修改"必要信息"的發送方式,要麼更換緩存的框架。
第三個案例
背景
一個應用運行時報OOM,調大記憶體後繼續報OOM。嘗試在異常發生時拍攝一個“堆轉儲快照”(使用-XX:+HeapDumpOnOutOfMemoryError命令),但發生OOM時依然獲取不到快照文件。
使用jstat(一個JVM自帶的工具,可以用來監控應用運行的記憶體狀態)監控,也沒有發現問題,但OOM還是時而發生。
問題
JVM允許直接堆外空間進行信息處理,但是堆外空間的大小終究還是受到物理記憶體空間的限制。
本應用所在的電腦使用32位系統,最多只能管理4GB大小的記憶體,而案例中更是只擁有2GB記憶體。1.6GB記憶體劃給了JVM堆,只剩下0.4GB記憶體能作為堆外空間使用。
Full GC是正常清理堆外空間的唯一方法,或者在發生OOM後,在Java程式中觸發異常捕獲來執行回收(使用System.gc())。但這兩種情況都不會觸發系統報OOM。
如果應用恰好打開了-XX:+DisableExplicitGC,則上述的第二種方法也會失效,OOM就會拋出。在本例中,應用恰好有使用到CometD(一個Web事件的路由匯流排,用於編寫網路應用)給予的庫函數,它會進行大量NIO操作,從而對堆外空間進行了大量占用。
經驗
開發或管理依賴記憶體不充足的應用時,需要關註其記憶體使用的情況。除了上述對堆外空間的使用會造成隱患外,還有幾種情況需要考慮:
- 線程堆棧
- Socket緩存區——存在兩個緩存區用於接收和發送網路信息,連接越多,占用的緩存也就越多
- JNI代碼——對本地方法的調用會間接使用到堆外空間
- JVM和GC也可能會用到堆外的空間
如何解決
加強對堆外空間的管理,選用這方面性能更好的工具加入到程式中。
第四個案例
背景
一個應用在進行大併發壓力測試時,發現請求響應時間比預期的長。用mpstat(Linux自帶的實時系統監控工具,可以用於查看每個CPU核心的統計數據)查看後發現有其他程式占用了絕大部分的CPU算力,而應用卻只持有一小部分,這是不合理的。
使用dtrace(一個Solaris系統下才有的工具,其特性似乎已被Linux的更高版本所吸收)檢查系統調用對CPU算力的占用份額,發現份額最多的時fork調用,該調用用於產生新進程。
問題
通過Runtime.getRuntime().exec(),該應用主動調用了Shell腳本,而調用的過程涉及到了進程的創建:複製當前進程-新進程執行命令-退出新進程。反覆操作導致fork調用頻發,從而占用了CPU算力。
經驗
儘量不要有直接調用OS的命令的操作,尤其是不要頻繁進行這種操作。
如何解決
應用改用Java的API來實現相同功能,解決了問題。
第五個案例
背景
一個MIS(信息管理系統)發生了JVM自動關閉的情況,並且在關閉前發生過大量異常,異常顯示的是網路連接斷開。
問題
該MIS與一個OA(辦公自動化系統)系統通過網路交互,並且MIS使用了非同步調用的方式去調用的OA系統的服務。但是OA系統的處理速度很慢,而MIS創建完一次調用需要的資源(主要是線程和Socket連接)後,資源就會一直待在MIS使用的JVM記憶體里。
隨著調用越來越多,資源在JVM記憶體里堆積得也越來越多,JVM會頻繁面臨對這些資源堆滿記憶體時的臨界處理,直到完全崩潰。
經驗
使用通信中間件,使服務調用方和服務方進行解耦,避免直接調用帶來的性能問題。
如何解決
改用消息隊列來實現OA系統與MIS之間的交互。
第六個案例
背景
一個應用所在的JVM在Minor GC(指對新生代進行的垃圾收集)時,會出現500毫秒的停頓,一般來說對於網路服務,停頓毫秒數在兩位數左右才比較能讓人接受。
該應用所在的JVM使用的收集器是ParNew+CMS(在JVM的啟動參數里開啟這兩個收集器的使用,並配置參數規定其執行)。
問題
應用每十分鐘載入文件進行分析,會形成一個HashMap,該HashMap至少有100萬個元素在內。
對GC日誌進行觀察,發現Minor GC發生的前後對記憶體的占用變化不大,說明有效資源比無效資源要多得多。
ParNew使用的是複製演算法,這個演算法會對正在被引用的新生代資源進行複製操作,可想而知在這樣的資源很多的時候,複製操作會占用很多時間。且在生命周期未到達可以進入老年代之前,這樣的複製會一直進行。
經驗
調整JVM參數,讓資源在第一次Minor GC後直接進老年代,等到Major GC時再清理它們。
使用-XX:SurviviorRatio=65536(survivor區和eden區的比例為1:65536,則survivor區幾乎無法使用),-XX:MaxTenuringThreshold=0(設置堆內資源複製進其他區的次數超過設定值時,就直接進入老年代。設定值為0,說明第一次複製就可以直接進入老年代),或-XX:+Always-Tenure(也是第一次複製就進入老年代)。右側命令任選其一。
不過以上的做法只是權宜之計。
調整程式數據結構,降低數據結構的占用空間。在本例中,主要還是因為HashMap的空間效率有限導致的,在鍵值對均為long類型的情況下進行了包裝,導致冗餘度極高。
對於基本類型的數據不要有過度包裝,尤其是在其數量很多時。
如何解決
使用空間利用效率更高的數據結構。
第七個案例
背景
一個Windows上的桌面程式,發現偶爾會出現一分鐘左右的停頓。
問題
停頓由GC造成,在加入-XX:+PrintReferenceGC參數後進行觀察時發現準備收集到開始收集的階段占據了絕大部分的回收時間。
進一步發現該程式在最小化時,工作記憶體被交換進磁碟空間。從而可能在GC時再次發生交換,而IO操作就會花費大量時間。
經驗
調試時可以加入-XX:+PrintGCApplicationStoppedTime -XX:+PrintGCDateStamps -Xloggc:gclog.log,這裡是三個獨立的參數,前兩個分別用來列印GC的停頓時長和發生時刻,而第三個將列印出來的信息輸入到文件中。
如何解決
加入參數-Dsun.awt.keepWorkingSetOnMinimize=true,該參數允許Java程式在最小化時也可以占有工作記憶體,這一點也可以被應用在各種Java編輯器上。
第八個案例
背景
一個應用用於離線分析任務,設定了-XX:MaxGCPauseMillis=500ms(GC會儘可能將時間壓縮在這個限制下,但不保證)。但分析日誌發現實際運行發現停頓在3000ms以上,且回收的操作只占了幾百ms。
問題
應用記憶體在多個應用線程,只有當所有應用線程都抵達安全點(JVM根據自身機制設定的,勒令全部線程暫停工作的時刻)後,GC才開始。
檢查應用線程抵達安全點的狀態,發現部分線程抵達安全點後,仍在等待其他很慢的應用線程抵達安全點。按理說,那些"很慢的線程"中的安全點應該設置得妥當,才能保證即時暫停下來。
對於JVM而言,它會尋找線程的程式中可以放置安全點的地方進行放置,且存在以下規則:對於一個迴圈體,如果該迴圈是可數迴圈(以int類型或更小類型作為索引),則內部不會有安全點;如果該迴圈是不可數迴圈(以long類型或者更大類型作為索引),則內部存在安全點。
對應用的代碼進行檢查,發現那段代碼中恰好有一處使用int類型作為索引的迴圈,但迴圈的操作涉及網路連接,耗時很多。
經驗
分析GC時,應該對GC總時間和GC實際工作時間進行對比,這兩個值相近才是比較合理的。如果相差過大(一般只會是總時間>>實際工作時間),說明GC的準備工作中存在問題。
分析GC可以從分析安全點入手來瞭解情況,主要使用參數-XX:+PrintSafepointStatistics和-XX:PrintSafepointStatisticsCount=1來記錄安全點的一些信息(同時使用兩個參數)。主要是觀察在存線上程抵達安全點時,是否有尚未抵達安全點的線程。
一般來說,它的信息會統計各類狀態的線程數,在日誌中體現為[threads: total(STW發生時的線程總數) initially_running(STW發生時正在運行的線程數) wait_to_block(STW發生時需要堵塞的線程數,這部分線程就正在執行,直到抵達安全點)]
也會記錄時間的使用情況[time: spin(GC線程自旋花費的時間,也就是在等待工作線程堵塞花費的時間) block sync cleanup vmop]。
如果要找尋線程的具體信息,可以通過參數-XX:+SafepointTimeout和-XX:SafepointTimeoutDelay=指定值來進行,前者指定JVM對線程在約定進入安全點的時刻之後,延遲一段時間進入會觸發超時記錄,後者指定這個延遲的具體值。如果觸發記錄,則線程的具體信息會被記錄下來,可供進一步調查。
對於迴圈體中執行重型操作(如處理網路連接)中,索引可以修改成long類型或以上,方便安全點在迴圈執行中設置。
如何解決
將該處迴圈的索引設置為long類型,問題解決。