第3章:Java堆内存溢出 - 原因与典型场景归纳

Java堆内存溢出,说白了就是你的对象把堆给撑爆了。我在这行干了十几年,见过的堆溢出案例少说也有上百个。每次生产环境一报警,十有八九都是堆上的问题。今天我把最常见的几种场景掰开揉碎了讲给你听。

堆内存溢出的核心原因就两个:要么对象太多,要么对象太大。但具体到代码里,表现形式可就五花八门了。我个人习惯把堆溢出归纳成三个典型场景:大对象分配、内存泄漏、GC负载过高。你慢慢往下看。

3.1 大对象直接进入老年代

什么叫大对象?JVM里有个参数叫-XX:PretenureSizeThreshold,超过这个阈值的对象直接在老年代分配。为什么要这么设计?因为大对象在新生代来回复制太费性能了。

我在项目中遇到过这么个事:有个数据分析系统,每天要处理上百万条日志,每条日志解析后生成一个Map。代码写得挺正常,但跑了不到半天就OOM了。

看日志是怎么说的:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at java.util.HashMap.resize(HashMap.java:703)
    at java.util.HashMap.putVal(HashMap.java:662)
    at java.util.HashMap.put(HashMap.java:611)
    at com.example.LogProcessor.parseLog(LogProcessor.java:45)

问题出在哪?你想想看,一条日志解析出来的Map里有5000多个键值对,这个Map本身就是一个大对象。JVM每次都得把它扔到老年代,老年代空间很快就满了。

解决思路其实不复杂:

  • 检查是否有一次性分配超大对象的情况
  • 考虑把大对象拆分成多个小对象分批处理
  • 或者直接调大堆内存(但这只是治标)

用-XX:+PrintGCDetails看下日志,你会发现在OOM之前,老年代的占用率一直在90%以上,而且几乎不做Full GC。嗯,这里要注意,大对象导致的OOM往往伴随着频繁的Full GC,因为老年代满了JVM只能拼命回收,但回收不掉——对象还有引用呢。

3.2 内存泄漏 - 最隐蔽的杀手

内存泄漏是我最头疼的问题。它不像大对象那样一眼就能看出来,而是像温水煮青蛙,慢慢把你的堆空间吃掉。

常见的泄漏场景有这几种:

泄漏类型 典型表现 我踩过的坑
静态集合类 往static HashMap里不断加元素 一次配置缓存忘记清理,仨月没重启直接OOM
未关闭资源 InputStream/Connection不close 当年用JDBC没及时关ResultSet
内部类持有外部引用 非静态内部类隐式持有外部类this Android开发里Activity泄漏是重灾区
ThreadLocal滥用 线程池里ThreadLocal不remove 曾经有个系统因此OOM频率越来越高

我来给你拆一个最常见的泄漏案例:ThreadLocal使用不当。

代码长这样:

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

public void processRequest(Request req) {
    UserContext ctx = buildContext(req);
    userHolder.set(ctx);
    // 业务逻辑...
    // 忘了调用 userHolder.remove();
}

这段代码在普通服务器上可能没问题,但换成线程池就出事了。线程池里的线程是复用的,每个线程都绑着一个UserContext对象。线程不销毁,这个对象就永远没法回收。时间一长,堆内存里全是没人用但又被引用着的对象。

我曾经帮一个金融系统排查过这个坑。他们的交易系统每秒钟处理几千笔订单,每个订单都生成一个上下文。上线两周后开始出现OOM。用jmap dump下来一看,堆里密密麻麻全是UserContext实例。那把堆转储文件有将近2个G,我本地电脑差点卡死。

排查内存泄漏,我一般这么做:

  1. 先看GC日志:关注老年代占用曲线是不是一路向上
  2. jmap -histo:看哪些类的实例数异常多
  3. jmap -dump:抓堆快照,然后拿MAT或VisualVM分析

用MAT打开堆快照,直接看支配树或泄漏嫌疑人报告,哪个对象占了一堆内存还保持着引用,一目了然。

我曾经犯过一个低级错误:线上排查内存泄漏时,直接在生产环境运行了jmap -dump。那台机器堆大小是8G,dump文件生成的过程把磁盘写满了,导致服务彻底挂了。切记——dump之前先检查磁盘空间,或者用jmap -dump:live只dump存活对象。

3.3 GC负载过高导致的溢出

这个场景有点绕。你说GC不是应该回收内存吗?怎么GC还能导致溢出?

其实是这样的:当堆内存快满的时候,JVM会频繁触发GC。如果GC回收不掉多少空间(比如全是内存泄漏),那CPU就会一直被GC线程占用。最终可能两种情况同时发生:一是STW时间太长导致应用卡死,二是GC本身也需要分配内存——有时候GC线程会在分配失败时抛出OOM。

典型的GC负载过高症状:

  • CPU飙升到100%,其中GC线程占了80%以上
  • Full GC越来越频繁,从每10分钟一次变成每分钟好几次
  • 每次GC只能回收不到5%的空间

我见过最离谱的一个案例:某电商系统的促销活动页面,代码里有个死循环不断创建新对象。上线后10分钟,整个集群的GC时间占比从正常的1%暴涨到60%,然后所有的节点相继OOM。整个站点挂了将近半小时。

怎么判断是GC负载过高导致的?看GC日志里这两个指标:

GC停顿时间和吞吐量。如果Full GC的停顿时间超过1秒,而且吞吐量低于95%,你就得警惕了。

我的习惯是给每个Java应用都加上GC日志参数。别嫌麻烦,真出事的时候这是第一手证据:-Xloggc:/var/log/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps

下面这张图总结了堆内存溢出的三种典型路径,以及它们之间的关系:

Java堆内存溢出三种典型场景 堆内存溢出 大对象分配 内存泄漏 GC负载过高 PretenureSizeThreshold 老年代快速占满 静态集合/ThreadLocal 对象无法被GC回收 GC停顿时间 > 1s CPU被GC线程耗尽 最终结果:Java heap space / GC Overhead Limit Exceeded

3.4 如何快速定位堆溢出源头

说了那么多,关键还是得会排查。我给自己总结了一套三步排查法,百试不爽:

第一步:看异常类型

堆溢出抛的错误通常有两种:

Exception 1: java.lang.OutOfMemoryError: Java heap space
Exception 2: java.lang.OutOfMemoryError: GC Overhead Limit Exceeded

Java heap space多半是对象太多或太大,GC Overhead Limit Exceeded则是GC忙活半天啥也没干成。我一般遇到后者,第一反应就是查内存泄漏。

第二步:看GC日志趋势

重点关注老年代使用率的曲线。如果是锯齿形(升上去又降下来),说明对象确实被回收了,可能只是瞬时流量过高。如果是阶梯式上涨(只升不降),那基本就是泄漏了。

第三步:看堆转储中的支配树

用MAT打开dump文件,点开支配树视图,看看哪个对象占的内存最大。然后右键选Merge Shortest Paths to GC Roots,排除掉弱引用,剩下的就是泄漏根了。

避坑指南:不要第一次排查就上生产环境抓dump。先在测试环境复现,不行的话用-XX:+HeapDumpOnOutOfMemoryError自动导出。我曾经因为手贱在生产环境dump导致磁盘满了,被运维追着骂了一个下午。

3.5 关于堆参数调优的几点建议

调参不是万能的,但调错了经常出问题。我见过不少开发直接把-Xmx设成服务器物理内存的90%,结果系统其他进程没内存用,直接卡死。

我的建议:

  • 堆大小:一般设成物理内存的50%~70%,留点余量给操作系统和元空间
  • 新生代大小:我习惯设成堆的1/3到1/4,别搞太大,否则Minor GC时间太长
  • 大对象阈值:-XX:PretenureSizeThreshold设成1M~2M就行,太小了老年代压力大

最后说一句,堆溢出不是靠调参就能一劳永逸的。你真正要做的,是找到那个让堆溢出的代码逻辑,把它修掉。参数只是给你争取时间来排查问题,别搞反了。