14、JVM 调优实战 - 面试题:JVM中都有哪些常见的垃圾回收器,各自的特点是什么?

1. 案例:一个日处理上亿数据的计算系统

案例的背景,有一个数据计算系统,日处理数据量在上亿的规模。

这个系统会不停的从MySQL数据库以及其它数据源里提取大量的数据,加载到自己的JVM内存里来进行计算处理,如下图所示:

 

这个数据计算系统会不停的通过SQL语句和其他方式从各种数据存储中提取数据到内存中来进行计算,大致当时的生产负载是每分钟大概需要执行500次数据提取和计算的任务。

但是这是一套分布式运行的系统,所以生产环境部署了多台机器,每台机器大概每分钟负责执行100次数据提取和计算的任务。

每次会提取大概1万条左右的数据到内存里来计算,平均每次计算大概需要耗费10秒左右的时间。

然后每台机器是4核8G的配置,JVM内存给了4G,其中新生代和老年代分别是1.5G的内存空间,如下图:

 

3. 这个系统多快会塞满新生代?

可知,该系统每台机器上部署的实例,每分钟会执行100次数据计算任务,每次是1万条数据需要计算10秒的时间。

这里计算一下每次1万条数据大概会占用的内存空间。算法如下

由于每条数据都是比较大的,大概每条数据包含了平均20个字段,可以认为平均每条数据在1KB左右的大小。那么每次计算任务的1万条数据就对应了10MB的大小。

新生代内存使用计算:

如果新生代是按照 8:1:1 的比例来分配Eden和两块Survivor的区域,那么就是, Eden区就是1.2GB,每块Survivor区域在100MB左右,如下图:

 

按照这个内存大小,每次执行一个计算任务,就会在Eden区里分配10MB左右的对象,那么一分钟大概对应100~120次计算任务。

基本上一分钟过后,Eden区里就全是对象,基本就全满了。

所以说,本小节的问题,新生代里的 Eden区,基本上1分钟左右就迅速填满了。

4. 触发Minor GC的时候会有多少对象进入老年代?

上一小节说了,新生代的Eden区在1分钟过后都塞满对象了,然后会触发Minor GC回收一部分的垃圾对象。

第一步,计算老年代的可用内存空间是否钓大鱼新生代全部对象?

如下图,老年代是空的,大概有1.5G的可用内存空间,新生代的Eden区大概有1.2G的对象。

 

这里会发现老年代的可用内存空间有1.5GB,新生代的对象总共有 1.2 GB,即使一次Minor GC过后,全部对象都存活,老年代也能放的下的,那么此时就会直接执行Minor GC了

根据前面第二小节的内容可知,每个计算任务1万条数据需要计算10秒钟,假设此时80个计算任务都执行结束了,但是还有20个计算任务共计200MB的数据还在计算中,此时就是200MB的对象是存活的,不能被垃圾回收掉,然后有1GB的对象是可以垃圾回收的。

 

此时一次Minor GC会回收掉1GB的对象,然后200MB的对象超过了任意一块Survivor区域的内存大小。

所以此时会通过空间担保机制,让这200MB对象直接进入老年代去,占用里面200MB内存空间,然后Eden区就清空了。如下图所示:

 

5. 系统运行多久,老年代大概就会填满?

按照上述计算,每分钟会进行一次Minor GC,然后有大概200MB左右的数据进入老年代。

随着时间推移,假设2分钟过去了,此时老年代已经有400MB内存被占用,只有1.1GB的内存可以。

当第3分钟运行完毕,要进行Minor GC时,会根据配置决定是否先检查老年代可用空间是否大于新生代全部对象?

此时老年代可用空间1.1GB,新生代对象有1.2GB,假设一次Minor GC过后新生代对象全部存活,老年代是放不下的,此时就要看一个参数是否打开了。

如果“-XX:-HandlePromotionFailure” 参数被打开了,默认开启。就会进入第二步检查,查看老年代可用空间是否大于历次Minor GC过后进入老年代的对象的平均大小。

如果此时老年代的1.1GB空间,是大于每次Minor GC后平均200MB对象进入老年代的大小的。

所以基本可以推测,本次Minor GC后大概率还是有200MB对象进入老年代,1.1G可用空间是足够的。

所以此时就会放心执行一次Minor GC,然后又是200MB对象进入老年代。

运行了7分钟过后,7次Minor GC执行过后,大概1.4G对象进入老年代,老年代剩余空间就不到100MB了,几乎快满了。如下图:

 

6. 这个系统运行多久,老年代会触发1次Full GC?

在第8分钟运行结束的时候,新生代又满了,执行Minor GC之前进行检查,发现老年代只有100MB内存空间,比之前Minor GC进入老年代的200MB对象要小,此时就会触发一次Full GC

Full GC会把老年代的垃圾对象都给回收了,假设此时老年代被占据的1.4G空间里,全部是可以回收的对象,那么此时就会全把这些对象都回收了,如下图:

 

按照这个运行模型,基本上平均就是七八分钟一次Full GC,这个频率相当高了。因为每次Full GC速度都很慢,性能很差。

7. 该案例应该如何进行JVM优化?

因为该系统是数据计算系统,每次Minor GC的时候,必然会有一批数据没计算完毕。但由于内存模型的问题,每次Survivor区域放不下存活对象。

这里需要对生产系统进行调整,增加新生代的内存比例,3GB左右的堆内存,其中2GB分配给新生代,1GB留给老年代。

这样Survivor区大概就是200MB,每次刚好能放得下Minor GC过后存活的对象,如下图所示:

 

只要每次Minor GC过后200MB存活对象可以放 Survivor区域,那么下一次Minor GC的时候,这个Survivor区域的对象对应的计算任务早就结束了,都是可以回收的。

此时比如Eden区里1.6GB空间被占满了,然后Survivor1区里有200MB上一轮Minor GC后存活的对象,如下图:

 

此时执行Minor GC,就会把Eden区里1.6GB对象回收掉,Survivor1区域里的200MB对象也会回收掉,然后Eden区里剩余的200MB存活对象会放入Survivor2区里,如下图:

 

以此类推,基本上就很少有对象会进入老年代中,老年代里的对象也不会太多的。

通过这个分析和优化,就能成功的把生产系统的老年代Full GC的频率从几分钟一次降低到了几个小时一次,大幅度提升了系统的性能,避免了频繁Full GC对系统运行的影响。

为了避免动态年龄判定规则把Survivor区中的对象直接升入老年代,在这里如果新生代内存有限,可以调整 “-XX:SurvivorRatio=8” 这个参数,默认是说 Eden区比例为80%,从而降低Eden区的比例,给两块 Survivor区更多的内存空间,然后让每次 Minor GC后的对象进入Survivor区中,还可以避免动态年龄判定规则直接把他们升入老年代。

8.垃圾回收器简介

在新生代和老年代进行垃圾回收的时候,都是要用垃圾回收器进行回收的,不同的区域用不同的垃圾回收器。

1、 Serial和Serial Old垃圾回收器:分别用来回收新生代和老年代的垃圾对象

工作原理,是单线程运行,垃圾回收的时候会停止系统的其他工作线程,导致系统卡死,然后让他们进行垃圾回收。现在一般后台Java系统几乎不用了。

2、 ParNew和CMS垃圾回收器:ParNew现在一般都是用在新生代的垃圾回收器,CMS是用在老年代的垃圾回收器,都是多线程并发的机制,性能更好,是现在线上生产系统的标配组合。

3、 G1垃圾回收器:统一收集新生代和老年代,采用了更加优秀的算法和设计机制。