Java 中的垃圾回收

前言

说来惭愧,作为一名前 Android 开发者,对于 Java 的理解理应十分透彻才对。可不瞒你说,就在写这篇文章之前,你要是问我关于 Java 中垃圾回收(GC)的问题,我准一脸懵逼,哈哈(我居然还有脸笑)~

所以呢,在后继学习 iOS 开发的过程中,我特别注重原理的掌握。当然,如果你问我 Objective-C 中的内存管理机制,我肯定是有所了解的,不信你看:《Objective-C 高级编程》读书笔记之 ARC

出来混,迟早是要还的。

我想,现在也是时候把在 Java 那欠下的债清一清了,今天就来先来补一补——Java 中的垃圾回收机制。


什么是垃圾回收

乍一看,垃圾回收所做的事情应当恰如其名——查找并清除垃圾。事实上却恰恰相反。垃圾回收会跟踪所有仍在使用的对象,然后将剩余的对象标记为垃圾。牢记了这点之后,我们再来深入地了解下这个被称为「垃圾回收」的自动化内存回收在 JVM 中到底是如何实现的。

手动内存管理

在介绍现代版的垃圾回收之前,我们先来简单地回顾下需要手动地显式分配及释放内存的那些日子。如果你忘了去释放内存,那么这块内存就无法重用了。这块内存被占有了却没被使用。这种场景被称之为内存泄露

内存泄露曾经是个非常普遍的问题。你只能通过不断地修复自己的代码来与它们进行抗争。因此,需要有一种更优雅的方式来自动释放无用内存,以便减少人为错误的可能性。这种自动化过程又被称为垃圾回收(简称 GC)。

自动内存管理

第一代的垃圾回收器是 1959 年 Lisp 引入的,这项技术迄今为止一直在不断演进。

引用计数

咦,这不是 Objective-C 中的概念吗?其实许多语言比如说 Perl,Python 以及 PHP,采用的都是这种方式。这个通过一张图可以很容易说明:

绿色的云代表的是程序中仍在使用的对象。从技术层面上来说,这有点像是正在执行的某个方法里面的局部变量,亦或是静态变量之类的。不同编程语言的情况可能会不一样,因此这并不是我们关注的重点。

蓝色的圆圈代表的是内存中的对象,可以看到有多少对象引用了它们。灰色圆圈的对象是已经没有任何人引用的了。因此,它们属于垃圾对象,可以被垃圾回收器清理掉。

看起来还不错对吧?没错,不过这里存在着一个重大的缺陷。很容易会出现一些孤立的环(detached cycle),它们中的对象都不在任何域内,但彼此却互相引用导致引用数不为 0。下面便是一个例子:

看到了吧,红色部分其实就是应用程序不再使用的垃圾对象。由于引用计数的缺陷,因此会存在内存泄露。

有几种方法可以解决这一问题,比如 OC 中便可以使用 weak 引用来解决,接下来让我们来看下 JVM 所采用的方法。

标记删除

首先,JVM 对于对象可达性的定义要明确一些。它可不像前面那样用绿色的云便含糊了事的,而是有着非常明确及具体的垃圾回收根对象(Garbage Collection Roots)的定义:

  • 局部变量
  • 活动线程
  • 静态字段
  • JNI 引用

JVM 通过标记删除的算法来记录所有可达(存活)对象,同时确保不可达对象的那些内存能够被重用。这包含两个步骤:

  • 标记:是指遍历所有可达对象,然后在本地内存中记录这些对象的信息
  • 删除:会确保不可达对象的内存地址可以在下一次内存分配中使用

JVM 中的不同 GC 算法,比如说 Parallel Scavenge,Parallel Mark+Copy,CMS 都是这一算法的不同实现,只是各阶段略有不同而已,从概念上来讲仍然是对应着上面所说的那两个步骤。

这种实现最重要的就是不会再出现泄露的对象环了:

缺点就是应用程序的线程需要被暂停才能完成回收,如果引用一直在变的话你是无法进行计数的。这个应用程序被暂停以便 JVM 可以收拾家务的情况又被称为 Stop The World pause(STW)。这种暂停被触发的可能性有很多,不过垃圾回收应该是最常见的一种。

因为垃圾回收的时候,需要整个的引用状态保持不变,否则判定是判定垃圾,等我稍后回收的时候它又被引用了,这就全乱套了。所以,GC 的时候,其他所有的程序执行处于暂停状态,卡住了。
幸运的是,这个卡顿是非常短(尤其是新生代),对程序的影响微乎其微 (关于其他 GC 比如并发 GC 之类的,在此不讨论)。
所以 GC 的卡顿问题由此而来,也是情有可原,暂时无可避免。


碎片及整理(Fragmenting and Compacting)

JVM 在清除不可达对象之后,还得确保它们所在的空间是可以进行复用的。对象删除会导致碎片的出现,这有点类似于磁盘碎片,这会带来两个问题:

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

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


分代假设

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

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

这些结论最终构成了弱分代假设(Weak Generational Hypothesis)。基于这一假设,虚拟机内的内存被分为两类,新生代(Young Generation)及老生代(Old Generation)。后者又被称为年老代(Tenured Generation)。

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

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

而更为重要的是,分代假设对于某些应用来说并不成立。由于 GC 算法主要是为那些「快速消失」或者「永久存活」的对象而进行的优化,因此对于那些生命周期「适中」的对象,JVM 就显得无能为力了。


内存池

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

Eden 区

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

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

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

前面我们已经提到,对象间可能会存在跨代引用,因此最直观的做法便是扫描其它分区到 Eden 区的所有引用。但不幸的是这么做会做成分代的做法变得毫无意义。JVM 对此有它自己的妙招:卡片式标记(card-marking)。基本的做法是,JVM 将 Eden 区中可能存在老生代引用的对象标记为「脏」对象。关于这点Nitsan 的博客这里有更进一步的介绍。

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

Survivor 区

紧挨着 Eden 的是两个 Survivor 区,分别是 ‘from’ 区和 ‘to’ 区。值得一提的是其中的一个 Survivor 区始终都是空的。

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

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

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

垃圾回收器会跟踪每个对象历经的回收次数,来判断它们是否已经「足够年老」,可以传播至老年代中。在一轮 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:

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

元空间

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

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

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

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

新生代 GC(Minor GC)vs. 老生代 GC(Major GC)vs. Full GC

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

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

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

新生代 GC

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

  1. 只要 JVM 无法为新创建的对象分配空间,就肯定会触发新生代 GC,比方说 Eden 区满了。因此对象创建得越频繁,新生代 GC 肯定也更频繁
  2. 一旦内存池满了,它的所有内容就会被拷贝走,指针又将重新归零。因此和经典的标记(Mark),清除(Sweep),整理(Compact)的过程不同的是,Eden 区和 Survivor 区的清理只涉及到标记和拷贝。在它们中是不会出现碎片的。写指针始终在当前使用区的顶部
  3. 在一次新生代 GC 事件中,通常不涉及到年老代。年老代到年轻代的引用被认为是 GC 的根对象。而在标记阶段中,从年轻代到年老代的引用则会被忽略掉
  4. 和通常所理解的不一样的是,所有的新生代 GC 都会触发 stop-the-world 暂停,这会中断应用程序的线程。对绝大多数应用而言,暂停的时间是可以忽略不计的

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

老年代 GC 与 Full GC

你会发现关于这两种 GC 其实并没有明确的定义。JVM 规范或者垃圾回收相关的论文中都没有提及。不过从直觉来说,根据新生代 GC(Minor GC)清理的是新生代空间的认识来看,不难得出以下推论(这里应当从英文出发来理解,Minor, Major 与 Full GC,翻译过来的名称已经带有明显的释义了):

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

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


垃圾回收算法

标记清除算法(Mark-Sweep)

通过名字就应该猜到了,就是标记哪些是可回收的,然后进行清除回收处理。

标记-清除算法分为两个阶段:标记阶段和清除阶段。标记阶段的任务是标记出所有需要被回收的对象,清除阶段就是回收被标记的对象所占用的空间。

优点:简单,易实现
缺点:容易产生内存碎片,对于后面分配大空间时,找不到足够的空间,而主动会触发一次内存回收,增加内存回收的次数。

复制算法

此方法将内存按容量分为两块,例如 A、B 两块,每次只使用其中的一块,当要进行回收操作时,将 A 中还存活的对象复制到 B 块中(假设上次使用 A),然后对 A 中所有对象清空就又构成一个完整的内存块。这种方法就避免了标记清除的内存碎片问题。

优点:快速高效,不会产生内存碎片。
缺点:可用内存会减少一半,因为是按照均分的。

注意: 效率也与存活对象的多少有关,如果存活对象多,复制就多,效率就低了。

标记整理算法(Mark-Compact)

标记整理算法就是在标记清除方法的基础上进行了优化,主要是在标记完成后将这些存活的对象向一端移动,然后将末尾边界后的所有内存空间清除。

优点: 适合存活对象多的,不产生内存碎片

分代回收算法

分代回收算法其实不算一种新的算法,而是根据复制算法和标记整理算法的的特点综合而成。这种综合是考虑到 Java 的语言特性的。

下面来回顾一下两种老算法的适用场景:

复制算法:适用于存活对象很少,回收对象多
标记整理算法: 适用用于存活对象多,回收对象少

于是,我们根据对象存活的生命周期将内存划分为若干个不同的区域。一般情况下将堆区划分为老年代(Old Generation)和新生代(Young Generation),老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法:

新生代:由于新生代产生很多临时对象,大量对象需要进行回收,所以采用复制算法是最高效的。
老年代:回收的对象很少,都是经过几次标记后都不是可回收的状态转移到老年代的,所以仅有少量对象需要回收,故采用标记清除或者标记整理算法。

所以以上整个过程就达到了最高效的回收办法——分代回收算法

深入理解分代回收算法

为什么不是一块 Survivor 区而是两块?

这里涉及到一个新生代和老年代的存活周期的问题,比如一个对象在新生代经历 15 次(仅供参考)GC,就可以移到老年代了。问题来了,当我们第一次 GC 的时候,我们可以把 Eden 区的存活对象放到 Survivor A区,但是第二次 GC 的时候,Survivor A 区的存活对象也需要再次用 Copying 算法,放到 Survivor B 区上,而把刚刚的 Survivor A 区和 Eden 区清除。第三次 GC 时,又把 Survivor B 区的存活对象复制到 Survivor A 区,如此反复。

所以,这里就需要两块 Survivor 区来回倒腾。

为什么 Eden 区这么大而 Survivor 区要分的少一点?

新创建的对象都是放在 Eden 区,这是很频繁的,尤其是大量的局部变量产生的临时对象,这些对象绝大部分都应该马上被回收,能存活下来被转移到 Survivor 区的往往不多。所以,设置较大的 Eden 区和较小的 Survivor 区是合理的,大大提高了内存的使用率,缓解了 Copying 算法的缺点。

新的问题又来了,从 Eden 区往 Survivor 区转移的时候 Survivor 区不够了怎么办?——直接放到老年代去。

Eden 区和两块 Survivor 区的工作流程?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 分配了一个又一个对象
放到 Eden 区
// 不好,Eden 区满了,只能 GC(新生代 GC:Minor GC)了
把 Eden 区的存活对象 copy 到 Survivor A 区,然后清空 Eden 区(本来 Survivor B 区也需要清空的,不过本来就是空的)
// 又分配了一个又一个对象
放到 Eden 区
// 不好,Eden 区又满了,只能 GC(新生代 GC:Minor GC)了
把 Eden 区和 Survivor A 区的存活对象 copy 到 Survivor B 区,然后清空 Eden 区和 Survivor A 区
// 又分配了一个又一个对象
放到 Eden 区
// 不好,Eden 区又满了,只能 GC(新生代GC:Minor GC)了
把 Eden 区和 Survivor B 区的存活对象 copy 到 Survivor A 区,然后清空 Eden 区和 Survivor B 区
// ...
// 有的对象来回在 Survivor A 区或者 B 区呆了比如 15 次,就被分配到老年代 Old 区
// 有的对象太大,超过了 Eden 区,直接被分配在 Old 区
// 有的存活对象,放不下 Survivor 区,也被分配到 Old 区
// ...
// 在某次 Minor GC 的过程中突然发现:
// 不好,老年代 Old 区也满了,这是一次大 GC(老年代 GC:Major GC)
Old 区慢慢的整理一番,空间又够了
// 继续 Minor GC
// ...
// ...

以上为了说明原理,只是最简化版本。


小结

至此,关于 Java 虚拟机垃圾回收(GC)的讲解就告一段落了,后面要补的知识还很多,路还很长,一步一脚印咯:D


参考

我知道是不会有人点的,但万一有人想不开呢?