5. 垃圾回收调优
预备知识
- 掌握 GC 相关的 VM 参数,会基本的空间调整
- 掌握相关工具
- 明白一点:调优跟应用、环境有关,没有放之四海而皆准的法则
调优是一项比较高级的技能,也不是所有的初学者一下子就能掌握,这里建议大家
如果希望将来走上调优的道路的话,首先需要掌握与 GC 相关的 JVM 参数,然后会一些基本的空间调整,参数可以到
Oracle 官网去找。有的时候不知道自己用的是哪个垃圾回收器,设置的堆大小、新生代的大小,不知道,就可以通过
“C:\Program Files\Java\jdk1.8.0_91\bin\java” -XX:+PrintFlagsFinal -version | findstr “GC”
这个命令查看当前环境下虚拟机它的虚拟机参数。
5.1 调优领域
垃圾回收调优仅仅是调优领域的一个方向,以后要对程序进行调优,不仅要对GC进行调优,还要对各个领域深入分析和调优,当然,GC领域调优是比较明显的,它主要影响网络延迟,因为一旦发生了 STW,应用程序的响应时间就会变长。除了GC以外,还要考虑下面这些方向:
- 内存
- 锁竞争
- cpu 占用
- io
5.2 目标
要清楚调优的目标,清楚应用程序是来干什么的,到底是做科学运算还是做互联网项目,如果是科学运算,那么追求的是高吞吐量,延长
一点时间来说不太紧要;如果做的是互联网项目,那响应时间就是一个非常重要的指标了,因为如果每次的垃圾回收带来了长时间的响应
时间,那么就给用户造成了不好的体验。确定了目标以后再去选择合适的垃圾回收器。对于高吞吐量的垃圾回收器,推荐 ParallelGC,
如果追求的是响应时间优先,那么可以选择的有 CMS、G1(JDK9默认,在超大堆内存下优于CMS,还可以以目标的方式进行自我调整)、ZGC(java12引入的体验式的垃圾回收器,目标也是超低的延迟)。
- 【低延迟】还是【高吞吐量】,选择合适的回收器
- CMS、G1、ZGC
- ParallelGC
- Zing(一款垃圾回收器,对外宣称零停顿,几乎没有STW的时间,而且也可以管理超大的内存)
5.3 最快的 GC 是不发生 GC
如果虚拟机经常发生GC,考虑一下是不是自己的代码写的有问题
- 查看 Full GC 前后的内存占用,考虑下面几个问题
-
数据是不是太多(加载了不必要的数据到内存,内存过多,导致GC频繁,堆的压力过大)
-
resultSet = statement.executeQuery(“select * from 大表”) (会把所有数据从JDBC读入java内存,会导致频繁的GC)
“select * from 大表 limit n” 加一些限制条件,避免把一些无用的数据放到java的内存中去
-
-
数据表示是否太臃肿(也是加载了不必要的数据,查数据的时候有些属性用不上但还是加载了)
- 对象图(不是说查一个对象就把它所有相关联的对象都查出来,用到哪个查哪个其实更好)
- 对象大小
- java中最小的一个object都要占16个字节,包装类Integer 24 字节,int 4 字节,
-
是否存在内存泄漏
- static Map map,不断地往里放又不移除,最后肯定会造成频繁的GC
- 对于长时间存活的对象建议使用软、弱引用
- 第三方缓存实现,如redis,它们都会考虑对象的过期,因为是第三方软件,根本不会造成对java堆的压力,因为它们使用的是他们自己的内存,不使用java的堆内存,不受垃圾回收的影响,而且它们在内存管理方面也做了一定的优化,它也可以实现堆外内存,它缓存中的对象也可以使用非java的堆内存,这样也不会给垃圾回收造成太大的压力。所以建议在实现一些缓存的时候,建议使用第三方的缓存,不要自己用个map,防止一个编程不当造成内存泄露,造成GC效率不高。
-
总结:是不是加载了一些无用的数据
对象的大小是不是应该做一些瘦身优化
做缓存应用时尽可能考虑第三方的缓存
5.4 新生代调优
-
新生代的特点
- 所有的new操作的内存分配非常廉价
- 当new一个对象时,对象首先会在伊甸园分配,这个速度是非常非常快的。
- 每个线程都会在伊甸园中给它分配一块私有的区域,叫TLAB,它是每个线程局部的、私有的、分配的一块缓冲区,当new一个对象时,它首先会检查这个TLAB缓冲区中有没有可用内存,如果有,优先在这块区域进行一个对象的分配。为什么这么分配呢,因为对象的分配也有一个线程安全的问题,比如说线程1要用这段内存,那它在分配还没有结束的时候,线程2不能使用这块内存,否则会造成分配混乱,因此我们在分配对象时也要做一个并发安全的保护,当然这个操作是由JVM帮我们做的,那能不能减少线程间对内存分配时的并发冲突呢,TLAB这个线程分配局部缓冲区,它就上场了,它的作用就是让每个线程用自己私有的伊甸园内存来进行对象的分配,这样即使多个线程同时创建对象时,也不会造成对内存占用的干扰,伊甸园中创建对象效率是非常高的。
- TLAB:thread-local allocation buffer
- 在新生代死亡对象的回收代价是零
- 当新生代发生垃圾回收的时候,我们要先知道一点,我们以前介绍的所有垃圾回收器它们采用的都是复制算法,复制算法它是把伊甸园、幸存区From中幸存的对象给它复制到幸存区To中去,复制完之后,伊甸园、幸存区From它们的内存都被释放出来了,因此,死亡对象的回收代价是零
- 新生代中大部分对象用过即死
- 垃圾回收时大部分对象都被回收,只有少数存活下来
- Minor GC 的时间远远低于 Full GC
- 幸存对象很少,又采用的是复制拷贝算法。
- 所有的new操作的内存分配非常廉价
-
新生代越大越好吗?
-
新生代设的小了,创建的可用对象少,会触发 Minor GC,造成 STW,会造成短暂的暂停。
-
新生代设的大了,老年代的可用空间相对就少了,老年代空间少了,而新生代的垃圾充足,那再触发垃圾回收就是 Full GC了,Full GC 的暂停时间比新生代的 Minor GC的暂停时间更长,用更长的时间才能完成垃圾回收。
-
Oracle 建议新生代内存大于整个堆内存的25%,,小于整个堆内存的50%,也就是占到堆内存的1/4以上,1/2以下。
-
新生代空间大了,意味着垃圾回收的时间较长。随着新生代的空间大小的增大吞吐量是先增后减,新生代也不是越大越好,要找到这个临界点,使吞吐量最大。
-
新生代尽可能调大,但也不要太大
新生代的垃圾回收是复制算法,复制算法也是分成两个阶段,第一个阶段标记,第二个阶段进行复制,这两个阶段,其实复制花费的时间更多一些,因为内复制牵扯到内存块的移动,另外要更新其它对象的引用地址,这个相对会比较耗时一些,而新生代的对象绝大部分是朝生夕死的,只有少量的对象会存活下来,既然只有少量的对象存活下来,那它复制占用的时间也是相对较短的,而标记时间相对于复制时间来讲又不是那么重要了,新生代在调大的情况下,主要的耗费时间还是在复制上,所以即使增的很大,效率也不会有明显的下降。
那究竟调为多大合适呢?
- 新生代能容纳所有【并发量 * (请求-响应)】的数据
- 新生代理想情况下是能够容纳一次请求到响应过程中 所产生的对象 * 并发量,比如说一次请求响应,一次请求响应过程中可能会产生很多新的对象,这些新的对象加起来占用了512K的内存,这时候的并发量大约是1000,也就是同一时刻有1000个用户来访问,新生代比较理想内存就是每一个请求响应内存乘以并发量,也就是 512K * 1000,大约是512M,为什么设成这么大就说它是比较理想的状态呢,这是因为这一次请求响应的过程以后其中大部分对象都会被回收,而只要这一次请求加上并发量所占用的内存不超过新生代的内存它就不会触发或较少的触发新生代的垃圾回收了这样呢就可以大约估算出新生代的内存占用划分为多少比较合适。
幸存区的内存调优
-
幸存区大到能保留【当前活跃对象+需要晋升对象】
- 幸存区的内存设置要遵从这么几个规则,第一个是幸存区也要足够的大,大到能保留当前活跃的对象加上需要晋升的对象,幸存区中可以看成两类对象,第一类对象是生命周期较短,也许下一次垃圾回收就要把它回收掉了,但是由于现在正在使用它,暂时不能回收,所以它就留在幸存区中;另一类呢是将来肯定会被晋升到老年代,因为大家都在用它,但是由于年龄还不够,所以暂时还在幸存区中,还没有被晋升,幸存区中可以看成存储的是这两类对象,一类是马上要被回收的,一类是将来肯定会被晋升的,那幸存区的大小得大到这两类对象都能够容纳,如果幸存区比较小,就会由JVM动态的去调整晋升的阈值,也许有一些对象虽然年龄还没有到达阈值,但是由于内存比较小,会提前晋升到老年代中去,如果一个周期比较短的对象由于幸存区内存比较小,晋升到了老年代,那它得等到老年代触发 Full GC 才能被回收掉,这相当于延长了这个对象的生存时间,这样不太好。最好能实现存活时间比较短的对象在下次新生代的垃圾回收时就把它回收掉了,真正需要长时间存活的对象才把它晋升到老年代去。
-
我们一边希望存活时间短的对象留在幸存区,以便下一次垃圾回收能把它回收掉,而另一边我们又希望长时间存活的对象尽快的被晋升,因为如果是一个长时间存活的对象,你把它留在幸存区里,只能够耗费幸存区的内存,并且要把幸存区存活的对象从From复制到To中,新生代主要耗费的时间就是在对象的复制上,如果有大量长时间存活的对象它们不能及早的晋升,那么它们得留在幸存区,被复制来复制去,这样呢对形成反而是一个负担,所以遇到这种情况就要设置一下它的晋升阈值,让晋升阈值设置的比较小,让这些存活时间长的对象尽快的晋升到老年代中去。
-
晋升阈值配置得当,让长时间存活对象尽快晋升
-
-XX:MaxTenuringThreshold=threshold
- 上面的参数可以调整最大晋升阈值
-
-XX:+PrintTenuringDistribution
-
上面的参数会在垃圾回收时把幸存区中的存活对象详情显示出来,我们可以更细致的决定把最大晋升阈值调整多少合适,让那些长时间存活的对象能够尽早的晋升。
-
Desired survivor size 48286924 bytes, new threshold 10 (max 10) // 年龄 空间总和 空间累计总和数 - age 1: 28992024 bytes, 28992024 total - age 2: 1366864 bytes, 30358888 total - age 3: 1425912 bytes, 31784800 total ...
-
-
5.5 老年代调优
以 CMS 为例
-
CMS 的老年代内存越大越好
- CMS 这种垃圾回收器它是一种低响应时间的,并且是一个并发的垃圾回收器,它的垃圾回收器线程在工作的时候其它的用户线程也能够并发的执行,这样有一个缺点,产生浮动垃圾,如果浮动垃圾产生导致内存不足,这时候就会造成 CMS 并发失败,并发失败了的话那 CMS 垃圾回收器就不能正常工作了,它就会退化为 SerialOld,一个穿行的老年代的垃圾回收器,这个效率就特别低了,一下子就会 STW,导致响应时间变得特别长,所以在规划老年代的内存的时候,就要把它规划得更大一些,越大越好,这样就是为了预留更多的空间避免浮动垃圾引起的并发失败。
-
先尝试不做调优,如果没有 Full GC 那么已经······,否则先尝试调优新生代
- 在做老年代调优之前,先尝试一下不做调优,为什么呢,如果程序正常运行了一段时间以后,并没有发现 Full GC,也就是不是由老年代内存不足引起的垃圾回收,那说明老年代的空间很充裕,如果没有 Full GC 说明这个系统已经工作的很好了,所以就先别尝试做老年代的调优了,即使发生了 Full GC,也应该先从调优新生代开始。
-
观察发生 Full GC 时老年代内存占用,将老年代内存预设调成 1/4 ~ 1/3
- 等新生代调优完了,还是经常发生 Full GC,再回过头来看看老年代的设置,一旦老年代仍然发生 Full GC,观察一下 Full GC时老年代是由于超过了多大的内存导致了 Full GC 的发生,可以在原有 Full GC 是的基础上调大 1/4 ~ 1/3,这样就划分了一个更合理的内存给老年代使用,减少老年代 Full GC 的产生。
- -XX:CMSInitiatingOccupancyFraction=percent
- 这个参数控制老年代的内存占用达到老年代内存的百分之多少的时候就使用 CMS 进行垃圾回收,这个值越低,老年代垃圾回收的触发时机越早。一般会把这个比例设置到 75%~80%之间,也就是预留 20%~25%的空间给那些浮动垃圾。
5.6 案例
-
案例1 Full GC 和 Minor GC 频繁
- 当程序运行期间 GC 特别的频繁,尤其是 Minor GC 达到了一分钟上百次,其实遇到这种情况 GC 特别频繁,说明我们的空间紧张,那究竟是哪部分空间紧张呢,我们进一步分析。如果是新生代内存紧张,高峰期时大量的对象被创建,很快就把新生代的空间塞满了,塞满了就会造成幸存区空间紧张了,它的对象的晋升阈值就会降低,导致很多生存周期很短的对象也会被晋升到老年代去,这样情况就进一步恶化了,老年代存了大量这种存储周期很短的对象进一步触发老年代的 Full GC 的频繁发生。通过监测工具监测堆空间各个区域的大小,发现新生代的空间设置的太小了,我们内存优化应该先从新生代开始,所以解决方法就是先试着增大新生代内存,新生代内存增大以后,内存充裕了,新生代的垃圾回收就变得不那么频繁了,同时增大了幸存区的空间以及晋升的阈值,这样就可以让很多生命周期较短的对象尽可能的被留在新生代里,而不要晋升到老年代,这样就让老年代的 Full GC 也不容易出现了,老年代的 Full GC 也不那么频繁了,通过调整新生代大小解决了 GC 频发问题。
-
案例2 请求高峰期发生 Full GC,单次暂停时间特别长(CMS)
- 这个案例是由于业务要的是低延迟,所以在垃圾回收器的选择上选择了 CMS 这种垃圾回收器,单次暂停时间比较长我们就要去分析看是哪一部分耗费的时间比较长,我们去查看 GC 日志,去看看 CMS 的哪个阶段耗费的时间较长,CMS 分为初始标记、并发标记、重新标记、并发清理,其中初始标记以及并发标记都是比较快的,比较慢的就在重新标记,通过查看 GC 日志可以查看每一阶段耗费的时间在 GC 日志里找到,如果在重新标记阶段花费的时间比较长,在重新标记时会扫描整个的堆内存,不光是要扫描老年代的对象,同时也要扫描新生代的对象,如果业务高峰的时候,新生代的对象比较多,那么标记时间就会变得非常长,因为它要根据这个对象找它的引用,遍历算法耗时太多了,在重新标记之前可以先对新生代的对象做一次垃圾回收,减少新生代对象的数量,这样就可以减少在重新标记阶段耗费的时间,下面的参数就可以执行这个操作,清理之后查找和标记的数量要比以前少得多,这样呢就可以解决这个问题。
- -XX:+CMSScavengeBeforeRemark
-
案例3 老年代充裕情况下,发生 Full GC(1.7)
- 1.8 有一个元空间作为方法区的实现,而1.7以前的采用的是永久代作为方法区的实现,在1.7及以前永久代的内存不足也会导致 Full GC,1.8 以后由于改成了元空间,元空间默认情况下使用了操作系统的空间,一般情况下是比较充裕的,不会发生元空间的空间不足问题,而 1.7 以前呢永久代空间设小了,就会触发堆的Full GC。