Java进阶必修课:Full GC 频繁出现时,不要只会重启服务
线上服务突然变慢,接口耗时飙升,CPU 变高,日志里开始频繁出现 Full GC。
这时候最常见的处理方式就是一句话:
“先重启吧。”
重启当然有用,因为它能立刻把内存清空,服务看起来也恢复正常。
但问题是,过一段时间它往往还会再来。
为什么会频繁 Full GC?到底是哪类对象把内存拖住了?
这篇文章不想讲太多抽象概念,只讲三件事:
- Full GC 到底是什么
- 线上常见的 Full GC 原因有哪些
- 遇到 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 日志
重点看两个问题:
- Full GC 多久一次
- 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 配置不匹配
但绝大多数业务项目里,代码问题比参数问题更常见。
所以排查顺序最好是:
- 先看业务代码是不是制造了大量长期存活对象
- 再看堆大小是不是明显不合理
- 最后再谈更细的 GC 参数调优
否则很容易变成:
代码没改,参数调了一堆,问题只是晚点复发。
七、给你一个很实用的判断口诀
你可以把 Full GC 问题先粗分成两类:
1. Full GC 后降得下来
大致原因:
- 瞬时对象太多
- 一次性查询/导出太大
- 短时间流量冲高
- 批量任务太猛
解决方向:
- 分页
- 分批
- 限流
- 降低一次处理量
2. Full GC 后降不下来
大致原因:
- 缓存无边界
- 集合长期持有
- ThreadLocal 未清理
- 静态对象持有
- 真实的内存泄漏
解决方向:
- 找出长期引用
- 给缓存加边界
- 清理 ThreadLocal
- 控制历史数据驻留时间
更多推荐

所有评论(0)