Java虚拟机垃圾回收:内存分配与回收策略 方法区垃圾回收 以及 JVM垃圾回收的调优方法
在《Java对象在Java虚拟机中的创建过程》了解到对象创建的内存分配,在《Java内存区域 JVM运行时数据区》中了解到各数据区有些什么特点、以及相关参数的调整,在《Java虚拟机垃圾回收(一) 基础》中了解到如何判断对象是存活还是已经死亡?在《Java虚拟机垃圾回收(二) 垃圾回收算法》了解到Java虚拟机垃圾回收的几种常见算法,在《Java虚拟机垃圾回收(三) 7种垃圾收集器》了解到几种收集器的特点和应用等。
下面来了解总结前面的一些内容:主要包括内存分配与回收策略、方法区垃圾回收、以及JVM垃圾回收的调优方法、垃圾收集器选择。
1、内存分配与回收策略
通过在《Java虚拟机垃圾回收(二) 垃圾回收算法》"4、分代收集算法"中,我们知道目前几乎所有商业虚拟机的垃圾收集器都采用分代收集算法,对于HotSpot一般的年代内存划分,如下图:
对象的内存分配从大体上讲:
在堆上分配(JIT编译优化后可能在栈上分配),主要在新生代的Eden区中分配;
如果启用了本地线程分配缓冲,将线程优先在TLAB上分配;
少数情况下,可能直接分配在老年代中。
分配的细节取决于当前使用哪种垃圾收集器组合,以及JVM中内存相关参数设置。
接下来将会讲解几条最普遍的内存分配规则。
1-1、对象优先在Eden分配
前面文章曾介绍HotSpot虚拟机新生代内存布局及算法
(1)、将新生代内存分为一块较大的Eden空间和两块较小的Survivor空间;
(2)、每次使用Eden和其中一块Survivor;
(3)、当回收时,将Eden和使用中的Survivor中还存活的对象一次性复制到另外一块Survivor;
(4)、而后清理掉Eden和使用过的Survivor空间;
(5)、后面就使用Eden和复制到的那一块Survivor空间,重复步骤3;
默认Eden:Survivor=8:1,即每次可以使用90%的空间,只有一块Survivor的空间被浪费;
大多数情况下,对象在新生代Eden区中分配;
当Eden区没有足够空间进行分配时,JVM将发起一次Minor GC(新生代GC);
Minor GC时,如果发现存活的对象无法全部放入Survivor空间,只好通过分配担保机制提前转移到老年代。
1-2、大对象直接进入老年代
大对象指需要大量连续内存空间的Java对象,如,很长的字符串、数组;
经常出现大对象容易导致内存还有不少空间就提前触发GC,以获取足够的连续空间来存放它们,所以应该尽量避免使用创建大对象;
"-XX:PretenureSizeThreshold":
可以设置这个阈值,大于这个参数值的对象直接在老年代分配;
默认为0(无效),且只对Serail和ParNew两款收集器有效;
如果需要使用该参数,可考虑ParNew+CMS组合。
1-3、长期存活的对象将进入老年代
JVM给每个对象定义一个对象年龄计数器,其计算流程如下:
在Eden中分配的对象,经Minor GC后还存活,就复制移动到Survivor区,年龄为1;
而后每经一次Minor GC后还存活,在Survivor区复制移动一次,年龄就增加1岁;
如果年龄达到一定程度,就晋升到老年代中;
"-XX:MaxTenuringThreshold":
设置新生代对象晋升老年代的年龄阈值,默认为15;
1-4、动态对象年龄判定
JVM为更好适应不同程序,不是永远要求等到MaxTenuringThreshold中设置的年龄;
如果在Survivor空间中相同年龄的所有对象大小总和大于Survivor空间的一半,大于或等于该年龄的对象就可以直接进入老年代;
1-5、空间分配担保
在前面曾简单介绍过分配担保:
当Survivor空间不够用时,需要依赖其他内存(老年代)进行分配担保(Handle Promotion);
分配担保的流程如下:
在发生Minor GC前,JVM先检查老年代最大可用的连续空间是否大于新生所有对象空间;
如果大于,那可以确保Minor GC是安全的;
如果不大于,则JVM查看HandlePromotionFailure值是否允许担保失败;
如果允许,就继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小;
如果大于,将尝试进行一次Minor GC,但这是有风险的;
如果小于或HandlePromotionFailure值不允许冒险,那这些也要改为进行一次Full GC;
尝试Minor GC的风险--担保失败:
因为尝试Minor GC前面,无法知道存活的对象大小,所以使用历次晋升到老年代对象的平均大小作为经验值;
假如尝试的Minor GC最终存活的对象远远高于经验值的话,会导致担保失败(Handle Promotion Failure);
失败后只有重新发起一次Full GC,这绕了一个大圈,代价较高;
但一般还是要开启HandlePromotionFailure,避免Full GC过于频繁,而且担保失败概率还是比较低的;
JDK6-u24后,JVM代码中已经不再使用HandlePromotionFailure参数了;
规则变为:
只要老年代最大可用的连续空间大于新生所有对象空间或历次晋升到老年代对象的平均大小,就会进行Minor GC;否则进行Full GC;
即老年代最大可用的连续空间小于新生所有对象空间时,不再检查HandelPromotionFailure,而直接检查历次晋升到老年代对象的平均大小;
2、回收方法区
在《Java内存区域 JVM运行时数据区》曾介绍过方法区及相关的回收问题,虽然JVM规范规定这个区域可以不实现垃圾收集,且针对常量池和类型卸载的收回效果不佳,但方法区实现垃圾回收是必要的,下面再来详细了解。
2-1、方法区(永久代)的主要回收对象
1、废弃常量
与回收Java堆中对象非常类似;
2、无用的类
同时满足下面3个条件才能算"无用的类":
(1)、该类所有实例都已经被回收(即Java椎中不存在该类的任何实例);
(2)、加载该类的ClassLoader已经被回收,也即通过引导程序加载器加载的类不能被回收;
(3)、该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法;
2-2、需要注意方法区回收的应用
在大量使用反射、动态代理、经常动态生成大量类的应用,要注意类的回收;
如运行时动态生成类的应用:
1、CGLib在Spring、Hibernate等框架中对类进行增强时会使用;
2、VM的动态语言也会动态创建类来实现语言的动态性;
3、另外,JSP(第一次使用编译为Java类)、基于OSGi频繁自定义ClassLoader的应用(同一个类文件,不同加载器加载视为不同类)等;
2-3、HotSpot虚拟机的相关调整
1、在JDK7中
使用永久代(Permanent Generation)实现方法区,这样就可以不用专门实现方法区的内存管理,但这容易引起内存溢出问题;
有规划放弃永久代而改用Native Memory来实现方法区;
不再在Java堆的永久代中生成中分配字符串常量池,而是在Java堆其他的主要部分(年轻代和老年代)中分配;
更多请参考:http://docs.oracle.com/javase/8/docs/technotes/guides/vm/enhancements-7.html
2、在JDK8中
永久代已被删除,类元数据(Class Metadata)存储空间在本地内存中分配,并用显式管理元数据的空间:
从OS请求空间,然后分成块;
类加载器从它的块中分配元数据的空间(一个块被绑定到一个特定的类加载器);
当为类加载器卸载类时,它的块被回收再使用或返回到操作系统;
元数据使用由mmap分配的空间,而不是由malloc分配的空间;
3、相关参数
"-XX:MaxMetaspaceSize" (JDK8):指定类元数据区的最大内存大小;
"-XX:MetaspaceSize" (JDK8):指定类元数据区的内存阈值--超过将触发垃圾回收;
"-Xnolassgc":控制是否对类进行回收;
"-verbose:class"、"-XX:TraceClassLoading"、"-XX:TraceClassUnloading":查看类加载和卸载信息;
更多请参考:
《Java语言规范》12.7 卸载类和接口;
JDK8类元数据说明: http://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/considerations.html#sthref62
3、JVM垃圾回收的调优方法
内存回收与垃圾收集器是影响系统性能、并发能力的主要因素之一,一般都需要进行一些手动的测试、调整优化;
下面介绍的是一些思路,并非是具体的参数设置。
3-1、明确期望的目标(关注点)
首先应该明确我们的应用程序调整垃圾回收期望的目标(关注点)是什么?
在前文曾介绍过通常有这些关注点:
(1)、停顿时间
GC停顿时间越短就适合需要与用户交互的程序,良好的响应速度能提升用户体验;
与用户交互较多的场景,以给用户带来较好的体验;
如常见WEB、B/S系统的服务器上的应用;
(2)、吞吐量
吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间);
高吞吐量可以高效率地利用CPU时间,尽快完成运算的任务,主要适合在后台计算而不需要太多交互的任务;
应用程序运行在具有多个CPU上,对暂停时间没有特别高的要求;
程序主要在后台进行计算,而不需要与用户进行太多交互;
例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序;
(3)、覆盖区(Footprint)
在达到前面两个目标的情况下,尽量减少堆的内存空间,以获得更好的空间局部性;
可以减少到不满足前两个目标为止,然后再解决未满足的目标;
如果是动态收缩的堆设置,堆的大小将随着垃圾收集器试图满足竞争目标而振荡;
总结就是:低停顿、高吞吐量、少用内存资源;
一般这些目标都相互影响的,增大堆内存获得高吞吐量但会增长停顿时间,反之亦然,有时需折中处理。
3-2、JVM自适应调整(Ergonomics)
JVM有自适应选择、调整相关设置的功能;
一般都会先根据平台性能来选择好垃圾收集器,以及设置好其参数;
在运行中,一些收集器还会收集监控信息来自动地、动态的调整垃圾回收策略;
所以当我们不知道何如选择收集器和调整时,应该首先让JVM自适应调整;
然后通过输出GC日志进行分析,看能不能满足明确期望的目标(第一步);
如果不能满足,或者通过打印设置的参数信息,发现可以有更好的调优时,可以进行手动指定参数进行设置,并测试;
3-3、实践调优:选择垃圾收集器,并进行相关设置
需要明确一个观点:
没有最好的收集器,更没有万能的收集;
选择的只能是对具体应用最适合的收集器;
我们知道HotSpot有这些组合可以搭配使用:
Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;
到实践调优阶段,那必须要了解每个具体收集器的行为特点、优势和劣势、调节参数等(请参考前面的文章内容);
然后根据明确期望的目标,选择具体应用最适合的收集器;
当选择使用某种并行垃圾收集器时,应该指定期望的具体目标而不是指定堆的大小;
让垃圾收集器自动地、动态的调整堆的大小来满足期望的行为;
即堆的大小将随着垃圾收集器试图满足竞争目标而振荡;
当然有时发现问题,堆的大小、划分也是需要进行一些调整的,一般规则:
除非应用程序无法接受长时间的暂停,否则可以将堆调的尽可能大一些;
除非发现问题的原因在于老年代的垃圾收集或应用程序暂停次数过多,否则你应该将堆的较大部分分给年轻代;
等等…
例如,使用Parallel Scavenge/Parallel Old组合,这是一种值得推荐的方式:
1、只需设置好内存数据大小(如"-Xmx"设置最大堆);
2、然后使用"-XX:MaxGCPauseMillis"或"-XX:GCTimeRatio"给JVM设置一个优化目标;
3、那些具体细节参数的调节就由JVM自适应完成;
设置调整后,应该通过在产生环境下进行不断测试,来分析是否达到我们的目标;
更多"期望的目标和JVM自适应调整"信息请参考:
《垃圾收集调优指南》 2节 Ergonomics:http://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/ergonomics.html#ergonomics
更多"垃圾收集器选择"信息请参考:
《垃圾收集调优指南》 5节 Available Collectors:http://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/collectors.html#sthref27