1. 为什么我说“重新认识”——JDK11时代的内存变化
写这篇文章的起因,是前阵子帮某个业务系统做一次JVM参数调优,结果发现一个很有意思的现象:团队里大部分人还在用JDK8时代的思路来理解内存,以为JVM就是“堆 + 永久代 + 栈 + 方法区”那套老框架。但当我打开JDK11的GC日志时,发现不少旧观念已经不能直接用了一一尤其是永久代彻底变成历史、G1成为默认收集器之后,内存分布的逻辑从“物理分代”转向了“逻辑分代 + Region化布局”,很多排查手段和分析路径都得跟着调整。
这篇文章不打算面面俱到地复述《深入理解Java虚拟机》里的每一段描述,而是想把JDK11 + G1这条组合下的内存分布讲实、讲透:每个内存区域在哪里、谁在用、什么时候分配、什么时候回收、出了问题时日志和监控里看到的数据到底意味着什么。适合四种人看:刚把项目从JDK8迁到JDK11的同行;面试被问到JVM内存模型想答得更有深度的候选人;正在用G1但没搞懂GC日志里“Humongous”“Region”“RSet”这些词的同学;以及纯粹想刷新一遍底层认知的Java开发者。
先抛一个反直觉的结论:JDK11里,你在代码里new出来的对象,并不一定全部“活”在堆里,也不一定按照你想象的方式分代流转。JIT逃逸分析、TLAB、大对象Humongous分配、堆外缓存,甚至G1的Region归属方式,都会影响一个对象最终“住”在哪里。所以,内存分布不是一张静止的图,而是一条动态的流水线,我们得从头开始把这套流水线捋清楚。
2. JDK11下JVM内存区域的底层划分
2.1 运行时数据区还剩下几块
先摆一张基础地图。JDK11的JVM依然遵循Java虚拟机规范,把运行时数据区分为几大块:程序计数器、虚拟机栈、本地方法栈、堆、元空间(Metaspace,取代旧版永久代)。其中堆和元空间是线程共享的,其他区域基本是线程私有的。
JDK11里最容易被忽视的变化是元空间的彻底独立。从JDK8开始,永久代被删除,原本放在永久代里的类元数据、方法元数据、常量池的一部分,被搬到了本地内存中的Metaspace。JDK11延续并强化了这一设计,Metaspace默认可以无限增长,增长速度受物理内存限制。很多人在JDK8时代养成的习惯是“调大PermSize”,到了JDK11如果还沿用这个参数,不但没用,反而会直接报错——这两个参数已经被移除了。
2.2 字符串常量池和静态变量的真实归属
另一个常见认知误区是字符串常量池。老资料说字符串常量放在永久代或方法区里,而JDK11里,字符串常量池在堆上,更准确地说是在堆的“字符串去重存储”(StringTable)里。这意味着,一个经过intern()的字符串,它的char[]数组对象本身是在堆上分配并参与GC的。
静态变量呢?在JDK8以前,静态字段存储在永久代中,类卸载时随之回收。JDK11中,静态变量对应的引用和基础类型值,已经存放在堆上的Class对象里——这个Class对象在元空间里描述类结构,但它持有的静态字段数据本身在堆中。换句话说,静态变量是GC的“耙子”之一,它会以“GC Roots”的身份被遍历,但存储位置已经不在元空间。
| 内存区域 | JDK8及以前 | JDK11 | GC参与情况 |
|---|---|---|---|
| 堆 | 新生代 + 老年代 | 新生代(Eden/Survivor) + 老年代,物理上由Region组成 | Young GC / Mixed GC / Full GC参与 |
| 永久代 / 元空间 | 永久代在堆内有大小上限 | 元空间占用本地内存,动态扩展 | 周期性回收废弃类与类加载器 |
| 字符串常量池 | 永久代 | 堆 | 参与GC,可被回收 |
| 静态变量 | 永久代 | 堆上的Class对象中 | 作为GC Root,不直接参与年轻代提升 |
| 栈上分配对象 | 无 | JIT逃逸分析后可入栈 | 不参与GC,随栈帧弹出销毁 |
这张表建议截图保存,面试和排查时都挺有用。
2.3 元空间到底还回收什么
元空间最容易被误解成“无限就没事了”,其实它在高压力场景下也会出现瓶颈。元空间内部主要由ClassLoaderData、类元信息、方法字节码、常量池数据等组成。JDK11提供了元空间大小调整参数:-XX:MaxMetaspaceSize、-XX:MetaspaceSize(触发GC的阈值水位线),以及-XX:MinMetaspaceFreeRatio和-XX:MaxMetaspaceFreeRatio(控制扩展和收缩比例)。
如果你在JDK11上频繁加载卸载类(比如热部署、动态生成大量增强类),会看到元空间的周期性增长和回落。一个真实案例:某项目用了大量运行时编译动态代理,Metaspace一度涨到1.2GB,而实际有效类并不多——这说明类加载器泄漏了,旧类加载器没有被完全回收。排查时可以查看metaspace日志或使用jhsdb,具体方法后面章节详说。
3. 堆外和栈上:JDK11中对象并不全在堆里
3.1 TLAB与栈上分配的逻辑
JVM在堆上为每个线程划分了一个TLAB(Thread Local Allocation Buffer),这是一个Eden区内部的私有缓冲区。小对象优先在TLAB中分配,避免了线程间的锁竞争。很多人以为只要new对象就是在堆上分配,其实在服务端编译器(C2)开启逃逸分析后,一部分没有逃逸出方法作用域的对象可以直接在栈上分配(标量替换实现),这个对象完全不进入堆,也不参与GC。
我在一次压测中见过一个极端情况:某个高频方法内部创建一个临时容器对象,无逃逸,被JIT优化后栈上分配,结果Young GC次数少了20%以上。这不是玄学,而是逃逸分析实实在在起效。JDK11默认开启了标量替换,所以**“new出来的对象一定在堆上”这个说法是不准确**的——只能说绝大多数场景下,对象最终会落到堆里。
3.2 堆外内存:绕过JVM管辖的“法外之地”
JDK11里很多高性能库(Netty、RocketMQ)都使用堆外内存(Direct Memory)来减少GC压力与数据拷贝。Direct Memory是通过ByteBuffer.allocateDirect()分配的本地内存,由JVM统一管理上限(-XX:MaxDirectMemorySize),但不参与堆GC,回收依赖Cleaner机制。
堆外内存的分配速度比堆内慢(因为需要系统调用mmap),但读写性能在高并发IO场景下优势明显。需要特别注意:DirectMemory使用不当会出现“堆小但内存持续走高”的诡异现象,伴随GC日志几乎正常、但进程OOM的情况。排查时要监控MaxDirectMemorySize和本机内存的使用量,不能只盯着堆。
4. G1视角下的内存空间:Region、Humongous与RSet
4.1 Region化布局带来的影响
G1把整个Java堆划分为多个大小相等的Region,每个Region默认大小由堆初始大小和目标Region数量(默认2048)决定,范围为1MB~32MB,通过-XX:G1HeapRegionSize可以手动指定。每个Region会按需要被标记为Eden、Survivor、Old、Humongous中的一种。
这种“逻辑分代 + 物理不分代”的布局,带来的直接变化是:老年代对象和新生代对象在物理位置上完全打乱,年轻代不再是连续的地址空间。旧时代的“连续内存晋升”模型被彻底抛弃,取而代之的是基于Remembered Set的跨Region引用追踪。
4.2 Humongous对象:超过Region一半的大对象
G1有一个特殊分配路径:当对象大小超过Region容量的50%时,会直接晋升为大对象区域(Humongous Region),直接在老年代连续Region上分配,且不会在Young GC中被回收。Humongous区域从分配那一刻起就属于老年代。如果这种对象频繁出现,会导致堆碎片化和Mixed GC周期变长。
我在一个实际项目中遇到过非常典型的Humongous性能陷阱:某个团队用Gson解析大JSON,每解析一次产生一个20MB左右的byte[],在2GB堆上直接占据一整个Humongous Region,结果Full GC频繁发生。后来改成分段解析,问题立刻消失。这套排查方法会在第6章结合GC日志展开。
4.3 RSet:空间换时间的引用管理
G1为了实现在回收某个Region时不必扫描整个堆,引入了RSet(Remembered Set)。每个Region维护一个RSet,记录“从其他Region指向本Region对象”的引用信息。代价是:写屏障需要在每次引用赋值时维护RSet,增加一些额外开销;好处是:Young GC和Mixed GC扫描范围大幅缩小。
JDK11里可以关注-XX:G1RSetRegionEntries、-XX:G1RSetSparseRegionEntries这些参数,但大多数场景不需要手工调。如果观察GC日志发现“RSet Updating”时间特别长,说明跨Region引用很多,这时候优化手段是调整卡表处理策略,或者考虑提高Region大小以减少跨Region引用数量。这块偏进阶,一般用默认配置即可。
5. G1的回收机制与Stop The World的真实代价
5.1 Young GC:G1的日常回收
G1的Young GC发生在Eden区耗尽时,只回收Eden和Survivor区域,将存活对象复制到新的Survivor或晋升到Old。这个过程是多线程并行执行的,会有STW(Stop The World)停顿,但停顿时间通过增量回收和自适应预测控制在目标范围内-XX:MaxGCPauseMillis(默认200ms)。
Young GC的日志里重点看几项:Parallel Time、Def New、Copy。Parallel Time里含有多个子阶段,比如Ext Root Scanning(扫描GC Roots)、Update RS(更新RSet)、Scan RS(扫描RSet)、Object Copy(对象复制)。如果一个系统的大部分GC停顿时间耗在“Update RS”上,说明写屏障开销偏高,需要审视代码里是否存在大量引用赋值操作,或者代际引用是否过于频繁。
5.2 并发标记与Mixed GC:G1的“大扫除”
当堆占用率达到-XX:InitiatingHeapOccupancyPercent(默认45%)时,G1会启动并发标记周期。标记分为初始标记、并发标记、最终标记、清理四个阶段。初始标记和最终标记是STW的,但并发标记阶段与业务线程并行运行,不会停顿。
标记完成后,G1会触发Mixed GC:在Young GC的基础上,额外回收部分高回收收益的Old Region。这就是G1能控制停顿时间的核心逻辑——它不一次性回收全部老年代,而是按“垃圾最多、代价最小”的原则挑着回收。Mixed GC可以连续发生多次,直到老年代垃圾比例降到合理范围。
5.3 Full GC:G1最后的“兜底手段”
G1的Full GC通常不是主动触发,而是Mixed GC来不及回收时的退路,或者是Humongous分配失败、RSet更新失败等异常场景导致的。JDK11中G1的Full GC是单线程串行的,停顿时间通常非常长,几百毫秒到几十秒都有可能。如果你在GC日志中看到Full GC,第一反应不是调参数,而是排查为什么Mixed GC没有及时控制住堆增长。
常见原因有:
- 大对象(Humongous)频繁分配,导致老年代空间提前耗尽
- -XX:InitiatingHeapOccupancyPercent设置过低,并发标记频繁发生,但回收跟不上分配速度
- To-Space Exhausted:Survivor区放不下晋升对象,提前晋升到老年代
- 元空间扩展触发Full GC(JDK11中少见但可能)
6. 从GC日志看内存分布:一次真实问题排查
6.1 被误解的“jstat老年代”
先分享一个我实际踩过的坑。某个服务的GC日志显示老年代占用持续偏高,监控面板上FGC(Full GC Count)也隔几分钟就+1,团队的第一反应是“G1没把老年代内存释放干净”,于是开始调整G1HeapRegionSize和G1ReservePercent。但我在jmap和jhsdb分析后发现,老年代里占大头的不是业务对象,而是一批20MB左右的字节数组——也就是Humongous对象。
Humongous对象一旦进入老年代,不受Young GC控制,只能等Mixed GC或Full GC来清理。如果这批大对象是短期存活的,它会造成“堆占用虚高、回收收益为负”的局面。日志中如果大量出现“Humongous Allocation”相关记录,先排查代码中的大数组、大集合。
6.2 排查工具组合:jstat、jhsdb、GC日志三板斧
在JDK11上,我一般用以下命令组合定位问题:
# 查看堆概览和各代容量 jstat -gc <pid> 1000 # 查看GC统计与停顿分布 jstat -gccause <pid> 1000 # 堆内对象分布粗看 jhsdb jmap --heap --pid <pid> # 导出堆转储,强制Full GC后再对比堆大小 jhsdb jmap --histo:live --pid <pid>新JDK已经不支持老jmap的部分参数(比如-F),所以尽量用jhsdb。jhsdb jmap --heap能显示Region划分情况:Eden、Survivor、Old、Humongous各自占多少Region,这对判断大对象影响非常直观。
6.3 分析GC日志的关键节点
GC日志里需要盯住的字段是:[Pause: Young (Normal)] (G1 Evacuation Pause)、[Pause: Young (Mixed)] (G1 Evacuation Pause)、[Concurrent Cycle]、[Pause: Full]。识别频率和停顿时间后,建立几个阈值:
| 观察项 | 正常范围 | 异常信号 |
|---|---|---|
| Young GC频率 | 每秒或数秒一次 | 每秒数次且停顿超过平均停顿 |
| Mixed GC频率 | 并发标记完成后周期出现 | 长时间不出现又突然Full GC |
| Humongous Allocation | 极少 | 每次GC日志都出现 |
| Full GC | 几乎不出现 | 一天超过一次就需重视 |
| 堆占用率 | 平稳波动 | 持续上升且Mixed GC后不回落 |
7. 基于内存分布设计JVM参数:一组经过实测的基线配置
7.1 先厘清目标:停顿优先还是吞吐优先
调整JVM参数之前,先想清楚业务容忍度。互联网接口型应用,通常希望单次GC停顿更短,适合采用G1并设置合理的MaxGCPauseMillis;离线批处理型应用,更看重吞吐量,可以适当提高停顿目标,减少GC频率。
JDK11上我常用的基线参数如下(以8GB堆、16核服务为例):
-Xms8g -Xmx8g -XX:+UseG1GC -XX:MaxGCPauseMillis=150 -XX:ParallelGCThreads=8 -XX:ConcGCThreads=4 -XX:G1HeapRegionSize=4m -XX:InitiatingHeapOccupancyPercent=45 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/to/gc.log-Xms和-Xmx设为一致,避免JVM扩容带来的额外成本和毛刺。G1HeapRegionSize设置成4m的好处是,8GB堆会被划分为2048个Region(这是G1的设计目标),同时避免Region太小导致RSet膨胀。
7.2 参数不是越多越好
很多人喜欢堆砌-XX:+UseConcMarkSweepGC、-XX:UseAdaptiveSizePolicy这类参数,这在JDK11中不但没有意义,有些还直接不支持了。G1的特点是自适应调节:它会根据目标停顿和吞吐自动调整新生代大小、晋升阈值、回收Region数量。你调得越勤,反而可能破坏它的自适应性。
真正值得下功夫的参数就几个:堆大小、Region大小、并发标记阈值、停顿目标、GC日志开关。其他的交给G1自己决策。
7.3 停止手工调优的迷信,用数据说话
G1内部有很多启发式算法,比如-XX:G1NewSizePercent、-XX:G1MaxNewSizePercent控制新生代大小范围,-XX:G1MixedGCCountTarget控制Mixed GC次数。它们的作用是让G1“跟着业务走”。我的经验是:先跑一段时间,拿GC日志做充分分析,再决定是否修改任何一个参数,不要一上来就参数轰炸。
举个例子,如果Young GC频繁但停顿很短,说明新生代过小,可以适当提高G1NewSizePercent;如果Mixed GC后老年代占用仍持续在高位,才需要调整InitiatingHeapOccupancyPercent或检查Humongous问题。
8. 常见误区与易踩的坑
8.1 “内存越大越好”的幻觉
给堆分配过大内存不一定是好事。在G1下,堆越大,Region越多,RSet和并发标记的耗时也越大。一次分配24GB堆,Region数量超过6000,GC相关数据结构占用的内存和CPU都明显增加。业务峰值只需要6GB,硬给16G,结果就是GC停顿反而变长。合理评估对象规模再来定堆大小,比拍脑袋给两倍更可靠。
8.2 Survivor区并非越大越安全
年轻代里Eden和Survivor的比例不是固定8:1:1,G1是按目标停顿自动调整的。新人容易误调-XX:SurvivorRatio,在G1下这个参数的作用有限。Survivor过小可能导致对象提前晋升到老年代,Survivor过大又浪费新生代空间,导致Eden变小、Young GC更频繁。让G1自己调整即可。
8.3 “Full GC一旦发生就完蛋”需要区分场景
G1的Full GC确实很慢,但有些Full GC是“伪Full GC”——比如显式调用System.gc()。JDK11中如果没有加-XX:+DisableExplicitGC,业务代码里的System.gc()会触发Full GC,导致你误以为内存出问题,结果是代码bug。线上排查时先用jstack看是否有业务线程调用了System.gc(),别一看到Full GC就慌着堆调参。
8.4 元空间的二次踩坑
前面说过元空间可以无限扩展,这带来另一个坑:某些框架如果持续创建类加载器且不释放,元空间会不断攀升,最终触发系统级内存不足。因为元空间不在堆内,jstat -gc看不到它多大,得用jstat -gcutil或jcmd查看:
jcmd <pid> VM.metaspace如果发现元空间占用远高于预期,优先怀疑类加载器泄漏。
9. 实操建议:怎么给新项目起步并且长期观测
9.1 起步三步走
第一,先把堆大小定成一个合理值,不要小于稳定状态下的对象占用峰值。第二,开启GC日志,至少保留最近几十个文件,方便事后回放。第三,每天或每周定时导出GC日志统计,关注停顿分布的P99、Full GC频率和Humongous数量。
9.2 GC日志滚动配置
JDK11的日志配置建议使用新版命令行模式:
-Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=10,filesize=20m这样能自动切割日志,避免日志文件无限制膨胀。生产环境一定要开日志,没日志相当于盲人摸象,很多问题根本没有排查入口。
9.3 聊聊我推荐的分析思路
拿到一段GC日志后,我习惯按这么顺路走:
- 看格式是否正常、有没有Full GC;
- 看Young GC频率和平均停顿是否达到目标;
- 看Mixed GC有没有及时触发,老年代趋势是涨还是跌;
- 看Humongous对象数量,若有明显异常,走堆转储定位来源;
- 结合业务流量时间点,判断GC是否与高峰对应;
- 最后才决定是否动参数。
这套方法已经帮我把几个“莫名其妙慢”的线上应用拉回正轨,最大的体会是:G1是一个优秀的自动调优器,它比大多数手工参数组合都聪明。你要做的不是频繁干预它,而是理解它在做什么,并确保它能看到正确的运行环境。
如果你正在JDK11上维护一个高并发服务,建议花一两个小时,把堆内存的Region状态和GC日志细细看一遍——这比收藏一百篇理论文章都有用。