在 浅谈 JAVA 中的垃圾回收机制 这篇文章中,笔者从比较理论性的角度阐述了 JAVA 的垃圾回收机制。但在进行实践过程中,面对不同的业务场景,还是需要进行大量调整,以适应现实世界的场景和需求。本篇将进一步从垃圾回收的分区及算法角度,通过一个简单的例子,去剖析 JVM 需要做哪些类型的区域调整,才能安全地继续分配对象。
无论何时进行清理,JVM 都必须确保填充了不可访问对象的区域可以被重用。这可能(并最终将)导致内存碎片,类似于磁盘碎片,这里有两个问题:
为了避免此类问题,JVM 要确保碎片问题不会失控。因此,在垃圾回收期间,“内存碎片整理”过程也会发生,而不仅仅是标记和清除。这需要进程重新定位所有可到达的对象,消除(或减少)碎片。下面是一个例子:
首先,执行垃圾收集需要完全停止应用程序(STW)。因此对象越多,收集所有垃圾所花费的时间就越长。但如果我们有可能使用更小的内存区域呢?研究发现,应用程序中的大多数配置分为两类:
这些观察结果汇集在“弱分代假说”(Weak Generational Hypothesis)中。基于这一假设,虚拟机内部的内存被分为所谓的年轻代和年老代;后者有时也被称为 Tenured
。
有了这样的独立且可单独清理的区域,就可以使用多种不同的算法,这些算法在提高 GC 性能方面已经在实践中得到了充分的验证。这并不是说这种方法没有问题;首先,来自不同代的对象实际上可以相互引用,在收集代时,这些引用也可以算作“事实上的”GC 根。重要的是,分代假说在某些情况下可能并不成立。由于 GC 算法是针对“夭折”或“可能永远存活”的对象进行优化的,JVM 对于“中等”寿命的对象表现得相当糟糕。
堆中内存池的以下划分想必大家都很熟悉。可能不太容易理解的是,垃圾收集是如何在不同的内存池中执行其职责的呢?在不同的 GC 算法中,一些实现细节可能会有所不同,但是,这里所提到的概念实际上是相同的。
一般情况下,新对象的创建分配都是在 Eden 区完成;由于对象创建往往是并行发生的,由不同的线程完成,因此 Eden 区被进一步划分为一个或多个驻留在 Eden 空间中的线程本地分配缓冲区(简称 TLAB)。这些缓冲区允许 JVM 直接在相应的 TLAB 中的一个线程中分配对象,从而避免与其他线程进行复杂的竞争和同步。
当在 TLAB 中无法分配时(通常是因为没有足够的空间),分配转移到共享的 Eden 空间。如果也没有足够的空间,就会触发年轻代的垃圾收集过程,释放更多的空间。如果垃圾回收也没有在 Eden 中产生足够的空闲内存,则在年老代中分配对象。
当收集 Eden 时,GC 从根遍历所有可到达的对象,并将它们标记为活动的。
我们之前已经提到对象可以有跨代引用,所以在 gc 过程中,有必要检查其他代到 Eden 的所有引用。但是这种方式从某种程度上讲失去了分代的意义。JVM 提供了一种机制叫:卡片标记(*card-marking*)
。本质上,JVM 只是标记 Eden 中“脏”对象的粗略位置,这些对象可能有来自年老代的引用。
标记阶段完成后,Eden 中的所有活动对象都被复制到一个幸存者(Survivor)空间。整个 Eden 现在被认为是空的,可以重新分配更多的对象。这种方法称为“标记和复制”
:标记活动对象,然后复制(不是移动)到幸存者(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 空间的大小不足以容纳年轻代中的所有活动对象,也可能过早地进行升级。
年老代内存空间的实现要复杂得多。年老代通常很大,并且被不太可能成为垃圾的对象所占用。
年老代的 GC 发生频率也要低于年轻代。另外,由于大多数对象在年老代中都是活的,因此不会发生标记和复制。相反地,对象被四处移动以最小化碎片。清理年老代空间的算法通常建立在不同的基础上。原则上,所采取的步骤如下:
上面所描述的,年老代的 GC 必须处理显式压缩,以避免过多的碎片。
在 Java 8 之前,存在一个叫做“永久代”的特殊空间。这就是类等元数据的位置。此外,一些额外的东西,如字符串,也会被保存在 PermGen 中。这实际上给 Java 开发人员带来了很多黑盒空间,因为很难预测所有这些需要多少空间。如果没有比较准确的预测,那么在 runtime 期间就可能会出现 java.lang.OutOfMemoryError: Permgen space 。解决这个问题的方法通常是增加 PermGen 大小,如将允许的最大 PermGen 大小设置为 256 MB:
java -XX:MaxPermSize=256m glmapper
Java 8 中删除了永久代,支持了元数据区。那从这时开始,大多数杂项内容都被移到了常规 Java 堆中。但是类定义是被放到 Metaspace 中,Metaspace 也位于本机内存中,它不会干扰常规堆对象。默认情况下,Metaspace 的大小仅受 Java 进程可用的本机内存量的限制。需要注意,拥有这样看似无限的空间并不是没有成本的,如果让 Metaspace 不受控制地增长,反而会引入大量的交换或本地分配失败,也有可能因为 Metaspace 过大导致 OOM 出现。所以在实际的项目中,一般还是需要控制 Metaspace 的大小
java -XX:MaxMetaspaceSize=256m com.glmapper.Application
清理堆内存中不同部分的垃圾收集事件,通常称为 minor、major 和 full GC 事件
在年轻代发生的 GC 称为 Minor GC,这个定义是清晰且比较好理解的。但在处理 Minor GC 事件时,有一些点需要注意:
基于前面对于 Minor GC 定义理解,关于 Major GC 和 Full 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.734048.034048.00.034048.0272640.0194699.71756416.0181419.918304.017865.12688.02497.630.27500.0000.275
6.734048.034048.034048.00.0272640.0247555.41756416.0263447.918816.018123.32688.02523.140.35900.0000.359
7.734048.034048.00.034048.0272640.0257729.31756416.0345109.819072.018396.62688.02550.350.45100.0000.451
8.734048.034048.034048.034048.0272640.0272640.01756416.0444982.519456.018681.32816.02575.870.55000.0000.550
9.734048.034048.034046.70.0272640.016777.01756416.0587906.320096.019235.12944.02631.880.72000.0000.720
10.734048.034048.00.034046.2272640.080171.61756416.0664913.420352.019495.92944.02657.490.81000.0000.810
11.734048.034048.034048.00.0272640.0129480.81756416.0745100.220608.019704.52944.02678.4100.89600.0000.896
12.734048.034048.00.034046.6272640.0164070.71756416.0822073.720992.019937.13072.02702.8110.97800.0000.978
13.734048.034048.034048.00.0272640.0211949.91756416.0897364.421248.020179.63072.02728.1121.08710.0041.091
14.734048.034048.00.034047.1272640.0245801.51756416.0597362.621504.020390.63072.02750.3131.18320.0501.233
15.734048.034048.00.034048.0272640.021474.11756416.0757347.022012.020792.03200.02791.0151.33620.0501.386
16.734048.034048.034047.00.0272640.048378.01756416.0838594.422268.021003.53200.02813.2161.43320.050 1.484
这段代码是从 JVM 启动后的前 17 秒提取的。根据这些信息,我们可以得出结论:在 12 次 Minor GC 运行之后,执行了两次 Full GC 运行,总共运行了 50ms。
下面再看看从同一个 JVM 启动中收集的垃圾收集日志的输出, 使用 -XX:+PrintGCDetails 参数;
java -XX:+PrintGCDetails -XX:+UseConcMarkSweepGC yourProject
3.157: [GC (AllocationFailure)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 (AllocationFailure)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 (AllocationFailure)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 (AllocationFailure)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 (AllocationFailure)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 InitialMark)[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 (AllocationFailure)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 FinalRemark)[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,这个过程由不同的阶段组成:
因此,我们从实际的垃圾收集日志中看到的是,实际上只执行了一个 Major GC 清理年老代,而不是两个 Full GC 收集操作。
如果你是在排查应用中的服务延迟问题,那么 jstat 观察 gc 日志信息有可能会抓取到你想要关注的事件。通过对 GC 事件的分析,可以清楚的了解到两个影响当时所有活动线程延迟的停止事件,(比如 case 中 gc 耗时了 50 ms)。但是一般在调优的过程中,通过 jstat 其实很难抓到关键信息,更多时候是需要基于比较完整的 gc 日志,然后结合监控信息来统筹分析,才能更有效的 catch 到真正的原因。