首页 / 资讯中心 / 文章详情

JDK11与G1下的JVM内存分布:从Region模型到线上排障

JDK11与G1下的JVM内存分布:从Region模型到线上排障 ★ FEATURED ARTICLE
说到JVM内存分布不少人第一反应还是那套老图堆、栈、方法区、程序计数器再配上新生代、老年代、永久代。这套模型在JDK7前后确实够用但放到JDK11这个版本尤其是生产环境默认跑着G1垃圾收集器的时候很多细节已经不是当年那回事了。线上排障如果还拿老模型去猜很容易被各种违反直觉的现象卡住。这篇文章想结合JDK11和G1重新梳理一遍JVM内存到底怎么分布、对象从分配到回收经过了哪些区域、以及用什么工具能看到真实分布。适合谁刚转Java不久的新人能当进阶课看被线上GC折磨过的老手也能在排坑部分找到共鸣。1. 为什么说老一套的内存模型在JDK11下不够用了1.1 永久代 连续分代堆到底哪去了先回忆一下老的内存模型堆空间被切成三块连续大区域——一块Eden、两块Survivor、一块老年代方法区则对应永久代。这套模型对应的默认垃圾收集器是ParallelCMS也是这套布局。JDK8把永久代换成了元空间很多人以为改完就完事了。但JDK9开始G1变成默认收集器堆本身的组织方式都变了它不再按照Eden/Survivor/Old各占一整块连续空间来划分而是把堆切成大量Region小格子每个小格子动态决定自己当前担任哪个角色。有个类比挺贴切连续分代模型就像一个大仓库装卸工要按整片区域来找货、理货G1的Region模型等于把仓库切成一堆标准尺寸的货架格GC时只需要挑最乱的几个格子清理。别再想着堆里面横着切三块这个心智模型不换掉后面看日志和调参数都会别扭。1.2 JDK11下的内存全景该记住的就这几块JDK11真正需要记住的区域其实比老图少很多我整理成一张表区域线程私有/共享主要存放内容常见异常备注程序计数器私有当前字节码行号无执行Native方法时为空Java虚拟机栈私有栈帧局部变量表、操作数栈、动态链接、返回地址StackOverflowError / OOMHotSpot把本地方法栈合入了它堆共享对象实例、数组、StringTable、TLABOutOfMemoryError: Java heap spaceG1下以Region为单位管理元空间方法区实现共享类型信息、运行时常量池、JIT代码、静态变量OutOfMemoryError: Metaspace使用本地内存默认不受Xmx限制直接内存共享NIO DirectBufferOOMMaxDirectMemorySize默认约等于Xmx注意几个容易过时的点元空间用的不是堆内存而是本地内存字符串常量池StringTable在JDK7之后已经移到堆里JDK11同样如此静态变量是跟着Class对象走的实际也在堆上。这些变化不是理论问题而是直接决定OOM报什么错、GC压力落在哪。1.3 G1从JDK9开始默认内存认知绕不开它JDK11里G1不仅是默认收集器而且它的运作方式跟旧收集器有本质区别G1不追求每次把整个堆清一遍而是维护每个Region的回收价值和引用关系优先回收垃圾最多的Region目标是把停顿时间控制在一个可预测范围内。很多人手里还留着CMS时代的配置习惯比如-XX:UseConcMarkSweepGC、CMSInitiatingOccupancyFraction这类参数在JDK11上启动时要不被忽略要不直接报警告。GC日志也从PrintGCDetails变成了统一日志-xlog。这意味着如果你希望排障顺畅就得用G1的视角重新理解内存分布而不是继续用旧参数和旧思维硬套。2. 一次new过后对象到底经过了哪些内存区域2.1 线程私有区程序计数器、虚拟机栈、本地方法栈程序计数器是每线程一个的小区域存的是当前执行到的字节码偏移量。线程切换后能恢复执行位置靠的就是它。它不需要GC也不会OOM几乎不用关心。虚拟机栈才是重点。每个方法调用都会创建一个栈帧栈帧里有局部变量表、操作数栈、动态链接和方法返回地址。局部变量表存基本类型、对象引用和returnAddress操作数栈是字节码运算的工作台比如iadd指令就是把两个数弹出来、加完再压回去。压栈深度超过上限就抛StackOverflowError栈大小可以通过-Xss调整Linux x64上默认一般是1MB。实际调小到256KB能让单线程栈更省内存但递归深的代码会更容易SOE。这里有个老资料没提清楚的细节HotSpot并没有单独实现一个本地方法栈JNI调用也复用了这套栈结构。所以你在jstack里看到的Native栈帧和Java栈帧其实是在同一个调用栈上交替出现的。2.2 线程共享区堆、元空间、StringTable堆是对象的主战场几乎所有对象实例和数组都在这里分配。G1下堆的物理结构是一堆Region逻辑上分为Eden、Survivor、Old和Humongous。堆大小由-Xms和-Xmx控制建议生产环境把两者设为相同值避免动态扩容带来的停顿。元空间是方法区在JDK8以后的实现存类元信息、运行时常量池、JIT编译产物等直接使用本地内存。默认MaxMetaspaceSize不设上限坏处是如果类加载器泄漏元空间会一路涨到物理内存扛不住。排查时会看到OutOfMemoryError: Metaspace而不是堆OOM。StringTable也要单独说。JDK7之后它被移到了堆里JDK11依然如此。字符串字面量、intern字符串都会被它管理。它待在堆里意味着字符串对象和普通对象一样参与GC好处是G1的字符串去重功能-XX:UseStringDeduplication可以工作坏处是大量intern字符串会显著增加堆压力。2.3 直接内存被八股文漏掉的一块直接内存不算JVM运行时数据区但排障时绕不开。NIO的DirectByteBuffer通过ByteBuffer.allocateDirect()分配用的是Native内存不受堆大小限制而是受-XX:MaxDirectMemorySize限制默认约等于Xmx的值。举个例子堆设成4G直接内存也可能逼近4G再加上元空间、线程栈、JIT CodeCache一台8G物理机可能不知不觉就被吃满了。如果哪天你发现heap才用3G物理内存却快满了先别急着怀疑泄漏打开进程内存信息看看直接内存和元空间占比。2.4 从TLAB到Eden Regionnew对象的完整路径一次普通的new在JDK11G1下大致走这么几步检查常量池确认类已经加载。类没加载就先走加载流程在元空间生成类元数据。在堆上分配对象实例。G1下绝大多数对象先走TLABThread Local Allocation Buffer分配这是每线程私有的分配缓冲区无锁、速度快。TLAB剩余空间不足时如果剩余大小小于允许的最大浪费量就再申请一个新TLAB如果剩余空间还够大就直接在Eden Region里分配同时TLAB会被回填。大对象走特殊路径对象大小达到Region大小的50%以上直接分配在Humongous Region不经过TLAB。栈帧的局部变量表保存对象引用方法执行完栈帧弹出对象留在堆里等GC处理。理解TLAB很重要因为你通过jcmd看Eden Region的已用量时会发现它不是平滑增长而是一段段跳的——那是每个线程的TLAB在预分配和回收。如果你在日志里看到某个线程分配对象特别慢大概率不是GC问题而是TLAB参数和对象大小不匹配。3. G1的Region模型是如何重新划分堆的3.1 从连续分代到RegionG1的底层逻辑旧收集器Parallel、CMS的堆是连续分代布局新生代一块连续空间老年代一块连续空间。这种布局的痛点是碎片老年代如果没有足够连续空间大对象晋升可能失败最终退化成Full GC。G1把堆切成等大小的Region后新生代和老年代都变成逻辑上的Region集合不再要求物理连续。回收对象时也是以Region为单位判断哪些Region垃圾多就优先回收Garbage First这个名字就是这么来的。这个设计让停顿更可控也让堆的利用率更灵活。3.2 Region大小怎么来的2048这个数字的计算过程JVM设计目标是让Region数量大约在2048个左右。具体计算过程是堆大小除以2048结果向下取到2的幂次允许值是1MB、2MB、4MB、8MB、16MB、32MB最小1MB最大32MB。举个例子4G堆4GB / 2048 2MB所以Region大小是2MB8G堆算出来是4MB16G堆是8MB。你也可以用-XX:G1HeapRegionSize手工指定但一般不建议动让JVM按默认规则算就好。Region大小不是随意定的它直接影响两个东西一是大对象判定阈值超过Region 50%就算大对象二是单次回收的粒度。Region越大单个Region包含的对象越多复制和回收时STW时间可能越难控制。反过来Region太小大对象会占用非常多的连续Region也麻烦。3.3 四种RegionEden、Survivor、Old、Humongous的分工G1里的Region有四种角色状态Eden Region对象刚分配时所在区域除大对象外绝大多数new对象先到这儿。Survivor RegionYoung GC后存活下来的对象会被复制到这里每经过一轮GC年龄加1。Old RegionSurvivor里对象年龄达到阈值或者动态晋升条件满足时被移到这里。Humongous Region存放大对象需要多个连续Region一起组成。这四种角色不是固定不变的。Region会随着GC过程在Eden、Survivor、Old之间流转。Humongous Region比较特殊它不参与新生代GC的常规复制流程一般只能在并发标记周期或Full GC时被回收。所以大对象密集的应用堆里会飘着大量Humongous Region既占空间又拖累GC效率。3.4 Region状态如何一步步驱动GC流程G1的GC流程和Region状态强相关可以分成几个阶段Young GCEden Region被新对象塞满时触发STW暂停。存活对象从Eden复制到Survivor年龄够老的晋升到Old。新生代大小不是固定的而是根据-XX:MaxGCPauseMillis的目标停顿时间动态调整。并发标记周期当逻辑老年代Region的占用比例达到-XX:InitiatingHeapOccupancyPercent默认45%时G1开始并发标记。它不会等着老年代彻底满而是提前找出哪些Old Region垃圾多、值得回收。Mixed GC并发标记之后进行。它不回收整个老年代只挑回收价值高的Old Region再配合必要的Eden和Survivor。如果垃圾浪费比例低于-XX:G1HeapWastePercent默认的5%就会停止Mixed GC。Full GC最不想看到的阶段。当并发回收失败、Humongous Region找不到连续空间或者晋升失败时G1会退化为Full GC。JDK11下这个Full GC是单线程串行STW的停顿时间可能非常长。这也是生产环境最需要防的情况。4. 实操用jcmd和jstat看清G1的内存分布4.1 影响G1内存分布的7个关键参数先看这张参数表都是我在实际排障中会重点检查的参数默认值作用-Xms / -Xmx物理机1/64堆起始/最大大小生产建议设相同值-XX:G1HeapRegionSize自动按堆算Region大小一般不要手动指定-XX:MaxGCPauseMillis200msG1调整新生代大小的核心目标-XX:G1NewSizePercent5%新生代Region占堆下限-XX:G1MaxNewSizePercent60%新生代Region占堆上限-XX:InitiatingHeapOccupancyPercent45%触发并发标记周期的老年代Region占用阈值-XX:MaxTenuringThreshold15对象晋升老年代的最大年龄这里要特别留意InitiatingHeapOccupancyPercent。它默认45%意思不是等老年代满了才做标记而是老年代Region占用到45%左右就开始并发标记给回收留足提前量。如果一个服务长期把老年代用到80%以上才触发FGC多半是IHOP配得不对或者并发标记周期跟不上对象分配速度。4.2 三个命令看懂Region分布JDK11下我惯用的排查顺序是这样jps -l找到目标进程PID然后jcmd pid GC.heap_info jstat -gcutil pid 1000前者能直接看到Region分布后者看GC节奏和分代占用。jcmd GC.heap_info输出里会有一行类似garbage-first heap total 4096M, used 2800M region size 2M, 2048 regions Eden: 512 regions, Survivor: 32 regions, Old: 800 regions, Humongous: 64 regions这一行信息量很大。Region size 2M说明这是4G堆的默认结果Humongous占64个Region意味着有大对象占了128MB左右这是个危险信号。Eden占512格等于1G说明新生代当前被G1动态放得比较大。配合按时GC日志的话我还会开统一日志-Xlog:gc*,gcheapdebug:filegc.log:time,uptime,level,tagsJDK9以后这门语言不再认PrintGCDetails上面这段才是正道。4.3 一个4G堆服务的GC节奏现场解读拿之前排查过的一个案例来说当时是某中间件服务16G堆配8G物理分配上限这里按4G堆模式简化现象是YGC每2秒一次Mixed GC每次回收完老年代很快又涨回去。用jcmd GC.heap_info一看Humongous Region占了64格而且每次Young GC都伴随大量对象复制。再用jcmd的class histogram功能查到有一个byte[]类型的对象占了大几百MB源头是某张配置表被整体加载进内存做缓存。这类大对象超过Region一半后进Humongous绕过正常GC路径只能拖到并发标记或Full GC才回收。把缓存拆成128KB的分片后Humongous Region消失YGC间隔从2秒拉到20秒左右整个服务的GC压力肉眼可见地降下来。这个案例里我没动任何GC参数只是消除Humongous就解决了问题。很多时候内存分布问题根源并不在参数而在对象形态。5. 排坑实录G1内存相关的典型故障与调参教训5.1 G1内存高频问题排查速查表现象可能原因第一步排查常用处理方式频繁Full GCIHOP设太高、Humongous过多、存活对象太多jstat看FGC频率打开gc日志看触发原因调整IHOP拆大对象Metaspace OOM类加载器泄漏、动态代理类太多jcmd GC.class_histogram看类增长dump分析类加载器引用YGC频繁但Eden一直不降TLAB设置过小、Eden太小看jstat的E区用量和YGC间隔先调低MaxGCPauseMillis试试堆用量不高但物理内存吃紧直接内存、元空间超预期查进程内存明细限制MaxDirectMemorySize、MaxMetaspaceSize日志里出现Humongous allocation失败连续Region不足jcmd GC.heap_info看Humongous占用拆分大对象、必要时调大Region5.2 为什么我不建议你手动设置-Xmn和SurvivorRatioG1最大的卖点就是自适应根据MaxGCPauseMillis目标动态调整新生代Region数量。如果你手动设置-Xmn把新生代固定成一大块等于把G1的方向盘焊死了。停顿预测模型失效后可能出现Young GC间隔变长但单次停顿飙升或者新生代过大导致Mixed GC迟迟跟不上。SurvivorRatio同理。G1内部有自己的动态晋升判断不仅要看年龄还要看Survivor空间够不够。手工指定SurvivorRatio可能让Survivor过小对象晋升过早Old Region快速膨胀。要调就调MaxTenuringThreshold这样的软参数别动-Xmn这类硬参数。5.3 三条最值得记住的G1内存调优心得第一先观测再调参。新服务上线用默认参数先跑观察3到5天GC日志拿到基线再动手。很多人上来就把IHOP改成70%以为能延缓并发标记结果对象分配一快反而频繁Full GC。第二遇到诡异问题先查大对象。Humongous Region在GC日志里有明显特征jcmd GC.heap_info里一眼能看到。绝大多数怎么调参都没用的案例最后都追到某个大数组或大缓存上。第三每次只改一个参数并保留基线。G1参数之间会相互影响比如你把MaxGCPauseMillis从200降到50G1就会把新生代缩得很小YGC频率暴涨然后你还怀疑G1不行其实是你给它设了个不可能完成的目标。最后记录一点自己的体会。每次看到有人讨论G1内存最怕听到的还是老年代快满了所以FGC这种表达。在G1的世界里老年代不是一个连续区块而是一堆Region的总和旧参数也不该顺手就搬。建议你先强制自己把心智模型换过来堆是2048个标准格子每个格子随时可以切换身份。只要这一步切过来后面读日志、调参数、查故障都会顺很多。参数错了能改回来但认知错了怎么调都像是在隔靴搔痒。
阅读完成 · 觉得有帮助?
咨询建站