第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,我本地电脑差点卡死。
排查内存泄漏,我一般这么做:
- 先看GC日志:关注老年代占用曲线是不是一路向上
- jmap -histo:看哪些类的实例数异常多
- 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
下面这张图总结了堆内存溢出的三种典型路径,以及它们之间的关系:
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就行,太小了老年代压力大
最后说一句,堆溢出不是靠调参就能一劳永逸的。你真正要做的,是找到那个让堆溢出的代码逻辑,把它修掉。参数只是给你争取时间来排查问题,别搞反了。