22讲如何优化垃圾回收机制 - 图122讲如何优化垃圾回收机制

你好,我是刘超。

22讲如何优化垃圾回收机制 - 图2我们知道,在Java开发中,开发⼈员是⽆需过度关注对象的回收与释放的,JVM的垃圾回收机制可以减轻不少⼯作量。但完 全交由JVM回收对象,也会增加回收性能的不确定性。在⼀些特殊的业务场景下,不合适的垃圾回收算法以及策略,都有可能导致系统性能下降。

⾯对不同的业务场景,垃圾回收的调优策略也不⼀样。例如,在对内存要求苛刻的情况下,需要提⾼对象的回收效率;在
CPU使⽤率⾼的情况下,需要降低⾼并发时垃圾回收的频率。可以说,垃圾回收的调优是⼀项必备技能。

这讲我们就把这项技能的学习进⾏拆分,看看回收(后⾯简称GC)的算法有哪些,体现GC算法好坏的指标有哪些,⼜如何根据⾃⼰的业务场景对GC策略进⾏调优?

垃圾回收机制

掌握GC算法之前,我们需要先弄清楚3个问题。第⼀,回收发⽣在哪⾥?第⼆,对象在什么时候可以被回收?第三,如何回收这些对象?

回收发⽣在哪⾥?

JVM的内存区域中,程序计数器、虚拟机栈和本地⽅法栈这3个区域是线程私有的,随着线程的创建⽽创建,销毁⽽销毁;栈 中的栈帧随着⽅法的进⼊和退出进⾏⼊栈和出栈操作,每个栈帧中分配多少内存基本是在类结构确定下来的时候就已知的,因此这三个区域的内存分配和回收都具有确定性。

那么垃圾回收的重点就是关注堆和⽅法区中的内存了,堆中的回收主要是对象的回收,⽅法区的回收主要是废弃常量和⽆⽤的类的回收。

对象在什么时候可以被回收?

那JVM⼜是怎样判断⼀个对象是可以被回收的呢?⼀般⼀个对象不再被引⽤,就代表该对象可以被回收。⽬前有以下两种算法

可以判断该对象是否可以被回收。

引⽤计数算法:这种算法是通过⼀个对象的引⽤计数器来判断该对象是否被引⽤了。每当对象被引⽤,引⽤计数器就会加1; 每当引⽤失效,计数器就会减1。当对象的引⽤计数器的值为0时,就说明该对象不再被引⽤,可以被回收了。这⾥强调⼀
点,虽然引⽤计数算法的实现简单,判断效率也很⾼,但它存在着对象之间相互循环引⽤的问题。

可达性分析算法:GC Roots 是该算法的基础,GC Roots是所有对象的根对象,在JVM加载时,会创建⼀些普通对象引⽤正常对象。这些对象作为正常对象的起始点,在垃圾回收时,会从这些GC Roots开始向下搜索,当⼀个对象到 GC Roots 没有任何引⽤链相连时,就证明此对象是不可⽤的。⽬前HotSpot虚拟机采⽤的就是这种算法。

以上两种算法都是通过引⽤来判断对象是否可以被回收。在 JDK 1.2 之后,Java 对引⽤的概念进⾏了扩充,将引⽤分为了以下四种:
22讲如何优化垃圾回收机制 - 图3

如何回收这些对象?

了解完Java程序中对象的回收条件,那么垃圾回收线程⼜是如何回收这些对象的呢?JVM垃圾回收遵循以下两个特性。

⾃动性:Java提供了⼀个系统级的线程来跟踪每⼀块分配出去的内存空间,当JVM处于空闲循环时,垃圾收集器线程会⾃动检查每⼀块分配出去的内存空间,然后⾃动回收每⼀块空闲的内存块。

不可预期性:⼀旦⼀个对象没有被引⽤了,该对象是否⽴刻被回收呢?答案是不可预期的。我们很难确定⼀个没有被引⽤的对象是不是会被⽴刻回收掉,因为有可能当程序结束后,这个对象仍在内存中。

垃圾回收线程在JVM中是⾃动执⾏的,Java程序⽆法强制执⾏。我们唯⼀能做的就是通过调⽤System.gc⽅法来”建议”执⾏垃圾收集器,但是否可执⾏,什么时候执⾏?仍然不可预期。

GC算法

JVM提供了不同的回收算法来实现这⼀套回收机制,通常垃圾收集器的回收算法可以分为以下⼏种:

22讲如何优化垃圾回收机制 - 图4

如果说收集算法是内存回收的⽅法论,那么垃圾收集器就是内存回收的具体实现,JDK1.7 update14 之后Hotspot虚拟机所有
的回收器整理如下(以下为服务端垃圾收集器):
22讲如何优化垃圾回收机制 - 图5
其实在JVM规范中并没有明确GC的运作⽅式,各个⼚商可以采⽤不同的⽅式实现垃圾收集器。我们可以通过JVM⼯具查询当前JVM使⽤的垃圾收集器类型,⾸先通过ps命令查询出进程ID,再通过jmap -heap ID查询出JVM的配置信息,其中就包括垃圾收集器的设置类型。
22讲如何优化垃圾回收机制 - 图6

GC性能衡量指标

⼀个垃圾收集器在不同场景下表现出的性能也不⼀样,那么如何评价⼀个垃圾收集器的性能好坏呢?我们可以借助⼀些指标。

吞吐量:这⾥的吞吐量是指应⽤程序所花费的时间和系统总运⾏时间的⽐值。我们可以按照这个公式来计算GC的吞吐量:系统总运⾏时间=应⽤程序耗时+GC耗时。如果系统运⾏了100分钟,GC耗时1分钟,则系统吞吐量为99%。GC的吞吐量⼀般不能低于95%。

停顿时间:指垃圾收集器正在运⾏时,应⽤程序的暂停时间。对于串⾏回收器⽽⾔,停顿时间可能会⽐较⻓;⽽使⽤并发回收器,由于垃圾收集器和应⽤程序交替运⾏,程序的停顿时间就会变短,但其效率很可能不如独占垃圾收集器,系统的吞吐量也很可能会降低。

垃圾回收频率:多久发⽣⼀次指垃圾回收呢?通常垃圾回收的频率越低越好,增⼤堆内存空间可以有效降低垃圾回收发⽣的频率,但同时也意味着堆积的回收对象越多,最终也会增加回收时的停顿时间。所以我们只要适当地增⼤堆内存空间,保证正常的垃圾回收频率即可。

查看&分析GC⽇志

已知了性能衡量指标,现在我们需要通过⼯具查询GC相关⽇志,统计各项指标的信息。⾸先,我们需要通过JVM参数预先设置GC⽇志,通常有以下⼏种JVM参数设置:

-XX:+PrintGC 输出GC⽇志
-XX:+PrintGCDetails 输出GC的详细⽇志
-XX:+PrintGCTimeStamps 输出GC的时间戳(以基准时间的形式)
-XX:+PrintGCDateStamps 输出GC的时间戳(以⽇期的形式,如 2013-05-04T21:53:59.234+0800)
-XX:+PrintHeapAtGC 在进⾏GC的前后打印出堆的信息
-Xloggc:../logs/gc.log ⽇志⽂件的输出路径

这⾥使⽤如下参数来打印⽇志:

-XX:+PrintGCDateStamps -XX:+PrintGCDetails -Xloggc:./gclogs
打印后的⽇志为:
22讲如何优化垃圾回收机制 - 图7
上图是运⾏很短时间的GC⽇志,如果是⻓时间的GC⽇志,我们很难通过⽂本形式去查看整体的GC性能。此时,我们可以通过GCViewer⼯具打开⽇志⽂件,图形化界⾯查看整体的GC性能,如下图所示:
22讲如何优化垃圾回收机制 - 图8

22讲如何优化垃圾回收机制 - 图9

通过⼯具,我们可以看到吞吐量、停顿时间以及GC的频率,从⽽可以⾮常直观地了解到GC的性能情况。

这⾥我再推荐⼀个⽐较好⽤的GC⽇志分析⼯具,GCeasy是⼀款⾮常直观的GC⽇志分析⼯具,我们可以将⽇志⽂件压缩之后,上传到GCeasy官⽹即可看到⾮常清楚的GC⽇志分析结果:
22讲如何优化垃圾回收机制 - 图10

22讲如何优化垃圾回收机制 - 图11

22讲如何优化垃圾回收机制 - 图12

GC调优策略

找出问题后,就可以进⾏调优了,下⾯介绍⼏种常⽤的GC调优策略。

降低Minor GC频率

通常情况下,由于新⽣代空间较⼩,Eden区很快被填满,就会导致频繁Minor GC,因此我们可以通过增⼤新⽣代空间来降低Minor GC的频率。

可能你会有这样的疑问,扩容Eden区虽然可以减少Minor GC的次数,但不会增加单次Minor GC的时间吗?如果单次Minor GC
的时间增加,那也很难达到我们期待的优化效果呀。

我们知道,单次Minor GC时间是由两部分组成:T1(扫描新⽣代)和T2(复制存活对象)。假设⼀个对象在Eden区的存活时间为500ms,Minor GC的时间间隔是300ms,那么正常情况下,Minor GC的时间为 :T1+T2。

当我们增⼤新⽣代空间,Minor GC的时间间隔可能会扩⼤到600ms,此时⼀个存活500ms的对象就会在Eden区中被回收掉, 此时就不存在复制存活对象了,所以再发⽣Minor GC的时间为:两次扫描新⽣代,即2T1。

可⻅,扩容后,Minor GC时增加了T1,但省去了T2的时间。通常在虚拟机中,复制对象的成本要远⾼于扫描成本。

如果在堆内存中存在较多的⻓期存活的对象,此时增加年轻代空间,反⽽会增加Minor GC的时间。如果堆中的短期对象很多,那么扩容新⽣代,单次Minor GC时间不会显著增加。因此,单次Minor GC时间更多取决于GC后存活对象的数量,⽽⾮
Eden区的⼤⼩。

降低Full GC的频率

通常情况下,由于堆内存空间不⾜或⽼年代对象太多,会触发Full GC,频繁的Full GC会带来上下⽂切换,增加系统的性能开销。我们可以使⽤哪些⽅法来降低Full GC的频率呢?

减少创建⼤对象:在平常的业务场景中,我们习惯⼀次性从数据库中查询出⼀个⼤对象⽤于web端显示。例如,我之前碰到过
⼀个⼀次性查询出60个字段的业务操作,这种⼤对象如果超过年轻代最⼤对象阈值,会被直接创建在⽼年代;即使被创建在了年轻代,由于年轻代的内存空间有限,通过Minor GC之后也会进⼊到⽼年代。这种⼤对象很容易产⽣较多的Full GC。

我们可以将这种⼤对象拆解出来,⾸次只查询⼀些⽐较重要的字段,如果还需要其它字段辅助查看,再通过第⼆次查询显示剩余的字段。

增⼤堆内存空间:在堆内存不⾜的情况下,增⼤堆内存空间,且设置初始化堆内存为最⼤堆内存,也可以降低Full GC的频率。

选择合适的GC回收器

假设我们有这样⼀个需求,要求每次操作的响应时间必须在500ms以内。这个时候我们⼀般会选择响应速度较快的GC回收器,CMS(Concurrent Mark Sweep)回收器和G1回收器都是不错的选择。

⽽当我们的需求对系统吞吐量有要求时,就可以选择Parallel Scavenge回收器来提⾼系统的吞吐量。

总结

今天的内容⽐较多,最后再强调⼏个重点。

垃圾收集器的种类很多,我们可以将其分成两种类型,⼀种是响应速度快,⼀种是吞吐量⾼。通常情况下,CMS和G1回收器的响应速度快,Parallel Scavenge回收器的吞吐量⾼。

在JDK1.8环境下,默认使⽤的是Parallel Scavenge(年轻代)+Serial Old(⽼年代)垃圾收集器,你可以通过⽂中介绍的查

询JVM的GC默认配置⽅法进⾏查看。

通常情况,JVM是默认垃圾回收优化的,在没有性能衡量标准的前提下,尽量避免修改GC的⼀些性能配置参数。如果⼀定要改,那就必须基于⼤量的测试结果或线上的具体性能来进⾏调整。

思考题

以上我们讲到了CMS和G1回收器,你知道G1是如何实现更好的GC性能的吗?

期待在留⾔区看到你的⻅解。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他⼀起讨论。
22讲如何优化垃圾回收机制 - 图13

  1. 精选留⾔

22讲如何优化垃圾回收机制 - 图14Jxin
1.7及前的都还好说,毕竟⼤部分开发都拜读过<深⼊理解jvm>。
回归整体,记得有点模糊了,如果有错误还请⽼师指正。⾸先cms在1.9已经被标记为废弃,主要原因在于标记清除下的悬浮内存,导致内存空间碎⽚化,进⽽导致fullGC的发⽣。不过其并⾏执⾏垃圾回收的性能还是值得认可的,⾄少1.9后主推的G1在常规情况下也是不如它的效率好的。接下来,说下G1,拼G1的堆内存结构⽐较特殊,虽然也有年代划分,但从物理⻆度上却不⼀样。G1将整块内存分配成若⼲个同等⼤⼩的reg。新⽣代(两个sub区加ed区)和⽼年代各⾃由不同数量的reg组成。垃圾回收的算法应该算是标记整理。所以其规避了cms内存碎⽚化的问题,⼤⼤降低了fullGC的频率。所以它虽然常态性能略输于c
ms但却没有cms特殊情况下的极端性能问题,总体更稳定。值得⼀提的是G1中各代的内存区域⾥reg间不⼀定是连续的,所以对于cpu缓存加载机制并不是特别友好,⽽且⼤对象占据超过⼀个reg时还带来内存浪费的问题。所以总的来说1.8可以⽤G1但得考虑场景,⾸先这个内存空间要⼤,保证每个reg尽量⼤,以减少内存浪费,保守估计8g以上⽤g1。实际公司很少会去升级j
dk版本,⼤部分都是1.8,好在oracle⼀些1.9 10 11 12的特性都有以补丁的⽅式落到1.8。所以1.8还是⽐较安全实⽤的,虽然我们公司还是1.7,推不动哈。
2019-07-13 14:05
作者回复
赞,Region这块 Jxin讲解的通俗易懂。
2019-07-17 10:19

22讲如何优化垃圾回收机制 - 图15Liam
22讲如何优化垃圾回收机制 - 图16 1 minor gc是否会导致stop the world?
2 major gc什么时候会发⽣,它和full gc的区别是什么?
2019-07-14 10:25
作者回复
Liam提出的这两个问题⾮常好。
1、不管什么GC,都会发送stop the world,区别是发⽣的时间⻓短。⽽这个时间跟垃圾收集器⼜有关系,Serial、PartNew、P arallel Scavenge收集器⽆论是串⾏还是并⾏,都会挂起⽤户线程,⽽CMS和G1在并发标记时,是不会挂起⽤户线程,但其他时候⼀样会挂起⽤户线程,stop the world的时间相对来说⼩很多了。

2、major gc很多参考资料指的是等价于full gc,我们也可以发现很多性能监测⼯具中只有minor gc和full gc。
⼀般情况下,⼀次full gc将会对年轻代、⽼年代以及元空间、堆外内存进⾏垃圾回收。⽽触发Full GC的原因有很多:
a、当年轻代晋升到⽼年代的对象⼤⼩⽐⽬前⽼年代剩余的空间⼤⼩还要⼤时,此时会触发Full GC;
b、当⽼年代的空间使⽤率超过某阈值时,此时会触发Full GC;
c、当元空间不⾜时(JDK1.7永久代不⾜),也会触发Full GC;
d、当调⽤System.gc()也会安排⼀次Full GC;
2019-07-14 16:12

22讲如何优化垃圾回收机制 - 图17QQ怪
G1与CMS的优势在于以下⼏点:
1、并⾏与并发:G1能够更充分利⽤多CPU、多核环境运⾏
2、分代收集:G1虽然也⽤了分代概念,但相⽐其他收集器需要配合不同收集协同⼯作,但G1收集器能够独⽴管理整个堆
3、空间管理:与CMS的标记⼀清理算法不同,G1从整体上基于标记⼀整理算法,将整个Java堆划分为多个⼤⼩相等的独⽴区域(Region),这种算法能够在运⾏过程中不产⽣内存碎⽚
4、可预测的停顿:降低停顿时间是G1和CMS共同⽬标,但是G1追求低停顿外,还能建⽴可预测的停顿时间模型,能让使⽤者明确指定⼀个⻓度为M毫秒的时间⽚段内,消耗在垃圾收集器上的时间不得超过N毫秒。
2019-07-13 14:12
作者回复
赞。

理解G1中的⼏个重要概念:Region、SATB、RSet以及Pause Prediction Model,能更好的理解G1相对CMS的⼀些具体优势在哪⾥了。

2019-07-15 10:29

22讲如何优化垃圾回收机制 - 图18别忘微笑
超哥,⼀个web应⽤,多久⼀次Full GC才算正常呢
2019-07-15 10:35
作者回复
需要根据具体的业务来分析,正常⼩对象且请求平缓的应⽤服务中,⼏天⼀次较为正常。如果有⼤量⼤对象创建或者承受⾼并发场景的服务,Full GC可能会更频繁。
2019-07-16 10:46

22讲如何优化垃圾回收机制 - 图19我⼜不乱来
超哥,我建议可以分享⼀下那些对象可以作为gc root的对象,为什么这些对象可以做为gc root对象?
2019-07-13 08:35
作者回复
在Java语⾔⾥,可作为GC Root对象的包括如下⼏种: 1. Java虚拟机栈中的引⽤的对象 ; 2. ⽅法区中的类静态属性引⽤的对象 ; 3. ⽅法区中的常量引⽤的对象 ; 4. 本地⽅法栈中JNI的引⽤的对象。

我们知道,垃圾回收⼀般是回收堆和⽅法区的对象,⽽堆中的对象在正常情况下,⼀般是通过常量、全局变量、静态变量等间接引⽤堆中的对象,所以这些可以作为GC Root。
在任何上述的GCRoot中,有引⽤可以指向时,我们称之为对象可达。

2019-07-15 09:43

22讲如何优化垃圾回收机制 - 图20nightmare
⽼师看完有两个疑问,第⼀这么查看minor gc回收之后 eden区存活对象的多少,第⼆ jmap -heap pid在图中只能看年轻代paral lel gc看不到⽼年代的是什么垃圾回收器 对于提问 cms垃圾回收器还是分⽼年代和年轻代回收分多个阶段有和程序并⾏的阶段也有stop the world阶段 回收⼀整块⽼年代时间⽐较久,⽽ gc把年轻代和⽼年代也有划分,不过拆成⼀个region了,对region的回收成本低,⽽且会判断那些region回收的对象更多,⽽且cms要经过多次full gc才可能把不⽤的内存归还给操作系统 ⽽g1只需要⼀次full gc就可以
2019-07-13 02:07
作者回复
我们可以通过jstat -gc pid interval查看每次GC之后,具体的每⼀个分区的内存使⽤率变化情况。我们可以通过查看JVM设置参数来查看具体的垃圾收集器的设置参数,使⽤的⽅式有很多,例如jcmd pid VM.flags可以查看到相关的设置参数。
2019-07-17 14:54

22讲如何优化垃圾回收机制 - 图21N
⽼师您好,公司ES服务器设置最⼤最⼩堆内存26个G,G1GC, XX:MaxGCPauseMillis =500,⼀段时间内old gc 都会稳定在50
0ms以内,但每天总会有1-2次old gc 时间很⻓,⼤概3000ms.请问该如何优化呢?
2019-07-20 15:27
作者回复
设置的并发标记线程数量是多少呢?可以通过-XX:ConcGCThreads尝试适当调整这个数量,为服务器CPU核数的1/4,可以提
⾼并发标记的效率。

由于JVM 垃圾回收和内存分配这块的调优错综复杂,需要我们再结合服务器上跑的相关的业务以及GC⽇志逐步调优。
2019-07-22 10:18

22讲如何优化垃圾回收机制 - 图22nightmare
⽼师,查看minor gc存活对象的命令是什么呢
2019-07-15 11:07
作者回复
具体存活的对象是在随时变化的,很难追踪,⽬前只能通过各个区域的⼤⼩来分析GC效率。
2019-07-17 14:56

22讲如何优化垃圾回收机制 - 图23nightmare
⽼师你的垃圾回收器serial old 和 parallel old都是标记整理算法吧,⽽图⽚上写的是标记清除算法?
2019-07-14 20:23
作者回复
serial old是标记清除算法,parallel old是标记整理算法。
2019-07-15 09:54

22讲如何优化垃圾回收机制 - 图24-W.LI-
⽼师好!Serial Old不是标记整理算法么?Serial new是复制吧。我记得年轻代都是采⽤复制的,⽼年代除了CMS是标记清除(存在内存碎⽚)别的好像都是标记整理整理吧。
2019-07-13 21:32
作者回复
是标记整理算法。
2019-07-14 16:23