资讯动态

JVM内存区域实战:五大区域原理与OOM排查调优

发布时间:2026/10/16 6:32:26 来源:尧图企业网站定制
1. 一次线上OOM让我重新认识了JVM内存区域的真实分量很多Java开发第一次接触JVM内存区域是为了应付面试。堆、栈、方法区、程序计数器这几样背熟了以为就算过关。但真实线上环境根本不按面试题出牌内存区域的知识是用来解决事故的。我吃过这个亏某个订单处理服务在下午高峰期集中超时日志高频刷出java.lang.OutOfMemoryError: GC overhead limit exceeded当时我的第一反应是堆不够顺手就把-Xmx从4g提到8g结果第二天更糟——Full GC频率不但没降单次停顿反而更长服务直接进入半瘫痪状态。后来一步步用jstat看数据才发现问题根本不在堆总量而在年轻代内部的配比和晋升节奏。那次事故让我花了很长时间重新梳理JVM内存区域的完整地图。HotSpot VM的运行时内存大致可以分成六块堆里的新生代Eden Space加两块Survivor Space、堆里的老年代Old Gen、堆外的Code Cache、堆外线程栈以及存放类元数据的MetaspaceJava 8之前的名字是Perm Gen。标题里点名的五块区域——Eden Space、Survivor Space、Old Gen、Code Cache、Perm Gen——恰好覆盖了堆内和堆外最容易被搞混的部分。先把这五块区域的基本职责列个表后面逐一展开内存区域位置主要存放内容关联GC典型异常Eden Space堆·新生代新对象的主要出生地Minor GC新对象分配失败Survivor SpaceS0/S1堆·新生代经过GC仍然存活、尚未晋升的对象Minor GC对象过早晋升Old Gen堆·老年代长期存活对象与超大对象Major / Full GC老年代空间不足Code Cache堆外本地内存JIT编译后的机器码无GCCode Cache已满Perm GenJava 8前/ MetaspaceJava 8后堆外本地内存类元数据、常量池、方法信息等类卸载PermGen / Metaspace OOM看一眼这张表就能感觉到JVM内存区域之间不是孤岛Eden的对象会流向SurvivorSurvivor的会晋升到Old GenCode Cache和Perm Gen/Metaspace则在堆外悄悄占地方。任何一个环节失衡症状可能完全不同。下面我按对象流动的顺序把每块区域拆开讲。2. 年轻代的运转真相Eden Space和Survivor Space凭什么按8:1:1划分2.1 分代假设是整张内存地图的地基分代思想源于一个被反复验证的规律绝大多数Java对象朝生夕灭只有极少数能活过多次GC。所以HotSpot把堆切成新生代和老年代新生代内部又切成Eden和两块Survivor。默认配比是8:1:1也就是Eden占新生代的八成S0和S1各占一成。这个比例不是拍脑袋定的它基于经验统计让Eden承担大量短命对象的分配让Survivor充当缓冲留置区把多活了几轮但依然短命的对象拦在新生代内部。新生代使用的复制算法是这套设计的核心。Minor GC时存活对象被复制到空闲的Survivor区原有区域一次性清空。复制算法最怕存活对象太多因为复制成本会直线上升但它特别适合极少存活的场景——大部分对象直接死去只要把那百分之几的存活对象搬个家就行。Eden越大Minor GC触发频率越低Survivor越大容纳中间存活对象的能力越强两者互相牵制这也是为什么8:1:1只是一个起点具体调参要看业务对象的真实存活率。2.2 对象在Survivor之间流转的完整过程我按一次Minor GC的前后来描述。Eden区满了JVM触发Minor GC从GC Roots出发做可达性分析存活对象被标记出来。这些存活对象并不会留在Eden里等待下次GC而是被复制到当前空闲的那块Survivor比如S0对象头的Mark Word里记录一个年龄值初始为1。下一次Minor GC时S0里的存活对象再被复制到S1年龄加一S0被清空。如此反复两块Survivor轮流充当目的地和清理现场任何时刻都只有一块有数据。这里有个新手容易漏掉的细节晋升并不完全等到年龄到15。HotSpot有动态年龄判断机制如果Survivor空间中某个年龄及以上的对象大小总和超过了Survivor空间的一半默认TargetSurvivorRatio是50%那么这批对象会提前晋升到Old Gen。我见过有人开着jstat观察发现年龄才几岁的对象就进了老年代以为收集器出了bug其实就是这个动态机制在起作用。它的存在是为了防止Survivor被某一段存活对象堵死代价是可能把一部分短命对象提前送入老年代。2.3 新生代相关参数与常见的翻车操作-Xmn直接指定新生代总大小。调大它能减少Minor GC次数但老年代会被挤小。-XX:SurvivorRatioEden与单个Survivor的比值默认8。嫌Survivor不够用就把它调小比如改成6或4。-XX:MaxTenuringThreshold最大晋升年龄阈值默认15配合动态年龄判断共同决定晋升时机。-XX:UseAdaptiveSizePolicy自适应大小调整。默认开启时JVM会按GC统计自动改动各区域比例如果你希望配置稳定可控可以关掉它。实际项目里我见过最典型的翻车操作是为了减少Minor GC把Eden调得巨大同时把Survivor压得很小。表面上Minor GC确实少了但Survivor装不下两三轮GC的存活对象动态年龄判断提前触发大量本该死在新生代的对象涌向老年代。Old Gen被短命对象污染之后Full GC从一天几次变成一小时几次整体延迟反而更差。所以我的习惯是先按默认比例跑一个业务周期用jstat记录Survivor的占用情况和晋升节奏再决定要不要动比例。一次只动一个参数是内存调参的铁律。3. Old Gen晋升门槛、Full GC风暴与观察方法3.1 三种进入老年代的途径进老年代的路有三条第一条是新生代里的对象年龄达标后被晋升第二条是动态年龄判断触发提前晋升第三条是超大对象直接进老年代由-XX:PretenureSizeThreshold控制超过阈值的对象直接在老年代分配避免它在Eden和Survivor之间做高成本的复制搬运。很多人会问Survivor这个缓冲层是不是多余的为什么不让对象在Eden里待一段时间就直接晋升答案在于筛选两个字。很多对象能熬过第一轮GC但活不过第二轮如果第一轮就晋升老年代会被大量短期对象塞满。Survivor相当于一道过滤网用能活过几次GC来筛选只有经得起考验的对象才配进入老年代。这也是分代设计中最精妙的部分——表面上多复制了一次实际上大幅降低了老年代的回收压力。3.2 Major GC和Full GC的区别以及晋升失败的连锁反应先把术语理清楚Major GC通常指针对老年代的回收Full GC则是对整个堆加元数据区域的完整回收。不同收集器下两者的触发条件和代价差别很大。以老牌的CMS为例它会在老年代快满时启动并发标记清除但如果并发清理期间预留空间不够就会发生Concurrent Mode FailureJVM被迫退化成串行Full GC服务停顿时间陡增。所以只要看到老年代使用率持续逼近上限就离Full GC风暴不远了。另一种隐蔽的坑叫Promotion Failure也就是晋升失败Minor GC结束存活对象要复制到老年代结果老年代剩余空间不够分配不下JVM只能中断Minor GC并升级为Full GC。这类问题的特征曲线很典型Minor GC越来越频繁、每次Minor GC后老年代占用反而上升、Full GC不定期爆发。遇到这种模式不要只盯堆大小优先排查Survivor配比和晋升阈值从源头减少晋升量才是正解。3.3 用jstat盯老年代压力的实操节奏日常观察老年代我用的命令是jstat -gcutil pid 1000每一秒输出一次。重点关注O列老年代使用率和FGC列Full GC次数。如果O列长期在八成以上徘徊FGC间隔越来越短基本可以判定老年代超负荷。这时候要做两件事一是估算对象晋升速率用jstat -gc里OU列在两个时间点的差值除以间隔时间算出一个大概的晋升吞吐量看看是否高得异常二是用jmap -histo:live pid导出一份堆内对象排行榜重点看数量最多、占空间最大的对象是否和业务生命周期对得上。我处理过一个报表服务老年代曲线持续上涨jmap导出来的对象清单里排第一的是一个报表模板的实例数量高达几百万。排查了一圈发现是缓存层把模板对象重复写进了静态Map业务线程每次取用后又放了一份副本。修掉重复写入老年代曲线立刻平稳。这锅虽然不该JVM背但要是不懂内存区域的流转逻辑很难从堆层面反推到业务代码。4. Code Cache那块总是被遗忘的堆外本地内存4.1 JIT编译产物为什么会单独占一块地方Java源码先编译成字节码字节码进JVM后如果一直解释执行性能会很难看。HotSpot的做法是统计热点方法用JIT编译器把字节码编译成机器码缓存起来直接执行。这些机器码的存放区域就是Code Cache位于堆外的本地内存不属于-Xmx管辖。很多人排查内存问题时只盯堆完全忽略堆外。JVM进程的完整内存占用远不止一个堆除了Code Cache还有Metaspace、线程栈、Direct ByteBuffer等。我见过有人在容器里只给JVM开一个很大的-Xmx却忘了容器本身有内存上限最后进程被系统级的OOM Killer干掉连Java层的OutOfMemoryError都来不及打。那个场景里堆外那部分内存就是压垮骆驼的最后一根稻草。Code Cache有大小上限默认的ReservedCodeCacheSize在240MB左右具体跟JVM版本和是否开启分层编译有关。分层编译是Java 7引入的机制C1编译器先把方法快速编译成优化程度较低的代码跑起来之后C2编译器再针对高热度方法重新编译成优化更极致的机器码。中间会产生大量编译产物所以开启分层编译后Code Cache的消耗速度明显加快。4.2 Code Cache打满的隐蔽后果Code Cache满时JVM会给出类似Compiler has encountered code cache is full的告警不同版本措辞略有差别。此时JIT编译器停止编译新方法已经编译的机器码不受影响但新热点方法只能退回解释执行整体性能会显著回落。这个问题的隐蔽之处在于服务不报业务异常接口照常返回但吞吐量莫名掉一截CPU占用率还可能因为解释执行而上升。特别是那些动态生成字节码密集的框架——动态代理、模板引擎、脚本引擎——更容易把Code Cache打到上限。排查方法很简单启动参数加-XX:PrintCodeCacheJVM退出时会打印一份完整统计运行期可以用jcmd pid VM.native_memory summary看Native Memory的整体分布。想让Code Cache更从容可以把-XX:ReservedCodeCacheSize调大到512MB甚至更高但我建议先确认业务确实有高强度的动态类生成再动手。普通Web服务默认值其实够用硬调大只是浪费本地内存。还有一个-XX:UseCodeCacheFlushing开关允许JVM在紧张时清理部分不再使用的编译代码但它存在副作用新版JDK更推荐直接给足空间或者通过调整编译阈值减少编译量。5. Perm Gen的谢幕与Metaspace的接棒类元数据搬家记5.1 Perm Gen里原来装的是什么标题里点到Perm Gen这是Java 8之前HotSpot特有的一块区域。它放的是类元数据Class对象的结构信息、方法字节码、字段描述、常量池、注解信息还有静态变量和一部分JIT内部数据。永久代这个名字很有误导性好像里面的东西永远不会被回收。其实类也是可以卸载的只是条件苛刻需要类加载器连同它加载的所有类一起变成垃圾而这个条件在常规业务里很难满足。Perm Gen最让人头疼的问题是它有固定大小上限而且默认值不大。一旦运行期动态生成大量类——典型场景是反射、动态代理、热部署或者某些模板引擎在运行期编译模板——Perm Gen很容易被塞满抛出java.lang.OutOfMemoryError: PermGen space。遇到这个报错调大-XX:MaxPermSize确实是常规操作但更根本的问题往往是类加载器泄漏每次热部署都新建类加载器旧的却迟迟不被回收类元数据越积越厚空间被慢慢吃干。这也是早年很多应用一热部署就连续重启的根源。5.2 Java 8之后换成Metaspace治标还是治本Java 8正式移除Perm Gen类元数据搬进Metaspace。Metaspace使用本地内存默认没有上限只受操作系统可用内存约束。这解决了PermGen space这类OOM但把问题转移到了另一头如果Metaspace失控增长最终会吃爆整台机器的内存进程被系统杀掉或者抛出java.lang.OutOfMemoryError: Metaspace。所以Metaspace并不是越大越好我的做法是显式设置上限比如-XX:MaxMetaspaceSize512m防止失控同时设置-XX:MetaspaceSize来控制初始扩容阈值避免应用启动后频繁做元空间扩容。对大多数业务系统来说Metaspace稳定占用也就一两百MB。如果你发现它持续上涨停不下来优先排查类加载器泄漏——这一点和当年排查Perm Gen OOM的思路完全一致本质上都是谁在不断创建新的类结构而不释放。5.3 字符串常量池搬家的连带影响这里还得补一个历史细节字符串常量池在Java 7就搬离了Perm Gen移到了堆上Java 8之后Perm Gen彻底消失所以Intern字符串相关的内存问题如今反映在堆上而不是Metaspace。看到java.lang.OutOfMemoryError: String table overflow这类报错时别再去翻Perm Gen配置方向应该在堆和字符串引用本身。排查思路要跟版本一起升级这也是老Java开发者容易踩的惯性坑。6. 定位和调优把五块区域纳入日常监控6.1 jstat、jmap、jcmd三件套的用法讲完区域落地到工具。我日常使用频率最高的三个命令jstat -gcutil pid 1000每秒输出新生代、Survivor、老年代、元空间的使用率和GC统计适合观察晋升节奏和GC频率的趋势。jmap -histo:live pid输出堆内对象实例数和占用大小的降序排行定位业务对象的异常堆积。jcmd pid VM.native_memory summary查看Native Memory整体分布Code Cache、Metaspace、线程栈的占用尽收眼底。注意NMT需要在启动时加-XX:NativeMemoryTrackingsummary否则只能看个大概。这三个工具配合基本能摸清堆内哪块在涨和堆外哪块在涨。老年代涨、Full GC频繁先用jstat确认节奏再用jmap看对象画像堆一切正常但进程内存持续上涨就必须查Metaspace和Code Cache。千万别把所有内存问题一股脑推给堆。6.2 不同业务场景的参数基线与调整顺序场景关注点推荐基线标准Web服务对延迟敏感堆稳定、GC停顿可控-Xms与-Xmx相等避免堆震荡G1下先调-XX:MaxGCPauseMillis批处理/数据密集任务吞吐优先、晋升速率堆可适当放大但必须给Metaspace设上限关注Survivor配比动态类生成密集脚本引擎、热部署类元数据与编译缓存调大-XX:MaxMetaspaceSize和-XX:ReservedCodeCacheSize同时排查类加载器泄漏容器环境整体内存不超限堆MetaspaceCode Cache线程栈Direct Buffer合计后留出余量别卡极限调整顺序上我坚持一次只改一个变量观察一个完整业务周期再动下一个。JVM内存区域是联动的同时调两个参数出了问题你根本分不清是哪个改动引发的。这个习惯帮我避免了很多次拍脑袋调参造成的二次事故。6.3 几个反复出现的调参坑第一调大-Xmn之后忘记复查老年代比例。新生代扩大意味着老年代缩小吞吐型场景可能会触发更频繁的老年代回收把Minor GC省下的时间成倍赔进去。第二盲目设置堆内存却忽略堆外开销。JVM的完整内存占用是堆加Metaspace加Code Cache加线程栈加Direct Buffer任何一块超限都可能拖垮服务。第三堆内存只加不减Full GC不降反升。这种时候最该做的是dump堆做分析而不是继续往上加内存——内存加得越多掩盖问题的时间越久。再分享一个小偏好我每次接手一个新服务第一件事不是看业务代码而是先跑一遍jstat和jcmd把内存家底摸清楚记录各个区域的基线占用。这个习惯让我在后来的对比排查中省了太多时间——所有内存问题的第一步都是先知道正常时应该是什么样子。等你在测试环境亲手复现一次从Minor GC到晋升的全过程再在线上看一次Full GC降级的酸爽这些区域在你脑子里就不是面试题了而是一张张会动的地图。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑