二谈 GC:从分区到 GC 事件

2,676 阅读13分钟

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

浅谈 JAVA 中的垃圾回收机制 这篇文章中,笔者从比较理论性的角度阐述了 JAVA 的垃圾回收机制。但在进行实践过程中,面对不同的业务场景,还是需要进行大量调整,以适应现实世界的场景和需求。本篇将进一步从垃圾回收的分区及算法角度,通过一个简单的例子,去剖析 JVM 需要做哪些类型的区域调整,才能安全地继续分配对象。

分段和压缩

无论何时进行清理,JVM 都必须确保填充了不可访问对象的区域可以被重用。这可能(并最终将)导致内存碎片,类似于磁盘碎片,这里有两个问题:

  • 写操作变得更加耗时,因为寻找下一个足够大的空闲块不再是一个简单的操作。
  • 在创建新对象时,JVM 是在连续块中分配内存。因此,如果碎片升级到没有单独的自由碎片足够大来容纳新创建的对象,就会发生分配失败。

为了避免此类问题,JVM 要确保碎片问题不会失控。因此,在垃圾回收期间,“内存碎片整理”过程也会发生,而不仅仅是标记和清除。这需要进程重新定位所有可到达的对象,消除(或减少)碎片。下面是一个例子:

分代假设

  • 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。

  • 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。

  • 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。

首先,执行垃圾收集需要完全停止应用程序(STW)。因此对象越多,收集所有垃圾所花费的时间就越长。但如果我们有可能使用更小的内存区域呢?研究发现,应用程序中的大多数配置分为两类:

  • 大多数对象很快就不再使用了
  • 对象存活时间不会很长

这些观察结果汇集在“弱分代假说”(Weak Generational Hypothesis)中。基于这一假设,虚拟机内部的内存被分为所谓的年轻代和年老代;后者有时也被称为 Tenured

有了这样的独立且可单独清理的区域,就可以使用多种不同的算法,这些算法在提高 GC 性能方面已经在实践中得到了充分的验证。这并不是说这种方法没有问题;首先,来自不同代的对象实际上可以相互引用,在收集代时,这些引用也可以算作“事实上的”GC 根。重要的是,分代假说在某些情况下可能并不成立。由于 GC 算法是针对“夭折”或“可能永远存活”的对象进行优化的,JVM 对于“中等”寿命的对象表现得相当糟糕。

内存池

堆中内存池的以下划分想必大家都很熟悉。可能不太容易理解的是,垃圾收集是如何在不同的内存池中执行其职责的呢?在不同的 GC 算法中,一些实现细节可能会有所不同,但是,这里所提到的概念实际上是相同的。

Eden

一般情况下,新对象的创建分配都是在 Eden 区完成;由于对象创建往往是并行发生的,由不同的线程完成,因此 Eden 区被进一步划分为一个或多个驻留在 Eden 空间中的线程本地分配缓冲区(简称 TLAB)。这些缓冲区允许 JVM 直接在相应的 TLAB 中的一个线程中分配对象,从而避免与其他线程进行复杂的竞争和同步。

当在 TLAB 中无法分配时(通常是因为没有足够的空间),分配转移到共享的 Eden 空间。如果也没有足够的空间,就会触发年轻代的垃圾收集过程,释放更多的空间。如果垃圾回收也没有在 Eden 中产生足够的空闲内存,则在年老代中分配对象。

当收集 Eden 时,GC 从根遍历所有可到达的对象,并将它们标记为活动的。

我们之前已经提到对象可以有跨代引用,所以在 gc 过程中,有必要检查其他代到 Eden 的所有引用。但是这种方式从某种程度上讲失去了分代的意义。JVM 提供了一种机制叫:卡片标记(*card-marking*)。本质上,JVM 只是标记 Eden 中“脏”对象的粗略位置,这些对象可能有来自年老代的引用。

标记阶段完成后,Eden 中的所有活动对象都被复制到一个幸存者(Survivor)空间。整个 Eden 现在被认为是空的,可以重新分配更多的对象。这种方法称为“标记和复制”:标记活动对象,然后复制(不是移动)到幸存者(Survivor)空间。

Survivor

Eden 空间的旁边是两个名为 from 和 to 的 Survivor 空间。两个 Survivor 空间中的一个总是空的(为什么?因为这里使用的算法是 Copy)。

在执行下次年轻代收集时,空的 Survivor 空间就会慢慢被填充对象。整个年轻代(包括 Eden 空间和非空的“from” Survivor 空间)中的所有活对象都被复制到“to”Survivor 空间。在此过程完成后,“to”现在包含对象,而“from”不包含对象,在完成之后,实际上 “from”“to” 角色就已经发生互换了。

在两个存活空间之间复制活动对象的过程会重复几次,直到某些对象被认为已经成熟并且“足够老”。根据分代假设,已经存在一段时间的对象预计将继续使用很长时间。这样的“终身”对象就可以被提升到年老代。当这种情况发生时,对象不会从一个 Survivor 空间移动到另一个 Survivor 空间,而是移动到年老代,直到它们无法可达为止。

为了确定对象是否“足够老”,GC 会跟踪特定对象幸存下来的集合数量;在 GC 完成每一代对象收集之后,那些仍然活着的对象的年龄会增加。当年龄超过一定的使用期阈值时,对象将被提升到年老代。

JVM 可以动态调整实际的使用期阈值,指定 -XX:+MaxTenuringThreshold 可以设置它的上限,设置-XX:+MaxTenuringThreshold=0 会导致立即提升,而不会在 Survivor 空间之间复制。默认情况下,现代 JVM 上的这个阈值设置为 15 个 GC 周期,这也是 HotSpot 中的最大值。

如果 Survivor 空间的大小不足以容纳年轻代中的所有活动对象,也可能过早地进行升级。

Old Generation

年老代内存空间的实现要复杂得多。年老代通常很大,并且被不太可能成为垃圾的对象所占用。

年老代的 GC 发生频率也要低于年轻代。另外,由于大多数对象在年老代中都是活的,因此不会发生标记和复制。相反地,对象被四处移动以最小化碎片。清理年老代空间的算法通常建立在不同的基础上。原则上,所采取的步骤如下:

  • 通过在所有可通过 GC 根访问的对象旁边设置标记位来标记可到达的对象
  • 删除所有不可访问的对象
  • 通过将活动对象连续地复制到年老代的开始部分来压缩年老代空间的内容

上面所描述的,年老代的 GC 必须处理显式压缩,以避免过多的碎片。

PermGen

在 Java 8 之前,存在一个叫做“永久代”的特殊空间。这就是类等元数据的位置。此外,一些额外的东西,如字符串,也会被保存在 PermGen 中。这实际上给 Java 开发人员带来了很多黑盒空间,因为很难预测所有这些需要多少空间。如果没有比较准确的预测,那么在 runtime 期间就可能会出现 java.lang.OutOfMemoryError: Permgen space 。解决这个问题的方法通常是增加 PermGen 大小,如将允许的最大 PermGen 大小设置为 256 MB:

java -XX:MaxPermSize=256m glmapper

Metaspace

Java 8 中删除了永久代,支持了元数据区。那从这时开始,大多数杂项内容都被移到了常规 Java 堆中。但是类定义是被放到 Metaspace 中,Metaspace 也位于本机内存中,它不会干扰常规堆对象。默认情况下,Metaspace 的大小仅受 Java 进程可用的本机内存量的限制。需要注意,拥有这样看似无限的空间并不是没有成本的,如果让 Metaspace 不受控制地增长,反而会引入大量的交换或本地分配失败,也有可能因为 Metaspace 过大导致 OOM 出现。所以在实际的项目中,一般还是需要控制 Metaspace 的大小

java -XX:MaxMetaspaceSize=256m com.glmapper.Application

Minor GC、Major GC 和 Full GC

清理堆内存中不同部分的垃圾收集事件,通常称为 minor、major 和 full GC 事件

Minor GC

在年轻代发生的 GC 称为 Minor GC,这个定义是清晰且比较好理解的。但在处理 Minor GC 事件时,有一些点需要注意:

  • Minor GC 总是在 JVM 无法为新对象分配空间时触发的,例如 Eden 满了。因此,对象的分配率越高,Minor GC 发生的频率就越高。
  • 在 Minor GC 事件中,年老代会被有效地忽略。从年老代到年轻代的引用被认为是 GC 根。
  • Minor GC 会触发 STW ,挂起应用程序线程。对于大多数应用程序,如果 Eden 中的大多数对象可以被认为是垃圾,并且不会复制到幸存者/年老代,那么暂停的时间可以忽略。如果不是,并且大多数新生对象都不符合收集条件,那么 Minor GC 暂停就会开始花费相当多的时间。

Major GC 和 Full GC

基于前面对于 Minor GC 定义理解,关于 Major GC 和 Full GC 也就比较好理解了

  • Major GC:收集老年代的 GC
  • Full GC:收集包括年轻代和老年代的 GC

不过实际情况还是要更复杂,比如 Major GC 可能是由 Minor GC 触发的,所以在很多情况下将两者分开是不可能的。所以我们对于 GC 关注的不应该是到底是 Minor 还是 Major ,而应该关注 GC 是否停止了所有应用程序线程,或者是否能够与应用程序线程并发进行。

下面通过一个 case,并且使用两种不同工具输出进行比较(基于 CMS 收集器)

第一次是通过 jstat 的输出来了解:jstat -gc -t 4235 1s # 4235 进程 ID

Time S0C    S1C    S0U    S1U      EC       EU        OC         OU       MC     MU    CCSC   CCSU   YGC     YGCT    FGC    FGCT     GCT   
 5.7 34048.0 34048.0  0.0   34048.0 272640.0 194699.7 1756416.0   181419.9  18304.0 17865.1 2688.0 2497.6      3    0.275   0      0.000    0.275
 6.7 34048.0 34048.0 34048.0  0.0   272640.0 247555.4 1756416.0   263447.9  18816.0 18123.3 2688.0 2523.1      4    0.359   0      0.000    0.359
 7.7 34048.0 34048.0  0.0   34048.0 272640.0 257729.3 1756416.0   345109.8  19072.0 18396.6 2688.0 2550.3      5    0.451   0      0.000    0.451
 8.7 34048.0 34048.0 34048.0 34048.0 272640.0 272640.0 1756416.0  444982.5  19456.0 18681.3 2816.0 2575.8      7    0.550   0      0.000    0.550
 9.7 34048.0 34048.0 34046.7  0.0   272640.0 16777.0  1756416.0   587906.3  20096.0 19235.1 2944.0 2631.8      8    0.720   0      0.000    0.720
10.7 34048.0 34048.0  0.0   34046.2 272640.0 80171.6  1756416.0   664913.4  20352.0 19495.9 2944.0 2657.4      9    0.810   0      0.000    0.810
11.7 34048.0 34048.0 34048.0  0.0   272640.0 129480.8 1756416.0   745100.2  20608.0 19704.5 2944.0 2678.4     10    0.896   0      0.000    0.896
12.7 34048.0 34048.0  0.0   34046.6 272640.0 164070.7 1756416.0   822073.7  20992.0 19937.1 3072.0 2702.8     11    0.978   0      0.000    0.978
13.7 34048.0 34048.0 34048.0  0.0   272640.0 211949.9 1756416.0   897364.4  21248.0 20179.6 3072.0 2728.1     12    1.087   1      0.004    1.091
14.7 34048.0 34048.0  0.0   34047.1 272640.0 245801.5 1756416.0   597362.6  21504.0 20390.6 3072.0 2750.3     13    1.183   2      0.050    1.233
15.7 34048.0 34048.0  0.0   34048.0 272640.0 21474.1  1756416.0   757347.0  22012.0 20792.0 3200.0 2791.0     15    1.336   2      0.050    1.386
16.7 34048.0 34048.0 34047.0  0.0   272640.0 48378.0  1756416.0   838594.4  22268.0 21003.5 3200.0 2813.2     16    1.433   2      0.050    1.484

这段代码是从 JVM 启动后的前 17 秒提取的。根据这些信息,我们可以得出结论:在 12 次 Minor GC 运行之后,执行了两次 Full GC 运行,总共运行了 50ms。

下面再看看从同一个 JVM 启动中收集的垃圾收集日志的输出, 使用 -XX:+PrintGCDetails 参数;

java -XX:+PrintGCDetails -XX:+UseConcMarkSweepGC yourProject

3.157: [GC (Allocation Failure) 3.157: [ParNew: 272640K->34048K(306688K), 0.0844702 secs] 272640K->69574K(2063104K), 0.0845560 secs] [Times: user=0.23 sys=0.03, real=0.09 secs] 
4.092: [GC (Allocation Failure) 4.092: [ParNew: 306688K->34048K(306688K), 0.1013723 secs] 342214K->136584K(2063104K), 0.1014307 secs] [Times: user=0.25 sys=0.05, real=0.10 secs] 
... cut for brevity ...
11.292: [GC (Allocation Failure) 11.292: [ParNew: 306686K->34048K(306688K), 0.0857219 secs] 971599K->779148K(2063104K), 0.0857875 secs] [Times: user=0.26 sys=0.04, real=0.09 secs] 
12.140: [GC (Allocation Failure) 12.140: [ParNew: 306688K->34046K(306688K), 0.0821774 secs] 1051788K->856120K(2063104K), 0.0822400 secs] [Times: user=0.25 sys=0.03, real=0.08 secs] 
12.989: [GC (Allocation Failure) 12.989: [ParNew: 306686K->34048K(306688K), 0.1086667 secs] 1128760K->931412K(2063104K), 0.1087416 secs] [Times: user=0.24 sys=0.04, real=0.11 secs] 
13.098: [GC (CMS Initial Mark) [1 CMS-initial-mark: 897364K(1756416K)] 936667K(2063104K), 0.0041705 secs] [Times: user=0.02 sys=0.00, real=0.00 secs] 
13.102: [CMS-concurrent-mark-start]
13.341: [CMS-concurrent-mark: 0.238/0.238 secs] [Times: user=0.36 sys=0.01, real=0.24 secs] 
13.341: [CMS-concurrent-preclean-start]
13.350: [CMS-concurrent-preclean: 0.009/0.009 secs] [Times: user=0.03 sys=0.00, real=0.01 secs] 
13.350: [CMS-concurrent-abortable-preclean-start]
13.878: [GC (Allocation Failure) 13.878: [ParNew: 306688K->34047K(306688K), 0.0960456 secs] 1204052K->1010638K(2063104K), 0.0961542 secs] [Times: user=0.29 sys=0.04, real=0.09 secs] 
14.366: [CMS-concurrent-abortable-preclean: 0.917/1.016 secs] [Times: user=2.22 sys=0.07, real=1.01 secs] 
14.366: [GC (CMS Final Remark) [YG occupancy: 182593 K (306688 K)]14.366: [Rescan (parallel) , 0.0291598 secs]14.395: [weak refs processing, 0.0000232 secs]14.395: [class unloading, 0.0117661 secs]14.407: [scrub symbol table, 0.0015323 secs]14.409: [scrub string table, 0.0003221 secs][1 CMS-remark: 976591K(1756416K)] 1159184K(2063104K), 0.0462010 secs] [Times: user=0.14 sys=0.00, real=0.05 secs] 
14.412: [CMS-concurrent-sweep-start]
14.633: [CMS-concurrent-sweep: 0.221/0.221 secs] [Times: user=0.37 sys=0.00, real=0.22 secs] 
14.633: [CMS-concurrent-reset-start]
14.636: [CMS-concurrent-reset: 0.002/0.002 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

根据这些信息,我们可以看到在运行了 12 Minor GC 之后,年老代的收集被拆解了。不同于两次 Full GC 运行,这些被拆解的 action 实际上只是在年老代中运行的一次 GC,这个过程由不同的阶段组成:

  • 初始标记阶段,跨度为 0.0041705秒(约 4ms)。这个阶段会有 STW 事件,停止所有应用程序线程进行初始标记。
  • 标记和预清理阶段,与应用程序线程并发执行。
  • 重新标记阶段,跨度为 0.0462010 秒(大约 46ms)。这一阶段再次 STW 事件。
  • 并发清除阶段,没有停止应用程序线程,执行清理动作。

因此,我们从实际的垃圾收集日志中看到的是,实际上只执行了一个 Major GC 清理年老代,而不是两个 Full GC 收集操作。

总结

如果你是在排查应用中的服务延迟问题,那么 jstat 观察 gc 日志信息有可能会抓取到你想要关注的事件。通过对 GC 事件的分析,可以清楚的了解到两个影响当时所有活动线程延迟的停止事件,(比如 case 中 gc 耗时了 50 ms)。但是一般在调优的过程中,通过 jstat 其实很难抓到关键信息,更多时候是需要基于比较完整的 gc 日志,然后结合监控信息来统筹分析,才能更有效的 catch 到真正的原因。