堆内存深度剖析:新生代、老年代、元空间的结构与GC流程

大家好,我是你们的老朋友。今天咱们来聊聊堆内存的内部结构。说实话,很多同学做了好几年Java开发,对堆的理解还停留在“对象都放堆里”这个层面。但真要排查OOM或者调优GC,这点认知远远不够。

我个人习惯把堆内存比作一个三层楼的仓库。新生代是临时堆放区,老年代是长期存储区,元空间则是仓库的管理档案室。这三者怎么协作?GC又是怎么流转的?咱们一层层拆开看。

新生代:Eden与Survivor的“生死局”

新生代是对象诞生的地方。绝大多数对象在这里“出生即死亡”——据统计,超过98%的对象活不过第一轮GC。

新生代内部又分成三块:Eden区和两个Survivor区(通常叫From和To,或者S0和S1)。比例默认是8:1:1。嗯,这个比例很关键,我后面会讲为什么这么设计。

核心要点:Eden区是对象分配的主战场。new出来的对象,只要不是特别大,基本都先扔到Eden里。

你可能会问:“为什么需要两个Survivor区?” 我刚开始学的时候也困惑过。说白了,这是为了避免内存碎片化。如果只有一个Survivor区,每次GC后存活对象复制过去,会产生大量碎片。两个Survivor区轮流倒腾,就能保证始终有一块干净整齐的空间。

我在项目中遇到过这样一个坑:有个同事把SurvivorRatio调成了2:1:1,结果Eden区太小,对象频繁晋升到老年代,老年代GC压力暴增。后来我建议改回8:1:1,Full GC频率直接降了60%。

老年代:对象的“养老院”

对象在新生代熬过若干次GC(默认15次,可通过-XX:MaxTenuringThreshold调整)后,就会晋升到老年代。老年代的特点是:对象存活率高,GC频率低,但每次GC耗时较长

老年代主要存放两类对象:

  • 长期存活的对象(比如缓存、单例、长连接对象)
  • 大对象(超过-XX:PretenureSizeThreshold阈值,直接进入老年代)

避坑指南:我曾经接手过一个系统,老年代持续增长但GC回收不掉。排查后发现是ThreadLocal使用不当,导致线程池中的线程持有大量对象引用。嗯,ThreadLocal用完一定要remove,这个教训我记了好几年。

老年代的GC算法主要是标记-整理(Mark-Compact)。为什么不用复制算法?因为老年代存活对象太多,复制成本太高。标记-整理的做法是:先标记出存活对象,然后把它们向一端移动,清理掉边界外的空间。这样能有效避免碎片化。

元空间:从永久代到元空间的进化

Java 8之前,方法区叫“永久代”(PermGen),也在堆内存里。Java 8之后改成了元空间(Metaspace),移到了本地内存(Native Memory)。

为什么要改?说白了,永久代的大小很难预估。你设个256MB,可能不够用;设个512MB,又浪费。而且永久代一旦OOM,基本只能重启。元空间使用本地内存,默认只受系统内存限制,大大降低了这类问题。

对比项 永久代(Java 7及以前) 元空间(Java 8及以后)
存储位置 堆内存 本地内存
默认大小 固定(可配) 无上限(受系统内存限制)
存储内容 类元数据、常量池、静态变量 类元数据(常量池和静态变量移到了堆)
GC回收 Full GC时回收 触发条件更灵活

元空间主要存什么?类的结构信息、方法字节码、运行时常量池等。注意,静态变量和字符串常量池在Java 8之后移到了堆内存,别搞混了。

个人建议:虽然元空间默认无上限,但我还是建议设置-XX:MaxMetaspaceSize。为什么?防止类加载器泄漏导致元空间撑爆系统内存。我曾经见过一个动态生成类的框架,因为没控制好,元空间涨到了8GB,差点把服务器搞挂。

GC流程:对象的一生

好了,结构讲完了,咱们串一下GC流程。你想想看,一个对象从出生到消亡,经历了什么?

  1. 对象分配:new出来的对象,优先放在Eden区。如果Eden区空间不足,触发Minor GC。
  2. Minor GC:扫描Eden和Survivor区,标记存活对象。存活对象复制到另一个Survivor区(比如从S0复制到S1)。Eden和原Survivor区直接清空。
  3. 对象晋升:对象每熬过一次Minor GC,年龄加1。年龄超过阈值(默认15),晋升到老年代。
  4. 老年代GC:老年代空间不足时,触发Major GC(或Full GC)。使用标记-整理算法回收。
  5. 元空间GC:当元空间达到-XX:MetaspaceSize阈值时,触发Full GC来回收卸载类的元数据。

这里有个细节要注意:Minor GC触发时,如果老年代空间不足以容纳晋升的对象,会提前触发Full GC。我在调优时经常遇到这种情况——新生代GC频繁,但老年代也扛不住,最后Full GC成了常态。

实战经验:有一次我排查一个系统,Minor GC每次耗时不到10ms,但Full GC要2秒多。一看GC日志,发现每次Minor GC都有大量对象晋升到老年代。调大了Survivor区比例,并提高了晋升阈值,Full GC频率从每小时10次降到了每天2次。

代码示例:观察GC行为

光说不练假把式。咱们写段代码,配合JVM参数,看看GC到底怎么工作的。

// 示例:观察对象晋升
public class GCObserver {
    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) {
        byte[] allocation1, allocation2, allocation3, allocation4;
        
        allocation1 = new byte[2 * _1MB];  // 2MB,放Eden
        allocation2 = new byte[2 * _1MB];  // 2MB,放Eden
        allocation3 = new byte[2 * _1MB];  // 2MB,放Eden
        // 此时Eden已用6MB(假设Eden 8MB),再分配4MB会触发Minor GC
        allocation4 = new byte[4 * _1MB];  // 触发Minor GC
    }
}

配合JVM参数运行:

-Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 
-XX:+PrintGCDetails -XX:+UseSerialGC

你会看到类似这样的输出:

[GC (Allocation Failure) [DefNew: 7486K->512K(9216K), 0.0054323 secs] 
7486K->6656K(19456K), 0.0054876 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]

解读一下:新生代从7486K回收到了512K,但堆总内存从7486K变成了6656K。说明有部分对象(约6144K)晋升到了老年代。嗯,这就是GC的典型行为。

小技巧:生产环境建议加上-XX:+PrintGCDetails和-XX:+PrintGCDateStamps,把GC日志输出到文件。这样出问题时,你能回放GC的全过程,定位问题会快很多。

好了,堆内存的结构和GC流程就讲到这里。下一章咱们聊聊GC算法的具体实现,以及怎么根据业务场景选择合适的GC器。有什么问题欢迎在评论区交流。