线上服务突然变慢,接口耗时飙升,CPU 变高,日志里开始频繁出现 Full GC。
这时候最常见的处理方式就是一句话:

“先重启吧。”

重启当然有用,因为它能立刻把内存清空,服务看起来也恢复正常。
但问题是,过一段时间它往往还会再来。

为什么会频繁 Full GC?到底是哪类对象把内存拖住了?

这篇文章不想讲太多抽象概念,只讲三件事:

  1. Full GC 到底是什么
  2. 线上常见的 Full GC 原因有哪些
  3. 遇到 Full GC 频繁时,应该怎么排查、怎么改

一、先用一句大白话说清楚:什么叫 Full GC?

你可以简单理解成:

JVM 发现内存压力已经比较大了,要来一次“尽可能全面”的垃圾回收。

它和普通的 Young GC 不一样。
Young GC 主要回收新生代,通常比较快。
而 Full GC 往往会涉及更多区域,停顿时间也更长,所以更容易让你感知到系统卡顿。

你在线上最常看到的症状一般是:

  • 接口偶尔卡 1~3 秒,甚至更久
  • 机器 CPU 上升
  • GC 日志里 Full GC 次数明显变多
  • 服务内存一直涨,涨到某个点就来一次大回收
  • 回收完没多久又涨上去

比如你可能会看到类似日志:

[Full GC (Allocation Failure) 1024M->780M(1024M), 2.8456789 secs]

这句其实很好理解:

  • GC 前用了 1024M
  • GC 后还剩 780M
  • 总堆大小是 1024M
  • 这次 Full GC 停了 2.84s

真正值得警惕的不是“发生了一次 Full GC”,而是:

Full GC 之后,内存还是降不下去。

这通常说明,不是垃圾太多,而是活着的对象太多


二、别一看到 Full GC 就调大堆内存

这是最常见的误区。

很多人一看到 Full GC,第一反应就是:

  • -Xmx 调大一点
  • 容器内存再给多一点
  • 先顶住再说

有时候这确实能延缓问题,但本质上只是:

让问题来得晚一点,不是解决问题。

因为如果你的代码一直在制造“大量长期存活对象”,
那你把堆从 2G 调到 4G,最多只是从“半小时一次 Full GC”变成“2 小时一次 Full GC”。

所以正确思路不是先扩内存,而是先判断:

到底是内存真的不够,还是对象不该活这么久。


三、遇到 Full GC 频繁,先用这个最简单的判断方法

我很建议先用下面这个思路判断:

1. 看 Full GC 后内存降得多不多

如果 Full GC 后内存明显降下来了,比如:

GC 前:1800M GC 后:300M

这说明大部分对象其实是可以回收的。
更像是:

  • 短时间对象创建过多
  • 批量任务太大
  • 查询/导出一次性吃掉太多内存

这种问题重点看“瞬时内存压力”。


如果 Full GC 后还是很高,比如:

GC 前:1800M GC 后:1500M

这就比较危险了。
说明大量对象还活着,更像是:

  • 缓存没有边界
  • 集合一直在涨
  • ThreadLocal 没清
  • 某些对象被意外长期引用
  • 存在内存泄漏或接近泄漏的问题

这种问题重点看“谁一直不释放”。


四、最常见的 4 类 Full GC 问题,我用代码给你讲清楚


场景 1:缓存只加不减,老年代迟早被塞满

这是线上特别常见的一种。

很多人为了优化性能,自己加了本地缓存:

public class UserCache {
    private static final Map<Long, User> CACHE = new ConcurrentHashMap<>();

    public User getUser(Long userId) {
        return CACHE.computeIfAbsent(userId, this::loadUserFromDb);
    }

    private User loadUserFromDb(Long userId) {
        return userRepository.findById(userId);
    }
}

看起来没毛病,但问题是:

这个缓存没有上限,也没有过期。

如果系统用户越来越多,或者 key 越来越分散,这个 Map 会一直涨。
这些对象会长期存活,最后不断把老年代撑满,Full GC 就会越来越频繁。

正确改法:缓存必须有边界

比如用 Caffeine:

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;

import java.time.Duration;

public class UserCache {
    private final Cache<Long, User> cache = Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(Duration.ofMinutes(10))
            .build();

    public User getUser(Long userId) {
        return cache.get(userId, this::loadUserFromDb);
    }

    private User loadUserFromDb(Long userId) {
        return userRepository.findById(userId);
    }
}

这个方案为什么有效?

因为它解决了两个关键问题:

  • maximumSize:限制最多缓存多少对象
  • expireAfterWrite:让旧对象有机会被淘汰

一句话总结:

缓存不是不能用,而是不能无限用。


场景 2:一次查太多数据,List 直接把堆顶满了

很多业务代码里都能看到这种写法:

public List<Order> exportOrders() { return orderMapper.selectAllUnpaidOrders(); }

如果这个表有几十万、几百万数据,一次性全查到内存里,再做导出、组装、转换,很容易造成:

  • 大量对象瞬间创建
  • 年轻代扛不住,频繁 Young GC
  • 对象晋升太快
  • 老年代压力变大
  • 最终触发 Full GC

错误的不是“查数据”,而是“一次性全塞内存”

正确改法一:分页处理

public void exportOrders() {
    int pageSize = 1000;
    int pageNo = 1;

    while (true) {
        List<Order> orders = orderMapper.selectPage(pageNo, pageSize);
        if (orders.isEmpty()) {
            break;
        }

        writeToFile(orders);
        orders.clear();
        pageNo++;
    }
}

正确改法二:流式处理

如果你的框架支持流式查询,也可以边查边处理,而不是一次性全部加载。

为什么有效?

因为你把原来“1 次吃掉 20 万条”的内存压力,拆成了“每次只处理 1000 条”。

这类问题特别适合一句经验话:

批量处理不是不能做,而是别一口吃成胖子。


场景 3:ThreadLocal 没清理,线程池把脏数据一直留着

这也是生产环境里很典型的坑。

比如你这样写:

private static final ThreadLocal<UserContext> CONTEXT = new ThreadLocal<>();

public void process(UserContext context) {
    CONTEXT.set(context);
    doBusiness();
}

如果这是在线程池环境里执行的,线程会复用。
你这次放进去的对象,如果不清理,下次这个线程还可能带着它。

时间一长,可能导致:

  • 上下文串数据
  • 某些大对象被 ThreadLocal 持有更久
  • 内存不该活这么久却一直活着
  • Full GC 频率逐渐升高

正确改法:一定要 finally remove

为什么有效?

因为线程池不会销毁线程,但 remove() 可以把这次请求留下的引用断开。
对象没有被继续引用,就更容易被 GC 回收。

这个问题特别隐蔽,因为它不是立刻爆,而是慢慢拖垮。


场景 4:对象明明没用,却还被集合长期引用着

看下面这个例子:

@Service
public class ReportService {
    private final List<String> history = new ArrayList<>();

    public void generateReport() {
        String result = buildLargeReport();
        history.add(result);
    }
}

如果 result 很大,而这个 history 又一直保留,内存就会越来越大。
很多人会说:

“我只是想留个历史记录。”

但 JVM 不会理解“你只是想留一点”,
它只知道:这个对象还在被引用,所以不能回收。

正确改法:不要无限保存历史对象

例如只保留最近 N 条:

@Service
public class ReportService {
    private final Deque<String> history = new ArrayDeque<>();
    private static final int MAX_HISTORY = 100;

    public synchronized void generateReport() {
        String result = buildLargeReport();
        history.addLast(result);

        if (history.size() > MAX_HISTORY) {
            history.removeFirst();
        }
    }
}

如果真的只是日志用途,更应该落磁盘、落对象存储、落数据库,而不是常驻堆内存。


五、线上排查 Full GC,我建议你按这个顺序来

别一上来就疯狂改 JVM 参数。
先看清楚问题像哪一类。


第一步:先看 GC 日志

重点看两个问题:

  1. Full GC 多久一次
  2. Full GC 后内存降了多少

如果你看到的是:

  • Full GC 很频繁
  • 每次停顿都很长
  • 回收后内存还是很高

那就说明要重点怀疑“对象长期存活过多”。


第二步:用 jstat 看趋势

可以先看最基础的趋势:

jstat -gcutil <pid> 1000 10

它能帮你快速看出:

  • 老年代是不是一直在涨
  • Full GC 次数是不是持续增加
  • GC 后 Old 区有没有明显回落

你不用一开始就把每个字段研究透。
先看一个大方向就够:

Old 区总是高位运行,而且 FGC 一直加,就是该重点排查了。


第三步:确认是不是代码层对象留太久

优先回看这几类代码:

  • 本地缓存
  • 大集合
  • 批量导出/批量查询
  • ThreadLocal
  • 定时任务结果累积
  • 静态 Map、静态 List
  • 大对象拼接,比如超长字符串、超大 JSON

很多 Full GC 问题,最后都不是 JVM 神秘故障,
而是业务代码里有“对象一直留着不放”。


第四步:必要时导出堆内存快照

如果趋势看不出来,就要上堆快照了。

常见做法是导出 heap dump,然后用工具看:

  • 哪类对象最多
  • 谁占内存最大
  • 谁在引用它们

你不用把这一步讲得太复杂,文章里只需要让读者知道:

最终要找的不是“GC 为什么回收慢”,而是谁一直占着内存不放。


六、不要把 Full GC 问题都归咎于 JVM 参数

这也是一个很容易走偏的点。

确实,JVM 参数不合理也可能让 GC 表现变差。
比如:

  • 堆太小
  • 元空间太小
  • 容器内存限制和 JVM 配置不匹配

但绝大多数业务项目里,代码问题比参数问题更常见

所以排查顺序最好是:

  1. 先看业务代码是不是制造了大量长期存活对象
  2. 再看堆大小是不是明显不合理
  3. 最后再谈更细的 GC 参数调优

否则很容易变成:

代码没改,参数调了一堆,问题只是晚点复发。


七、给你一个很实用的判断口诀

你可以把 Full GC 问题先粗分成两类:

1. Full GC 后降得下来

大致原因:

  • 瞬时对象太多
  • 一次性查询/导出太大
  • 短时间流量冲高
  • 批量任务太猛

解决方向:

  • 分页
  • 分批
  • 限流
  • 降低一次处理量

2. Full GC 后降不下来

大致原因:

  • 缓存无边界
  • 集合长期持有
  • ThreadLocal 未清理
  • 静态对象持有
  • 真实的内存泄漏

解决方向:

  • 找出长期引用
  • 给缓存加边界
  • 清理 ThreadLocal
  • 控制历史数据驻留时间

更多推荐