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

阅读更多
换一批

没有更多推荐了,返回首页