Java垃圾回收手册(二):Java中的垃圾回收

Java中的垃圾回收

翻译原文 => plumbr Java GC handbook

前文参见:

Java垃圾回收手册(一):初识垃圾回收

对标记删除垃圾回收算法的介绍更多还是偏理论性质的。实践中,为了更好地满足现实的场景及需求,还需要对算法进行大量的调整。举个简单的例子,我们来看下JVM需要记录哪些信息才能让我们得以安全地继续分配对象空间。

碎片和整理

无论何时进行垃圾清除,JVM在清除不可达对象之后,还得确保它们所在的空间是可以被重新分配出去的。对象删除最终会导致碎片的出现,这有点类似于磁盘碎片,从而会带来两个问题:

  • 写操作会变得更加费时,因为查找下一个可用空闲块已不再是一个简单操作。
  • JVM在创建新对象的,会在连续的区块中分配内存。因此如果碎片已经严重到没有一个单独的连续空闲块能够容纳新创建的对象时,内存分配便会报错。

为了避免此类情形,JVM需要确保碎片化在可控范围内。因此,在垃圾回收的过程中,除了进行标记和删除外,还有一个“内存去碎片化”的过程。在这个过程当中,会给可达对象重新分配空间,让它们互相紧挨着对方,这样便可以去除碎片。下图展示的便是这一过程:

Java垃圾回收手册(二):Java中的垃圾回收_第1张图片
fragmented-vs-compacted-heap.png

分代假设

如前所述,垃圾回收过程中需要完全中止应用运行。显然,对象越多,回收的时间也越长。那么我们能不能在更小的内存区域上进行回收呢?通过可行性调查,一组研究人员发现应用中绝大多数的内存分配会分为两大类:

  • 绝大部分的对象很快会变为不可用状态。
  • 还有一些,它们的存活时间通常也不会很长。

这些观察结论来自于弱分代假设(Weak Generational Hypothesis)。基于这一假设,虚拟机内的内存被分为两类,新生代(Young Generation)及老生代(Old Generation)。后者有时也被称为年老代(Tenured Generation)。

Java垃圾回收手册(二):Java中的垃圾回收_第2张图片
object-age-based-on-GC-generation-generational-hypothesis.png

有了各自独立的可清除区域后,这才有了针对各自不同区域的使用不同回收算法的可能性,正是它们一直以来在持续提升着GC的性能。

这并不说这样的方式是没有问题的。比如说,不同分代中的对象可能彼此间有引用,在进行分代回收时,它们便为视为是“事实上”的GC根对象(GC roots)。

而更为重要的是,分代假设对于某些应用来说并不成立。由于GC算法主要是为那些要么“快速消失”要么“永久存活”的对象而进行的优化,而对于那些生命周期不长不短的对象来说,JVM的表现就非常差

内存池

大家对下图中关于堆的内存池划分应该是非常熟悉的了。不过大家可能不太清楚的是在不同的内存池中,垃圾回收是如何履行它的职责的。值得注意的是,虽然不同的GC算法细节实现上有所不同,但是本章中所提到的概念却是大同小异的。

Java垃圾回收手册(二):Java中的垃圾回收_第3张图片
java-heap-eden-survivor-old.png

伊甸区

新对象被创建时,通常便会被分配到伊甸区。由于通常都会有多个线程在同时分配大量的对象,因为伊甸区又被进一步划分成一个或多个线程本地分配缓冲(Thread Local Allocation Buffer,简称TLAB)。有了这些缓冲区使得JVM中大多数对象的分配都可以在各个线程自己对应的TLAB中完成,从而避免了线程间昂贵的同步开销。

如果在TLAB中无法完成分配(通常是由于没有足够的空间),便会到伊甸区的共享空间中进行分配。如果这里还是没有足够的空间,则会触发一次新生代垃圾回收的过程来释放空间。如果垃圾回收后伊甸区还是没有足够的空间,那么这个对象便会到老生代中去分配。

当进行伊甸区的回收时,垃圾回收器会从根对象开始遍历所有的可达对象,并将它们标记为存活状态。

前面我们已经提到,对象间可能会存在跨代引用,因此最直观的做法便是扫描其它分区到伊甸区的所有引用。是这么做就完全违背了分代的初衷。JVM对此有它自己的妙招:卡片式标记(card-marking)。基本的做法是,JVM将伊甸区中可能存在老生代引用的对象标记为"脏”对象。关于这点Nitsan的博客这里有更进一步的介绍。

Java垃圾回收手册(二):Java中的垃圾回收_第4张图片
TLAB-in-Eden-memory.png

标记完成后,所有存活对象会被复制到其中的一个存活区。于是整个伊甸区便可认为是清空了,又可以重新用来分配对象了。这一过程便被称为“标记复制”:存活对象先被标记,随后被复制到存活区中。

存活区

紧挨着伊甸区的是两个存活区,分别是from区和to区。值得一提的是其中的一个存活区始终都是空的。

空的存活区会在下一次新生代GC的时候迎来它的居民。整个新生代中的所有存活对象(包含伊甸区以及那个非空的名为from的存活区)都会被复制到to区中。一旦完成之后,对象便都跑到to区中而from区则被清空了。这时两者的角色便会发生调转。

Java垃圾回收手册(二):Java中的垃圾回收_第5张图片
how-java-garbage-collection-works.png

存活对象会不断地在两个存活区之间来回地复制,直到其中的一些对象被认为是已经成熟,“足够老”了。请记住这点,基于分代假设,已经存活了一段时间的对象,在相当长的一段时间内仍可能继续存活。

这些“年老”的对象会被提升至老年代空间。出现对象提升的时候,这些对象则不会再被复制到另一个存活区,而是直接复制到老年代中,它们会一直待到不再被引用为止。

垃圾回收器会跟踪每个对象历经的回收次数,来判断它们是否已经“足够年老”,可以传播至老年代中。在一轮GC完成之后,每个分区中存活下来的对象的年龄便会加一。当一个对象的年龄超过了一个特定的年老阈值之后,它便会被提升到老年代中。

JVM会动态地调整实际的年龄阈值,不过通过指定-XX:+MaxTenuringThreshold参数可以给该值设置一个上限。将-XX:+MaxTenuringThreshold设置为0则立即触发对象提升,而不会复制到存活区中。在现代的JVM中,这个值默认会被设置为15个GC周期。在HotSpot虚拟机中这也是该值的上限。

如果存活区的大小不足以存放所有的新生代存活对象,则会出现过早提升。

老生代

老生代的内存空间的实现则更为复杂。老生代的空间通常都会非常大,里面存放的对象都是不太可能需要被回收的。

老生代的GC比新生代的GC发生的频率要少得多。由于老生代中的多数对象都被认为是存活的,所以在GC中不会采用标记-复制算法,而是把这些对象挪到一起以减少碎片。老生代的回收算法通常都是根据不同的理论来构建的。不过大体上都会分成如下几步:

  • 标记可达对象,设置通过GC根对象可达的所有对象的标记位
  • 删除不可达对象
  • 整理老生代空间的对象,将存活对象复制到老生代开始的连续空间内。

从以上描述中可知,为了避免过度碎片化,老生代的GC是明确需要进行整理操作的。

持久代

在Java 8以前还有一个特殊的空间叫做持久代(Permanent Generation)。这是元数据比如类相关数据存放的地方。除此之外,像驻留的字符串(internalized string)也会被存放在持久代中。这的确给Java开发人员带来了不少麻烦事,因为很难评估这究竟会使用到多少空间。评估不到位偏会抛出java.lang.OutOfMemoryError: Permgen space的异常。只要不是真的因为内存泄漏而引起的OutOfMemoryError异常,可以通过增加持久代空间的大小来解决这一问题,比如下例中的把持久代最大空间设置为256MB:

java -XX:MaxPermSize=256m com.mycompany.MyApplication

元空间

由于元数据空间大小的预测是件繁琐且低效的工作,于是Java 8中干脆就去掉了持久代,转而推出了元空间。从此以后,那些个杂七杂八的东西便都存储到正常的Java堆了。

但是,类定义如今则是存储到了元空间里。它存储在本地内存中,不会与堆 内存相混杂。默认情况下,元空间的大小只受限于Java进程的可用本地内存的大小。这大大解放了开发人员,他们不会再因为多增加了一个类而引发java.lang.OutOfMemoryError: Permgen space异常了。值得注意的是,虽然看似元空间大小毫无限制了,但这一些并非是没有代价的——如果任由元空间无节制地增长,你可能会面临的是频繁的内存交换(swapping)或者是本地内存分配失败。

如果你希望避免此类情况,可以像下例中这样限制一下元空间的大小,将它设置成比如256MB:

java -XX:MaxMetaspaceSize=256m com.mycompany.MyApplication

Minor GC vs Major GC vs Full GC

清除堆内存不同区域的垃圾回收事件又被称为Minor GC,Major GC,以及Full GC事件。本章我们将介绍一下不同事件的区别在哪里。不过你会发现其实各自的差别并不是那么重要。

重要的是我们希望知道应用是否到达它的SLA了,而这又只能去监控应用的处理延时或者吞吐量。只有在这个时候讨论GC事件才有意义。这些事件的关键之处在于它们是否停止了应用的运行,以及停了多久

不过由于Minor GC, Major GC,Full GC这几个术语被广泛使用却又没有一个清晰的定义,我们还是先来详细地介绍一下它们的区别再说吧。

Minor GC

新生代垃圾的回收被称作Minor GC。这个定义非常清晰,理解起来也不会有什么歧义。不过当处理新生代GC事件时,还是有一些有意思的东西值得注意的:

  • 只要JVM无法为新创建的对象分配空间,就肯定会触发新生代GC,比方说Eden区满了。因此对象创建得越频繁,新生代GC肯定也更频繁。
  • 在一次新生代GC事件中,通常不涉及到年老代。年老代到年轻代的引用被认为是GC的根对象。而在标记阶段中,从年轻代到年老代的引用则会被忽略掉。
  • 和通常所理解的不一样的是,所有的新生代GC都会触发“stop-the-world”暂停,这会中断应用程序的线程。对绝大多数应用而言,如果Eden区中的大多数对象都是垃圾对象并且永远不会被拷贝到Survivor区/年老代中的话,暂停的时间是可以忽略不计的。如果恰好相反的话,那么绝大多数的新生对象都不应该被回收,新生代GC的暂停时间就会变得相对较长了。

现在来看新生代GC还是很清晰的——每一次新生代GC都会对年轻代进行垃圾清除。

Major GC vs Full GC

你会发现关于这两种GC其实并没有明确的定义。JVM规范或者垃圾回收相关的论文中都没有提及。不过从直觉来说,基于Minor GC是清理新生代空间的认识来看,不难得出以下推论:

  • Major GC是清理老年代的空间。
  • Full GC是清理整个堆——包括新生代与老年代空间

不幸的是这么理解会有一点复杂与困惑。首先许多老年代GC其实是由新生代GC触发的,因此在很多情况下两者无法孤立来看待。另一方面许多现代的垃圾回收器会对老年代进行部分清理,因此,使用“清理”这个术语则显得有点牵强。

那么问题就来了,先别再纠结某次GC到底是Major GC还是Full GC了,你更应该关注的是这次GC是否中断了应用线程还是能够和应用线程并发地执行。

即便是在JVM的官方工具中,也存在着这一困扰。通过一个例子来说明应该更容易理解一些。我们用两款工具来跟踪某个运行着CMS回收器的JVM,来比较下它们的输出有什么不同:

首先通过jstat的输出来查看下GC的信息:

my-precious: me$ jstat -gc -t 4235 1s

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次新生代GC后出现了两次Full GC,共耗时50ms。通过GUI的工具也可以获取到同样的信息,比如说jsonsole或者是jvisualvm。

在接受这一结论前,我们再来看下同样是这次JVM启动后所输出的GC日志。很明显-XX:+PrintGCDetails给我们讲述的是一段截然不同却更为详尽的故事:

java -XX:+PrintGCDetails -XX:+UseConcMarkSweepGC eu.plumbr.demo.GarbageProducer

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次新生代GC后的确出现了一些“不太寻常”的事情。但并不是执行了两次Full GC,这个“不寻常”的事情其实只是在年老代中执行了一次包含了数个阶段的GC而已:

  • 初始标记阶段,从0.0041705 秒或者说4ms的时候开始。这一阶段是一次“stop-the-world”事件,所有的应用线程都被暂停以便进行初始标记。
  • 并发地执行标记和预清理(Preclean)的阶段。这是和应用线程一起并发执行的。
  • 最终标记阶段,从0.0462010秒或者说46毫秒的时候开始。这一阶段也同样是“stop-the-world”的。
  • 并发地进行清除操作。正如名字所说的,这一阶段也无需中断应用线程,可以并发地执行。

因此我们从实际的GC日志中所看到的是这样——其实没有什么两次所谓的Full GC,只有一次清理年老代空间的Major GC而已。

如果你再看下jstat输出的结果,就不难得出结论了。它确切地指出了两次stop-the-world事件,总耗时50ms,这段时间内所有活跃线程都会出现延迟响应。不过如果你想据此来优化吞吐量的话,很可能会徒劳无功——jstat只列出了两次stop-the-world的初始标记及最终标记的部分,而并发执行的那部分工作却被它给隐藏掉了。

你可能感兴趣的:(Java垃圾回收手册(二):Java中的垃圾回收)