☰
JDK11与G1下的JVM内存分布:从Region模型到线上排障
2026/10/12 3:44:48 网站建设 项目流程

说到JVM内存分布,不少人第一反应还是那套老图:堆、栈、方法区、程序计数器,再配上新生代、老年代、永久代。这套模型在JDK7前后确实够用,但放到JDK11这个版本,尤其是生产环境默认跑着G1垃圾收集器的时候,很多细节已经不是当年那回事了。线上排障如果还拿老模型去猜,很容易被各种"违反直觉"的现象卡住。这篇文章想结合JDK11和G1,重新梳理一遍JVM内存到底怎么分布、对象从分配到回收经过了哪些区域、以及用什么工具能看到真实分布。适合谁?刚转Java不久的新人能当进阶课看,被线上GC折磨过的老手也能在排坑部分找到共鸣。

1. 为什么说老一套的内存模型在JDK11下不够用了

1.1 "永久代 + 连续分代堆"到底哪去了

先回忆一下老的内存模型:堆空间被切成三块连续大区域——一块Eden、两块Survivor、一块老年代,方法区则对应永久代。这套模型对应的默认垃圾收集器是Parallel,CMS也是这套布局。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 Region:new对象的完整路径

一次普通的new,在JDK11+G1下大致走这么几步:

  1. 检查常量池,确认类已经加载。类没加载就先走加载流程,在元空间生成类元数据。
  2. 在堆上分配对象实例。G1下绝大多数对象先走TLAB(Thread Local Allocation Buffer)分配,这是每线程私有的分配缓冲区,无锁、速度快。
  3. TLAB剩余空间不足时,如果剩余大小小于允许的最大浪费量,就再申请一个新TLAB;如果剩余空间还够大,就直接在Eden Region里分配,同时TLAB会被回填。
  4. 大对象走特殊路径:对象大小达到Region大小的50%以上,直接分配在Humongous Region,不经过TLAB。
  5. 栈帧的局部变量表保存对象引用,方法执行完栈帧弹出,对象留在堆里等GC处理。

理解TLAB很重要,因为你通过jcmd看Eden Region的已用量时,会发现它不是平滑增长,而是一段段跳的——那是每个线程的TLAB在预分配和回收。如果你在日志里看到某个线程分配对象特别慢,大概率不是GC问题,而是TLAB参数和对象大小不匹配。

3. G1的Region模型是如何重新划分堆的

3.1 从连续分代到Region:G1的底层逻辑

旧收集器(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大小是2MB;8G堆算出来是4MB,16G堆是8MB。你也可以用-XX:G1HeapRegionSize手工指定,但一般不建议动,让JVM按默认规则算就好。

Region大小不是随意定的,它直接影响两个东西:一是大对象判定阈值(超过Region 50%就算大对象),二是单次回收的粒度。Region越大,单个Region包含的对象越多,复制和回收时STW时间可能越难控制。反过来Region太小,大对象会占用非常多的连续Region,也麻烦。

3.3 四种Region:Eden、Survivor、Old、Humongous的分工

G1里的Region有四种角色状态:

  • Eden Region:对象刚分配时所在区域,除大对象外,绝大多数new对象先到这儿。
  • Survivor Region:Young GC后存活下来的对象会被复制到这里,每经过一轮GC年龄加1。
  • Old Region:Survivor里对象年龄达到阈值,或者动态晋升条件满足时,被移到这里。
  • Humongous Region:存放大对象,需要多个连续Region一起组成。

这四种角色不是固定不变的。Region会随着GC过程在Eden、Survivor、Old之间流转。Humongous Region比较特殊,它不参与新生代GC的常规复制流程,一般只能在并发标记周期或Full GC时被回收。所以大对象密集的应用,堆里会飘着大量Humongous Region,既占空间又拖累GC效率。

3.4 Region状态如何一步步驱动GC流程

G1的GC流程和Region状态强相关,可以分成几个阶段:

Young GC:Eden 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*,gc+heap=debug:file=gc.log:time,uptime,level,tags

JDK9以后这门语言不再认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占用拆分大对象、必要时调大Region

5.2 为什么我不建议你手动设置-Xmn和SurvivorRatio

G1最大的卖点就是自适应:根据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降到50,G1就会把新生代缩得很小,YGC频率暴涨,然后你还怀疑G1不行,其实是你给它设了个不可能完成的目标。


最后记录一点自己的体会。每次看到有人讨论G1内存,最怕听到的还是"老年代快满了所以FGC"这种表达。在G1的世界里,老年代不是一个连续区块,而是一堆Region的总和;旧参数也不该顺手就搬。建议你先强制自己把心智模型换过来:堆是2048个标准格子,每个格子随时可以切换身份。只要这一步切过来,后面读日志、调参数、查故障都会顺很多。参数错了能改回来,但认知错了,怎么调都像是在隔靴搔痒。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询