极客时间已完结课程限时免费阅读

23 | 如何优化垃圾回收机制?

23 | 如何优化垃圾回收机制?-极客时间

23 | 如何优化垃圾回收机制?

讲述:李良

时长13:21大小12.22M

你好,我是刘超。
我们知道,在 Java 开发中,开发人员是无需过度关注对象的回收与释放的,JVM 的垃圾回收机制可以减轻不少工作量。但完全交由 JVM 回收对象,也会增加回收性能的不确定性。在一些特殊的业务场景下,不合适的垃圾回收算法以及策略,都有可能导致系统性能下降。
面对不同的业务场景,垃圾回收的调优策略也不一样。例如,在对内存要求苛刻的情况下,需要提高对象的回收效率;在 CPU 使用率高的情况下,需要降低高并发时垃圾回收的频率。可以说,垃圾回收的调优是一项必备技能。
这讲我们就把这项技能的学习进行拆分,看看回收(后面简称 GC)的算法有哪些,体现 GC 算法好坏的指标有哪些,又如何根据自己的业务场景对 GC 策略进行调优?

垃圾回收机制

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

1. 回收发生在哪里?

JVM 的内存区域中,程序计数器、虚拟机栈和本地方法栈这 3 个区域是线程私有的,随着线程的创建而创建,销毁而销毁;栈中的栈帧随着方法的进入和退出进行入栈和出栈操作,每个栈帧中分配多少内存基本是在类结构确定下来的时候就已知的,因此这三个区域的内存分配和回收都具有确定性。
那么垃圾回收的重点就是关注堆和方法区中的内存了,堆中的回收主要是对象的回收,方法区的回收主要是废弃常量和无用的类的回收。

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

那 JVM 又是怎样判断一个对象是可以被回收的呢?一般一个对象不再被引用,就代表该对象可以被回收。目前有以下两种算法可以判断该对象是否可以被回收。
引用计数算法:这种算法是通过一个对象的引用计数器来判断该对象是否被引用了。每当对象被引用,引用计数器就会加 1;每当引用失效,计数器就会减 1。当对象的引用计数器的值为 0 时,就说明该对象不再被引用,可以被回收了。这里强调一点,虽然引用计数算法的实现简单,判断效率也很高,但它存在着对象之间相互循环引用的问题。
可达性分析算法:GC Roots 是该算法的基础,GC Roots 是所有对象的根对象,在 JVM 加载时,会创建一些普通对象引用正常对象。这些对象作为正常对象的起始点,在垃圾回收时,会从这些 GC Roots 开始向下搜索,当一个对象到 GC Roots 没有任何引用链相连时,就证明此对象是不可用的。目前 HotSpot 虚拟机采用的就是这种算法。
以上两种算法都是通过引用来判断对象是否可以被回收。在 JDK 1.2 之后,Java 对引用的概念进行了扩充,将引用分为了以下四种:

3. 如何回收这些对象?

了解完 Java 程序中对象的回收条件,那么垃圾回收线程又是如何回收这些对象的呢?JVM 垃圾回收遵循以下两个特性。
自动性:Java 提供了一个系统级的线程来跟踪每一块分配出去的内存空间,当 JVM 处于空闲循环时,垃圾收集器线程会自动检查每一块分配出去的内存空间,然后自动回收每一块空闲的内存块。
不可预期性:一旦一个对象没有被引用了,该对象是否立刻被回收呢?答案是不可预期的。我们很难确定一个没有被引用的对象是不是会被立刻回收掉,因为有可能当程序结束后,这个对象仍在内存中。
垃圾回收线程在 JVM 中是自动执行的,Java 程序无法强制执行。我们唯一能做的就是通过调用 System.gc 方法来"建议"执行垃圾收集器,但是否可执行,什么时候执行?仍然不可预期。

GC 算法

JVM 提供了不同的回收算法来实现这一套回收机制,通常垃圾收集器的回收算法可以分为以下几种:
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现,JDK1.7 update14 之后 Hotspot 虚拟机所有的回收器整理如下(以下为服务端垃圾收集器):
其实在 JVM 规范中并没有明确 GC 的运作方式,各个厂商可以采用不同的方式实现垃圾收集器。我们可以通过 JVM 工具查询当前 JVM 使用的垃圾收集器类型,首先通过 ps 命令查询出进程 ID,再通过 jmap -heap ID 查询出 JVM 的配置信息,其中就包括垃圾收集器的设置类型。

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
打印后的日志为:
上图是运行很短时间的 GC 日志,如果是长时间的 GC 日志,我们很难通过文本形式去查看整体的 GC 性能。此时,我们可以通过GCViewer工具打开日志文件,图形化界面查看整体的 GC 性能,如下图所示:
通过工具,我们可以看到吞吐量、停顿时间以及 GC 的频率,从而可以非常直观地了解到 GC 的性能情况。
这里我再推荐一个比较好用的 GC 日志分析工具,GCeasy是一款非常直观的 GC 日志分析工具,我们可以将日志文件压缩之后,上传到 GCeasy 官网即可看到非常清楚的 GC 日志分析结果:

GC 调优策略

找出问题后,就可以进行调优了,下面介绍几种常用的 GC 调优策略。

1. 降低 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 区的大小。

2. 降低 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 性能的吗?
期待在留言区看到你的见解。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起讨论。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 18

提建议

上一篇
22 | 深入JVM即时编译器JIT,优化Java编译
下一篇
24 | 如何优化JVM内存分配?
unpreview
 写留言

精选留言(37)

  • Jxin
    2019-07-13
    1.7及前的都还好说,毕竟大部分开发都拜读过<深入理解jvm>。 回归整体,记得有点模糊了,如果有错误还请老师指正。首先cms在1.9已经被标记为废弃,主要原因在于标记清除下的悬浮内存,导致内存空间碎片化,进而导致fullGC的发生。不过其并行执行垃圾回收的性能还是值得认可的,至少1.9后主推的G1在常规情况下也是不如它的效率好的。接下来,说下G1,拼G1的堆内存结构比较特殊,虽然也有年代划分,但从物理角度上却不一样。G1将整块内存分配成若干个同等大小的reg。新生代(两个sub区加ed区)和老年代各自由不同数量的reg组成。垃圾回收的算法应该算是标记整理。所以其规避了cms内存碎片化的问题,大大降低了fullGC的频率。所以它虽然常态性能略输于cms但却没有cms特殊情况下的极端性能问题,总体更稳定。值得一提的是G1中各代的内存区域里reg间不一定是连续的,所以对于cpu缓存加载机制并不是特别友好,而且大对象占据超过一个reg时还带来内存浪费的问题。所以总的来说1.8可以用G1但得考虑场景,首先这个内存空间要大,保证每个reg尽量大,以减少内存浪费,保守估计8g以上用g1。实际公司很少会去升级jdk版本,大部分都是1.8,好在oracle一些1.9 10 11 12的特性都有以补丁的方式落到1.8。所以1.8还是比较安全实用的,虽然我们公司还是1.7,推不动哈。
    展开

    作者回复: 赞,Region这块 Jxin讲解的通俗易懂。

    共 4 条评论
    51
  • Liam
    2019-07-14
    1 minor gc是否会导致stop the world? 2 major gc什么时候会发生,它和full gc的区别是什么?

    作者回复: Liam提出的这两个问题非常好。 1、不管什么GC,都会发送stop the world,区别是发生的时间长短。而这个时间跟垃圾收集器又有关系,Serial、PartNew、Parallel 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;

    共 2 条评论
    46
  • 我又不乱来
    2019-07-13
    超哥,我建议可以分享一下那些对象可以作为gc root的对象,为什么这些对象可以做为gc root对象?

    作者回复: 在Java语言里,可作为GC Root对象的包括如下几种: 1. Java虚拟机栈中的引用的对象 ; 2. 方法区中的类静态属性引用的对象 ; 3. 方法区中的常量引用的对象 ; 4. 本地方法栈中JNI的引用的对象。 我们知道,垃圾回收一般是回收堆和方法区的对象,而堆中的对象在正常情况下,一般是通过常量、全局变量、静态变量等间接引用堆中的对象,所以这些可以作为GC Root。 在任何上述的GCRoot中,有引用可以指向时,我们称之为对象可达。

    共 2 条评论
    20
  • QQ怪
    2019-07-13
    G1与CMS的优势在于以下几点: 1、并行与并发:G1能够更充分利用多CPU、多核环境运行 2、分代收集:G1虽然也用了分代概念,但相比其他收集器需要配合不同收集协同工作,但G1收集器能够独立管理整个堆 3、空间管理:与CMS的标记一清理算法不同,G1从整体上基于标记一整理算法,将整个Java堆划分为多个大小相等的独立区域(Region),这种算法能够在运行过程中不产生内存碎片 4、可预测的停顿:降低停顿时间是G1和CMS共同目标,但是G1追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定一个长度为M毫秒的时间片段内,消耗在垃圾收集器上的时间不得超过N毫秒。
    展开

    作者回复: 赞。 理解G1中的几个重要概念:Region、SATB、RSet以及Pause Prediction Model,能更好的理解G1相对CMS的一些具体优势在哪里了。

    共 2 条评论
    18
  • FelixFly
    2019-11-19
    在 JDK1.8 环境下,默认使用的是 Parallel Scavenge(年轻代)+Serial Old(老年代)垃圾收集器。老师,这个地方你写错了吧,用jinfo -flags 进程ID打印出-XX:+UseParallelGC是使用的这个,这个在官方文档说的是-XX:+UseParallelGC启用,-XX:+UseParallelOldGC这个会自动启用,应该为Parallel Scavenge(年轻代)+Parallel Old(老年代)垃圾收集器 官网参数说明(查看的是linux下的) -XX:+UseParallelGC Enables the use of the parallel scavenge garbage collector (also known as the throughput collector) to improve the performance of your application by leveraging multiple processors. By default, this option is disabled and the collector is chosen automatically based on the configuration of the machine and type of the JVM. If it is enabled, then the -XX:+UseParallelOldGC option is automatically enabled, unless you explicitly disable it.
    展开
    共 4 条评论
    14
  • 别忘微笑
    2019-07-15
    超哥,一个web应用,多久一次Full GC才算正常呢

    作者回复: 需要根据具体的业务来分析,正常小对象且请求平缓的应用服务中,几天一次较为正常。如果有大量大对象创建或者承受高并发场景的服务,Full GC可能会更频繁。

    共 2 条评论
    9
  • K
    2019-08-04
    老师好,我有两个问题想问一下。1.比如说看到jvm的参数,-XX:+UseConcMarkSweepGC,这个参数是单独指定了老年代的收集器呢,还是年轻代、老年代都指定了?

    作者回复: 运行XX:+UseConcMarkSweepGC命令,默认会指定年轻代和老年代的垃圾收集器,分别为ParNewGC和ConcMarkSweepGC两种收集器。

    3
  • 2019-09-11
    很棒,很清晰的讲明白了什么是垃圾?有几种收集垃圾的方式?有几种具体的垃圾收集器? 请问老师收集垃圾具体是一个什么原理?标记对应的内存可用了,还是将对应的数据都一个个清空了?

    作者回复: 标记可以回收的对象,然后在垃圾回收时将对象回收

    共 2 条评论
    2
  • 发条橙子 。
    2019-07-24
    超哥 我想问下,相同的方法多次执行,再没有JIT编译的前提下,每一次执行都会进行一次解释执行莫?

    作者回复: 是的

    2
  • N
    2019-07-20
    老师您好,公司ES服务器设置最大最小堆内存26个G,G1GC, XX:MaxGCPauseMillis =500,一段时间内old gc 都会稳定在500ms以内,但每天总会有1-2次old gc 时间很长,大概3000ms.请问该如何优化呢?

    作者回复: 设置的并发标记线程数量是多少呢?可以通过-XX:ConcGCThreads尝试适当调整这个数量,为服务器CPU核数的1/4,可以提高并发标记的效率。 由于JVM 垃圾回收和内存分配这块的调优错综复杂,需要我们再结合服务器上跑的相关的业务以及GC日志逐步调优。

    2
  • nightmare
    2019-07-13
    老师看完有两个疑问,第一这么查看minor gc回收之后 eden区存活对象的多少,第二 jmap -heap pid在图中只能看年轻代parallel gc看不到老年代的是什么垃圾回收器 对于提问 cms垃圾回收器还是分老年代和年轻代回收分多个阶段有和程序并行的阶段也有stop the world阶段 回收一整块老年代时间比较久,而 gc把年轻代和老年代也有划分,不过拆成一个region了,对region的回收成本低,而且会判断那些region回收的对象更多,而且cms要经过多次full gc才可能把不用的内存归还给操作系统 而g1只需要一次full gc就可以
    展开

    作者回复: 我们可以通过jstat -gc pid interval查看每次GC之后,具体的每一个分区的内存使用率变化情况。我们可以通过查看JVM设置参数来查看具体的垃圾收集器的设置参数,使用的方式有很多,例如jcmd pid VM.flags可以查看到相关的设置参数。

    2
  • Evan
    2020-03-21
    CMS 收集器 有一个浮动垃圾概念, 可能出现“Concurrent Mode Failure” 导致Full GC, 能否解决一下本质原因?

    作者回复: 出现“Concurrent Mode Failure”,我们可以使用多种方式来解决,例如将CMS触发的阈值调低,提前触发CMS GC,释放老年代内存空间,满足新生代对象提升到老年代,或应用服务直接分配大对象到老年代。我们也可以尝试调大年轻代和老年代的大小,满足对应对象的分配空间。

    1
  • 阿卧
    2020-02-17
    cms回收器只能在老年代使用吗。

    作者回复: CMS只会回收老年代和永久代(元数据区)的对象。

    1
  • K
    2019-08-04
    第二个问题就是,比如说我指定了:-XX:+UseConcMarkSweepGC,也指定了:-XX:+UseParallelOldGC,那么年轻代、老年代分别是用了什么垃圾回收器呢?麻烦老师解答一下,谢谢!

    作者回复: 我觉得前者会被后者覆盖,可以自己试试,然后通过指令查询相关的生效参数。

    1
  • nightmare
    2019-07-15
    老师,查看minor gc存活对象的命令是什么呢

    作者回复: 具体存活的对象是在随时变化的,很难追踪,目前只能通过各个区域的大小来分析GC效率。

    共 2 条评论
    1
  • -W.LI-
    2019-07-13
    老师好!Serial Old不是标记整理算法么?Serial new是复制吧。我记得年轻代都是采用复制的,老年代除了CMS是标记清除(存在内存碎片)别的好像都是标记整理整理吧。

    作者回复: 是标记整理算法。

    1
  • Policy
    2022-06-26
    老师,这个没懂:所以再发生 Minor GC 的时间为:两次扫描新生代,即 2T1。是因为时间间隔长了一倍,所以扫描对象的数量多了一倍?
  • 🦄
    2022-06-09
    我们用的是parallel old垃圾收集器,这个Full GC的触发条件可以配置么? 老年代内存不停的增加,有没有可能年轻带最后一次回收导致JVM做Fullgc,整个内存不够导致应用直接挂掉呀,我发现了这种现象,就是一直不做fullgc,然后最后做的那一次应用挂了,这种怎么排查问题呀
  • 杨雪宁
    2022-03-29
    老师,一直有个疑问,有说CMS适用于10G以内的内存,这个说法对吗?
  • 远方
    2021-08-25
    堆外内存只有在full gc的时候才会回收忙吗? ygc的时候会回收堆外内存么? 超哥