堆内存管理基础:新生代、老年代、永久代/元空间
聊到JVM堆内存,很多新手容易犯迷糊。其实说白了,堆内存的分代设计就是为了解决一个核心问题:大部分对象都是朝生夕死的。你想想看,一个典型的Web请求里创建了几百个临时对象,请求处理完就没人用了。针对这个特点,JVM把堆分成了几个区域,每个区域用不同的回收策略。
新生代:对象诞生的地方
几乎所有对象刚创建时,都躺在新生代里。我有个习惯,启动项目时总喜欢先看一眼新生代大小——如果太小,业务稍微上来点流量就频繁触发Minor GC,反而拖慢性能。
新生代又分为三个小块:
- Eden区:对象在这里出生。大部分对象活不过第一轮GC。
- Survivor区(S0 / S1):它是对象的“缓冲带”。GC过后,还活着的对象从Eden挪到这里。
为什么要设计两个Survivor?我解释一下:
- GC时,把Eden和其中一块Survivor中存活的对象,复制到另一块空的Survivor。
- 这样操作效率高,而且避免了内存碎片。
- S0和S1是交替工作的,始终保证有一块是空的。
关键指标
| 参数 | 说明 | 默认值(JDK 8) |
|---|---|---|
| -Xmn | 新生代总大小 | 视物理内存而定(通常为堆的1/3左右) |
| -XX:SurvivorRatio | Eden与Survivor的比例 | 8(Eden:S0:S1 = 8:1:1) |
| -XX:TargetSurvivorRatio | Survivor空间利用率阈值 | 50% |
💡 避坑提示:我曾经遇到一个业务,SurvivorRatio设成了3,结果Eden特别小,对象频繁晋升到老年代,老年代很快就满了。恢复默认的8之后,情况好很多。经验是,如果Survivor区太小,对象还来不及在新生代被回收就被推到老年代,老年代压力就会很大。
老年代:对象的“养老院”
对象在新生代里撑过多次GC后,年龄够了,就会晋升到老年代。老年代里的对象生命周期长,回收频率低,但一旦触发GC,那是全局性的——也就是我们常说的Full GC。Full GC很重,如果老年代一直不够用,程序就会出现长时间停顿。
什么情况下对象会提前进入老年代?
- 晋升年龄阈值:默认15次GC后,年龄到15就升上去。参数
-XX:MaxTenuringThreshold可以调。 - 大对象直接进入老年代:如果对象大小超过
-XX:PretenureSizeThreshold,就不会在新生代分配,直接扔老年代。 - 动态年龄判定:JVM会根据Survivor区的实际使用情况自动调整,不一定非要等到15次。
⚠️ 注意:我在项目中见过一个大对象直接进入老年代的情况。那是一个业务初始化时创建了一个很大的缓存结构,没有设置PretenureSizeThreshold,结果它每次都往新生代放,导致Eden瞬间被打满,Minor GC频率极高。后来把这个参数设成1MB,大对象直接进了老年代,新生代GC压力立刻降下来了。
永久代/元空间:存放类元数据的地方
JDK 8以前,类的元数据(Class、Method、Field等)存在永久代里,大小通过-XX:PermSize和-XX:MaxPermSize控制。那时候我踩过不少坑——项目里用CGLIB动态生成类,一不小心就把永久代撑爆了,抛出java.lang.OutOfMemoryError: PermGen space。
JDK 8以后,永久代被彻底移除,取而代之的是元空间。元空间不在Java堆里,而是使用本地内存。这样设计的好处很明显:元空间默认只受操作系统物理内存限制,不需要我们费力去估算大小。
永久代 vs 元空间 对比
| 对比项 | 永久代(JDK 7及之前) | 元空间(JDK 8及之后) |
|---|---|---|
| 内存位置 | Java堆内 | 本地内存 |
| 默认上限 | 64MB(32位)/ 82MB(64位) | 无(物理内存限制) |
| GC触发条件 | 永久代满了会触发Full GC | 元空间满了触发Full GC(很少发生) |
| 调优参数 | -XX:PermSize / -XX:MaxPermSize | -XX:MetaspaceSize / -XX:MaxMetaspaceSize |
| 常见OOM场景 | 类加载过多、CGLIB代理 | 类泄漏、元空间未设置上限 |
💡 避坑提示:我建议在生产环境上还是要给元空间设个上限。虽然理论上可以用完物理内存,但万一出现类加载泄漏(比如热部署不停加载新类),元空间会无限膨胀下去,最后把整个系统内存吃光。设置-XX:MaxMetaspaceSize=256m,会给系统一个兜底。
一张图看懂堆内存分代与GC流程
下面这张图把新生代、老年代、元空间以及GC触发路径串了起来,你可以对照着回顾一遍。
小结一下关键点
- 新生代:每天处理大量短期对象,GC频率高、速度快。Eden放新对象,Survivor做缓冲。
- 老年代:存放长生命周期对象,GC频率低但代价大。对象晋升得过快是老年代OOM的主要信号。
- 元空间:JDK 8后用来放类的元数据,使用本地内存。我建议一定加上
-XX:MaxMetaspaceSize。 - 参数调优方向:观察大对象比例、晋升速率、Full GC频率,然后针对性调整比例和阈值。
⚠️ 再强调一个容易忽略的点:有时候你会发现老年代明明还有很多空间,但发生了Full GC。那很可能是元空间或永久代满了,或是System.gc()被显式调用了。我在一个项目里见过有人每5分钟调一次System.gc(),结果GC不停,系统基本不可用。排查时一定要用jstat或jconsole看清楚触发原因。
嗯,有了这个基础,后面分析Heap Dump时你就能一眼看出对象的年龄分布、晋升情况,以及内存泄漏到底出在哪个区。先消化好这些概念,后面的案例会更有感觉。