JDK11 + G1下的JVM内存布局与对象分配:从原理到排查 1. 为什么说我们可能误解了JVM内存很多Java开发者在学习JVM内存分布时脑子里基本是“堆、栈、方法区”这三板斧。这个框架没错但在JDK11 G1这个组合下很多细节已经变了。比如方法区在JDK8之后已经被元空间Metaspace取代永久代成为历史堆内存的物理布局也不再是传统的连续分区而是被拆成了一个个大小不等的Region还有那些容易被忽略的堆外内存、直接缓冲区、JIT编译器相关的内存开销。这套内容不是让你背概念应付面试而是为了解决实际工作中的三个问题内存排查时看一眼内存快照能大致判断出问题方向JVM参数调优时知道哪些参数在G1下真正生效哪些已经失效代码层面理解对象分配路径知道为什么频繁创建大对象会让G1的GC压力骤增。这篇文章适合有一定Java基础但没系统梳理过JVM内存模型的人也适合正在排内存问题、想深入了解G1内存布局的开发者。我会按“内存区域划分 — 对象分配路径 — G1的Region化改造 — 实操验证方法 — 常见问题排查”这条线往下讲所有内容都基于JDK11的默认配置和G1垃圾收集器。2. 内存区域划分的准确理解2.1 线程私有区域栈、程序计数器、本地方法栈先讲线程私有部分这部分相对简单但容易被人忽略。每个Java线程在创建时JVM会为它分配一个独立的Java虚拟机栈JVM Stack。这个栈里装的是栈帧Stack Frame每次方法调用就压入一个栈帧。栈帧里包含局部变量表、操作数栈、动态链接、方法出口等信息。局部变量表主要存基本类型和对象引用也就是我们常说的“栈里存引用、堆里存对象”的那个栈。需要注意的是栈的深度是有限制的超出深度会抛StackOverflowError。这个深度由-Xss参数控制默认在JDK11里通常是1MB左右但对于递归很深或者方法重入频繁的场景可以适当调大不过调大意味着每个线程占用的系统内存更多线程数上限会被压缩是一个需要权衡的参数。程序计数器Program Counter Register是最小的一块内存区域它就是当前线程正在执行的字节码指令的行号指示器。字节码解释器就是通过改变这个计数器的值来选取下一条要执行的字节码指令。分支、循环、跳转、异常处理、线程恢复等基础功能都依赖它。这块区域是唯一一个在Java虚拟机规范中不会出现OutOfMemoryError的区域因为它的内存占用是固定的。本地方法栈Native Method Stack和虚拟机栈作用类似区别在于它服务于Native方法。在JDK11的HotSpot虚拟机实现里本地方法栈和虚拟机栈合并成同一个栈实现所以-Xss这个参数对两者都有效。2.2 线程共享区域堆的物理与逻辑视角堆Heap是JVM内存的最大组成部分也是G1垃圾收集器重点管理区域。在JDK11中如果没指定-Xms和-Xmx默认堆的初始大小是物理内存的1/64最大大小是物理内存的1/4。这只是一个参考值实际服务器上建议显式配置避免动态扩容带来的性能抖动。堆内部逻辑上分为年轻代和老年代年轻代里又分为Eden区和两个Survivor区S0、S1比例默认是8:1:1。但这个比例仅适用于“经典的分代收集器”比如Parallel Scavenge在G1下这套逻辑已经被Region化布局重新定义了但分代概念仍然保留只是物理上不再是连续的一块区域后面我会单独展开。堆外还有一个容易被忽视的“元空间”Metaspace。JDK8之前叫永久代PermGen位于JVM堆内JDK8之后永久代被移除元空间改为使用本地内存Native Memory。这意味着类元数据不再受到堆大小的限制而是受操作系统可用内存限制。在JDK11中元空间默认是无限使用的但你仍然可以通过-XX:MaxMetaspaceSize来限制。很多时候动态生成类比如CGLIB代理、反射生成类过多会导致元空间膨胀这个在排查内存问题时经常遇到。2.3 直接内存与JIT内存开销除了上面这些“规范内”的区域JVM在实际运行中还会使用一块“直接内存”Direct Memory。它由NIO引入通过DirectByteBuffer对象来操作使用堆外内存做读写可以避免数据在堆内和堆外之间来回拷贝对网络通信、文件IO这类场景提升明显。但这块内存需要我们自己管好它的回收依赖于Cleaner机制如果DirectByteBuffer对象一直不能被GC回收堆外内存就有被耗尽的风险。需要注意的是直接内存的大小由-XX:MaxDirectMemorySize控制默认等于堆最大值。还有一个很多人没意识到的内存消耗点JIT编译器。C1和C2编译器在运行时会把热点代码编译成本地机器码这个编译过程本身需要占用内存。代码缓存Code Cache存储编译后的机器码默认大小是240MB左右JDK11中-XX:InitialCodeCacheSize默认约2.5MB-XX:ReservedCodeCacheSize默认约240MB。对于大型应用如果代码量很大这个缓存可能会不够系统会通过-XX:UseCodeCacheFlushing来做部分清理但效果有限如果频繁触发清理其实说明代码缓存偏小了。3. 对象分配路径与指针压缩3.1 对象是怎么一步步进入堆内存的我们创建一个对象背后的事情比想象中多。这个过程一般可以拆成几个关键节点第一类加载检查。当JVM遇到一条new指令时它首先去方法区元空间检查这个类的符号引用是否能被找到并解析如果类没有被加载会先触发类加载过程。第二分配内存。对象所需内存大小在类加载完成后即可确定在HotSpot中对象头 实例数据 对齐填充。分配方式有两种指针碰撞Bump the Pointer和空闲列表Free List。堆内存规整时比如使用Serial、Parallel收集器配合压缩整理用的是指针碰撞而像CMS这种基于标记清除算法的收集器堆内存不规整就需要空闲列表来分配。G1的分配策略更复杂因为它是基于Region的会在Region内进行指针碰撞分配并引入了TLAB机制来减少并发竞争。第三TLAB分配。TLAB的全称是Thread Local Allocation Buffer中文叫线程本地分配缓冲区。每个线程在Eden区里预分配一小块内存这块内存默认大小是Eden区的1%可以用-XX:TLABWasteTargetPercent调整。线程在TLAB内分配对象时不需要加锁因为这块内存只属于当前线程。只有当TLAB空间不够时才需要去共享Eden区通过CAS方式分配或者重新申请一个新的TLAB。这种设计极大降低了并发下对象分配的性能损耗是JVM一个非常巧妙的优化。第四初始化设置。分配完成后JVM会把对象头里的Mark Word设置好包括对象锁状态、GC分代年龄、对象的hashCode等然后执行构造函数把实例字段初始化。这个路径里最容易被人忽略的是TLAB相关的参数和对象大小对分配效率的影响。如果一个对象特别大大到TLAB装不下但又不至于大到大对象阈值它就会直接在Eden的共享区域分配这种情况下的分配成本变高而且大对象容易提前进入老年代影响GC频率。3.2 指针压缩到底压缩了什么说到对象头就不得不提压缩指针。在64位JVM中如果堆小于32GB准确说是-XX:MaxHeapSize 32G默认会开启指针压缩-XX:UseCompressedOops对象头中的Class Pointer会从64位压缩到32位。为什么要这么做一个普通的Java对象引用在64位下占8字节压缩后只占4字节。这意味着同样大小的堆能容纳更多对象同时更少的GC根扫描内存带宽消耗。但压缩的前提是堆内对象地址必须在4GB对齐的范围内实际上被压缩的引用是偏移量而不是绝对地址基于基地址偏移量的方式寻址所以一旦堆大小超过32GB对象寻址需要更多位数指针压缩自动失效内存占用反而可能增加。这就带来一个很实际的经验如果你的应用堆内存规划在32GB以内那完全不需要去关闭压缩指针如果堆已经达到了30GB以上你需要仔细评估是否值得扩大到32GB以上——因为一旦超过32GB压缩指针失效同一个对象可能会多占用一部分内存导致实际可用的对象容量不升反降。很多开发者踩过的坑就是盲目把-Xmx调大到40GB、50GB结果发现内存比32GB时更紧张了。关于32G这个临界值它不是一个能配参数改掉的数字而是和Oops压缩技术本身的寻址限制相关。在HotSpot源码里压缩后的引用最多支持32G的堆空间2的35次方字节32G因为4字节指针表示的是8字节对齐的偏移所以在32G以内用4字节指针可以实现任意对象寻址。这就是为什么网上总有人说“堆内存超过32G要慎重”的原因。3.3 大对象的去向与G1的Humongous区域G1引入了一个“大对象区”的概念。当一个对象的大小超过Region大小的50%时这个对象就会被视为“Humongous Object”它不会在年轻代分配而是会直接在老年代区域里分配而且会占用连续的一个或多个Region。注意是连续的Region因为G1认为大对象的GC拷贝成本太高不希望它参与正常的年轻代晋升复制。这个机制带来的直接影响是频繁创建大对象会让老年代内存碎片化加剧GC路径会变得更长。举个实际案例我之前排查过一个服务每隔几分钟就会生成一个几十MB的缓存对象然后立刻抛弃。在G1下这些对象直接进入老年代而且占用了连续的Region导致老年代的Region碎片化越来越严重最终触发了很多次Mixed GC和Full GC服务响应时间直线上升。最后优化方式不是调GC参数而是从代码层面改成复用缓存对象或者拆分成多个小对象。如果你发现日志里对应G1的Humongous分配次数很多可以关注一下这个方向。4. G1垃圾收集器如何重塑堆内存布局4.1 从连续分区到Region化G1Garbage First的设计初衷是“可预测的停顿时间模型”它通过把堆内存划分为大量大小相等的Region来实现精细化管理。在JDK11中一个Region的大小在1MB到32MB之间默认是堆大小的1/2048具体由-XX:G1HeapRegionSize参数控制但该项目标值只是一个参考JVM会根据堆大小动态调整。Region化给JVM带来的变化是革命性的以前的收集器要么做新生代回收Minor GC要么做老年代回收Major GC/Full GC整个堆被物理划分为两块连续区域。而G1把堆拆成一个个小格子每个Region可以属于Eden、Survivor、Old、Humongous空间但是这种归属关系不是固定的会随着GC的进行动态调整。同时G1还能通过预测模型决定每次只回收一部分Region而不是全堆扫描这就是它能够控制停顿时间的基础。一个常见的误区是认为G1没有年轻代和老年代的物理划分。实际并非如此G1有逻辑分代只是Region可以在不同代之间“漂移”这也是“Garbage First”的意思——它优先回收那些垃圾对象最多的Region。4.2 Remembered Set与Card Table隐藏的内存开销G1的Region化设计虽然带来了灵活性但也引入了一个新的内存开销结构Remembered Set简称RSet和Card Table。由于对象引用是跨Region的比如老年代Region里的对象引用了年轻代Region里的对象在年轻代回收时就必须知道哪些老年代对象引用了年轻代对象以保持根集合的完整性。G1的做法是跟踪每个Region的RSet它记录着有哪些外部Region引用了本Region内部的对象。在回收一个Region时通过RSet可以快速找到所有引用它的外部Region而不需要全堆扫描。这里要注意RSet本身也要占用内存在G1下RSet占用的空间通常是堆大小的1%到5%左右。内存越大Region越多RSet相应也会膨胀。如果你的堆特别大比如超过64GBRSet可能就会成为不可忽视的内存负担。并且RSet的维护是在写屏障Write Barrier中完成的每个引用类型的写操作都会产生一定开销所以G1下的写屏障成本比并行收集器高。Card Table是RSet的一种实现基础。在HotSpot中每个Region内部按512字节划分Card每个Card对应一个字节的标记位。当执行引用写操作时写屏障会标记对应的Card为dirty后续GC时扫描dirty card来更新RSet。这个标记过程也叫“Dirty Card Queue”处理如果这个队列处理不及时就会引发“Refine”线程的压力俗称“RSet更新堆积”。4.3 三色标记与SATB快照G1的并发标记采用的是三色标记法Tri-color Marking配合SATBSnapshot At The Beginning机制。三色标记法把对象分成三类白色尚未被扫描到的对象灰色当前对象被扫描到但它引用的对象还没全部扫描完黑色该对象以及它所有引用的对象都已经扫描完成。在并发标记阶段GC线程和应用线程是同时运行的应用线程可能会改变对象间的引用关系。为了防止对象被错误回收即漏标G1使用SATB在执行并发标记开始时记录整个堆对象图的一个快照。之后即使某个对象在这个快照中本应该是存活的但引用被并发修改为nullG1依然保持它的存活状态在下一个回收周期再来处理。这种设计规避了并发标记阶段对象存活状态的不确定性但代价是会增加“浮动垃圾”Floating Garbage的数量。简单说SATB会让本次GC不回收那些已经变成垃圾但不在快照中的对象它们只能留到下一次GC再处理。这也解释了为什么G1在某些场景下一次GC结束后堆占用并没有明显减少——那是浮动垃圾在作祟。4.4 G1的GC触发条件与Mixed GC流程G1的GC模式分为Young GC和Mixed GC以及Full GC退化情况。Young GC的触发条件是年轻代的Region达到占用阈值。G1有一个“目标停顿时间”参数-XX:MaxGCPauseMillis默认200ms。G1会根据历史Pause预测模型和当前年轻代Region的占用动态决定触发Young GC的时机以及要回收哪些年轻代Region。Mixed GC是在并发标记周期完成后收集所有年轻代Region和部分高价值老年代Region的GC。它会按照回收收益即释放空间大小与回收成本之比对老年代Region进行排序优先回收性价比最高的Region直到满足停顿时间预测。这里有个关键的参数-XX:InitiatingHeapOccupancyPercent简称IHOP它控制并发标记周期的发起条件默认是45%。意思是当堆内已分配对象大小占总堆大小的45%时G1会主动发起并发标记为后续的Mixed GC做准备。你可以根据应用实际的内存使用模式调整这个阈值过高会导致老年代来不及清理就出现并发失败过低会频繁触发标记周期。还有一个容易被踩坑的点当Mixed GC来不及回收老年代内存或者RSet更新不及时导致“转移失败”Evacuation Failure时G1会退化到Full GC。这种Full GC是单线程的JDK10之后已经实现并行Full GC但G1的Full GC仍然是一个比较重的STW事件停顿时间不可控是我们尽量要避免的。5. 实测JDK11 G1内存布局的观测方法5.1 用JDK自带工具观察内存概况我们写一个简单的Java程序在生产模式下模拟对象分配然后观测它的内存状态。以下是代码示例import java.util.ArrayList; import java.util.List; public class MemoryDemo { static class DataChunk { byte[] payload new byte[1024 * 1024]; // 1MB } public static void main(String[] args) throws Exception { ListDataChunk keepAlive new ArrayList(); for (int i 0; i 100; i) { if (i % 5 0) { Thread.sleep(10); } keepAlive.add(new DataChunk()); if (i 50) { System.out.println(Press Enter to continue...); System.in.read(); } } Thread.sleep(10000); } }用JDK11启动java -Xms512m -Xmx512m -XX:UseG1GC -XX:PrintGCDetails -XX:PrintFlagsFinal MemoryDemo执行期间我们可以用jstat -gc实时查看堆内存各区域S0C、S1C、EC、OC、MC等的变化。查看堆的Region状态可以用jmap -heap不过JDK11里这个输出对G1的显示已经比较完善可以看到Region大小以及当前各类型的Region数量。通过-XX:PrintFlagsFinal可以看到所有参数的默认值比如G1HeapRegionSize、MaxGCPauseMillis、InitiatingHeapOccupancyPercent这比查文档更直接。5.2 对象分配后各区域的变化解读以-Xms512m -Xmx512m启动时G1会把堆划分成大概512个Region每个Region大小约1MB因为512MB/20480.25MB但Region大小有最小值限制通常最小为1MB所以这里实际是每个Region 1MB堆里有512个Region。程序创建了100个DataChunk对象每个对象里有一个1MB的字节数组这100个对象的存在会让堆占用大概100MB左右。此时新生代在Eden区中不断分配对象Survivor区暂未晋升。你可以看到Eden Region数在增长Old Region数保持为0。当年轻代占用触达阈值时会发生Young GC。存活的对象会被复制到Survivor区并且分代年龄从0变成1。连续多次Young GC后对象年龄达到-XX:MaxTenuringThresholdG1默认是15或者Survivor区放不下时对象晋升到老年代。在这个过程中我们可以看到一个关键点老年代Region的数量不是线性增长的而是随着晋升过程动态增长的Region的去向取决于存活对象的分布。5.3 观察RSet和Humongous区域的技巧想观测RSet大小单靠jstat很难更实用的方式是开启-XX:UnlockDiagnosticVMOptions -XX:PrintRegionStatistics这会打印每个Region的RSet占用、Humongous区域数量以及对应的对象大小。Humongous区域的检测可以从日志中看到。启动时加-Xlog:gchumongoustraceJDK9之后JVM统一日志体系替代了-XX:PrintGCDetails对应输出会标记哪一步发生了Humongous分配。这类日志在排查大对象导致的GC异常时价值很高。一个较常见的实际问题线上服务在高峰期内存突然增长此时看jstat -gc发现Eden区平稳Old区却快速上升同时Mixed GC频繁且停顿时间超过200ms。这种情况下大概率是提前晋升或Humongous分配在作祟。如果搭配上-Xlog:gcphasestrace能看到每个Region的回收耗时可以准确定位是RSet扫描耗时长还是拷贝耗时高。6. 常见问题与排查技巧实录6.1 为什么-Xmx调大后Full GC反而更频繁了一个典型的反直觉案例堆越调越大卡顿越严重。原因是把堆从32GB扩到40GB后压缩指针失效每个对象引用从4字节变成8字节对象头也变大整个堆能容纳的对象数量反而变少。同时G1的Region数量增加RSet和Card Table的开销也随之膨胀GC标记和回收的路径变长。最终的净结果是虽然堆大了但可用空间比例没有上升同时GC频率和停顿时间都增加了。遇到这种情况不要急着加堆。先算一下当前服务的峰值内存占用再考虑是否可以通过优化对象结构、减少缓存常驻来降低内存需求或者保持32GB以内、通过多实例部署横向扩缩容来替代单机堆扩容。6.2 Mixed GC后老年代仍然满的排查路径老年代在Mixed GC后仍然高占用这是G1调优中最常见的疑难杂症。一般按以下路径排查看是否发生了Humongous分配检查GC日志中humongous alloc的出现频率和大小看IHOP-XX:InitiatingHeapOccupancyPercent设置是否合理如果默认45%对应用场景偏低标记周期频繁但回收收益不高也会导致老年代迟迟不能清理看RSet是否过大-XX:PrintRegionStatistics配合-XX:UnlockDiagnosticVMOptions查看RSet的占比是否异常最后检查是否存在“死循环引用更新点”也就是代码中高频写引用字段导致RSet更新Flush压力过大GC线程一直在处理RSet而无法快速回收。如果确认是前两种情况代码层面的优化比调参更有效比如拆大对象、改用堆外存储、缩短缓存TTL等。6.3 Metaspace OutOfMemory的典型案例某个使用Spring框架的Web应用每次在运行期间不停地生成新的代理类一段时间后出现java.lang.OutOfMemoryError: Metaspace。排查方式通过jstat -gc观察MC和MU确认元空间增长速度通过jcmd pid GC.class_histogram或者jmap -clstats pid查询类加载器数量和每个加载器加载的类数量确认是不是动态类生成导致对可疑的类加载器做堆转储分析使用jmap -dump:formatb,fileheap.bin导出堆然后用分析工具查看加载的类。注意类加载器本身在堆内但类元数据在元空间堆转储看不到元空间的细节需要配合jcmd VM.metaspace来查询。解决方案一般有两个方向一是调整-XX:MaxMetaspaceSize但这只能延缓不是根治二是找到动态生成类的源头比如CGLIB代理配置是否可以复用代理类、反射调用的缓存是否合理、类加载器泄漏是否存在通过修复代码来减少类元数据的无限制增长。7. 写在最后的排查心得在我实际处理过的大量JVM内存问题中真正靠压榨GC参数救回来的场景大概只有三成剩下七成都是代码层面的问题被GC参数掩盖了。比如频繁创建大对象、没有边界的长生命周期缓存、类加载器泄漏、引用字段的写竞争这些问题的正确解法是回代码里修而不是无限调堆大小。至于那些几台机器上的参数组合我有两个最朴素的建议生产环境堆大小尽量保持在32GB以内不要轻易挑战压缩指针的边界如果单个实例确实装不下优先考虑多实例部署配合负载均衡来横向扩展。GC参数的调整一定要基于监控数据而不是经验直觉。至少在线上压测环境里跑一周收集gc.log配合jstat、JFRJDK11已经内置了JFR可以用-XX:StartFlightRecording开启它对性能的影响极小来观察全面指标之后再做微调。最后分享一个小技巧JDK11的通用日志配置-Xlog:gc*:filegc.log:time,uptime,level,tags比老的-XX:PrintGCDetails更清晰而且支持按level过滤比如只想看GC发生的阶段就用-Xlog:gcphasesdebug。把这些日志保留到谷歌云盘或者内网日志平台长期沉淀你会在某一天发现某次神秘的超时问题其实是一周前的一次Humongous分配引起的连锁反应。