Java codeCache

1.問題

隨着系統不斷變大,訪問量不斷增加,出現了啓動後的負載很高的問題。

關於啓動後負載高的原因,網上很多文章都說是由於啓動後隨着代碼的執行,jvm的jit編譯器將部分熱點代碼編譯爲目標機器代碼,由於編譯線程佔用了大量的cpu導致系統負載高。爲了驗證這個說法,在系統啓動後使用jstack獲取線程dump,並通過top –H –p查看當前進程中哪些線程在大量消耗cpu。結果發現,編譯線程雖然cpu佔用率比其他線程略高,但是差距並不明顯。另外還發現,resin處理請求的線程每一個cpu佔用率雖然都不是很高,但是加起來的總佔用率就相當可觀了。因此猜測,由於jit編譯器需要代碼執行超過一定頻率纔會將其編譯,系統剛啓動的時候大部分代碼都是出於解釋執行階段,而解釋執行的性能會比編譯執行慢很多,也因此會導致這個階段負載很高。等主要的熱點代碼都進入了編譯執行階段,系統負載自然就恢復了。

Jvm提供了一個參數-Xcomp,可以使jvm運行在純編譯模式下,所有方法在第一次被調用的時候就會被編譯成機器代碼。加上這個參數之後,系統啓動之後負載確實不會上升了,但是隨之而來的問題是啓動時間變得很長,是原來的2倍還多。除了純編譯方式和默認的mixed之外,從jdk6u25開始引入了一種分層編譯的方式。Hotspot jvm內置了2種編譯器,分別是client方式啓動時用的C1編譯器和server方式啓動時用的C2編譯器。C2編譯器在將代碼編譯成機器碼之前,需要收集大量的統計信息以便在編譯的時候做優化,因此編譯後的代碼執行效率也高,代價是程序啓動速度慢*,並且需要比較長的執行時間才能達到最高性能。相比之下,C1編譯器的目標在於使程序儘快進入編譯執行階段,因此編譯前需要收集的統計信息比C2少很多,編譯速度也快不少。代價是編譯出的目標代碼比C2編譯的執行效率要低。儘管如此,C1編譯的執行效率也比解釋執行有巨大的優勢。分層編譯方式是一種折衷方式,在系統啓動之初執行頻率比較高的代碼將先被C1編譯器編譯,以便儘快進入編譯執行。隨着時間推進,一些執行頻率高的代碼會被C2編譯器再次編譯,從而達到更高的性能。

* 在實際測試時會發現不同啓動方式之間啓動時間差距並不明顯,這是因爲應用啓動時還需要加載類和資源文件等,這些磁盤操作比編譯更耗時,所以編譯方式對啓動時間的影響會被弱化。

可以通過以下jvm參數開啓分層編譯模式:

-XX:+TieredCompilation

在jdk8中,當以server模式啓動時,分層編譯默認開啓。

需要注意的是,分層編譯方式只能用於server模式中,如果以client模式啓動,-XX:+TieredCompilation參數將會被忽略(前提是當前jvm版本和平臺同時支持client和server模式,如果僅支持server模式的話,-client參數將會被忽略。Jvm版本和支持的啓動方式可以參考下表)。

JVM版本

-client

-server

-d64

Linux 32-bit

32-bit client compiler

32-bit server compiler

Error

Linux 64-bit

64-bit server compiler

64-bit server compiler

64-bit server compiler

Mac OS X

64-bit server compiler

64-bit server compiler

64-bit server compiler

Solaris 32-bit

32-bit client compiler

32-bit server compiler

Error

Solaris 64-bit

32-bit client compiler

32-bit server compiler

64-bit server compiler

Windows 32-bit

32-bit client compiler

32-bit server compiler

Error

Windows 64-bit

64-bit server compiler

64-bit server compiler

64-bit server compiler

測試環境加上分層編譯參數之後,效果很明顯,在大多數情況下啓動之後負載都不會升高,有時候即使有會升高,也比默認的恢復快很多。因此在線上一臺resin加了分層編譯參數。啓動後負載不到10,並且回落比較快,算是達到了目標。然而大概過了半個小時,開始有大量的請求超時,而沒有超時的請求響應時間也明顯變長。試過幾次之後都是同樣的現象。查看cpu使用率和負載,比正常情況下有所偏高,但是還在正常範圍內,gc也正常。對於出現超時的原因,起初懷疑是代碼編譯從C1編譯切換到C2編譯造成的。經過調查,懷疑和codeCache有關。

2.codeCache簡介

Java代碼在執行時一旦被編譯器編譯爲機器碼,下一次執行的時候就會直接執行編譯後的代碼,也就是說,編譯後的代碼被緩存了起來。緩存編譯後的機器碼的內存區域就是codeCache。這是一塊獨立於java堆之外的內存區域。除了jit編譯的代碼之外,java所使用的本地方法代碼(JNI)也會存在codeCache中。不同版本的jvm、不同的啓動方式codeCache的默認大小也不同。

JVM 版本和啓動方式

默認 codeCache大小

32-bit client, Java 8

32 MB

32-bit server, Java 8*

48M

32-bit server with Tiered Compilation, Java 8

240 MB

64-bit server, Java 8*

48M

64-bit server with Tiered Compilation, Java 8

240 MB

32-bit client, Java 7

32 MB

32-bit server, Java 7

48 MB

32-bit server with Tiered Compilation, Java 7

96 MB

64-bit server, Java 7

48 MB

64-bit server with Tiered Compilation, Java 7

96 MB

* jdk8中server模式默認採用分層編譯方式,如果需要關閉分層編譯,需要加上啓動參數-XX:-TieredCompilation

3.codeCache滿了會怎麼樣

隨着時間推移,會有越來越多的方法被編譯,codeCache使用量會逐漸增加,直至耗盡。在codeCache滿了之後會發生什麼?

在jdk1.7.0_4之前,你會在jvm的日誌裏看到這樣的輸出:

Java HotSpot(TM) 64-Bit Server VM warning: CodeCache is full. Compiler has been disabled.

Jit編譯器被停止了,並且不會被重新啓動。已經被編譯過的代碼仍然以編譯方式執行,但是尚未被編譯的代碼就只能以解釋方式執行了。

針對這種情況,jvm提供了一種比較激進的codeCache回收方式:Speculative flushing。在jdk1.7.0_4之後這種回收方式默認開啓,而之前的版本需要通過一個啓動參數來開啓:-XX:+UseCodeCacheFlushing。在Speculative flushing開啓的情況下,當codeCache將要耗盡時,最早被編譯的一半方法將會被放到一個old列表中等待回收。在一定時間間隔內,如果方法沒有被調用,這個方法就會被從codeCache充清除。

很不幸的是,在jdk1.7中,當codeCache耗盡時,Speculative flushing釋放了一部分空間,但是從編譯日誌來看,jit編譯並沒有恢復正常,並且系統整體性能下降很多,出現大量超時。在oracle官網上看到這樣一個bug:http://bugs.java.com/bugdatabase/view_bug.do?bug_id=8006952 由於codeCache回收算法的問題,當codeCache滿了之後會導致編譯線程無法繼續,並且消耗大量cpu導致系統運行變慢。Bug裏影響版本是jdk8,但是從網上其他地方的信息看,jdk7應該也存在相同的問題,並且沒有被修復。

4.codeCache調優

以client模式或者是分層編譯模式運行的應用,由於需要編譯的類更多(C1編譯器編譯閾值低,更容易達到編譯標準),所以更容易耗盡codeCache。當發現codeCache有不夠用的跡象(通過上一節提到的監控方式)時,可以通過啓動參數來調整codeCache的大小。

-XX:ReservedCodeCacheSize=256M

具體應該設置爲多大,可以根據監控數據估算,例如單位時間增長量、系統最長連續運行時間等。如果沒有相關統計數據,一種推薦的設置思路是設置爲當前值(或者默認值)的2倍。

需要注意的是,這個codeCache的值不是越大越好。對於32位jvm,能夠使用的最大內存空間爲4g。這個4g的內存空間不僅包括了java堆內存,還包括jvm本身佔用的內存、程序中使用的native內存(比如directBuffer)以及codeCache。如果將codeCache設置的過大,即使沒有用到那麼多,jvm也會爲其保留這些內存空間,導致應用本身可以使用的內存減少。對於64位jvm,由於內存空間足夠大,codeCache設置的過大不會對應用產生明顯影響。

在jdk8中,提供了一個啓動參數XX:+PrintCodeCache在jvm停止的時候打印出codeCache的使用情況。其中max_used就是在整個運行過程中codeCache的最大使用量。可以通過這個值來設置一個合理的codeCache大小,在保證應用正常運行的情況下減少內存使用。

5.問題的解決

問題的前因後果都弄清楚了,也就好解決了。上面提到過純編譯方式和分層編譯方式都可以解決或緩解啓動後負載過高的問題,那麼我們就有2種選擇:

1) 採用分層編譯方式,並修改codeCache的大小爲256M

2) 採用純編譯方式,並修改codeCache的大小爲256M

我們在線上2臺resin分別使用了上面2種方案,並加了codeCache監控。經過一段時間運行發現,在啓動後負載控制方面,純編譯方式要好一些,啓動之後負載幾乎不上升,而分層編譯方式啓動後負載會有所上升,但是不會很高,也會在較短時間內降下來。但是啓動時間方面,分層編譯比原來的默認啓動方式縮短了大概10秒(原來啓動需要110-130秒),而純編譯方式啓動時間比原來多了一倍,達到了250秒甚至更高。所以看起來分層編譯方式是更好的選擇。

然而jdk7在codeCache的回收方面做的很不好。即使我們將codeCache設置爲256M,線上還是輕易達到了設置的報警閾值200M。而且一旦codeCache滿了之後又會導致系統運行變慢的問題。所以我們的目標指向了jdk8。

測試表明,jdk8對codeCache的回收有了很明顯的改善。不僅codeCache的增長比較平緩,而且當使用量達到75%時,回收力度明顯加大,codeCache使用量在這個值上下浮動,並緩慢增長。最重要的是,jit編譯還在正常執行,系統運行速度也沒有收到影響。

因此我們的選擇是,升級jdk8。目前已經有4臺resin升級了jdk8,整體運行良好。

參考資料

http://www.2cto.com/os/201311/259533.html

https://docs.oracle.com/javase/8/embedded/develop-apps-platforms/codecache.htm

http://blog.csdn.net/xlnjulp/article/details/26354567

https://www.safaribooksonline.com/library/view/java-performance-the/9781449363512/ch04.html

http://www.oraclejavamagazine-digital.com/javamagazine_open/20130708?pg=42#pg42

https://docs.oracle.com/javase/8/embedded/develop-apps-platforms/codecache.htm

http://bugs.java.com/bugdatabase/view_bug.do?bug_id=8006952

http://hellojava.info/?tag=usecodecacheflushing

http://sepulkarium.blogspot.hk/2013/03/java-jit-and-code-cache-issues.html

https://bugs.openjdk.java.net/browse/JDK-8051955

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章