← 返回首页

JVM 内存模型:一段代码看懂 GC 何时触发

面试的时候,Java GC 是个绕不开的话题。Minor GC、Full GC、Stop-The-World——这些词都听过。但真让你看一段代码,说出"GC 在哪一刻发生,回收了哪些对象",不少人就开始含糊了。

这一篇,我用一个完整的例子,把 GC 的来龙去脉走一遍。不背八股文,只看代码和现象。

一、先有个心理模型:JVM 堆长什么样

在 HotSpot JVM 里,堆(Heap)大致是这样划分的:

HotSpot 堆内存结构

Young Gen
Eden 区8/10
S0
S1
Old Gen
老年代(Tenured)大对象 / 长寿对象

新对象绝大多数分配在 Eden 区。Eden 满了,就触发 Minor GC,把活下来的对象挪到 Survivor(S0/S1),熬过 15 次(默认)的对象晋升到老年代。老年代也满了,就触发 Full GC——这是大家最不想看到的。

二、写一段会"动"的代码

下面这段代码,会制造出"新对象不断产生"和"老对象常驻"两种情况:

GC.java Java
public class GC {
    // 老对象:被静态变量引用,不会被回收
    private static List<Object> longLived = new ArrayList<>();

    public static void main(String[] args) {
        // 每轮往老对象集合里塞一个 10MB 的大数组
        while (true) {
            byte[] buf = new byte[10 * 1024 * 1024]; // 10MB
            longLived.add(buf);

            // 临时对象:循环结束就没人引用,等下一次 Minor GC
            String msg = "hi-" + System.currentTimeMillis();

            System.out.println("已分配 " + longLived.size() + " 个 10MB 数组");
        }
    }
}

运行的时候,我们可以加几个 JVM 参数,让 GC 行为"显形":

command Bash
java -Xms200m -Xmx200m \
     -XX:+PrintGCDetails \
     -XX:+PrintGCDateStamps \
     GC.java

-Xms-Xmx 都设 200MB,堆固定大小,好观察。打印 GC 日志让我们看清每一次 GC 触发时机。

三、观察日志:GC 何时发生

跑起来,日志大概长这样(简化过):

已分配 1 个 10MB 数组
已分配 2 个 10MB 数组
...
[GC (Allocation Failure)  78M -> 24M(200M), 0.012 secs]
  Eden: 156M -> 0M
  Survivor: 0M -> 8M
  Old:    16M -> 24M
[Full GC (Ergonomics)  198M -> 188M(200M), 0.085 secs]
  Eden: 0M -> 0M
  Survivor: 0M -> 0M
  Old:    188M -> 188M
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

解读一下:

第一次 Minor GC

Eden 区被填满(我们持续分配新对象),触发 Allocation Failure,JVM 启动 Minor GC。Survivor 装不下的就晋升到老年代,这就是为什么 Old 也在涨。

后来的 Full GC

老年代快满了,JVM 决定来一次"大扫除"。Full GC 会暂停所有应用线程(Stop-The-World),把整个堆都扫一遍,试图腾出空间。

最终的 OOM

但我们的 longLived 一直持有所有 byte[],Full GC 也回收不掉任何东西,最后 OutOfMemoryError

关键观察: 不是所有 GC 都能"解决问题"。如果存在内存泄漏(对象一直被强引用),GC 再勤快也救不了你。这就是为什么光会调 GC 参数不够,得先排查泄漏

四、GC 何时触发?一张表说清楚

GC 触发条件速查

Minor GC
Eden 区空间不足,需要分配新对象时高频
Major GC
老年代空间不足 / 准备晋升但老年代装不下中频
Full GC
老年代满 / Metaspace 满 / System.gc() / 担保失败低频但伤

五、怎么调优?三条朴素的建议

1. 让对象"早死"

能用局部变量,就别用静态集合。能早 null 的大对象,就别留到方法结束。Minor GC 越频繁,但每次越快,远比一次 Full GC 友好。

2. 合理设置堆大小

-Xms-Xmx 建议设成一样,避免堆动态扩容带来的性能波动。新生代:老年代 经验值 1:2

3. 选对收集器

JDK 8 默认 Parallel GC(吞吐优先);JDK 9+ 默认 G1(延迟优先);ZGC 适合超大堆、低延迟场景。没有银弹,看业务挑。

六、写在最后

GC 是 Java 程序员绕不开的"老朋友"。它的设计很优雅——把内存管理的脏活累活交给运行时,让我们专心写业务。

但它也不是银弹。写出能被回收的对象,比调 GC 参数重要一百倍。愿你下一次看到 GC 日志时,不再是"它在干嘛?",而是"哦,我知道这一刻发生了什么"。

One-liner takeaway

新对象进 Eden,Eden 满就 Minor GC;活的进 Survivor,熬过 15 次进老年代;老年代满就 Full GC,Stop-The-World。让对象"早死",比调参更重要。