Young GC 频繁并不等于堆内存不足。本文从 JVM 堆分代、对象可达性和 G1 回收日志出发,解释新生代回收过密、对象晋升过快与大对象分配的区别,并给出日志采集、命令排查和代码改造方法。

问题背景:服务没 OOM,为什么吞吐量却越来越差

有一类 Java 性能问题很容易被误判:堆使用率没有长期接近上限,Full GC 也不频繁,但监控中 Young GC 次数很多,应用线程的 CPU 使用率偏高,接口吞吐量逐渐下降。

这时,问题往往不是“堆太小”,而是对象分配速度超过了垃圾收集器能够稳定处理的速度。应用不断创建短命对象,新生代很快被填满,JVM 只能反复暂停应用线程回收。每次暂停可能只有几十毫秒,但累计起来会消耗大量 CPU 和时间。

排查这类问题,不能只看某一时刻的堆占用率,而要回答三个问题:

  1. 对象是以多快的速度被创建的?
  2. Young GC 后还有多少对象存活,并被晋升到老年代?
  3. 存活对象为什么还被引用,是否存在不必要的缓存、队列或批处理集合?

先明确几个概念:JVM 内存不等于“已使用堆内存”

日常所说的 JVM 内存,至少要区分几个区域:

  • Java 堆:绝大多数普通对象和数组的分配区域。
  • 线程栈:保存方法调用帧、局部变量和部分引用。线程过多时,栈空间也会成为问题。
  • Metaspace:保存类元数据,不属于 Java 堆。
  • 直接内存:例如 NIO ByteBuffer.allocateDirect 使用的堆外内存。
  • 代码缓存和本地库内存:JIT 编译代码以及 JVM、JNI 使用的本地内存。

Young GC 主要处理堆中的新生代对象。以分代收集器的思路来看,新创建的对象通常先进入 Eden 区;Eden 没有足够空间时触发年轻代回收。仍然存活的对象会被复制到 Survivor 区,经过多次回收或因为 Survivor 放不下,可能晋升到老年代。

因此,Young GC 频繁可能只有两种基本原因:

  • 分配速率很高,但对象大多很快死亡:主要损耗是回收频率和 CPU。
  • 分配速率高,同时有大量对象存活:除了回收频率,还会产生晋升压力,最终可能引发并发标记、Mixed GC,甚至 Full GC。

用一段代码重现“批处理对象存活过久”

下面的示例模拟导出任务:每次读取一批数据,构造结果对象,并暂存在一个列表中,直到整个任务完成后才统一写出。代码本身没有内存泄漏,但保留范围过大,会让本应尽快死亡的对象跨越多次 Young GC。

import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;

public class AllocationPressureDemo {

    public static void main(String[] args) throws Exception {
        List<byte[]> allResults = new ArrayList<>();

        for (int batch = 0; batch < 10_000; batch++) {
            for (int i = 0; i < 100; i++) {
                String line = "order-" + batch + "-" + i + "-payload";
                byte[] result = (line.repeat(100)).getBytes(StandardCharsets.UTF_8);
                allResults.add(result);
            }

            if (batch % 100 == 0) {
                System.out.println("processed=" + batch
                        + ", retained=" + allResults.size());
            }
        }
    }
}

这里有两类对象:字符串拼接产生的中间对象,以及最终保存到 allResults 的字节数组。后者一直被列表引用,因此不会在 Young GC 中回收。随着任务进行,列表本身和数组会持续存活,最终增加老年代压力。

如果业务只需要逐批写入文件或发送下游,可以把保留范围缩小:

public static void processInBatch() {
    for (int batch = 0; batch < 10_000; batch++) {
        List<byte[]> currentBatch = new ArrayList<>(100);

        for (int i = 0; i < 100; i++) {
            String line = "order-" + batch + "-" + i + "-payload";
            currentBatch.add(line.repeat(100)
                    .getBytes(StandardCharsets.UTF_8));
        }

        writeAndRelease(currentBatch);
    }
}

private static void writeAndRelease(List<byte[]> batch) {
    // 实际应用中可写入文件、响应流或消息生产者
    long total = 0;
    for (byte[] item : batch) {
        total += item.length;
    }
    if (total == 0) {
        throw new IllegalStateException("empty batch");
    }
}

currentBatch 在一次循环结束后不再被引用,数据才有机会在后续 Young GC 中及时回收。这里的关键不是手动调用 System.gc(),而是缩短对象的生命周期。

第一步:从 GC 日志确认到底发生了什么

在支持统一日志参数的 Java 版本中,可以使用类似配置:

java \
  -Xms4g -Xmx4g \
  -Xlog:gc*,safepoint:file=/var/log/app-gc.log:time,uptime,level,tags:filecount=5,filesize=50M \
  -jar app.jar

-Xms-Xmx 是否设置成相同值不是解决 Young GC 的必然方案,但固定堆大小有助于减少扩缩容变量。生产环境仍应根据容器限制和本地内存预算决定。

观察日志时,不要只统计 GC 次数,应重点记录:

  • 两次 Young GC 的间隔,估算对象分配速度。
  • 每次回收前后的堆大小,例如 X->Y
  • 暂停时间是否逐渐增长。
  • Old 区占用是否持续抬升。
  • 是否出现晋升失败、并发周期启动或大对象分配相关信息。

如果每次都是“回收前很高、回收后很低”,且老年代稳定,通常更像短命对象分配过多;如果回收后仍然保留较多对象,且老年代连续上涨,就应该继续查对象存活和引用链。

Java 8 环境常见的 GC 日志参数是 -XX:+PrintGCDetails-XX:+PrintGCDateStamps-Xloggc:/path/gc.log。不同垃圾收集器和 JDK 版本的日志格式并不完全相同,分析时要先确认实际使用的收集器,不要直接套用另一套日志解析规则。

第二步:用运行时命令验证堆和对象分布

问题发生时,可以先采集低成本信息:

jcmd <pid> GC.heap_info
jcmd <pid> GC.class_histogram > /tmp/class-histogram.txt
jcmd <pid> VM.flags > /tmp/vm-flags.txt

GC.class_histogram 能帮助回答“哪些类占用了最多实例和空间”,但它本身可能带来额外开销,不应在高峰期无计划地反复执行。

如果怀疑某个集合、缓存或队列持有对象,类直方图只能告诉你“对象很多”,不能直接说明“是谁引用了它们”。这时应结合堆转储,在 MAT、VisualVM 等工具中查看支配树和引用链,重点寻找:

  • 静态集合或单例缓存;
  • 未及时消费的阻塞队列;
  • 任务提交后仍被线程池队列保留的闭包对象;
  • ThreadLocal 未清理造成的线程级引用;
  • 批处理代码中生命周期过长的 ListMap 和字节数组。

如果使用 G1,还要注意大对象。G1 会把堆划分为多个 Region,较大的数组或字符串底层存储可能占用连续的 Humongous Regions。大对象分配会改变回收行为,甚至造成更明显的分配停顿。此时应检查是否把整份文件、超大 JSON 或无上限批量结果一次性读入内存,而不是先急着调大堆。

常见误区

1. 看到 Young GC 多,就直接增大堆

增大堆可能延长两次 GC 的间隔,却不能减少每秒创建的对象数量。如果根因是循环中重复构造临时对象、序列化多次或批量范围过大,问题只会被推迟,老年代压力还可能变得更难观察。

2. 手动调用 System.gc()

它不能降低业务对象的分配速率,而且可能引入不必要的停顿。在排查阶段可以通过日志和实验验证回收行为,但不应把它当作常规内存管理手段。

3. 看到对象存活就判断为内存泄漏

对象只要仍然可达,就不会被回收;可达不代表泄漏。一次导出任务、一个缓存条目或一个消息重试队列都可能是合理引用。判断泄漏要结合业务生命周期:任务结束后引用是否释放,缓存是否有容量和过期策略,队列是否有消费上限。

4. 只盯着平均 GC 停顿

平均值会掩盖少量长暂停。应同时观察最大暂停、暂停次数、应用线程被 Safepoint 阻塞的时间,以及请求超时和线程池排队是否同步发生。GC 问题最终要回到业务指标验证,而不是停留在一张日志图上。

一套可执行的排查顺序

  1. 记录 JDK 版本、垃圾收集器、堆大小、容器内存限制和 GC 日志配置。
  2. 统计 Young GC 频率、暂停时间、回收前后堆占用和老年代趋势。
  3. 用类直方图确认增长最快的对象类型,避免凭经验猜测。
  4. 对可疑时间段制作一次堆转储,沿引用链查找真正的持有者。
  5. 回到代码检查循环、序列化、集合、队列、缓存和批处理边界。
  6. 优先减少对象创建和存活范围,再评估堆大小、Region 或收集器参数。
  7. 修改后用相同流量和相近数据规模进行对照,确认 GC、吞吐量和接口延迟同时改善。

总结

Young GC 频繁不是一个足够完整的结论,它只是告诉我们:新生代很快被填满了。真正需要区分的是,问题来自短命对象分配过多,还是来自大量对象存活并不断晋升。

排查时应把 GC 日志、对象直方图、堆转储和业务代码放在同一条证据链上:先看发生了什么,再看哪些对象留下来,最后追溯是谁让它们继续可达。很多所谓的 JVM 调优,最终并不需要增加复杂参数,而是把一个过大的列表拆小,把一次重复序列化去掉,或让一个缓存和队列拥有明确的边界。对垃圾收集器而言,最有效的优化往往是让不该活太久的对象尽早结束生命周期。

最后修改:2026 年 09 月 12 日
如果觉得我的文章对你有用,请随意赞赏