判断对象是否存活

引用计数算法

在对象中添加一个引用计数器,每有一处引用就加一,引用失效就减一,计数器为0的对象就是不可能再被使用的

优点是原理简单,判定效率高

缺点是占用额外内存空间,难解决对象之间相互循环引用的问题(相互引用着对方导致它们的引用计数都不为零,引用计数算法也就无法回收它们)

可达性分析算法

通过一系列称为 GC Roots 的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为引用链(ReferenceChain),如果某个对象到 GC Roots 间没有任何引用链相连,或者用图论的话来说就是从 GC Roots 到这个对象不可达时,则证明此对象是不可能再被使用的

固定可作为 GC Roots 的对象包括:

  • 在虚拟机栈中引用的对象
  • 在方法区中类静态属性引用的对象
  • 在方法区中常量引用的对象
  • 在本地方法栈中 JNI (即 Native 方法)引用的对象
  • Java 虚拟机内部的引用
  • 所有被同步锁( synchronized 关键字)持有的对象
  • 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等

引用

强引用

代码中普遍存在的引用赋值,只要强引用存在,垃圾收集器就不会回收被引用的对象

软引用

在系统将要发生内存溢出异常前,会把这些对象列入回收范围进行二次回收,通常实现缓存

弱引用

关联的对象只能存活到下一次垃圾收集

虚引用

又称幽灵引用或幻影引用,无法通过虚引用取得一个对象实例,为对象设置虚引用的唯一目的是为了能在对象被回收时收到系统通知

两次标记

对象在进行可达性分析后发现没有与 GC Roots 相连的引用链,就会被第一次标记

之后根据对象是否有必要执行 finalize()方法进行一次筛选,虚拟机认为对象没有覆盖 finalize()方法或者已经调用过一次该方法则没有必要执行

如果这个对象被判定为有必要执行 finalize()方法,那么该对象将会被放置在一个名为 F-Queue 的队列之中,并在稍后由一条虚拟机自动建立的、低调度优先级的 Finalizer 线程去执行它们的 finalize() 方法。虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束,为了防止方法执行缓慢甚至死循环导致内存回收子系统的崩溃

执行 finalize()方法后收集器将对 F-Queue 中的对象进行第二次小规模的标记,如果对象要在 finalize()中成功自救,只要重新与引用链上的任何一个对象建立关联即可

任何一个对象的 finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的 finalize()方法不会被再次执行,因此无法自救

finalize()方法不等同于 C和 C++语言中的析构函数,是不推荐使用的语法

回收方法区

方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型

回收废弃常量

与回收 Java 堆中的对象非常类似,虚拟机中没有任何地方引用常量池中的常量,如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个常量就将会被系统清理出常量池

回收不再使用的类型

需要同时满足三个条件:

  1. 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例
  2. 加载该类的类加载器已经被回收,通常很难达成
  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

满足上述条件的无用类仅仅被允许回收,不是必然被回收

垃圾收集算法

从如何判定对象消亡的角度出发,垃圾收集算法可以划分为“引用计数式垃圾收集”(Reference Counting GC)和“追踪式垃圾收集”(Tracing GC)两大类

分代收集理论

建立在三个假说之上:

  1. 弱分代假说:绝大多数对象都是朝生夕灭的
  2. 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡
  3. 跨代引用假说:跨代引用相对于同代引用来说仅占极少数

在此基础上认为应该将 Java 堆划分出不同的区域,然后将回收对象依据其熬过垃圾收集过程的次数分配到不同的区域之中存储

在 Java 堆划分出不同的区域之后,垃圾收集器可以每次只回收其中某一个或者某些部分的区域 ,能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法

现在的商用 Java 虚拟机里,设计者一般至少会把 Java 堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域,在新生代中每次垃圾收集大量对象死去,存活的对象逐渐晋升到老年代

根据前两条假说逻辑推理可以得出第三条假说:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的,跨代引用导致新生代对象在回收时存活,因此逐渐晋升到老年代,跨代引用也消除

依据第三条假说,在新生代上建立一个全局的数据结构称为“记忆集”,这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生新生代收集时,只有包含了跨代引用的小块内存里的对象才会被加入到到 GCRoots 进行扫描,避免了对整个老年代的扫描

标记-清除算法

标记-清除算法

首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象

缺点有两个:

  • 执行效率不稳定,如果 Java 堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低
  • 内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作

标记-复制算法

标记-复制算法

将可用内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉

如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可

缺点是将可用内存缩小为了原来的一半,造成了空间浪费

“Appel式回收”

对上述算法的优化,基于一个认识——新生代中的对象有98%熬不过第一轮收集

把新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次分配内存只使用 Eden 和其中一块 Survivor。发生垃圾搜集时,将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上,然后直接清理掉 Eden 和已用过的那块 Survivor 空间,当一个 Survivor 空间不足以容纳一次 Minor GC 之后存活的对象时,就需要依赖其他内存区域进行分配担保。虚拟机默认 Eden 和 Survivor 的大小比例是8:1

标记-整理算法

标记-整理算法

标记-复制算法不适合老年代

针对老年代对象的存亡特征,提出标记-整理算法,其中的标记过程仍然与标记-清除算法一样,但后续步骤是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存

移动式和非移动式回收算法

标记-整理算法移动存活对象,内存回收时会更复杂;标记-清除算法不移动对象,为了解决碎片问题内存分配会更复杂

从整个程序看,移动对象更划算,因为内存分配和访问相比垃圾收集频率要高得多

还有一种居中的方案是平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间

经典垃圾收集器

HotSpot虚拟机的垃圾收集器

如果两个收集器之间存在连线,就说明它们可以搭配使用

JDK 9 表示在这之后这些组合被废弃

Serial 收集器

最基础、历史最悠久的收集器

一个单线程工作的收集器,单线程不仅仅指使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。

Serial/Serial Old 收集器运行

是 HotSpot 虚拟机运行在客户端模式下的默认新生代收集器

简单而高效,是所有收集器里额外内存消耗最小的

ParNew 收集器

Serial 收集器的多线程并行版本

ParNew 收集器运行

目前只有它能与 CMS 收集器配合工作

Parallel Scavenge 收集器

同样基于标记 -复制算法实现,也是能够并行收集的多线程收集器

特点是与其他收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间不同,Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量

$$ 吞吐量 = \frac{运行用户代码时间}{运行用户代码时间+运行垃圾收集时间} $$

UseAdaptiveSizePolicy 参数激活以后虚拟机能动态调整新生代的大小(-Xmn)、Eden 与Survivor 区的比例(-XX: SurvivorRatio)、晋升老年代对象大小(-XX: PretenureSizeThreshold)等细节参数

Serial Old 收集器

Serial 收集器的老年代版本

Serial/Serial Old 收集器运行

Parallel Old 收集器

Parallel Scavenge 收集器的老年代版本

Parallel Scavenge/Parallel Old 收集器运行

CMS 收集器(Concurrent Mark Sweep)

以获取最短回收停顿时间为目标

基于标记-清除算法实现

运行过程

  • 初始标记(CMS initial mark):标记 GC Roots 能直接关联到的对象,速度很快
  • 并发标记(CMS concurrent mark):从 GC Roots 的直接关联对象开始遍历整个对象图,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行
  • 重新标记(CMS remark):修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短
  • 并发清除(CMS concurrent sweep):清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的

CMS 收集器运行

由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体上来说 CMS 收集器的内存回收过程是与用户线程一起并发执行的

缺点

  1. 对处理器资源非常敏感

    • 并发占用一部分线程导致应用程序变慢
  2. 无法处理“浮动垃圾”

    • 在 CMS 的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行就还会产生新的垃圾对象,但这一部分垃圾对象是出现在标记过程结束以后, CMS 无法在当次收集中处理掉它们,只好等待下一次垃圾收集时再清理掉,这一部分垃圾就称为“浮动垃圾 ”
    • 由于在垃圾收集阶段用户线程还在持续运行,那就还需要预留足够内存空间提供给用户线程使用,因此 CMS 收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用
  3. 标记-清除算法产生大量空间碎片

Garbage First 收集器

能够建立“停顿时间模型”,意思是能够支持指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过 N 毫秒这样的目标

面向堆内存任何部分来组成回收集进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大

分区

把连续的 Java 堆划分为多个大小相等的独立区域(Region),每一个 Region 都可以根据需要扮演新生代的 Eden 空间、Survivor 空间或者老年代空间,收集器能够对扮演不同角色的 Region 采用不同的策略去处理。Region中还有一类特殊的 Humongous 区域,专门用来存储大对象,G1认为只要大小超过了一个 Region 容量一半的对象即可判定为大对象

G1收集器之所以能建立可预测的停顿时间模型,是因为它将 Region 作为单次回收的最小单元,即每次收集到的内存空间都是 Region 大小的整数倍,这样可以有计划地避免在整个 Java 堆中进行全区域的垃圾收集。更具体的处理思路是让G1收集器去跟踪各个 Region 里面的垃圾堆积的 价值(和回收获得的空间和回收所需时间有关),然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间,优先处理回收价值收益最大的那些 Region

跨 Region 引用对象

使用记忆集避免全堆作为 GC Roots 扫描扫描,但在G1收集器上记忆集的应用要更复杂,它的每个 Region 都维护有自己的记忆集,这些记忆集会记录下别的 Region 指向自己的指针,并标记这些指针分别在哪些卡页的范围之内

运作过程

  • 初始标记:停顿线程,但耗时很短
  • 并发标记:可达性分析,当对象图扫描完成以后,还要重新处理 SATB 记录下的在并发时有引用变动的对象
  • 最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录
  • 筛选回收:根据用户所期望的停顿时间来制定回收计划,自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的

G1 收集器运行

设计原则

首先收集尽可能多的垃圾,追求能够应付应用的内存分配速率,而不追求内存快耗尽时一次把整个 Java 堆全部清理干净,收集的速度跟上对象分配的速度

优缺点

优点是从整体来看是基于标记-整理算法实现,但从局部(两个 Region 之间)上看又是基于标记-复制算法实现,这两种算法都意味着G1运作期间不会产生内存空间碎片

缺点是更复杂的卡表实现占用了更多的内存;写屏障更复杂消耗更多运算资源,负载高

低延迟垃圾收集器

垃圾收集器的三个重要指标:

  • 内存占用
  • 吞吐量
  • 延迟,延迟的重要性日益凸显

Shenandoah 收集器

ZGC 收集器

这两个收集器后续单独分开写

最后修改:2023 年 06 月 05 日
如果觉得我的文章对你有用,请随意赞赏