
Java堆新生代与老年代经过前几篇对PC寄存器、虚拟机栈、本地方法栈的探讨我们了解了线程私有区域的运行机制。本篇进入JVM管理的最大、最复杂的内存区域——Java堆Java Heap。堆是所有对象实例和数组的存储之地也是GC的主战场。理解堆的分代结构、对象分配流程、TLAB机制和逃逸分析是进行内存调优和GC调优的基础。本篇将系统讲解这些内容并介绍关键的JVM参数。Java堆概述堆的作用与特点Java堆是JVM管理的最大一块内存区域被所有线程共享在JVM启动时创建。其唯一目的就是存放对象实例和数组——几乎所有的对象实例都在堆上分配逃逸分析支持栈上分配是例外后文详述。Java堆的特点线程共享所有线程都能访问堆中的对象需考虑线程安全GC管理堆是垃圾收集器的主要工作区域可扩展通过-Xms和-Xmx控制初始和最大大小支持动态扩展逻辑连续物理可不连续只要逻辑上连续即可# 设置堆初始大小256MB, 最大大小1GBjava-Xms256m-Xmx1g-jaryour-app.jar堆的分代结构现代JVM将堆划分为新生代Young Generation和老年代Old Generation基于的观察是绝大多数对象朝生夕灭少数对象存活很久。这种分代设计让GC可以针对不同区域使用不同算法提升整体效率。Java堆 ┌──────────────────────────────────────────────┐ │ 新生代 (Young Gen) │ │ ┌────────┬────────┬────────┐ │ │ │ Eden │ S0 │ S1 │ │ │ │ (8/10) │ (1/10) │ (1/10) │ │ │ └────────┴────────┴────────┘ │ │ 新生代:老年代 1:2 (默认) │ ├──────────────────────────────────────────────┤ │ 老年代 (Old Gen) │ │ ┌──────────────────────────────────────┐ │ │ │ 存活较久的对象 │ │ │ └──────────────────────────────────────┘ │ └──────────────────────────────────────────────┘默认情况下新生代与老年代的比例为1:2-XX:NewRatio2新生代内部Eden与两个Survivor的比例为8:1:1-XX:SurvivorRatio8。这些比例的选择基于统计学经验——90%的对象在Minor GC时就被回收Survivor区只需10%的空间就够存放存活对象。新生代Eden、S0、S1三区划分新生代被划分为三个区域Eden、Survivor 0S0/From、Survivor 1S1/To。这三个区域协同工作实现复制算法。Eden区新对象首先在此分配除大对象外Survivor区GC后存活的对象从Eden复制到Survivor两个Survivor交替使用任意时刻一个为空另一个存放上次GC的存活对象对象分配流程新对象分配 │ ▼ ┌─────────┐ │ Eden? │──── 有空间 ────→ 在Eden分配 └─────────┘ │ 无空间 ▼ 触发 Minor GC │ ▼ ┌─────────────────────────────────────┐ │ Eden S0(非空) 中存活的对象 │ │ → 复制到 S1(空) │ │ → 清空 Eden 和 S0 │ │ → S0和S1角色交换 │ └─────────────────────────────────────┘ │ ▼ 存活对象年龄1 │ ▼ 年龄 阈值? ──── 是 ────→ 晋升到老年代 │ 否 ▼ 留在Survivor区 (下次GC继续)这个流程的关键设计是复制算法——GC时将存活对象从Eden和From Survivor复制到To Survivor然后清空Eden和From Survivor。这种算法避免了内存碎片但代价是浪费10%的Survivor空间始终有一个Survivor为空。Minor GC vs Major GC vs Full GCGC类型作用区域说明Minor GC新生代频繁发生速度快因为大部分对象朝生夕灭Major GC老年代较少发生通常伴随Minor GCFull GC整个堆最耗时应尽量避免Minor GC采用复制算法由于新生代存活率低复制开销小。每次Minor GC后存活对象的年龄加1当年龄达到阈值默认15-XX:MaxTenuringThreshold时晋升到老年代。动态年龄计算除了年龄阈值HotSpot还有动态年龄计算机制在Minor GC时如果Survivor区中相同年龄所有对象大小的总和超过Survivor空间的一半年龄大于或等于该年龄的对象直接晋升。Survivor空间 1MB 对象年龄分布: 年龄1: 300KB 年龄2: 400KB ← 累计 700KB 512KB (Survivor的一半) 年龄3: 200KB → 年龄2的对象全部晋升到老年代这个机制避免了一次性大量对象卡在Survivor区让晋升决策更灵活。老年代对象进入老年代的途径对象进入老年代有四种途径正常晋升年龄达到-XX:MaxTenuringThreshold默认15大对象直接分配超过-XX:PretenureSizeThreshold的对象直接在老年代分配动态年龄计算如前所述Survivor空间不足时提前晋升空间分配担保Minor GC前检查老年代连续空间是否大于新生代所有对象总空间如不足且HandlePromotionFailure允许执行Full GC大对象直接进入老年代大对象如长数组、大字符串需要大量连续内存空间如果先在Eden分配再复制到Survivor复制开销大且容易触发GC。因此-XX:PretenureSizeThreshold允许设置一个阈值超过该大小的对象直接在老年代分配# 大于1MB的对象直接在老年代分配java-XX:PretenureSizeThreshold1048576-jaryour-app.jar注意-XX:PretenureSizeThreshold只对Serial和ParNew收集器有效G1/CMS等收集器有自己的大对象处理方式G1将大对象分配在Humongous区。老年代的GC老年代存活率高不适合复制算法复制开销大且浪费空间。老年代通常使用标记-清除或标记-整理算法标记-清除CMS标记存活对象清除垃圾有内存碎片标记-整理Parallel Old/G1标记存活对象向一端移动整理无碎片但慢TLAB线程本地分配缓冲区问题堆是共享的堆是所有线程共享的多个线程同时在Eden分配对象时需要同步CAS或加锁这会成为性能瓶颈。对象分配是极高频操作如果每次都竞争同一块内存开销不可接受。TLAB的解决方案TLABThread Local Allocation Buffer是Eden区中的一块线程私有区域。每个线程在分配对象时优先在自己的TLAB中分配无需同步。TLAB耗尽时才去Eden竞争新的TLAB块。Eden区 ┌──────────────────────────────────────────┐ │ TLAB-A │ TLAB-B │ TLAB-C │ 共享区域 │ │(线程A) │(线程B) │(线程C) │ │ └──────────────────────────────────────────┘TLAB的分配流程新对象分配请求 │ ▼ 当前线程的TLAB有空间? ── 是 ──→ 在TLAB中分配 (无锁, 指针碰撞) │ 否 ▼ 尝试申请新的TLAB │ ▼ Eden有空间? ── 是 ──→ 分配新TLAB, 在其中分配对象 │ 否 ▼ 触发Minor GCTLAB中的对象分配采用指针碰撞Bump the Pointer技术——每个TLAB维护一个指向当前空闲位置的指针分配对象时只需将指针向前移动对象大小的距离无需同步。这是Java对象分配如此高效的核心原因。TLAB相关参数# 开启TLAB (默认开启)-XX:UseTLAB# 设置TLAB大小占Eden的比例 (默认1%, 即每个线程的TLAB约占Eden的1%)-XX:TLABSize65536# 设置固定TLAB大小(字节)# 或使用自适应-XX:ResizeTLAB# 让JVM动态调整TLAB大小(默认开启)# 查看TLAB相关日志-XX:PrintTLABTLAB对开发者是透明的绝大多数场景下默认配置就足够好。只有在极高并发分配场景下才需要调优。逃逸分析与栈上分配逃逸分析是什么逃逸分析Escape Analysis是JVM的一种优化手段——分析对象的动态作用域判断对象是否逃逸出方法或线程。如果一个对象没有逃逸JVM可以对其进行优化。逃逸分析的三种结果未逃逸NoEscape对象只在方法内部使用不会逃逸到方法外方法逃逸ArgEscape对象作为参数传递给其他方法但不会逃逸出线程线程逃逸GlobalEscape对象被其他线程访问如赋值给类变量、存入全局集合栈上分配如果对象未逃逸JVM可以选择栈上分配——直接在虚拟机栈帧中分配对象而非堆。这样对象随栈帧出栈自动销毁无需GC。// 适用: JDK 8/11/17publicclassEscapeAnalysisDemo{// 未逃逸的对象 → 可能栈上分配publiclongcomputeSum(){// Point对象只在方法内使用, 未逃逸PointpnewPoint(1,2);returnp.xp.y;}// 逃逸的对象 → 必须堆分配publicPointcreatePoint(){PointpnewPoint(1,2);returnp;// 对象逃逸到方法外}staticclassPoint{longx,y;Point(longx,longy){this.xx;this.yy;}}}标量替换即使不栈上分配逃逸分析还能支持标量替换Scalar Replacement——将未逃逸的对象拆解为基本类型标量直接用局部变量替代对象字段。// 原始代码publiclongcomputeSum(){PointpnewPoint(1,2);returnp.xp.y;}// 标量替换后 (JVM内部优化, 不可见)publiclongcomputeSum(){longx1;// Point.x拆解为标量longy2;// Point.y拆解为标量returnxy;// 不再创建Point对象}标量替换是栈上分配的更激进形式——对象根本不存在只有其字段以标量形式存在于局部变量表中。这完全消除了对象分配开销。逃逸分析的开启与观察# 开启逃逸分析 (JDK 8默认开启, JDK 11/17一直开启)-XX:DoEscapeAnalysis# 开启标量替换 (依赖逃逸分析)-XX:EliminateAllocations# 观察逃逸分析结果-XX:PrintEscapeAnalysis-XX:PrintEliminateAllocations验证栈上分配的效果// 适用: JDK 8/11/17publicclassStackAllocDemo{staticclassUser{intid;Stringname;}// 未逃逸, 可能栈上分配publicstaticvoidalloc(){UserunewUser();u.id1;u.nametest;}publicstaticvoidmain(String[]args){longstartSystem.currentTimeMillis();for(inti0;i100000000;i){alloc();// 1亿次分配}longendSystem.currentTimeMillis();System.out.println(耗时: (end-start)ms);// 开启逃逸分析: ~5ms (栈上分配/标量替换)// 关闭逃逸分析(-XX:-DoEscapeAnalysis): ~500ms (堆分配GC)}}逃逸分析的局限性逃逸分析本身有计算开销JVM只会对热点方法进行分析。此外分析结果是保守的——如果JVM无法确定对象不逃逸就认为它逃逸。因此不是所有未逃逸对象都能被优化。关键JVM参数详解-Xms 和 -Xmx# 初始堆大小 最大堆大小 2GBjava-Xms2g-Xmx2g-jarapp.jar参数含义建议-Xms初始堆大小生产环境与-Xmx相同避免堆动态扩展的开销-Xmx最大堆大小根据可用内存和GC暂停时间权衡生产环境强烈建议-Xms和-Xmx设置相同值。原因避免堆动态扩展时的性能抖动避免堆收缩后再次扩展时的内存碎片启动时即分配所有堆内存便于监控-XX:NewRatio# 新生代:老年代 1:3 (NewRatio3)java-XX:NewRatio3-jarapp.jar# 新生代:老年代 1:2 (默认)java-XX:NewRatio2-jarapp.jarNewRatio控制新生代与老年代的比例。增大新生代减小NewRatio可以减少Minor GC频率但老年代变小可能导致更频繁的Full GC。典型场景短生命周期对象多Web应用增大新生代NewRatio1或2缓存类对象多长生命周期对象增大老年代NewRatio3或4-XX:SurvivorRatio# Eden:S0:S1 8:1:1 (默认)java-XX:SurvivorRatio8-jarapp.jar# Eden:S0:S1 4:1:1java-XX:SurvivorRatio4-jarapp.jar减小SurvivorRatio会增大Survivor空间让对象在Survivor中存活更多轮次才晋升减少老年代增长速度。但如果Survivor过大Eden变小Minor GC更频繁。-XX:MaxTenuringThreshold# 对象年龄达到15时晋升 (默认)java-XX:MaxTenuringThreshold15-jarapp.jar# 对象年龄达到6时晋升java-XX:MaxTenuringThreshold6-jarapp.jar减小晋升阈值让对象更快进入老年代适合对象确实长期存活的场景增大阈值让对象在Survivor中多待几轮适合短期对象多的场景减少老年代压力。综合配置示例# 典型的Web应用配置java-Xms4g-Xmx4g\-XX:NewRatio2\-XX:SurvivorRatio8\-XX:MaxTenuringThreshold15\-XX:UseG1GC\-XX:MaxGCPauseMillis200\-jarapp.jar代码示例观察堆内存对象分配与GC日志// 适用: JDK 8/11/17// 运行: java -Xms20m -Xmx20m -XX:PrintGCDetails HeapDemoimportjava.util.ArrayList;importjava.util.List;publicclassHeapDemo{publicstaticvoidmain(String[]args)throwsException{Listbyte[]listnewArrayList();for(inti0;i100;i){// 每次分配1MBbyte[]datanewbyte[1024*1024];list.add(data);if(i%100){System.out.println(已分配 (i1)MB);Thread.sleep(100);}}}}GC日志JDK 8 G1会显示Eden、Survivor、Old的使用变化。JDK 11/17使用-Xlog:gc*查看统一日志# JDK 11/17 查看GC日志java-Xms20m-Xmx20m-Xlog:gc* HeapDemo观察TLAB行为// 适用: JDK 8/11/17// 运行: java -XX:UseTLAB -XX:PrintTLAB TLABDemopublicclassTLABDemo{publicstaticvoidmain(String[]args){longstartSystem.nanoTime();for(inti0;i10_000_000;i){ObjectobjnewObject();// 高频分配}longendSystem.nanoTime();System.out.println(耗时: (end-start)/1_000_000ms);// TLAB开启: ~50ms// TLAB关闭(-XX:-UseTLAB): ~300ms}}实践要点生产环境-Xms-Xmx避免堆动态扩展的性能抖动和内存碎片。这是一条几乎通用的最佳实践。G1收集器的分代差异G1将堆划分为多个Region默认2048个Eden/Survivor/Old不再是连续区域而是Region的集合。G1还有专门的Humongous区存放大对象超过Region一半大小。G1的调优粒度与传统收集器不同NewRatio和SurvivorRatio对G1影响较小。ZGC/Shenandoah的无分代新一代低延迟GCZGC、Shenandoah初期不区分新生代和老年代ZGC JDK 21引入了分代ZGC。这类收集器通过并发标记和并发转移实现低暂停分代的优化不再是必需。但分代ZGCJEP 439表明分代假设依然有价值。对象大小估算预估对象占用堆空间时考虑对象头开销64位JVM下普通对象头12字节数组对象头16字节和对齐填充8字节对齐。一个只有两个int字段的对象实际占24字节12头444填充。大对象的危害大对象如byte[10MB]可能直接进入老年代或在G1中占用Humongous Region增加GC压力。避免创建过大的单个对象考虑分片处理。-XX:PretenureSizeThreshold的局限性这个参数只对Serial/ParNew有效。G1的大对象阈值由Region大小决定Region大小的50%不能直接配置。逃逸分析不是银弹它只优化未逃逸对象逃逸对象仍在堆分配。不要为了栈上分配而刻意拆分方法——让JIT自己分析就好。可以用-XX:PrintEscapeAnalysisJDK 8观察分析结果。小结Java堆是所有线程共享的最大内存区域分为新生代Eden S0 S1默认8:1:1和老年代NewRatio2即新生代:老年代1:2。对象分配流程优先在TLAB分配 → TLAB耗尽则Eden分配 → Eden满触发Minor GC → 存活对象复制到Survivor → 年龄达标晋升老年代。大对象可直接进入老年代。TLAB通过线程本地分配缓冲区避免多线程分配竞争配合指针碰撞实现无锁高效分配是堆分配高性能的关键机制。逃逸分析让未逃逸对象可能栈上分配或被标量替换减少堆分配压力和GC开销JDK 8默认开启。关键参数-Xms/-Xmx控制堆大小生产建议相同-XX:NewRatio控制分代比例-XX:SurvivorRatio控制Eden/Survivor比例-XX:MaxTenuringThreshold控制晋升年龄。下一篇探讨方法区的演进——从永久代到元空间的变革理解JVM为什么废除了PermGen。更多内容JVM调优实战