它和
Java heap space不是一回事:堆不一定真满了,而是「GC 拼命干、却回收不出东西」。JVM 用这个错误做快速失败,避免进程陷入「CPU 100% 跑 GC、业务毫秒变分钟」的死循环。既然根子还是堆,就别先想着关掉这个检查,先查漏不动。
Issue & Env(案发现场与环境)
- 现象:应用运行缓慢,CPU 长时间打满,最终抛 OOM,日志里带
GC overhead limit exceeded。 - 环境:Java 8+,使用任何 GC(Parallel / G1 等),常在堆偏小或存在内存泄漏时触发。
StackTrace(堆栈日志)
java.lang.OutOfMemoryError: GC overhead limit exceeded
at java.base/java.util.HashMap.resize(HashMap.java:700)
at java.base/java.util.HashMap.putVal(HashMap.java:658)
at com.example.cache.InMemoryCache.put(InMemoryCache.java:93)
at com.example.cache.InMemoryCache.load(InMemoryCache.java:65)
at com.example.service.ProductService.getProduct(ProductService.java:41)
at com.example.controller.ProductController.detail(ProductController.java:28)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.lang.reflect.Method.invoke(Method.java:498)
注意堆栈顶部往往是普通的业务分配点,真正的信号是那句「GC overhead limit exceeded」本身。
Reproduce(复现代码)
一个无界缓存,不停往里塞对象,对象还被强引用持有:
public class LeakyCache {
static final Map<String, byte[]> CACHE = new HashMap<>();
public static void main(String[] args) {
for (int i = 0; ; i++) {
CACHE.put("key" + i, new byte[1024 * 1024]); // 每人 1MB,强引用不释放
}
}
}
跑上一阵,堆被活对象填满,GC 每次只能回收一点点,最终抛 GC overhead limit exceeded。
Root Cause(根因分析)
JVM 的触发条件是:GC 花费的时间超过总运行时间的 98%,而且每次 GC 回收的堆不足 2%。满足这两条时,JVM 认为进程已经「近乎停摆」,干脆抛错快速失败,而不是让你卡着。
它和「内存真不够」的区别在于成因:如果只是堆配小了、但对象都是短命的,GC 还能正常回收,你看到的是 Java heap space;而 GC overhead 通常是堆里积压了大量「长时间存活但最终无意义」的对象,每次 GC 扫一圈几乎腾不出地方,只能反复空转。
典型诱因有:无界缓存、静态集合不断追加、事件监听器 / 回调注册后从不注销、大对象被一个长生命周期集合抓着不放。所以它本质是泄漏或「活数据超过堆容量」的信号,而不是单纯调大堆就能糊弄过去。
The Fix(解决方案)
先开堆转储,等下次 OOM 时拿到现场:
java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/dumps -jar app.jar
拿到 .hprof 后用 Eclipse MAT 打开,看 Leak Suspects 报告:如果是一个对象或集合主导了堆,那就是泄漏,回到代码把引用解开;如果是一大堆正常对象、堆确实装不下,才考虑加大堆。
真需要加堆或换收集器:
# 加堆
java -Xms2g -Xmx4g -jar app.jar
# 大堆换 G1 / ZGC,减少停顿和开销
java -XX:+UseG1GC -Xmx4g -jar app.jar
java -XX:+UseZGC -Xmx4g -jar app.jar
临时的 -XX:-UseGCOverheadLimit 只是关掉这个「快速失败」的保护,让进程继续带着近乎瘫痪的状态跑。可以拿来临时抓现场,但绝不能当长期方案——它不解决任何内存问题,只是让你晚一点崩溃、更难看。
