垃圾回收机制:从判断对象可回收到生产级GC调优

垃圾回收机制:从判断对象可回收到生产级GC调优 一、如何判断对象可回收在JVM回收内存之前首先要回答一个问题到底哪些对象是垃圾1. 引用计数法Reference Counting原理每个对象维护一个计数器被引用一次1引用失效-1计数器为0时回收。public class ReferenceCounting { public Object ref; public static void main(String[] args) { ReferenceCounting obj1 new ReferenceCounting(); ReferenceCounting obj2 new ReferenceCounting(); obj1.ref obj2; // obj2 计数1 obj2.ref obj1; // obj1 计数1 obj1 null; // obj1 计数-1仍为1 obj2 null; // obj2 计数-1仍为1 // 循环引用两个对象永远不会被回收 } }缺点无法解决循环引用问题JVM不采用此方法。2. 可达性分析Reachability Analysis⭐原理从一组称为GC Roots的根对象出发通过引用链向下搜索未被任何路径引用的对象就是垃圾。GC Roots ↓ 对象A ← 对象B ↓ ↓ 对象C 对象D可达 对象E不可达垃圾GC Roots 包括哪些GC Roots 类型说明示例栈引用虚拟机栈栈帧中局部变量表引用的对象方法中的局部变量静态属性方法区中静态属性引用的对象public static User user new User()JNI引用本地方法栈中引用的对象Native 方法中的全局引用活跃线程正在运行的线程Thread 对象本身synchronized持有被同步锁持有的对象synchronized(obj)中的 objpublic class GCRootDemo { private static GCRootDemo staticObj new GCRootDemo(); // 静态属性 → GC Root public void method() { GCRootDemo localObj new GCRootDemo(); // 栈引用 → GC Root System.out.println(localObj); } }二、GC Roots 底层原理深入理解QGC Roots 的根节点是怎么来的谁设置的核心答案 GC Roots 的根节点不是手动设置的而是 JVM 在运行时自动识别和维护的。垃圾回收器通过JVM 内部的内存映射机制如 OopMap、栈帧遍历等来定位这些根节点。1. JVM 如何高效地找到 GC RootsGC 发生时JVM 不会笨拙地扫描所有内存去找引用。它用了两种关键技术技术一OopMap记录引用位置工作原理JIT 编译时在安全点的指令地址处记录 OopMapOopMap 告诉 GC当前栈帧的某个偏移量存放的是一个对象引用栈帧布局示例 偏移 0: 局部变量0 (int a) 偏移 4: 局部变量1 (Object ref) ← OopMap 记录 offset 4 是引用 偏移 8: 局部变量2 (long b)优点GC 时直接读取 OopMapO(1) 复杂度定位所有引用技术二安全点SafepointGC 发生时需要所有线程停在安全点才能开始扫描安全点通常设置在方法返回、循环跳转、异常抛出的位置在安全点的 OopMap 是准确且稳定的2. GC Roots 识别流程GC 发生如 Minor GC ↓ 所有线程到达 Safepoint ↓ ┌─────────────────────────────────────────────────┐ │ 遍历所有线程的栈帧 │ │ - 读取每个栈帧的 OopMap │ │ - 提取所有对象引用 → 加入 GC Roots 集合 │ ├────────────────────────────────────────────────┤ │ 读取方法区元空间的静态变量表 │ │ - 所有 static 字段的引用 → 加入 GC Roots │ ├─────────────────────────────────────────────────┤ │ 遍历 JNI 全局引用表 │ │ - JNI NewGlobalRef 创建的引用 → 加入 GC Roots │ ├─────────────────────────────────────────────────┤ │ 获取所有活跃线程对象 → 加入 GC Roots │ ├─────────────────────────────────────────────────┤ │ 获取所有被 synchronized 持有的 monitor 对象 │ │ → 加入 GC Roots │ └─────────────────────────────────────────────────┘ ↓ 从 GC Roots 开始遍历整个堆标记所有存活对象 ↓ 回收未被标记的对象3. GC Roots 面试追问问题答案GC Roots 是固定的吗动态变化GC时在Safepoint处生成瞬时快照能手动设置 GC Roots 吗不能直接设置但可通过静态集合、JNI全局引用间接影响常量池字符串是 GC Root 吗JDK7 不是在堆中但因内部引用很少被回收未初始化的局部变量是 GC Root 吗不是JVM认为它是无效引用三、Java 引用类型4种引用类型回收时机使用场景强引用永不回收除非不可达普通new Object()软引用内存不足时回收缓存、图片加载弱引用下次GC时回收无论内存是否充足WeakHashMap、ThreadLocal虚引用随时可能回收需配合引用队列对象回收跟踪、NIO DirectByteBuffer代码示例// 1. 强引用 Object strongRef new Object(); // GC 永远不会回收除非 strongRef null ​ // 2. 软引用 SoftReferencebyte[] softRef new SoftReference(new byte[1024 * 1024 * 100]); // 内存不足时softRef.get() 返回 null ​ // 3. 弱引用 WeakReferenceObject weakRef new WeakReference(new Object()); System.gc(); // 立即被回收weakRef.get() 返回 null ​ // 4. 虚引用必须配合引用队列 ReferenceQueueObject queue new ReferenceQueue(); PhantomReferenceObject phantomRef new PhantomReference(new Object(), queue); // phantomRef.get() 永远返回 null只能通过队列感知对象被回收四、实战补充强引用的多种形态除了new Object()强引用还有很多常见形式// ① 数组数组元素也是强引用 String[] names new String[10]; names[0] 张三; // names[0] 指向的字符串对象是强引用 ​ // ② 集合中的元素 Listbyte[] list new ArrayList(); list.add(new byte[1024 * 1024]); // 元素被 list 强引用 ​ // ③ 静态字段生命周期极长 public class Global { public static Object staticObj new Object(); } ​ // ④ 内部类隐式持有外部类引用 public class Outer { class Inner { // 内部类默认持有 Outer.this 这个强引用 } } ​ // ⑤ 匿名内部类 / Lambda若捕获外部变量 Runnable r new Runnable() { Override public void run() { System.out.println(Outer.this); } };什么时候强引用会被切断方式示例显式置 nullstrongRef null;局部变量出栈方法执行完毕局部变量自动失效集合中移除list.remove(index)或map.clear()重新赋值strongRef anotherObject;五、WeakHashMap 与 ThreadLocal 正确用法1. WeakHashMap 使用场景核心特性Entry 的key 是弱引用当 key 没有其他强引用时该 Entry 会在下次 GC 时被自动移除。// 场景缓存不需要单独清理 WeakHashMapString, byte[] imageCache new WeakHashMap(); ​ String key1 new String(avatar_123); // 必须用 new String imageCache.put(key1, new byte[1024 * 100]); ​ key1 null; // 外部强引用消失 System.gc(); // imageCache 中的这条记录会被自动清理2. ThreadLocal 使用场景// 场景线程内传递上下文最常见 public class UserContext { private static final ThreadLocalUser currentUser new ThreadLocal(); public static void setUser(User user) { currentUser.set(user); } public static User getUser() { return currentUser.get(); } public static void remove() { currentUser.remove(); } // 必须 } ​ // 使用模式 try { UserContext.setUser(loginUser); // 业务逻辑... } finally { UserContext.remove(); // 必须手动清理 }⚠️ ThreadLocal 内存泄漏原因keyThreadLocal 实例是弱引用→ GC 时 key 被回收value 是强引用在线程存活期间永远无法被访问 → 内存泄漏解决方案finally中调用remove()六、内存泄漏排查定义不再被程序使用的对象因为仍有引用存在导致 GC 无法回收→ 内存只增不减 → 最终 OOM常见内存泄漏场景场景原因解决方案集合类未清理List 不断 add用完不 remove及时 clear()ThreadLocal 未 remove线程池中的线程持有过期的 valuefinally 中 remove()监听器/回调未注销注册了监听器对象销毁时未注销注销监听器内部类持有外部类非静态内部类隐式持有外部类静态内部类 弱引用连接未关闭JDBC、IO、Socket 未 closetry-with-resources排查工具步骤命令/工具观察内存持续增长jstat -gc pid 1000发现 Full GC 频繁老年代占用越来越高导出堆内存jmap -dump:live,formatb,fileheap.hprof pid分析泄漏对象MAT / JProfiler / VisualVM七、垃圾回收算法1. 标记-清除Mark-Sweep标记阶段遍历所有对象标记存活对象 → [存活][存活][垃圾][存活][垃圾] 清除阶段遍历堆回收未标记的内存 → [存活][存活] [存活]优点简单、不需要移动对象缺点产生内存碎片、效率不稳定2. 复制算法Copying⭐ 细节详解核心机制HotSpot 新生代实现新生代内存布局为Eden 两个 Survivor 区S0 和 S1。任何时候只有一个 Survivor 区被用来存放存活对象称为to 区另一个是空的称为from 区不是随机选择两个 Survivor 区角色固定互换初始状态 ┌─────────────────────────────────────────────────┐ │ Eden │ S0 (from) │ S1 (to空) │ └─────────────────────────────────────────────────┘ ​ GC 发生时 扫描 Eden from 区S0将存活对象复制到 to 区S1 ​ GC 结束后 Eden 和 from 区S0被清空 to 区S1变成下一次 GC 的 from 区 空的区变成下一次的 to 区完整流程示例第1次GC前 [Eden][S0: 空][S1: 空] ← 新对象分配在 Eden 第1次GC 扫描 Eden存活对象复制到 S1 第1次GC后 [Eden:清空][S0: 空][S1: 有数据] ← S1 变成 from ​ 第2次GC前 新对象分配在 Eden 第2次GC 扫描 Eden S1存活对象复制到 S0 第2次GC后 [Eden:清空][S0: 有数据][S1: 空] ← S0 变成 from优点无内存碎片缺点需要预留一个空闲 survivor 空间空间浪费3. 标记-整理Mark-Compact标记存活对象 → [存活][垃圾][存活][垃圾][存活] 整理存活对象 → [存活][存活][存活] 连续排列优点无碎片缺点移动对象开销大应用老年代八、分代收集算法理论基础基于两个假设弱分代假说绝大多数对象朝生夕灭年轻代强分代假说活得越久越难被回收老年代分代结构JDK8默认堆结构 ┌──────────────────────────────────────────────────────────┐ │ 年轻代Young │ │ ┌──────────┬────────────┬────────────┐ │ │ │ Eden │ S0 (from) │ S1 (to) │ │ │ │ 80% │ 10% │ 10% │ │ │ └──────────┴────────────┴────────────┘ │ ├──────────────────────────────────────────────────────────┤ │ 老年代Old │ │ - │ └──────────────────────────────────────────────────────────┘分代收集策略区域使用算法原因年轻代复制算法存活率低复制开销小老年代标记-清除 或 标记-整理存活率高移动成本大对象晋升规则年龄阈值默认15每次 Minor GC 存活年龄1达到阈值晋升老年代动态年龄判断Survivor 中同龄对象总和超过 50%大于等于该年龄的直接晋升大对象直接进入老年代-XX:PretenureSizeThreshold九、常见垃圾回收器回收器演化史Serial单线程→ Parallel并行→ CMS并发→ G1区域化→ ZGC亚毫秒1. Serial / Serial Old串行单线程回收GC期间Stop The WorldSTW适用单核CPU、几百MB内存参数-XX:UseSerialGC2. Parallel Scavenge / Parallel Old并行多线程回收JDK8默认关注吞吐量 程序运行时间 / (运行时间 GC时间)参数-XX:UseParallelGC3. CMSConcurrent Mark Sweep设计目标降低 STW 时间关注低延迟四个阶段初始标记STW→ 标记 GC Roots 直接关联的对象并发标记并发→ 从 GC Roots 遍历所有对象重新标记STW→ 修正并发期间变化的对象并发清除并发→ 清除垃圾缺点内存碎片、浮动垃圾使用-XX:UseConcMarkSweepGC4. G1Garbage First⭐⭐设计目标替代CMS可预测的停顿时间默认200ms核心创新Region 分区堆内存分为约 2048 个 Region每个 1-32MBRSetRemembered Set每个 Region 记录谁引用了我避免全堆扫描GC 流程年轻代GCSTW→ 复制存活对象并发标记并发→ 标记老年代垃圾混合GCSTW部分→ 回收垃圾最多的老年代 Region参数-XX:UseG1GCJDK9默认5. ZGC / Shenandoah面试加分ZGCJDK15 正式可用停顿时间 1ms核心着色指针 读屏障十、面试高频题1. Minor GC / Major GC / Full GC 区别类型触发区域STW情况Minor GC年轻代Eden满很短Major GC老年代较长Full GC整个堆 方法区最长2. Stop The World 是什么GC 期间所有应用线程暂停目标是尽量减少 STW 时间和次数。3. 如何查看和调优 GC# 查看 GC 情况 jstat -gc pid 1000 ​ # 常用 JVM 参数 -Xms4g -Xmx4g # 堆大小 -XX:UseG1GC # 使用 G1 -XX:MaxGCPauseMillis200 # 目标停顿时间 -XX:PrintGCDetails # 打印 GC 日志 -XX:HeapDumpOnOutOfMemoryError # OOM 时导出堆十一、总结速查表知识点一句话总结可达性分析GC Roots 出发找不到的对象就是垃圾GC Roots 定位OopMap 安全点JVM自动识别复制算法两个 Survivor 角色互换不是随机选分代收集年轻代复制算法老年代标记-整理引用类型强 软 弱 虚回收强度递增ThreadLocal必须手动 remove()否则内存泄漏G1核心Region RSet 可预测停顿面试金句G1 通过将堆划分为多个 Region并行并发收集并跟踪每个 Region 的回收价值优先回收垃圾最多的 Region从而实现可预测的停顿时间。希望这篇博客能帮你更好理解垃圾回收。如果觉得有用欢迎点赞收藏