Java 垃圾收集器与内存分配策略
Java 垃圾收集器与内存分配策略
对象已死?
引用计数算法
- 在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
- 单纯的引用计数很难解决对象之间相互循环引用的问题。
可达性分析算法
- 基本思路:通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”。如果某个对象到 GC Roots 间没有任何引用链相连,或者从 GC Roots 到这个对象不可达时,则证明此对象是不可能再被使用的。
GC Roots 包括:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 堆栈中使用到的参数、局部变量、临时变量等
- 在方法区中类静态属性引用的对象,如 Java 类的引用类型静态变量
- 在方法区中常量引用的对象,如字符串常量池(String Table)里的引用
- 在本地方法栈中 JNI(即通常所说的 Native 方法)引用的对象
- Java 虚拟机内部的引用:
- 基本数据类型对应的 Class 对象
- 常驻的异常对象(如 NullPointerException、OutOfMemoryError 等)
- 系统类加载器
- 所有被同步锁(
synchronized
关键字)持有的对象 - 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存
- 除了这些固定的 GC Roots 集合外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入。
- 某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时就需要将这些关联区域的对象也一并加入 GC Roots 集合中,以确保可达性分析的正确性。
再谈引用
- 强引用(Strongly Reference):最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值。只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
- 软引用(Soft Reference):描述一些还有用,但非必须的对象。只被软引用关联的对象,在系统将要发生内存溢出异常前,会被列入回收范围进行二次回收。如果这次回收后仍没有足够的内存,才会抛出内存溢出异常。
- 弱引用(Weak Reference):用于描述非必须对象,但强度比软引用更弱。被弱引用关联的对象只能生存到下一次垃圾收集为止。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
- 虚引用(PhantomReference):最弱的一种引用关系。一个对象是否有虚引用存在,完全不会对其生存时间构成影响,也无法通过虚引用取得一个对象实例。设置虚引用关联的唯一目的是能在对象被收集器回收时收到系统通知。
生存还是死亡?
即使在可达性分析算法中判定为不可达的对象,也不一定“非死不可”。
真正宣告一个对象死亡,
至少要经历两次标记过程:
如果对象在可达性分析后发现没有与 GC Roots 相连接的引用链,则会被第一次标记。
随后进行一次筛选,条件是此对象是否有必要执行
finalize()
- 假如对象 没有覆盖
finalize()
方法,或者finalize()
方法已被虚拟机调用过,虚拟机将视为“没有必要执行”。 - 如果对象被判定为确有必要执行
finalize()
方法,该对象将被放置在一个名为 F-Queue 的队列中,由虚拟机自动建立的低调度优先级的 Finalizer 线程去执行它们的finalize()
方法。执行是指虚拟机会触发这个方法开始运行,但不承诺一定会等待它运行结束。
- 假如对象 没有覆盖
收集器将对 F-Queue 中的对象进行第二次小规模标记。如果对象在
finalize()
中成功拯救自己,只要重新与引用链上的任何一个对象建立关联即可。
回收方法区
废弃的常量
- 回收废弃常量与回收 Java 堆中的对象非常类似(通过可达性分析法,判断是否存在引用)。
- 常量池中其他类(接口)、方法、字段的符号引用也与此类似。
不再使用的类型
- 判定一个类型是否属于“不再被使用”:
- 该类的所有实例已被回收。
- 加载该类的类加载器已被回收。
- 该类对应的
java.lang.Class
对象没有在任何地方被引用。
垃圾收集算法
垃圾收集算法可划分为引用计数式垃圾收集(Reference Counting GC)
和追踪式垃圾收集(Tracing GC)
两大类。引用计数式垃圾收集算法在主流 Java 虚拟机中未涉及。
分代收集理论
- 建立在两个分代假说之上:
- 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。把它们集中放在一起,每次回收时只关注如何保留少量存活对象,而不是标记那些大量将要被回收的对象,从而以较低代价回收到大量空间。
- 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。把它们集中在一块,虚拟机便可以使用较低的频率来回收这个区域,从而兼顾垃圾收集的时间开销和内存的空间有效利用。
- Java 堆一般会划分为新生代(Young Generation)和老年代(Old Generation)两个区域。
- 问题:对象不是孤立的,对象之间可能存在跨代引用。
- 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构将老年代划分成若干小块,标识出老年代的哪一块内存存在跨代引用。当发生 Minor GC 时,只有包含了跨代引用的小块内存里的对象才会被加入到 GCRoots 进行扫描。
标记-清除算法
- 标记:首先标记出所有需要回收的对象。
- 清除:在标记完成后,统一回收所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
- 问题:标记、清除后会产生大量不连续的内存碎片。空间碎片太多可能导致在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
标记-复制算法
- 解决标记-清除算法面对大量可回收对象时执行效率低的问题。
- 将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完后,将还存活的对象复制到另一块上,然后再把已使用过的内存空间一次性清理掉。
- 代价是将可用内存缩小为原来的一半,空间浪费较多。
- 商用 Java 虚拟机大多采用这种收集算法回收新生代
- Andrew Appel 针对具备“朝生夕灭”特点的对象,提出了一种更优化的半区复制分代策略,现在称为“Appel 式回收”。
- 把新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间。
- 每次分配内存只使用 Eden 和其中一块 Survivor。发生垃圾收集时,将 Eden 和 Survivor 中仍然存活的对象一次性复制到另一块 Survivor 空间,然后直接清理掉 Eden 和已用过的那块 Survivor 空间。
- HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1。
- Appel 式回收“逃生门”的安全设计:
- 当 Survivor 空间不足以容纳一次 Minor GC 之后存活的对象时,就需要依赖其他内存区域(大多是老年代)进行分配担保(Handle Promotion)。
- 分配担保:如果另一块 Survivor 空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象将通过分配担保机制直接进入老年代。
- 当 Survivor 空间不足以容纳一次 Minor GC 之后存活的对象时,就需要依赖其他内存区域(大多是老年代)进行分配担保(Handle Promotion)。
- Andrew Appel 针对具备“朝生夕灭”特点的对象,提出了一种更优化的半区复制分代策略,现在称为“Appel 式回收”。
标记-整理算法
针对老年代对象的存亡特征,1974 年 Edward Lueders 提出了“标记-整理”(Mark-Compact)算法。
标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象向内存空间一端移动,然后直接清理掉边界以外的内存。
问题:
如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活的区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作。这种对象移动操作必须全程暂停用户应用程序才能进行,这样的停顿被最初的虚拟机设计者形象地描述为“Stop The World”。
- 从垃圾收集的停顿时间来看,不移动对象停顿时间更短。
- 从整个程序的吞吐量来看,移动对象更划算。
- 解决方案:虚拟机平时多数时间采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。基于标记-清除算法的 CMS 收集器在空间碎片过多时采用这种处理办法。
HotSpot 的算法细节实现
根节点枚举
- 固定可作为 GC Roots 的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)。
- 所有收集器在根节点枚举这一步骤时都必须暂停用户线程。
- 根节点枚举始终必须在能保障一致性的快照中进行。
- 虚拟机应有办法直接得到哪些地方存放着对象引用.
- 使用一组称为 OopMap 的数据结构来达到这个目的。一旦类加载动作完成时,HotSpot 就会把对象内什么偏移量上是什么类型的数据计算出来。
安全点
HotSpot 并没有为每条指令都生成 OopMap,只是在“特定的位置”记录了这些信息,这些位置被称为安全点(Safepoint)。
用户程序执行时并非在代码指令流的任意位置都能停顿下来开始垃圾收集,而是必须执行到安全点后才能暂停。
安全点位置的选取标准是
是否具有让程序长时间执行的特征。
- 指令序列的复用,如:
- 方法调用
- 循环跳转
- 异常跳转
- …
- 指令序列的复用,如:
如何在垃圾收集发生时让所有线程(不包括执行 JNI 调用的线程)都跑到最近的安全点,然后停顿下来?
- 抢先式中断(Preemptive Suspension):不需要线程的执行代码主动配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它重新中断,直到跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应 GC 事件。
- 主动式中断(Voluntary Suspension):当垃圾收集需要中断线程时,不直接对线程操作,仅设置一个标志位,各线程执行时会不停主动轮询这个标志,一旦发现中断标志为真时就会在最近的安全点主动中断挂起。轮询标志的地方和安全点是重合的,还要包括所有创建对象和其他需要在 Java 堆上分配内存的地方,为了检查是否即将发生垃圾收集,避免没有足够内存分配新对象。
安全区域
- 安全点机制保证了程序执行时,在不太长时间内就会遇到可进入垃圾收集过程的安全点。
- 程序“不执行”时(例如程序处于 Sleep 状态或 Blocked 状态),线程无法响应虚拟机的中断请求,不能走到安全点中断挂起自己,虚拟机也不可能持续等待线程重新被激活分配处理器时间。
- 安全区域是指在某一段代码片段中,引用关系不会发生变化,因此在这个区域内任意地方开始垃圾收集都是安全的。可以将安全区域看作扩展拉伸的安全点。
- 当用户线程执行到安全区域内的代码时,会标识自己已进入安全区域,当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。
- 当线程要离开安全区域时,必须检查虚拟机是否已完成根节点枚举(或垃圾收集过程中其他需要暂停用户线程的阶段)。如果完成了,线程继续执行;否则必须一直等待,直到收到可离开安全区域的信号为止。
记忆集与卡表
为解决对象跨代引用问题,垃圾收集器在新生代中建立了名为记忆集(Remembered Set)的数据结构,用以避免将整个老年代加进 GC Roots 扫描范围。
记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。
- 最简单的实现方式是使用非收集区域中所有含跨代引用的对象数组来实现这个数据结构。
在垃圾收集的场景中,收集器只需要通过记忆集判断某一块非收集区域是否存在指向收集区域的指针即可,无需了解所有跨代指针的细节。
- 可选择更粗犷的记录粒度:
- 字长精度:每个记录精确到一个机器字长。
- 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
- 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。
- 可选择更粗犷的记录粒度:
卡精度
是用一种称为“卡表”(Card Table)的方式实现记忆集。
- 卡表最简单的形式是一个字节数组。
- 一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,对应卡表的数组元素的值标识为 1(称为元素变脏,Dirty),没有则标识为 0。
写屏障
写屏障用于解决卡表元素如何维护的问题,例如何时变脏、由谁来将其变脏等。
何时变脏:当有其他分代区域中对象引用本区域对象时,其对应的卡表元素就应变脏,变脏时间点应发生在引用类型字段赋值的那一刻。
必须找到一个机器码层面的手段,将维护卡表的操作放入每一个赋值操作中。
写屏障可以看作虚拟机层面对“引用类型字段赋值”动作的 AOP 切面。
赋值前的写屏障叫写前屏障(Pre-Write Barrier),赋值后的叫写后屏障(Post-Write Barrier)。
应用写屏障后,虚拟机会为所有赋值操作生成相应的指令。
卡表在高并发场景下面临“伪共享”(False Sharing)问题:
现代中央处理器的缓存系统中以缓存行(Cache Line)为单位存储,当多线程修改独立变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或同步)而导致性能降低。
- 解决方案:不采用无条件写屏障,先检查卡表标记,只有当卡表元素未被标记过时才将其标记为变脏。
并发的可达性分析
从 GC Roots 往下遍历对象图,这一步骤的停顿时间与 Java 堆容量成正比。
三色标记
作为工具辅助推导:
- 白色:表示对象尚未被垃圾收集器访问。
- 黑色:表示对象已被垃圾收集器访问,且该对象的所有引用已被扫描。
- 灰色:表示对象已被垃圾收集器访问,但该对象上至少存在一个引用未被扫描。
问题:
当用户线程与收集器并发工作时,收集器在对象图上标记颜色,同时用户线程修改引用关系,即修改对象图结构,可能导致:
- 原本消亡的对象被错误标记为存活。
- 原本存活的对象被错误标记为已消亡。
当且仅当以下两个条件同时满足时,会产生“对象消失”问题:
- 赋值器插入一条或多条从黑色对象到白色对象的新引用。
- 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。
解决方案:
只需破坏上述两个条件之一即可。
- 增量更新(Incremental Update):破坏第一个条件,当黑色对象插入新的指向白色对象的引用时,将这个新引用记录下来,等并发扫描结束后,将这些记录过的引用关系中的黑色对象为根重新扫描一次。
- 原始快照(Snapshot At The Beginning,SATB):破坏第二个条件,当灰色对象删除指向白色对象的引用时,将这个删除的引用记录下来,在并发扫描结束后,将这些记录过的引用关系中的灰色对象为根重新扫描一次。即无论引用关系删除与否,都按照扫描开始那一刻的对象图快照进行搜索。
CMS 基于增量更新进行并发标记,而 G1、Shenandoah 则采用原始快照实现。
经典垃圾收集器
Serial 收集器
- 新生代收集器,采用标记-复制算法。
- 进行垃圾收集时,必须暂停其他所有工作线程,直到收集结束。
- 对于内存资源受限的环境,它是所有收集器中额外内存消耗(Memory Footprint)最小的。
ParNew 收集器
- 新生代收集器,采用标记-复制算法。
- 实质上是 Serial 收集器的多线程并行版本。
- 除了 Serial 收集器外,只有它能与 CMS 收集器配合工作。
- 面向低延迟场景。
Parallel Scavenge 收集器
- 新生代收集器,采用标记-复制算法。
- 并行收集的多线程收集器,目标是达到可控制的吞吐量(Throughput)。
Serial Old 收集器
- Serial 收集器的老年代版本,采用标记-整理算法。
- JDK 5 及之前版本中与 Parallel Scavenge 收集器搭配使用,也作为 CMS 收集器发生失败时的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。
Parallel Old 收集器
- Parallel Scavenge 收集器的老年代版本,采用标记-整理算法。
- 支持多线程并发收集,优先考虑吞吐量。
CMS 收集器
- 以获取最短回收停顿时间为目标的收集器,基于标记-清除算法,采用增量更新。
- 运作过程包括:
- 初始标记(CMS Initial Mark)
- 并发标记(CMS Concurrent Mark)
- 重新标记(CMS Remark)
- 并发清除(CMS Concurrent Sweep)
Garbage First 收集器
开创了收集器面向局部收集的设计思路和基于 Region 的内存布局形式。
能够建立“停顿时间模型”(Pause Prediction Model)的收集器。
- 停顿时间模型:支持指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过 N 毫秒。
面向堆内存任何部分组成回收集,衡量标准不再是其属于哪个分代,而是哪块内存中存放的垃圾数量最多、回收收益最大,即 G1 收集器的 Mixed GC 模式。
将连续的 Java 堆划分为多个大小相等的独立区域(Region),每个 Region 可根据需要扮演新生代的 Eden 空间、Survivor 空间或老年代空间,收集器可对不同角色的 Region 采用不同策略处理。
Region 中还有一类特殊的 Humongous 区域,专门存储大对象。G1 认为只要大小超过一个 Region 容量一半的对象即为大对象。对于超过整个 Region 容量的超级大对象,将存放在 N 个连续的 Humongous 区域中。
运作过程
大致可分为:
- 初始标记:标记 GC Roots 能直接关联到的对象,并修改 TAMS 指针的值。TAMS(Top at Mark Start)指针将 Region 中一部分空间划分出来用于并发回收过程中的新对象分配。
- 并发标记
- 最终标记
- 筛选回收:更新 Region 的统计数据,对各 Region 的回收价值和成本排序。将决定回收的 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。
G1 收集器从整体上是基于“标记-整理”算法实现,但从局部(两个 Region 之间)看又是基于“标记-复制”算法。
G1 无论在垃圾收集过程中内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都比 CMS 高。
低延迟垃圾收集器
衡量垃圾收集器的三项最重要的指标是:内存占用(Footprint)、吞吐量(Throughput)和延迟(Latency)。
Shenandoah 收集器
目标是实现一种能在任何堆内存大小下都能将垃圾收集的停顿时间限制在 10 毫秒以内的收集器。
Shenandoah
摒弃了在 G1 中耗费大量内存和计算资源去维护的记忆集,改用名为“连接矩阵”(Connection Matrix)
的全局数据结构记录跨 Region 的引用关系。
- 连接矩阵可简单理解为一张二维表格,若 Region N 有对象指向 Region M,则在表格的 N 行 M 列中打上标记。
工作过程:
- 初始标记
- 并发标记
- 最终标记
- 并发清理
- 并发回收:移动对象时,用户线程仍可能对被移动对象进行读写访问。移动对象是一次性行为,但移动后整个内存中所有指向该对象的引用仍为旧对象地址。Shenandoah 通过读屏障和“Brooks Pointers”的转发指针解决。
- 初始引用更新
- 并发引用更新
- 最终引用更新
- 并发清理
转发指针:
- 在原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己。
- 当对象有新副本时,只需修改一处指针的值,即旧对象上转发指针的引用位置指向新对象。
- 必须针对转发指针的访问操作采取同步措施,使收集器线程或用户线程对转发指针的访问只有其中之一能成功。
- 通过比较并交换(Compare And Swap,CAS)操作实现同步。
ZGC 收集器
- ZGC 收集器是一款基于 Region 内存布局的(暂时不设分代),使用了读屏障、染色指针和内存多重映射等技术实现可并发的标记-整理算法,以低延迟为首要目标的垃圾收集器。
染色指针技术
- 64 位指针的高 18 位不能用来寻址,但剩余的 46 位指针所能支持的 64TB 内存足以满足大型服务器的需求。
- ZGC 的染色指针技术将这剩余的 46 位指针宽度的高 4 位提取出来存储四个标志信息。
- 虚拟机可以直接从指针中查看其引用对象的三色标记状态、是否进入重分配集(即被移动过)、是否只能通过
finalize()
方法访问。 - 优点:
- 染色指针使得某个 Region 的存活对象被移走后,该 Region 立即可被释放和重用。
- 染色指针可大幅减少垃圾收集过程中的内存屏障使用数量。
- 染色指针作为可扩展的存储结构,可记录更多与对象标记、重定位过程相关的数据,以提高性能。
工作过程
- 并发标记
- 并发预备重分配
- 并发重分配
- 并发重映射
内存分配与回收策略
- 对象优先在 Eden 分配。
- 大对象直接进入老年代。
- 长期存活的对象将进入老年代。
- 动态对象年龄判定。
- 空间分配担保。