内存泄漏是 java 应用的"慢性杀手"——它不会立刻致命,却会在深夜流量高峰时,用一记 outofmemoryerror 送你的服务归西。 2024年阿里双十一技术复盘显示,通过精确内存治理,核心交易系统性能提升了40%。今天,我们就把这套"查出病因、对症下药"的方法 论,一次性讲透。
一、先分清:内存泄漏 ≠ 内存溢出
很多人把这两个概念混为一谈,这是排查方向错误的根源。
| 内存泄漏(memory leak) | 内存溢出(oom) | |
|---|---|---|
| 本质 | 无用对象无法被 gc 回收,持续占用堆内存 | 内存不够用了,jvm 抛出异常 |
| 关系 | 是因 | 是果 |
| 表现 | 堆内存持续增长,full gc 后也无法回落 | java.lang.outofmemoryerror: java heap space |
| 特征 | 渐进式恶化,重启后短时间复现 | 突发性崩溃 |
一句话总结:内存泄漏是"慢性病",oom 是它的"并发症"。
快速判定标准(满足2点即可确认泄漏)
- 堆内存使用率持续走高,full gc 后仍无法回落至合理区间,呈现「只增不减」趋势
- 日志频繁出现
gc overhead limit exceeded,且重启后短时间复现 - 老年代占比长期 >90%,full gc 频率越来越高,单次 gc 耗时 >1s
- 服务卡顿、接口超时与 gc 频率强关联
二、十大高频内存泄漏场景(附代码对比)
场景一:静态集合无限增长(泄漏之王,占比90%)
烂代码:
1public class cache {
2 private static final map<string, object> map = new hashmap<>();
3
4 public static void add(string key, object value) {
5 map.put(key, value); // 只存不取,永不清理
6 }
7}
8静态变量的生命周期与 jvm 一致。你往里塞一百万个对象,gc 想收也收不了——因为 gc roots(静态变量)一直攥着引用不放。
✅ 解决方案:
1// 方案1:使用 weakhashmap,key 不被强引用时自动回收
2private static final map<string, object> cache = new weakhashmap<>();
3
4// 方案2:使用成熟缓存框架(guava cache / caffeine),设置容量+过期策略
5cache<string, object> cache = caffeine.newbuilder()
6 .maximumsize(10_000)
7 .expireafterwrite(10, timeunit.minutes)
8 .build();
9
10// 方案3:必须用静态集合时,提供清理方法
11public static void clearcache() { map.clear(); }
12场景二:资源未关闭(io 流 / 数据库连接 / socket)
烂代码:
1public void readfile(string path) throws ioexception {
2 fileinputstream fis = new fileinputstream(path);
3 // 读取操作...
4 // 忘了关闭!底层文件句柄一直被占用
5}
6✅ 解决方案——try-with-resources(java 7+):
1public void readfile(string path) throws ioexception {
2 try (fileinputstream fis = new fileinputstream(path)) {
3 // 业务逻辑
4 } // 自动关闭,无需 finally
5}
6凡是实现了 autocloseable 的资源,都该用 try-with-resources。这不是建议,是铁律。
场景三:threadlocal 未清理(线程池场景下的定时炸弹)
烂代码:
1private static final threadlocal<bigobject> local = new threadlocal<>();
2
3// 线程池复用线程,但 local 从不清理
4public void handlerequest() {
5 local.set(new bigobject()); // 每次请求都塞一个大对象
6 // ... 请求结束,没有 remove()!
7}
8tomcat 等容器的线程池会复用线程。你不 remove(),这个大对象就跟着线程活到天荒地老。
✅ 解决方案:
1public void handlerequest() {
2 try {
3 local.set(new bigobject());
4 // 业务逻辑
5 } finally {
6 local.remove(); // ⚠️ 必须清理!
7 }
8}
9场景四:监听器/回调未注销
烂代码:
1public class activity {
2 public void oncreate() {
3 eventbus.register(this); // 注册了
4 }
5 // ondestroy 时忘了 unregister!activity 永远无法被回收
6}
7✅ 解决方案:
1@override
2protected void ondestroy() {
3 eventbus.unregister(this); // 注册和注销必须成对出现
4 super.ondestroy();
5}
6或者使用弱引用持有监听器:
eventbus.register(new weakreference<>(this));
场景五:非静态内部类持有外部类引用
烂代码:
1public class outerclass {
2 private string data = new byte[1024 * 1024]; // 1mb 大对象
3
4 private class innerclass { // 非静态内部类,隐式持有 outerclass 引用
5 public void dosomething() {
6 system.out.println(data);
7 }
8 }
9}
10只要 innerclass 实例还活着,outerclass 就永远不能被回收。这是 android 内存泄漏的头号元凶。
✅ 解决方案:
1// 改为静态内部类 + 弱引用
2private static class safehandler {
3 private final weakreference<outerclass> ref;
4 safehandler(outerclass outer) {
5 this.ref = new weakreference<>(outer);
6 }
7}
8场景六:hashmap key 未正确实现 equals/hashcode
1class person {
2 string name;
3 int age;
4 // 没有重写 equals 和 hashcode!
5}
6
7map<person, string> map = new hashmap<>();
8while (true) {
9 person p = new person("zhangsan", 18);
10 map.put(p, "value"); // 每次都是新对象,永远可以 put 进去
11}
12对象属性被修改后,hash 值改变,remove() 找不到它——内存就这么漏了。
✅ 解决方案: 永远重写 equals() 和 hashcode(),且保证一致性。
场景七:单例持有短生命周期对象
1public class singleton {
2 private user currentuser; // 单例生命周期 = jvm 生命周期
3
4 public void setuser(user user) {
5 this.currentuser = user; // 用户用完了,但单例还攥着不放
6 }
7}
8✅ 解决方案: 改用弱引用,或提供 clear() 方法主动置空。
场景八:string.intern() 滥用
对大量动态字符串调用 intern(),会把它们永久塞进字符串常量池,无法回收。
✅ 解决方案: 仅对高频复用的固定字符串使用 intern(),动态字符串绝不调用。
场景九:循环引用(两个对象互相持有)
1class a { b b; }
2class b { a a; }
3a a = new a(); b b = new b();
4a.b = b; b.a = a;
5// a = null; b = null; // 即使置空,两个对象仍互相引用,gc 无法回收
6现代 jvm 的 gc 已能处理大多数循环引用,但如果引用链上存在 gc roots(如静态变量),照样泄漏。
场景十:threadlocal + 线程池 = 灾难组合
这是场景三的延伸,也是生产环境最高频的泄漏源:
| 组件 | 问题 |
|---|---|
| 线程池 | 线程复用,不会销毁 |
| threadlocal | 不清理就一直跟着线程 |
| 结果 | 每次请求泄漏一个大对象,积少成多 |
铁律:threadlocal 用完必 remove(),没有例外。
三、排查方法 论:监控 → 分析 → 定位 → 解决 → 验证
第一步:监控发现异常(线上快速响应)
jdk 自带命令(零成本):
1# 1. 找到 java 进程 pid 2jps -l 3 4# 2. 实时监控 gc 情况(每秒采样,共10次) 5jstat -gc <pid> 1000 10 6 7# 重点关注: 8# fgct — full gc 总耗时(持续增大 = 频繁 full gc) 9# ou — 老年代已使用内存(持续走高不回落 = 泄漏铁证) 10
jvm 启动参数(生产必配):
1java -xms2g -xmx2g \ 2 -xx:+printgcdetails \ 3 -xx:+printgctimestamps \ 4 -xloggc:/logs/gc.log \ 5 -xx:+heapdumponoutofmemoryerror \ 6 -xx:heapdumppath=/logs/heapdump.hprof \ 7 -jar app.jar 8
-xx:+heapdumponoutofmemoryerror 是救命参数——oom 时自动导出堆快照,避免手动导出不及时。
第二步:生成堆快照(精准取证)
1# 手动导出(会触发 stw,建议低峰期执行) 2jmap -dump:format=b,file=/logs/heap.hprof <pid> 3 4# 或用 arthas(无侵入,推荐线上用) 5heapdump /logs/heap.hprof 6
第三步:mat 分析(定位元凶)
这是排查的核心环节。用 eclipse mat 打开 .hprof 文件:
| mat 功能 | 用途 |
|---|---|
| leak suspects report | 一键生成泄漏嫌疑报告,直接告诉你谁在漏 |
| dominator tree | 按 retained heap 排序,找到占用内存最多的对象 |
| path to gc roots | 追溯引用链,找到"谁在攥着泄漏对象不放" |
核心操作:
1dominator tree → 右键泄漏对象 → path to gc roots → exclude weak references 2
这条引用链的终点,就是你要修的代码。
第四步:对症下药
| 泄漏类型 | 解决策略 |
|---|---|
| 静态集合/缓存无限增长 | 限制容量 + lru 淘汰 + weakreference |
| 监听器/回调未移除 | 注册/注销成对出现 + weakreference 保存 |
| 线程池 / timer / executor | 应用关闭时调用 shutdown() |
| 非关闭资源 | try-with-resources 自动关闭 |
| 对象引用链过长 | 清理引用链,避免全局容器持有局部对象 |
| threadlocal 泄漏 | finally 块中必调 remove() |
第五步:验证与预防
| 手段 | 说明 |
|---|---|
| 压力测试 | jmeter 高并发压测,对比 gc 前后堆大小变化 |
| 持续监控 | prometheus + grafana 实时监控 jvm 内存,设置 90% 告警 |
| apm 工具 | skywalking / pinpoint / arthas,生产环境无侵入监控 |
| 代码规范 | 禁止原始类型的静态集合,缓存必须有过期策略 |
四、工具选型速查表
| 工具 | 用途 | 场景 | 成本 |
|---|---|---|---|
jps / jstat / jmap | 监控 + 快照 | 线上快速排查 | 免费 |
| visualvm | 可视化监控 + 堆分析 | 开发/测试 | 免费(jdk自带) |
| mat | 堆快照深度分析 | 精准定位泄漏源 | 免费 |
| arthas | 线上无侵入诊断 | 生产应急 | 免费(阿里开源) |
| jprofiler / yourkit | 内存+cpu联合分析 | 复杂场景 | 商用 |
| prometheus + grafana | 长期趋势监控 | 生产环境 | 免费 |
五、一张图总结排查流程
1发现内存异常(jstat 监控) 2 │ 3 ▼ 4生成堆快照(jmap / heapdumponoutofmemoryerror) 5 │ 6 ▼ 7mat 分析(leak suspects → dominator tree → gc roots 引用链) 8 │ 9 ▼ 10定位泄漏代码(静态集合?threadlocal?监听器?资源未关?) 11 │ 12 ▼ 13修复代码 + 压力测试验证 14 │ 15 ▼ 16apm 持续监控,预防复发 17
写在最后
内存泄漏的本质只有一句话:长生命周期对象,持有了短生命周期对象的引用,导致 gc 收不掉。
所有的排查工具、所有的最佳实践,都是围绕这一句话展开的。记住这十大场景,掌握 mat + jstat 的组合拳,你就能在 oom 找到你之前,先找到它。
别等服务崩了才想起查内存——那叫救火,不叫排查。
以上就是java内存泄漏常见场景排查与解决方案的详细内容,更多关于java内存泄漏排查与解决的资料请关注代码网其它相关文章!
发表评论