堆内存管理基础:新生代、老年代、永久代/元空间

聊到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:SurvivorRatioEden与Survivor的比例8(Eden:S0:S1 = 8:1:1)
-XX:TargetSurvivorRatioSurvivor空间利用率阈值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晋升流程 新生代 (Young Generation) Eden区 对象诞生地 Survivor 0 存活对象缓冲区 Survivor 1 空闲备用区 复制 老年代 (Old Generation) 长期存活对象 Major GC / Full GC 触发区 晋升(年龄达标) 大对象
直接进入 元空间 类元数据 本地内存 类加载 📌 核心流程 1. 对象在Eden创建 2. Minor GC后复制到Survivor 3. 年龄达标晋升到老年代 ⚠️ OOM预防核心关注点 新生代大小比例合理 | 老年代晋升不过快 | 元空间设上限防泄漏

小结一下关键点

  • 新生代:每天处理大量短期对象,GC频率高、速度快。Eden放新对象,Survivor做缓冲。
  • 老年代:存放长生命周期对象,GC频率低但代价大。对象晋升得过快是老年代OOM的主要信号。
  • 元空间:JDK 8后用来放类的元数据,使用本地内存。我建议一定加上-XX:MaxMetaspaceSize。
  • 参数调优方向:观察大对象比例、晋升速率、Full GC频率,然后针对性调整比例和阈值。

⚠️ 再强调一个容易忽略的点:有时候你会发现老年代明明还有很多空间,但发生了Full GC。那很可能是元空间或永久代满了,或是System.gc()被显式调用了。我在一个项目里见过有人每5分钟调一次System.gc(),结果GC不停,系统基本不可用。排查时一定要用jstat或jconsole看清楚触发原因。

嗯,有了这个基础,后面分析Heap Dump时你就能一眼看出对象的年龄分布、晋升情况,以及内存泄漏到底出在哪个区。先消化好这些概念,后面的案例会更有感觉。