判断对象是否需要回收的算法
引用计数法
- 原理:为每个对象分配一个引用计数器,每当有一个地方引用它时,计数器加1;当引用失效时,计数器减1。当计数器为0时,表示对象不再被任何变量引用,可以被回收。
- 缺点:不能解决循环引用的问题,即两个对象相互引用,但不再被其他任何对象引用,这时引用计数器不会为0,导致对象无法被回收。
可达性分析算法
既然引用计数法因为无法处理"循环引用"而被 JVM 弃用,那么 可达性分析算法(Reachability Analysis) 就成了 Java 垃圾回收的基石。
它的核心思路非常直观:通过一系列起点,寻找所有能被触达的对象。 如果一个对象无法从这些起点通过引用链找到,那它就是"死"的。
1. 核心概念:GC Roots
可达性分析的核心是 GC Roots 。你可以把它想象成一棵大树的根 ,或者一张社交网络的核心大 V。
只有满足特定条件的变量才能作为 GC Roots。在 Java 中,主要包括以下几类:
- 虚拟机栈(栈帧中的局部变量表):当前正在运行的方法中所使用的参数、局部变量等。
- 方法区中的类静态属性:Java 类的引用类型静态变量。
- 方法区中的常量:比如字符串常量池(String Table)里的引用。
- 本地方法栈中 JNI(Native 方法)的引用。
- 所有被同步锁(synchronized 关键字)持有的对象。
2. 算法执行过程
算法的运行逻辑分为两个主要阶段:
阶段一:标记 (Marking)
- 寻找根节点:首先枚举所有的 GC Roots。
- 追踪引用链:从每一个 GC Roots 出发,顺着引用关系(Reference)向下搜索。
- 标记存活对象:搜索所走过的路径称为"引用链(Reference Chain)"。所有在引用链上的对象都被标记为"存活"。
阶段二:判定 (Finalization)
当搜索结束后,如果一个对象到 GC Roots 没有任何引用链相连(用图论的话说,就是从 GC Roots 到这个对象不可达),则证明此对象不可用。
3. 对象真正"死亡"前的两次标记
注意:不可达的对象并不意味着立即会被回收。一个对象要宣告死亡,至少要经历两次标记过程:
- 第一次标记:在可达性分析后,发现没有与 GC Roots 相连的引用链,该对象被第一次标记。
- 筛选与执行 :
- JVM 会检查该对象是否有必要执行
finalize()方法。 - 如果对象没有重写
finalize(),或者该方法已经被 JVM 调用过,则直接判定为可回收。 - 如果对象重写了
finalize()且未被执行过,它会被放入一个F-Queue队列中,等待执行。
- JVM 会检查该对象是否有必要执行
- 第二次标记 :如果在
finalize()执行过程中,对象重新与引用链上的任何一个对象建立了关联(比如把自己赋值给某个类变量),那么它就会在第二次标记时被移出"即将回收"的集合。
注意 :由于
finalize()的执行优先级极低且不确定,现在 Java 官方已经极力不推荐使用它。
4. 为什么它能解决循环引用?
回到你之前提到的 objA 和 objB 互相引用的问题:
- 在引用计数法中,它们互相比心,计数器永远是 111。
- 在 可达性分析 中,即使它们互相引用,但只要从 GC Roots(比如当前的栈帧变量)出发找不到它们,它们就会被视为一个孤岛。
- 由于无法从根节点到达这个"孤岛",整个孤岛都会被判定为垃圾并整体回收。
GC回收的具体三种方式(算法)
JVM 垃圾收集(GC)的三种核心算法及其技术特性总结如下:
1. 标记-清除算法 (Mark-Sweep)
这是最基础的算法,后续算法均是对其缺点的改进。
- 执行逻辑 :
- 标记:遍历 GC Roots,识别出所有存活对象(或直接标记待清理对象)。
- 清理:在原地直接回收未被标记的对象所占用的内存空间。
- 缺点 :
- 内存碎片化:清理后的内存空间是不连续的,会产生大量零碎的空闲块。
- 分配效率低:当需要分配较大对象时,即使剩余总内存足够,也会因为找不到连续空间而被迫提前触发下一次 GC。
2. 标记-复制算法 (Mark-Copy)
该算法通过"空间换时间"的策略,解决了碎片化问题,是新生代的主要算法。
- 执行逻辑 :
- 标记:识别出当前内存区域中的存活对象。
- 复制:将所有存活对象按顺序拷贝到另一块完全连续的空闲内存空间中(在新生代中,通常是由 Eden 和其中一个 Survivor 区拷贝到另一个 Survivor 区)。
- 清空:直接一次性清理掉原有的内存区域。
- 缺点 :
- 内存利用率低:需要预留一部分内存空间(To Space)用于存放复制的对象,导致可用内存减小。
- 存活率依赖性:如果大部分对象都存活,复制操作会涉及大量的内存拷贝和引用地址更新,效率会大幅下降。
3. 标记-整理算法 (Mark-Compact)
该算法结合了前两者的优点,通过移动对象来消除碎片,是老年代的主流算法。
- 执行逻辑 :
- 标记:识别出所有存活对象。
- 整理:将所有存活对象向内存区域的一端移动,使它们在物理内存上紧密排列。
- 清除:直接清理掉存活对象边界以外的所有空间。
- 优点 :
- 无碎片且省内存:既解决了标记-清除的碎片问题,又不像标记-复制那样需要浪费一半或额外的预留空间。
- 缺点 :
- 移动开销大:在整理过程中,需要移动对象的内存位置,并更新所有指向这些对象的指针地址。这是一个极其耗时的操作,会导致较长的 STW(Stop The World)停顿,因此不适合在对象变动频繁的新生代使用。
在清除阶段,标记清除需要扫描全区,标记复制只需要扫描存活对象
要理解为什么"标记-清除"需要全量扫描而"标记-复制"不需要,我们需要拆解 标记阶段(Marking) 和 处理阶段(Sweeping/Copying) 的底层实现。
1. 标记阶段:两者确实都要扫描"存活对象"
在第一步"可达性分析"时,无论哪种算法,都必须从 GC Roots 出发遍历引用链。
- 共同点 :这一步的时间复杂度都是 O(Live Objects)O(\text{Live Objects})O(Live Objects)。
- 结论:在标记存活对象这一步,两者开销基本持平。
2. 处理阶段:扫描"存活对象" vs 扫描"全量内存"
这是两者效率产生巨大差异的根本原因。
标记-清除 (Mark-Sweep) 的"扫全区"
标记完存活对象后,JVM 必须回收那些没被标记的死亡对象。
- 执行逻辑 :JVM 无法直接跳到死亡对象的位置,它必须物理性地线性遍历 整个老年代内存地址(从起始地址 AddrstartAddr_{start}Addrstart 到结束地址 AddrendAddr_{end}Addrend)。
- 工作内容 :每经过一个内存块,都要检查其 Header 中的标记位。如果是存活的,跳过;如果是死亡的,将其起始地址和大小记录到 空闲列表 (Free List) 中。
- 效率瓶颈 :处理耗时 =f(堆的总大小)= f(\text{堆的总大小})=f(堆的总大小)。
- 结论 :即使存活率只有 1%1\%1%,你也必须把剩下的 99%99\%99% 的空间全部"摸"一遍。
标记-复制 (Mark-Copy) 的"局部扫描"
标记-复制算法在处理阶段的行为完全不同。
- 执行逻辑 :它只处理被标记为存活 的对象。每标记一个,就立即将其拷贝到 To 空间。
- 清空逻辑 :当所有存活对象拷贝完毕后,From 空间 里的剩余内容(即占 90%90\%90% 以上的垃圾)完全不进行扫描。
- 重置指针 :JVM 直接将 From 空间的 分配指针 (Allocation Pointer) 重置为起始位置。这意味着在逻辑上,这块内存瞬间变为空白。
- 效率优势 :处理耗时 =f(存活对象数量)= f(\text{存活对象数量})=f(存活对象数量)。
- 结论 :如果存活率只有 1%1\%1%,JVM 只需要处理这 1%1\%1% 的对象。剩下的 99%99\%99% 的垃圾空间,JVM 连看都不会看一眼,直接通过重置指针抹除。
垃圾回收器有哪些
Java 的垃圾回收器(Garbage Collector, GC)经历了从单线程到多线程,从全堆扫描到局部扫描的演进。根据其工作区域和核心目标(高吞吐量 vs 低延迟),可以将其分为以下几类:
1. 串行收集器 (Serial Collectors)
这是最基础的收集器,采用单线程工作方式,回收时会产生明显的 Stop The World (STW)。
- Serial GC :用于新生代,采用标记-复制算法。
- Serial Old GC :用于老年代,采用标记-整理算法。
- 适用场景:单核 CPU、微型应用或客户端模式(Client Mode)。
2. 并行收集器 (Parallel Collectors)
由于多核 CPU 的普及,为了提升吞吐量(Throughput),JVM 引入了多线程并行的收集器。
- Parallel Scavenge GC :用于新生代,采用标记-复制算法。其核心目标是达到一个可控制的吞吐量(运行代码时间 / 总时间)。
- Parallel Old GC :用于老年代,采用标记-整理算法。
- 适用场景:后台数据处理、科学计算等对停顿时间不敏感但对吞吐量要求高的场景。
3. 并发标记清除收集器 (CMS)
CMS(Concurrent Mark Sweep)收集器是 Java 虚拟机(HotSpot)中第一款真正意义上的并发 收集器。它的设计目标是获取最短回收停顿时间(Low Latency),特别适合于互联网网站或者 B/S 系统的服务端上,这类应用非常重视服务的响应速度。
CMS 收集器运行在老年代(新生代一般用标记复制算法),基于标记-清除(Mark-Sweep)算法实现。
CMS 的执行过程(四个阶段)
CMS 的运作过程比之前的收集器更复杂,分为四个主要步骤:
1. 初始标记 (Initial Mark) ------ 需要 STW
- 动作 :仅仅只是标记一下 GC Roots 能直接关联到的对象,以及被新生代对象引用的老年代对象。
- 特性:由于只扫描直接关联的对象,不进行深度引用链追踪,所以速度非常快。
- 停顿:会触发 Stop The World (STW),暂停所有用户线程。
2. 并发标记 (Concurrent Mark)
- 动作:从"初始标记"的对象开始,遍历整个老年代的对象引用图,标记所有可达的对象。
- 特性 :这个阶段耗时最长,但它是与用户线程并发运行的。GC 线程在后台运行,不影响业务逻辑。
- 挑战:因为用户线程在运行,可能会修改对象的引用关系,导致标记结果产生偏差。
3. 重新标记 (Remark) ------ 需要 STW
- 动作:为了修正"并发标记"期间,因用户程序继续运作而导致引用关系产生变动的那一部分对象的标记记录。
- 技术实现 :CMS 采用 增量更新(Incremental Update) 算法。当一个白色对象(未标记)被赋值给一个黑色对象(已处理)的字段时,JVM 会记录下这个写操作,重新标记阶段会以这些记录为准重新扫描。
- 停顿:会触发 STW,停顿时间通常比初始标记长,但远比并发标记短。
4. 并发清除 (Concurrent Sweep)
- 动作:清理掉标记阶段判定为死亡的对象。
- 特性 :由于使用的是标记-清除算法,不需要移动对象,因此这个阶段也可以与用户线程并发执行。
- 结果:释放出的内存空间是不连续的。
CMS 的技术缺陷
尽管 CMS 实现了并发收集,但它存在三个明显的缺点:
1. 内存碎片问题 (Memory Fragmentation)
- 原因:由于使用"标记-清除"算法,回收后不移动存活对象。
- 后果 :老年代会产生大量不连续的空闲空间。当有大对象需要分配内存但找不到足够大的连续空间时,即使老年代剩余总量充足,也会被迫触发一次 Full GC。
- 解决方案 :JVM 提供了参数(如
-XX:+UseCMSCompactAtFullCollection)在 Full GC 时开启内存碎片的合并整理,但这会导致 STW 时间变长。
2. 浮动垃圾 (Floating Garbage)
- 定义:在"并发清除"阶段,用户线程仍在运行,这期间产生的垃圾对象在当前收集中无法被标记,只能留到下一次 GC 再清理。
- 影响:CMS 不能等到老年代几乎完全填满了再进行收集,必须预留一部分空间供并发收集时的用户线程使用。
3. Concurrent Mode Failure(并发失败)
- 诱因:如果 CMS 运行期间预留的内存无法满足程序分配新对象的需要,就会发生并发失败。
- 处理逻辑 :一旦发生此错误,虚拟机将启动后备方案:临时启用 Serial Old 收集器。Serial Old 是单线程的,会进行全堆扫描并进行"标记-整理",这会导致非常长的 STW 停顿。
4.G1收集器
1. G1 内存布局基础
G1 摒弃了固定大小的物理分代,将堆划分为约 204820482048 个 Region。
- 角色定义:每个 Region 动态扮演 Eden、Survivor 或 Old 角色。
- 巨型对象(Humongous) :超过 Region 大小 50%50\%50% 的对象直接分配在连续的 Humongous Region 中。
2. G1 回收周期流程
G1 的运行过程是一个循环,主要包含三个部分:
阶段一:Young GC(年轻代回收)
当 Eden 区达到 G1 根据停顿目标计算出的逻辑阈值时触发。
- 动作 :将所有 Eden 和 Survivor Region 中的存活对象通过 标记-复制算法 疏散到新的 Survivor 或 Old Region。
- 特性 :全过程 STW (Stop The World),多线程并行执行。
阶段二:并发标记周期 (Concurrent Marking Cycle)
当老年代占比达到 IHOP 阈值 (默认 45%45\%45%)时启动,为 Mixed GC 做准备。
- 初始标记 (Initial Mark) :STW。标记 GC Roots 直接关联的对象(通常借用 Young GC 的 STW 完成)。
- 并发标记 (Concurrent Mark) :并发。遍历堆进行可达性分析,确定哪些对象存活。
- 最终标记 (Remark) :STW 。处理并发期间由 SATB (Snapshot-At-The-Beginning) 记录的引用变动。
- 清理 (Cleanup) :STW。回收完全为空的 Region,并根据存活率对老年代 Region 的回收价值进行排序。
3. 重点解析:Mixed GC (混合回收)
Mixed GC 是 G1 的核心精髓。在并发标记周期结束后,G1 并不立即回收老年代,而是根据 Cleanup 阶段生成的"价值清单"开始混合回收。
A. 回收集合 (CSet) 的组成
每一轮 Mixed GC 的回收范围(CSet)包括:
- 全部年轻代 Region。
- 部分收益最高的老年代 Region。
B. 执行特征:分轮次的 STW 拷贝
- 非并发回收 :与 CMS 的并发清除不同,G1 的回收阶段是 STW 的。
- 算法逻辑 :采用 标记-复制算法。将 CSet 中所有存活对象拷贝到空的 Region 中,并更新引用指针。
- 分轮执行 :为了不超时(
-XX:MaxGCPauseMillis),G1 会将选定的老年代 Region 分散到多次 Mixed GC 中完成(默认 8 轮)。每一轮之间,用户线程会恢复运行。
C. 为什么每轮都收年轻代?
- 处理新垃圾:轮次间隔期间,用户线程产生的 Eden 对象可以在本轮顺带清理。
- 简化 RSet:清空年轻代可以重置所有从年轻代指向老年代的引用记录,降低记忆集(Remembered Set)的维护成本。
D. 为什么采用"标记-复制"算法
因为新生代与回收价值高(大部分对象要被回收)的老年代,大部分对象要被回收,因此适用标记-复制算法的应用场景,只用移动一小部分存活对象到新空间。
4. 核心技术对比与总结
| 特性 | CMS 收集器 | G1 收集器 |
|---|---|---|
| 回收算法 | 标记-清除(并发执行) | 标记-复制(STW 并行执行) |
| 内存连续性 | 产生大量内存碎片 | 无碎片(Region 间拷贝) |
| 回收目标 | 尽可能回收全老年代 | 优先回收价值最高的 Region |
| 停顿控制 | 不可预测 | 可预测(根据目标调整 CSet 大小) |
| 清除环节 | 与用户线程并发(标记清除不改变存活对象的地址) | 与用户线程串行(标记复制会改变存活对象的地址) |
关键结论
- Mixed GC 是"温水煮青蛙":它通过多次短促的 STW 停顿,逐步蚕食老年代垃圾,避免了单次长时间 STW 的 Full GC。
- STW 是为了安全:移动对象(标记-复制)必然涉及指针修改,在没有 ZGC 那种"染色指针"技术前,必须通过 STW 保证用户线程不会访问到错误的内存地址。
什么时候用CMS,什么时候用G1
选择 CMS 还是 G1 ,本质上是在 内存碎片忍受度 、堆内存大小 以及 停顿时间预期 之间做权衡。
以下是根据底层原理总结的适用场景及其原因:
1. 适合使用 CMS 的场景
典型配置:堆内存在 4GB - 6GB 以下,且对老年代停顿敏感。
原因分析(基于底层原理):
- 低内存开销 :
CMS 采用物理分代,逻辑结构简单。相比 G1 需要维护复杂的 RSet(记忆集) 和大量的 Region 元数据,CMS 的额外空间占用(Memory Footprint)更小。在小内存环境下,G1 的元数据可能占用掉 10%∼20%10\% \sim 20\%10%∼20% 的堆空间。 - 并发清除的优势 :
CMS 的 Concurrent Sweep(并发清除) 阶段不需要移动对象,这意味着它在回收老年代时,用户线程几乎可以无感执行。在小堆上,即便产生碎片,触发 Full GC 的频率也较低。 - 硬件资源受限 :
CMS 的并发线程数相对较少。如果服务器 CPU 核心数不多(如 2-4 核),CMS 对业务吞吐量的影响通常比 G1 更小。
2. 适合使用 G1 的场景
典型配置:堆内存在 6GB 以上(尤其是超过 8GB),且有明确的停顿时间目标。
原因分析(基于底层原理):
- 解决内存碎片(标记-复制算法) :
G1 采用 Region 间的 标记-复制 。对于大内存应用,运行时间越长,CMS 产生的碎片就越致命,最终必然导致长达数秒甚至数十秒的 Serial Old Full GC。G1 通过局部压缩彻底规避了这个问题,保证了长时间运行的稳定性。 - 可预测的停顿时间 (
MaxGCPauseMillis) :
G1 的底层逻辑是 "价值优先" 。它通过维护每个 Region 的回收价值清单,在限定时间内只回收收益最高的 Region(CSet)。这使得它能在大堆环境下依然将停顿控制在 200ms200ms200ms 甚至更低,而 CMS 面对大堆时的 Remark(重新标记) 阶段耗时往往不可控。 - 高效处理大对象 :
G1 拥有专门的 Humongous Region。对于频繁产生大对象的应用,G1 能更好地管理这些对象,避免它们直接进入老年代导致频繁的 Full GC。 - 分阶段回收(Mixed GC) :
G1 不需要像 CMS 那样一次性清理掉整个老年代。通过 Mixed GC,它能将老年代的回收压力分摊到多次短促的停顿中,避免了"积压爆发"。