为什么会有垃圾回收,简单来说就是内存不够用了,需要清除一些不用的对象释放内存。
🎯 如何判断对象"可回收"?
垃圾回收的前提是准确判断哪些对象已经"死亡"。JVM主要使用可达性分析算法,而非简单的引用计数法(因其无法解决循环引用问题)。
可达性分析算法的核心思想 是:以一系列称为 "GC Roots" 的对象作为起始点,开始向下搜索,所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时(即对象不可达),则证明此对象不再被使用,可以被回收。
哪些对象可以作为GC Roots? 主要包括以下几类:
- 虚拟机栈中的引用:正在执行的各个方法中的局部变量表所引用的对象。
- 方法区中静态属性引用的对象:类的静态变量。
- 方法区中常量引用的对象:比如字符串常量池里的引用。
- 本地方法栈中引用的对象:JNI(Native方法)引用的对象。
- Java虚拟机内部的引用:如基本数据类型的Class对象,常驻的异常对象等。
- 被同步锁持有的对象 :例如
synchronized关键字锁定的对象。
为了更直观地理解对象从创建到被回收的完整路径,特别是可达性分析的过程,我们可以参考下面的流程图:
是
不可达
可达
是
否
是
对象实例化
在Eden区分配内存
对象被GC Roots引用
Eden区满
执行Minor GC
进行可达性分析
从GC Roots
是否可达?
被标记为可回收对象
对象内存被回收
存活对象被复制到
Survivor区或老年代
对象年龄增加
年龄超过阈值?
对象晋升至老年代
老年代空间不足?
执行Full GC
🔗 认识Java的引用类型
对象的"可达"与"不可达"与其引用类型息息相关。Java提供了四种强度不同的引用类型,它们对垃圾回收的影响各不相同:
| 引用类型 | 强度 | 被回收时机 | 常见应用场景 |
|---|---|---|---|
| 强引用 | 最强 | 永远不会被GC回收(即使OOM) | 平常的 Object obj = new Object() |
| 软引用 | 次之 | 在内存不足时,会被GC回收 | 实现内存敏感的缓存,如图片缓存 |
| 弱引用 | 较弱 | 只能生存到下一次GC发生之前 | 构建非必需的缓存,WeakHashMap |
| 虚引用 | 最弱 | 无法通过虚引用取得对象实例,其存在仅用于接收对象被回收的系统通知 | 用于在对象被回收时收到通知,例如管理堆外内存 |
⚙️ 核心垃圾回收算法
当确定对象可回收后,就需要具体的算法来执行清理。以下是三种经典算法:
-
标记-清除算法
- 过程:首先标记出所有需要回收的对象;在标记完成后,统一回收所有被标记的对象。
- 缺点 :效率问题,标记和清除两个过程的效率都不高;会产生大量不连续的内存碎片,可能导致后续无法分配大对象而提前触发GC。
-
复制算法
- 过程:将可用内存按容量分为大小相等的两块,每次只使用其中一块。当这一块用完了,就将还存活的对象复制到另一块上,然后把已使用的空间一次清理掉。
- 优点:实现简单,运行高效,没有内存碎片。
- 缺点:内存利用率低,只有一半内存可用。如果对象存活率高,复制开销会很大。
- 应用场景 :主要适用于新生代,因为新生代中98%的对象都是"朝生夕死"的。HotSpot虚拟机将新生代划分为一个Eden区和两个Survivor区,每次使用Eden和一个Survivor,回收时将存活对象复制到另一个Survivor区。
-
标记-整理算法
- 过程:标记过程与"标记-清除"一样。但后续不是直接清理,而是让所有存活的对象都向内存空间的一端移动,然后直接清理掉边界以外的内存。
- 优点:避免了内存碎片问题,也避免了复制算法的空间浪费。
- 缺点:移动对象需要一定开销,并且需要更新被移动对象的引用地址。
- 应用场景 :主要适用于老年代,因为老年代对象存活率高,不适合频繁复制。
🧠 分代收集策略:算法的实践智慧
现代JVM综合以上算法,采用了分代收集策略 。它基于一个经验法则:绝大多数对象都是朝生夕死的。因此,Java堆被划分为新生代和老年代,对不同生命周期的对象采用不同的回收策略。
- 新生代 :对象创建的地方。每次垃圾回收时都有大量对象死去,只有少量存活,因此采用复制算法,效率高。
- 老年代 :存放长期存活的对象。对象存活率高,没有额外的内存空间进行分配担保,因此采用标记-清除 或标记-整理算法。
🚀 主流垃圾收集器详解
垃圾收集器是上述算法的具体实现。JDK中提供了多种收集器,适用于不同场景。
| 收集器 | 目标 | 新生代算法 | 老年代算法 | 特点与适用场景 |
|---|---|---|---|---|
| Serial / Serial Old | 单线程、简单高效 | 复制 | 标记-整理 | 客户端模式,单CPU环境,简单有效 |
| Parallel Scavenge / Parallel Old | 高吞吐量 | 复制 | 标记-整理 | JDK8默认,后台运算、批处理任务 |
| ParNew + CMS | 低停顿时间 | 复制 | 并发标记-清除 | B/S系统,重视响应速度(CMS已废弃) |
| G1 | 兼顾吞吐量与停顿 | 整体标记-整理,局部复制 | 同上 | JDK9及以后默认,通用服务端应用,可预测停顿 |
| ZGC / Shenandoah | 超低停顿(<10ms) | 并发标记-整理/复制 | 同上 | 大内存、极致低延迟,如金融交易、实时系统 |
各个版本的JDK的垃圾收集器对比
不同JDK版本的默认垃圾收集器并不相同,它们随着版本迭代在不断演进,其核心目标是更好地平衡吞吐量 、延迟 和内存管理效率。
下表清晰地展示了这几个关键JDK版本的默认垃圾收集器及其核心变化。
| JDK 版本 | 默认垃圾收集器 | 核心变化与特点 |
|---|---|---|
| JDK 8 | Parallel GC (Parallel Scavenge + Parallel Old) | 追求高吞吐量,适合后台运算、数据处理等对停顿时间不敏感的场景。是JDK 8及之前版本在Server模式下的默认选择 。 |
| JDK 11 | G1 GC (Garbage-First) | 标志着默认GC从吞吐量优先转向低延迟目标。G1旨在尽可能缩短大型堆(如超过4GB )的停顿时间,同时保持良好的吞吐量,适用于需要更稳定响应时间的应用服务器 。 |
| JDK 17 | G1 GC | 继续以G1为默认,并对其进行了大量优化(如更多阶段的并行化 )。同时,ZGC 和Shenandoah作为成熟的低延迟收集器可供选择,但CMS收集器被移除 。 |
| JDK 21 | G1 GC | G1保持默认。最重要的进展是引入了分代式ZGC,通过应用分代思想显著提升了ZGC的性能 ,为未来可能成为默认选项铺平了道路 。 |
🔄 垃圾收集器的演进逻辑
JDK垃圾收集器的演进主要围绕解决三个核心问题:
- 吞吐量 vs. 延迟 :早期Parallel GC最大化吞吐量,但可能带来较长停顿。后续的G1、ZGC等更关注降低单次GC停顿时间。
- 堆内存增大 :随着应用内存需求增长,传统收集器在全堆GC时停顿时间变长。G1引入的Region分区 、ZGC的并发处理能力都是为了更好地管理大内存堆 。
- 可预测性 :现代应用(如微服务)需要更可预测的响应时间。G1的停顿预测模型 (
-XX:MaxGCPauseMillis)和ZGC的亚毫秒级停顿目标都是为此努力 。
💡 如何选择垃圾收集器?
没有"最好"的收集器,只有最合适你应用场景的。你可以参考以下思路:
- 追求极致吞吐量 :如果应用是后台计算任务,对停顿不敏感,Parallel GC可能仍然是好选择。
- 平衡吞吐量与延迟 :对于大多数面向用户的应用(如Web服务),G1 GC是稳健的默认选择,它在JDK 11及以后版本中能很好地平衡吞吐量和延迟 。
- 要求极低延迟 :若应用对停顿极度敏感(如金融交易、实时系统),且堆内存较大,可以考虑启用ZGC 或Shenandoah 。特别是在JDK 21及以上版本,分代ZGC是非常有吸引力的选项 。
- 特殊用途 :Epsilon GC (无操作GC)适用于短期存活或已知内存充足的无GC场景(如性能测试);Serial GC适用于微服务、资源受限环境(如容器)。
🛠️ 如何查看和指定垃圾收集器?
- 查看默认GC :使用JVM参数
-XX:+PrintCommandLineFlags,输出中若包含-XX:+UseG1GC则表示当前使用G1 。 - 指定GC :通过JVM启动参数指定,例如:
-XX:+UseG1GC(启用G1,JDK9后默认)-XX:+UseParallelGC(启用Parallel GC)-XX:+UseZGC(启用ZGC,适用于JDK 11以上 )
💎 简单总结
简单来说,JDK 8之后,默认垃圾收集器从注重吞吐量的Parallel GC 转向了注重低延迟的G1 GC。JDK 11开始,G1成为默认选择并持续优化,同时提供了ZGC等更先进的低延迟选项。JDK 21的分代ZGC进一步增强了其在低延迟场景下的竞争力。