在 Java 8 中,HotSpot 虚拟机的默认垃圾回收器是 ParallelOld。在 Java 11 中,默认回收器变成了 G1。
注意:从技术上讲,回收器的切换是在 Java 9 中进行的,但 G1 的主要增强是在 Java 10 和 11 中完成的。但实际上,很少有公司使用 Java LTS 以外的版本。
在本文中,我们将讨论垃圾回收理论的一些基础知识,以及这些理论在 HotSpot 中是如何实现的。这也将解释为什么要切换 Java 的默认垃圾回收器,以及 Java 垃圾回收方法在近来发生的一些变化。
垃圾回收是系统的一种“清理”活动,独立于应用程序的主处理线程,试图找出不再被使用的内存并将其释放以便可以继续重用。
Dijkstra 对垃圾回收的定义清晰地指出,引用计数是自动内存管理的一种形式,但不属于垃圾回收。
引用计数会在程序运行时更新每个对象的元数据(例如,在对一个引用类型对象的某个字段赋值时)。元数据的更新需要在应用程序线程上进行,因此不能清晰地将其划分为独立的活动。
回收算法从 root(一组已知是存活对象)开始,通过跟踪指针来确定存活对象。
这些跟踪回收器实现了图算法,将堆内存划分为存活的和可回收的。
在现代垃圾回收文献中,并发(Concurrent)和并行(Parallel)都被用来描述回收算法。它们听起来像是同义词,但实际上有着完全不同的含义:
它们可以被看成是另外两个术语的对立面——并发是 stop-the-world(STW)的对立面,并行是 single-thread(单线程)的对立面。
实际的垃圾回收器分为多个阶段,每个阶段还可能具备多种特征。
例如,某个阶段可能是单线程并发,或者是并行 STW。
注意:并发回收器比 STW 回收器要复杂得多。它们在计算开销方面要大得多,而且它们的行为还有需要注意的地方。
其他你应该知道的垃圾回收术语:
Exact 是一种保守模式,缺乏精确的信息,因此通常会造成更大的内存浪费。
一些资料还提到了移动回收器——包括压缩和驱逐算法。但这两种类型之间的差异太大,把它们组合在一起通常用处不大。
非移动回收器被称为就地回收器。这些算法需要知道可用内存块的列表才能够处理内存碎片以及合并可用的内存块。
我们从定义开始,先来考虑一些基本的事实:
分代假设基于对面向对象系统运行时行为的观察,它大致将对象分为两类:短期的临时对象和用于执行程序任务的长期对象。
注意:分代回收器并不一定总是比非分代回收器更高效,但几乎所有的应用程序都会从分代回收器中获得好处。
回收算法的 mark-sweep-compact(根据 Blackburn 和 McKinley)是这样定义的:
在分代回收算法中,年轻代回收器和老年代回收器通常使用的是完全不同的算法。
这导致我们很难准确地对不同阶段采用不同算法的回收器进行归类。例如,在 CMS 中,年轻代是通过驱逐算法那进行回收的,而老年代是通过标记清除算法进行回收的,如果并发回收失败(例如由于碎片),则退回到标记压缩算法。
在 HotSpot 中,传统的回收器将内存划分为 4 个内存池,分别是 Eden、Survivor 0、Survivor 1 和 Tenured。前三个被统称为年轻代,Tenured 是老年代。
年轻代空间是在年轻代回收周期中进行回收的,使用了并行 STW 驱逐算法, 将存活的对象转移到一个空间。
回收算法在当前活动的内存池中标记存活的对象,然后将其撤到非活动的内存池中。在回收结束时,两个空间被颠倒过来——活动的内存池变为非活动的(即为空),而非活动的变为活动的。有时候这也被叫作“半球”(hemispehric)回收。
半球回收可能会浪费内存。单遍算法无法预先知道正在回收的内存区域中有多少对象是存活的。这意味着用于存放驱逐对象的区域必须和被清理的区域一样大——因此算法需要两倍于实际存活对象大小的内存空间。
它还意味着不管在什么时候都有一半的空间是空的。这些特点导致它不适用于现代工作负载的老年代垃圾回收,因为这些老年代的对象集合可能很大:实际上,在生产环境中,HotSpot 回收器不会使用半球回收算法。
半球回收算法被用于回收年轻代。它非常适用于符合分代假设的工作负载——即内存区域里大部分都是垃圾对象。回收器受益于这样的一个事实:存活对象总是从年轻代被提升到老年代。
驱逐回收器的另一个主要优点是它们处理空闲空间的方式。最简单的方法是使用指向空闲空间的指针,当存活对象被驱逐时,很“自然”地被压缩。
驱逐算法是 OpenJDK 年轻代回收器的典型算法,它使用了对象跟踪。不过,回收只在一个阶段中进行,没有单独的标记、清除或压缩阶段。
对象的生存期通常是未知的,而且在实际应用程序中会动态发生变化。因此,追踪对象的实际生存周期是不可行的。
相反,HotSpot 记录了对象在垃圾回收过程中存活下来的次数,只需要在对象头部的元数据里添加几个比特的信息,在对象经历了足够多的垃圾回收之后,它就会被移动(提升)到更老的一代,由不同的垃圾回收器来管理。
这种机制与应用程序的内存分配速度存在一种有趣的交互。如果分配速度加快,那么年轻代将更快被填满——但“短命对象”的预期寿命(以毫秒为单位)保持不变。
这可能会导致更多对象在回收周期中存活下来,从而导致年轻代空间充满了还没有资格提升到老年代的对象。在这种情况下,JVM 别无选择,只能提前提升一些对象——这导致了“过早提升”。
很多这样的对象实际上都是短命的,在进入老年代后很快就会消失。可惜的是,JVM 没有回收它们的机制,要等到老年代空间的下一个回收周期才能回收它们。
开发人员经常对垃圾回收算法进行复杂性分析(有时候也叫作“大 O”)。然而,在实践当中,这种做法实际上并不是很令人满意。
他们可能天真地认为标记和压缩阶段的时间复杂度与活动对象集合的大小成线性关系,而清除阶段与整个堆大小成线性关系。
然而,即使不考虑在实际实现当中可能无法清晰地进行阶段隔离(如上面讨论的 HotSpot 年轻代回收器),仍然存在一个更深层次的问题。
垃圾回收本质上是一种通用算法。这意味着大 O 分析中的固有假设——当数据集增大时,起作用的是限制性行为——是不正确的。
生产环境中的算法需要在面对所有可能的输入和工作负载表现出可接受的行为。它们的渐近性行为与整体性能是不匹配的。
换句话说,活动对象集合和堆大小本质上是独立变化的(例如,不同的对象图拓扑)。这意味着对于不同的工作负载,缩放因子会产生非常不一样的效果。
例如,压缩时需要复制字节,因此,尽管压缩阶段在活动对象集合的大小上是呈线性的,但其他因素可能与要移动的对象大小有关。对于包含大量元素的大数组,这种说法就更加站不住脚。
对于各种不同形式的回收算法,还存在一些众所周知的二级效应。例如,在对只有少量存活对象的内存区域(“稀疏堆”)执行压缩时,活动的对象将被合并到更密集的区域。如果对象的生命周期很长,那么这个区域对于后续的回收周期来说就不那么稀疏了。
我们可以看到,与 CMS 之类的就地回收器相比,在程序的整个生命周期中,长寿对象将保持稀疏分布。事实上,随着时间的推移,空闲空间将变得越来越碎片化,空闲内存块列表的管理将变得越来越昂贵。
总的来说,不同回收方法的时间和空间成本模型是不同的,简单的算法复杂度分析也不是很管用。在 HotSpot 中,如果没有足够的连续空间,就地回收器最终会退回到压缩回收器。
我们讨论了 Java 虚拟机的垃圾回收机制。垃圾回收是计算机科学的一个成熟的领域,HotSpot 的垃圾回收器经过了良好的测试,可以很好地处理大堆工作负载。大多数 Java 应用程序不需要过多地担心垃圾回收行为。
如果对垃圾回收行为较为敏感,那么深入了解垃圾回收的原则(以及它在 JVM 中是如何实现的)对于开发人员来说会很有帮助。
在最近的 Java 版本中,垃圾回收子系统的改进再次成为关注的热点。要完全理解这些变化,就要很好地掌握这些基础知识。后续的文章将详细讨论这些更新,例如,为什么改变了默认回收器、这对升级到 Java 11 的团队意味着什么。