Java虚拟机:对象晋升机制与Finalize内存陷阱
一、 对象晋升从新生代到老年代的“晋级之路”对象从新生代进入老年代在JVM术语中被称为晋升Promotion。JVM并没有死板地规定晋升条件而是综合了对象年龄和对象体积两个维度进行动态决策。1.1 年龄晋升岁月是把杀猪刀也是通关文牒在新生代中对象每经历一次Minor GC新生代垃圾回收而未被回收其年龄Age就会增加 1 岁。当年龄达到某个阈值时对象就会被“强制”晋升到老年代。核心参数-XX:MaxTenuringThreshold这个参数用于设置对象晋升到老年代的年龄最大阈值。默认情况下该值为15。 注意一个极其重要的概念MaxTenuringThreshold设置的是最大年龄上限充分非必要条件。达到该年龄对象必然晋升到老年代。未达到该年龄不代表一定留在新生代为了动态调整JVM引入了-XX:TargetSurvivorRatio默认值 50。动态晋升规则如果 Minor GC 后Survivor 区幸存者区中相同年龄的对象的总大小超过 Survivor 区空间的50%JVM 会计算出一个更小的年龄阈值将那些“年龄偏大”的对象直接“特批”晋升到老年代目的是为了给新生代腾出更多空间。这意味着即便你设置了最大阈值为 15实际对象可能只到 2 岁就被晋升了。实战验证我们写一段代码强行让对象经历 17 次左右的 GC并观察日志public class MaxTenuringThreshold { public static final int _1M 1024 * 1024; public static void main(String args[]) { MapInteger, byte[] map new HashMap(); // 放入 5MB 的常驻对象 for (int i 0; i 5 * 1024; i) { map.put(i, new byte[1024]); } // 制造多次 Minor GC 创造晋升环境 for (int k 0; k 17; k) { for (int i 0; i 270; i) { byte[] g new byte[_1M]; } } } }启动参数-Xmx1024M -Xms1024M -XX:PrintGCDetails -XX:MaxTenuringThreshold5 -XX:PrintHeapAtGC结果分析从GC日志中我们会发现原本在新生代中历经沧桑的 5MB map 数据最终出现在了老年代而新生代使用率降至 0。这完美印证了对象年龄达到阈值后必然晋升的结论。1.2 体积晋升大对象的“直通车”并不是只有年龄大了才能进老年代。如果对象太大新生代装不下它就会直接绕过新生代通过“VIP通道”被分配到老年代。直观理解假设新生代 Survivor 区只有 5MB 大小突然来了一个 6MB 的“巨无霸”对象。哪怕它是刚出生的零岁对象因为它无论如何都放不进 Survivor 区为了不阻塞新生代的正常运行JVM 会在分配时直接将其丢进老年代。核心参数-XX:PretenureSizeThreshold这个参数用于设置大对象直接晋升老年代的阈值单位字节。只要对象大小 该值就会绕过 Eden直接在老年代分配。⚠️ 绕坑提醒重要限制该参数仅对串行回收器Serial GC和 ParNew 回收器有效对于常用的Parallel GC是无效的。默认该值为 0表示不指定大小限制由 JVM 运行情况自行决断。TLAB 干扰JVM 分配对象时会优先在TLABThread Local Allocation Buffer线程本地分配缓存中分配。如果对象不大哪怕它超过了PretenureSizeThreshold也有可能直接在 TLAB 分到了导致看似没进老年代。因此测试时必须禁用 TLAB-XX:-UseTLAB。实战验证public class PretenureSizeThreshold { public static void main(String args[]) { MapInteger, byte[] map new HashMap(); for (int i 0; i 5 * 1024; i) { map.put(i, new byte[1024]); } } }启动参数-Xmx32m -Xms32m -XX:UseSerialGC -XX:PrintGCDetails -XX:PretenureSizeThreshold1000 -XX:-UseTLAB结果分析当明确禁用 TLAB 后大于 1000 字节的 byte 数组被成功、干净利落地分配到了老年代。二、 致命陷阱被遗忘的finalize与内存泄漏如果说体积和年龄是老年代的“硬指标”那finalize方法就是 JVM 为了给开发者留后门而设下的“温柔陷阱”。finalize()是Object类的一个受保护方法允许子类重写用于在对象被 GC 回收前执行资源释放操作如关闭文件流、释放本地资源等。为什么不建议使用finalize()2.1 造成“对象复活”的幻觉在finalize()方法中如果执行了类似于this赋给某个全局引用的操作这个即将被回收的对象会瞬间恢复强引用。这不仅让 GC 宣告失效还会让对象像“僵尸”一样在堆中游荡。2.2 执行时间毫无保障finalize()的执行完全由FinalizerThread线程控制。如果不发生 GC或者FinalizerThread线程因为系统资源调度极度缓慢那么finalize()可能永远都不会执行。这对于依赖它释放“必须释放的资源”的程序来说是灾难性的。2.3 严重影响 GC 性能终极杀手这是最致命的一点。我们来看 JVM 底层的执行逻辑结合提供的图解强引用阻挡回收当一个重写了finalize的对象即将被回收时JVM 会创建一个java.lang.ref.Finalizer对象将其封装起来。链表队列Finalizer内部维护了一个双向链表next和prev指针通过referent字段强引用了原本的那个对象。排队阻塞这个Finalizer对象被放入一个ReferenceQueue引用队列中交由FinalizerThread线程排队依次执行finalize()方法。后果因为referent是强引用所以在finalize()执行完成并从这个队列中移除前该对象永远无法被垃圾回收。如果finalize()方法逻辑写得糟糕例如有长达1秒的Thread.sleep会导致FinalizerThread线程队列迅速积压。几十万个等待被清理的对象被强引用死死拽在内存中堆内存压力剧增最终必然引发OutOfMemoryErrorOOM致命 OOM 实战演示编写一个重写了finalize的类并疯狂创建对象。public class LongFinalize { public static class LF { private byte[] content new byte[512]; // 512字节 Override protected void finalize() { try { Thread.sleep(1000); // 模拟耗时操作 } catch (Exception e) {} } } public static void main(String[] args) { for (int i 0; i 50000; i) { new LF(); // 对象很快失去引用本应被回收 } } }启动参数-Xmx10m -Xms10m -XX:PrintGCDetails -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPathf.dump现象程序立刻爆出 OOM。使用 Eclipse MAT 工具分析得到的f.dump堆文件问题疑点分析你会发现内存中有上万个java.lang.ref.Finalizer实例占据了接近 95.8% 的内存。真相大白这些Finalizer对象如同锁链把本该回收的LF对象全部牢牢“栓”在了队列里导致它们永远无法被回收最终撑爆了内存。 对策永远不要在业务代码中重写finalize()。如果必须释放资源请使用try-with-resources实现AutoCloseable接口、显式close()方法或者使用CleanerJava 9 推出的虚引用替代方案。三、 温故知新JVM 垃圾回收核心参数清单1. 串行回收器 (Serial GC)-XX:UseSerialGC新生代和老年代都使用单线程串行回收器。-XX:SurvivorRatioEden 与 Survivor 区的比例。例如8代表 Eden : S0 : S1 8 : 1 : 1。-XX:PretenureSizeThreshold大对象直接进入老年代的阈值仅对 Serial / ParNew 有效。-XX:MaxTenuringThreshold对象晋升老年代的年龄最大阈值默认 15。2. 并行回收器 (Parallel GC - JDK 8 默认)-XX:UseParNewGC新生代使用并行收集器老年代配合 CMS 使用。-XX:UseParallelOldGC新生代Parallel Scavenge和 老年代Parallel Old都使用并行回收。-XX:ParallelGCThreads设置垃圾回收线程数通常等于 CPU 核数。-XX:MaxGCPauseMillis设置最大 GC 停顿时间毫秒。收集器会动态调整堆大小以满足此目标。-XX:GCTimeRatio设置吞吐量大小0-100 整数例如 n则 GC 耗时不超过总时间的1/(1n)。-XX:UseAdaptiveSizePolicy打开自适应策略JVM 自动调整新生代大小、Eden/Survivor 比例及晋升年龄。3. CMS 回收器 (Concurrent Mark Sweep - JDK 9前较常用)-XX:UseConcMarkSweepGC老年代使用 CMS新生代默认配合 ParNew。-XX:ParallelCMSThreads设定 CMS 并发线程数量。-XX:CMSInitiatingOccupancyFraction老年代占用率达到多少时触发 CMS GC默认为68%。-XX:UseCMSCompactAtFullCollectionCMS 完成后是否进行内存碎片整理压缩。-XX:CMSFullGCsBeforeCompaction设定多少次 CMS GC 后执行一次压缩整理减少碎片。-XX:CMSClassUnloadingEnabled允许 CMS 对类元空间Metaspace进行回收。4. G1 回收器 (Garbage-First - JDK 9 默认)-XX:UseG1GC启用 G1 垃圾回收器。-XX:MaxGCPauseMillis设置最大 GC 停顿时间G1 的核心目标。-XX:GCPauseIntervalMillis设置停顿间隔时间。