线上服务有一种问题很让人头疼:
刚启动时一切正常。
运行几个小时后,内存开始慢慢上涨。
再过一段时间,接口变慢、GC 变频繁,最后甚至直接 OOM。
很多时候,这背后就是一个很典型的问题:
内存泄漏。
不少人会把内存泄漏简单理解成:
“程序申请了内存,但是忘记释放。”
这个说法不能算错,但放在 Java 这类有垃圾回收机制的语言里,其实还不够准确。
更准确地说:
某些对象已经没有业务价值了,但程序中仍然存在引用指向它们,导致垃圾回收器认为这些对象还在使用,于是一直无法回收。
理解了这一点,很多内存泄漏问题其实就没那么神秘了。
一、先搞清楚:什么才算内存泄漏?
先看一个简单场景。
程序创建了一个对象:
User user = new User();此时内存里大概是:
user ↓ User对象当方法执行结束,并且再也没有地方引用这个对象时:
User对象就变成了“没人认识的对象”。
垃圾回收器发现它不可达,就可以把这块内存回收掉。
正常流程就是:
创建对象 ↓ 使用对象 ↓ 失去引用 ↓ GC回收但如果对象明明已经不用了,却仍然被某个地方引用着:
全局集合 ↓ User对象垃圾回收器看到:
还有引用指向它,那它应该还有用。
于是不会回收。
如果这种对象越来越多:
对象1 对象2 对象3 对象4 …… 对象100000内存就会一点点被吃掉。
这才是 Java 里最常见的内存泄漏。
二、垃圾回收器到底怎么判断一个对象能不能删?
很多人会以为:
没人用了就回收。
问题是,JVM 怎么知道“没人用了”?
核心思路叫:
可达性分析。
JVM 会从一批特殊的对象开始向下寻找。
这些对象叫:
GC Roots。
可以简单理解成:
GC Roots │ ├── 线程栈里的变量 ├── 静态变量 ├── JNI引用 └── JVM内部对象然后顺着引用关系往下找:
GC Root ↓ 对象A ↓ 对象B ↓ 对象C只要能够从 GC Root 一路找到某个对象,这个对象就被认为:
仍然存活。
反过来:
对象X → 对象Y虽然 X 和 Y 互相引用,但如果从 GC Root 根本找不到它们:
GC Root 对象X ↔ 对象Y │ └──── 找不到它们还是可以被 GC 回收。
所以 Java 内存泄漏真正的核心不是:
对象有没有引用。
而是:
对象是不是仍然可以从 GC Root 到达。
三、最常见的泄漏:集合只进不出
这大概是业务代码里最容易出现的一类问题。
比如为了做缓存,写了一个:
private static final Map<String, User> CACHE = new HashMap<>();每次请求都放进去:
CACHE.put(userId, user);但从来没有删除。
开始的时候:
CACHE ├── User1 ├── User2 └── User3运行久了:
CACHE ├── User1 ├── User2 ├── User3 ├── User4 ├── User5 ├── ... └── User500000关键在于:
static变量 ↓ CACHE ↓ User对象静态变量本身可以长期存在。
所以这些 User 对象始终可以从 GC Root 找到。
GC 就算跑一百次,也不会回收它们。
这就是一个标准的内存泄漏。
这里特别容易出现一种误解:
“我都用了 GC 语言了,怎么还会泄漏?”
因为 GC 只能帮你回收:
真正不可达的对象。
它没办法理解你的业务逻辑。
它不知道:
这个用户的数据三小时前就没用了它只知道:
还有引用,不能删。四、缓存其实是内存泄漏的高发区
很多程序都有缓存:
数据库查询 ↓ 放入内存 ↓ 下次直接读取本意是减少数据库压力。
但如果缓存只写:
cache.put(key, value);从来不:
cache.remove(key);也不设置:
最大容量 过期时间 淘汰策略那缓存迟早会变成:
一个永远只进不出的仓库。
这就像你租了一个仓库。
每天往里面放 100 个箱子:
第一天:100 第二天:200 第三天:300 ……但规定:
什么都不能扔。
那问题根本不是仓库够不够大。
而是:
迟早会满。
所以缓存系统一般都会有:
TTL LRU 最大容量 主动失效这样的机制。
比如:
最多缓存10000条 ↓ 超过后淘汰最久没使用的数据或者:
缓存30分钟 ↓ 超过30分钟自动删除本质上都是为了:
主动切断无用对象的引用链。
五、监听器和回调,也特别容易偷偷泄漏
这种问题更加隐蔽。
假设有一个事件中心:
eventBus.register(listener);某个页面或者对象创建时,把自己注册进去:
EventBus ↓ Listener ↓ 业务对象后来这个业务对象已经不用了。
按理说应该被回收。
但是:
EventBus还保存着它的 Listener。
于是引用链一直存在:
GC Root ↓ EventBus ↓ Listener ↓ 业务对象结果:
整个对象都回收不了。
如果不断:
注册 注册 注册 注册却从来没有:
unregister();内存就会慢慢涨。
所以看到:
register subscribe addListener这类代码时,最好顺便想一下:
对应的 unregister 在哪里? unsubscribe 在哪里? removeListener 在哪里?有注册,就应该考虑解绑。
六、ThreadLocal 为什么也会造成内存泄漏?
ThreadLocal是 Java 中非常经典的一个坑。
比如:
ThreadLocal<UserContext> context = new ThreadLocal<>();使用:
context.set(userContext);正常情况下,使用完最好:
context.remove();为什么?
因为在线程池里,线程不是请求结束就销毁。
可能是:
线程1 ↓ 处理请求A ↓ 处理请求B ↓ 处理请求C ↓ 一直活着ThreadLocal 的数据实际上和线程存在关联。
如果请求结束以后没有清理:
Thread ↓ ThreadLocalMap ↓ Value这个 Value 可能长期留在线程里。
普通线程如果很快结束,问题还不大。
但线程池里的线程可能:
活几小时 活几天 甚至跟服务一样久这时候问题就来了。
所以使用 ThreadLocal 时,一个非常实用的写法是:
try { threadLocal.set(value); // 业务代码 } finally { threadLocal.remove(); }重点就在:
finally无论业务有没有异常,最后都清掉。
七、数据库连接、文件流不释放,算不算内存泄漏?
严格来说,这类问题更接近:
资源泄漏。
比如:
Connection connection = ... InputStream input = ... Socket socket = ...如果用完以后不关闭:
数据库连接 文件句柄 Socket就会一直占着系统资源。
最终可能出现:
连接池耗尽 Too many open files Socket资源不足虽然它和普通 Java 堆内存泄漏不是完全一个概念,但在实际开发中经常一起讨论。
因为最终表现非常像:
资源一直占着不归还。
所以现在 Java 推荐:
try (InputStream input = ...) { // 使用 }也就是:
try-with-resources。
它能保证资源在作用域结束后自动关闭。
比手动:
input.close();更不容易漏。
八、为什么“对象很大”不一定是内存泄漏?
这个区别也很重要。
假设程序一次加载:
2GB文件然后内存瞬间涨到:
3GB这不一定是泄漏。
如果处理完以后:
引用消失 ↓ GC执行 ↓ 内存被回收那只是:
正常的大内存使用。
真正的内存泄漏通常更像:
500MB ↓ 800MB ↓ 1.2GB ↓ 1.8GB ↓ 2.5GB ↓ OOM而且即使经过 Full GC:
内存还是下不来这时候才非常值得怀疑:
是不是有什么对象一直被引用着?
九、还有一种情况:不是泄漏,而是“对象生产太快”
假设程序每秒创建:
100万个对象GC 虽然一直在回收:
创建 ↓ 回收 ↓ 创建 ↓ 回收但如果:
对象产生速度 > GC回收速度内存同样会不断上涨。
最终也可能 OOM。
这种情况不是典型泄漏,而是:
内存分配压力过大。
所以看到:
OutOfMemoryError不能直接下结论:
一定有内存泄漏还得继续看:
到底是对象不能回收 还是对象产生速度太快十、线上怎么判断是不是内存泄漏?
一个很常见的判断思路是:
先看 GC 之后的内存。
比如:
第一次 Full GC 后:500MB 第二次 Full GC 后:700MB 第三次 Full GC 后:1GB 第四次 Full GC 后:1.5GB如果每次垃圾回收以后:
存活对象还是越来越多。
那就比较可疑了。
因为这意味着:
GC明明努力回收了 ↓ 但仍然有大量对象不能删除下一步一般就会分析:
Heap Dump看看:
到底什么对象最多? 是谁引用了它? 为什么一直无法回收?最后最重要的往往不是:
哪个对象占了2GB而是:
谁把它拽住了?
也就是寻找引用链:
GC Root ↓ 对象A ↓ 对象B ↓ 巨大集合 ↓ 几十万个对象找到这条链,泄漏原因通常就快出来了。
十一、内存泄漏怎么预防?
比起线上 OOM 后再排查,平时写代码时提前防范成本低得多。
首先是各种集合。
看到:
static Map static List static Set最好问一句:
这里的数据什么时候删除?
如果答案是:
不知道那就值得警惕。
对于缓存:
一定考虑容量限制 一定考虑过期时间 一定考虑淘汰机制对于监听器:
注册 ↓ 记得注销对于 ThreadLocal:
set ↓ try ↓ finally remove对于连接和流:
申请资源 ↓ 使用 ↓ 及时关闭说到底,预防内存泄漏有一个很简单的原则:
任何生命周期比较长的对象,只要它开始保存其他对象的引用,就要考虑这些引用什么时候解除。
十二、一个很实用的判断方法
以后看到一段代码:
container.add(obj);不要只看:
add最好顺手问三个问题:
谁持有 container? container 能活多久? obj 什么时候 remove?如果答案是:
container是static ↓ 跟程序活得一样久 ↓ obj从不删除那基本已经闻到内存泄漏的味道了。
写在最后
内存泄漏其实没有想象中那么玄学。
对于 Java 这类带 GC 的语言来说,它最核心的逻辑就是:
一个已经没用的对象,因为仍然存在从 GC Root 到它的引用链,所以垃圾回收器无法判断它已经“没用”。
整个问题可以压缩成:
业务上已经不用 ↓ 引用却还存在 ↓ 对象仍然可达 ↓ GC无法回收 ↓ 对象越来越多 ↓ 内存持续上涨 ↓ 最终OOM所以真正需要关注的,从来不只是:
创建了多少对象而是:
这些对象用完以后 还有谁在引用它们?缓存没有淘汰、监听器没有解绑、ThreadLocal 没清理、静态集合无限增长,本质上其实都是同一件事:
对象该走的时候,没有人把那根引用线剪断。
想清楚“对象为什么还活着”,基本就抓住了内存泄漏问题的核心。