垃圾收集器主要是关注 Java 堆和方法区这两个区域的内存管理。
对象已死?
在堆里面存放着 Java 世界中几乎所有地对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”。
引用计数法
在对象中添加一个引用计数器,每当有一个地方引用过它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不能再被使用的。
客观地说,引用计数法(Reference Counting)虽然占用了一些额外地内存空间来进行计数,但它的原理简单,判定效率也很高,在大多数情况下它都是一个不错的选择。
但是,在 Java 领域,至少主流的 Java 虚拟机里面都没有选用引用计数法来管理内存,主要原因是,这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯地引用计数就很难解决对象之间相互循环引用的问题。
可达性分析算法
当前主流的商用程序语言(Java、C#,上溯至古老的 Lisp)的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连,或者用图论的话来说就是从 GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
在 Java 技术体系里面,固定可作为 GC Roots 的对象包括以下几种:
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
- 在方法区中类静态属性引用的对象,譬如 Java 类的引用类型静态变量。
- 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
- 在本地方法栈中 JNI (即通常所说的 Native 方法)引用的对象。
- Java 虚拟机内部的引用,如基本数据类型对象的 Class 对象,一些常驻的异常对象(比如 NullPointException、OutOfMemoryError)等,还有系统类加载器。
- 所有被同步锁(synchronized 关键字)持有的对象。
- 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。
除了这些固定的 GC Roots 集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整 GC Roots 集合。譬如分代收集和局部回收(Partial GC),如果只针对 Java 堆中某一块区域发起垃圾收集时(如最典型的只针对新生代的垃圾收集),必须考虑到内存区域是虚拟机自己的实现细节(在用户视角里任何内存区域都是不可见的),更不是孤立封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入 GC Roots 集合中去,才能保证可达性分析的正确性。
再谈引用
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判定对象是否存活都和“引用”离不开关系。
在 JDK 1.2 版之后,Java 堆引用的概念进行了扩充,将引用分为强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Week Reference)和虚引用(Phantom Reference) 4 种,这 4 种引用强度依次逐渐减弱。
强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj = new Object()”这种引用关系。无论任何情况下,只要强引用关系还在,垃圾收集器就永远不会回收掉被引用的对象。
软引用时用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在 JDK 1.2 版本之后提供了 SoftReference 类来实现软引用。
弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉被弱引用关联的对象。在 JDK 1.2 版本之后提供了 WeakReference 类来实现弱引用。
虚引用也称为“虚灵引用”或者“幻影引用”,它时最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在 JDK 1.2 版之后提供了 PhantomReference 类来实现虚引用。
生存还是死亡?
即使在可达性分析算法种判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。假如对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,那么虚拟机将这两种情况都是为“没有必要执行”。
如果这个对象被判定为确有必要执行 finalize() 方法,那么该对象将会被放置在一个名为 F-Queue 的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的 Finalize 线程去执行它们的 finalize() 方法。这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的 finalize() 方法执行缓慢,或者更极端地发生了死循环,将很可能导致 F-Queue 队列中地其他对象永久处于等待,甚至导致整个内存回收子系统地崩溃。
由于 finalize() 方法的运行代价高昂,不确定性大,无法保证各个对象的调用顺序,如今已被官方明确声明为不推荐使用语法。finalize() 能做的所有工作,使用 try-finally 或者其他方式都可以做得更好、更及时。
垃圾收集算法
从如何判定对象消亡地角度触发,垃圾收集算法可以划分为“引用计数式垃圾收集”(Reference Counting GC)和“追踪式垃圾收集”(Tracing GC)两大类,这两类也常被称作“直接垃圾收集”和“间接垃圾收集”。由于引用计数式垃圾收集算法在主流 Java 虚拟机中均未设计,所以本节介绍的所有算法均属于追踪式垃圾收集的范畴。
分代收集理论
分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:
- 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
- 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。
把 Java 堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域——因而才有了“Minnor GC”、“Major GC”、“Full GC”这样的回收类型的划分;也才能够针对不同区域安排与里面存储对象存亡特征相匹配的垃圾收集算法——因而发展出了“标记-复制算法”、“标记-清除算法”、“标记-整理算法”等针对性的垃圾收集算法。
需要强调的是,分代收集并非只是简单划分一下内存区域那么容易,它至少存在一个明显的困难:对象不是孤立的,对象之间会存在跨代引用。
假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的 GC Roots 之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要对分代收集理论添加第三条经验法则:
- 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说进占极少数。
这其实是可根据前两条假说逻辑推理得出的隐含结论:存在互相引用关系的两个对象是应该倾向于同时生寸或者同时消亡的。
依据这条假说,我们就只需在新生代上建立一个全局的数据结构(该结构被成为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生 Minor GC 时,只有包含了跨代引用的小块内存里的对象才会被加入到 GC Roots 进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。
标记-清除算法
最早出现也是最基础的垃圾收集算法是“标记-清除”(Mark-Sweep)。如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
之所以说它是最基础的收集算法,是因为后续的收集算法大多是以标记-清除算法为基础,对其缺点进行改进而得到的。它的主要缺点有两个:第一个是执行效率不稳定,如果 Java 堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随着对象数量增长而降低;第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
标记-清除算法的执行过程如下图所示:
标记-复制算法
为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,1969 年 Fenichel 提出了一种称为“半区复制”(Semispace Coping)的垃圾收集算法,它将可用内存划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行搞笑,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点。
标记-复制算法的执行过程如下图所示:
在 1989 年,Andrew Appel 针对具有“朝生熄灭”特点的对象,提出了一种更优化的半区复制分代策略,现在称为“Apple 式回收”。HotSpot 虚拟机的 Serial、ParNew 等新生代收集器均采用了这种策略来设计新生代的内存布局。Apple 式回收的具体做法是把新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次分配内存只使用 Eden 和其中一块 Survivor。发生垃圾收集时,将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外的一块 Survivor 空间上,然后直接清理 Eden 和已用过的那块 Survivor 空间。HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1,也即每次新生代中可用内存空间为整个新生代容量的 90%,只有一个 Survivor 空间,即 10% 的新生代是会被“浪费”的。当然,任何人都没有办法百分百保证每次回收都只有不多于 10% 的对象存活,因此 Appel 式回收还有以后个充当罕见情况的“逃生门”的安全设计,当 Survivor 空间不足以容纳一次 Minnor GC 之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。
标记-整理算法
标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费 50% 的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都 100% 存活的极端情况,所以在老年代一般不能直接选用这种算法。
针对老年代对象的存亡特征,1974 年 Edward Lueders 提出了另外一种有针对性地“标记-整理”(Mark-Compact)算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
标记-整理算法的执行过程如下图所示:
标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策:移动则内存回收时会更复杂,不移动则内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算。
另外,还有一种“和稀泥式”解决方案可以不在内存分配和访问上增加太大额外负担,做法是让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经达到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。CMS 收集器面临空间碎片过多时采用的就是这种处理办法。
经典垃圾收集器
上图展示了七种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用,图中收集器所处的区域,则表示它是属于新生代收集器抑或是老年代收集器。
Serial 收集器
Serial 收集器是一个单线程工作的收集器,但它的“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集巩工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。
Serial 收集器是 HotSpot 虚拟机运行在客户端模式下的默认新生代收集器,有着优于其他收集器的地方,那就是简单而高效(与其他收集器的单线程相比),对于内存资源受限的环境,它是所有收集器里额外内存消耗(Memory Footprint)最小的;对于单核处理器或处理器核心数较少的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。
![Serial-Serial Old收集器运行示意图](/images/java/Serial-Serial Old收集器运行示意图.webp)
ParNew 收集器
ParNew 收集器实质上是 Serial 收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括 Serial 收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与 Serial 收集器完全一致,在实现上这两种收集器也共用了相当多的代码。
自 JDK9 开始,ParNew 加 CMS 收集器的组合不再是官方推荐的服务器端模式下的收集器解决方案。官方希望它能完全被 G1 所取代。在不久以后,ParNew 会合并入 CMS,称为它专门处理新生代的组成部分。ParNew 可以说是 HotSport 虚拟机中第一款退出历史舞台的垃圾收集器。
![ParNew-Serial Old收集器运行示意图](/images/java/ParNew-Serial Old收集器运行示意图.webp)
Parallel Scavenge 收集器
Parallel Scavenge 收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器,也是能够并行收集的多线程收集器。
Parallel Scavenge 收集器的特点是它的关注点与其他收集器不同,CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是处理器用于运行代码的时间与处理器总消耗的时间的比值,即:吞吐量 = 运行用户代码时间/(运行用户代码时间 + 运行垃圾收集时间)。
Serial Old 收集器
Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。这个收集器的主要意义也是供客户端模式下的 HotSpot 虚拟机使用。
![Serial-Serial Old收集器运行示意图](/images/java/Serial-Serial Old收集器运行示意图.webp)
Parallel Old 收集器
Parallel Old 是 Parallel Scavenge 收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。
直到 Parallel Old 收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的搭配组合,在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器这个组合。
![Parallel Scavenge-Paraller Old收集器运行示意图](/images/java/Parallel Scavenge-Paraller Old收集器运行示意图.webp)
CMS 收集器
CMS(Concurrent Mark Sweep) 收集器是一种以获取最短回收停顿时间为目标的收集器。
CMS 是一款优秀的收集器,它最主要的优点在名字上已经体现出来:并发收集、低停顿,但是它至少有以下三个明显的缺点:
- 首先,CMS 收集器对处理器资源非常敏感。
- 然后,由于 CMS 收集器无法处理“浮动垃圾”(Floating Garbage),有可能出现“Concurrent Mode Failure”失败而导致另一次完全“Stop The World”的 Full GC 的产生。
- 最后,由于 CMS 是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。
Garbage First 收集器
同之前的收集器不同,G1 收集器垃圾收集的范围不再是整个新生代(Minor GC),或整个老年代(Major GC),又或整个 Java 堆(Full GC),而是面向堆内存任何部分来组成回收集(Collection Set,一般简称 CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是 G1 收集器的 Mixed GC 模式。
G1 开创的基于 Region 的堆内存布局是它能够实现这个目标的关键。虽然 G1 也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:G1 不再坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小相等的独立区域(Region),每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间,或者老年代空间。收集器能够对扮演不同角色的 Region 采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。
Region 中还有一类特殊的 Humongous 区域,专门用来存储大对象。G1 认为只要大小超过了一个 Region 容量一般的对象即可判定为大对象。每个 Region 的大小可以通过参数设定,取值范围为 1MB ~ 32MB,且应为 2 的 N 次幂。而对于那些超过了整个 Region 容量的超级大对象,将会被存放在 N 个连续的 Humongous Region 之中,G1 的大多数行为都把 Humongous Region 作为老年代的一部分来进行看待。
虽然 G1 仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。G1 收集器之所以能建立可预测的停顿时间模型,是因为它将 Region 作为单次回收的最小单元,即每次收集到的内存空间都是 Region 大小的整数倍,这样可以有计划地避免在整个 Java 队中进行全区域的垃圾手机。更具体地处理思路是让 G1 收集器去跟踪各个 Region 里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间,优先处理回收价值收益最大的那些 Region,这也就是 “Garbage First”名字的由来。
低延迟垃圾收集器
衡量垃圾收集器的三项最重要的指标是:内存占用(Footprint)、吞吐量(Throughput)和延迟(Latency),三者共同构成了一个“不可能三角”。三者总体的表现会随计数进步而越来越好,但是要在这三个方面同时具有卓越表现的“完美”收集器是极其困难甚至是不可能的,一款优秀的收集器通常最多可以同时达成其中的两项。
在内存占用、吞吐量和延迟这三项指标里,延迟的重要性日益凸显,越发备受关注。
Shenandoah 收集器
ZGC 收集器
内存分配与回收策略
- 对象优先在 Eden 分配
- 大对象直接进入老年代
- 长期存活的对象将进入老年代
- 动态对象年龄判定
- 空间分配担保