拓冰建站拓冰建站
首页 / 资讯中心 / 正文

内存为什么越跑越高?从对象回收讲透内存泄漏的原因与预防

线上服务有一种问题很让人头疼刚启动时一切正常。运行几个小时后内存开始慢慢上涨。再过一段时间接口变慢、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 MapString, 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 中非常经典的一个坑。比如ThreadLocalUserContext 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最好问一句这里的数据什么时候删除如果答案是不知道那就值得警惕。对于缓存一定考虑容量限制 一定考虑过期时间 一定考虑淘汰机制对于监听器注册 ↓ 记得注销对于 ThreadLocalset ↓ try ↓ finally remove对于连接和流申请资源 ↓ 使用 ↓ 及时关闭说到底预防内存泄漏有一个很简单的原则任何生命周期比较长的对象只要它开始保存其他对象的引用就要考虑这些引用什么时候解除。十二、一个很实用的判断方法以后看到一段代码container.add(obj);不要只看add最好顺手问三个问题谁持有 container container 能活多久 obj 什么时候 remove如果答案是container是static ↓ 跟程序活得一样久 ↓ obj从不删除那基本已经闻到内存泄漏的味道了。写在最后内存泄漏其实没有想象中那么玄学。对于 Java 这类带 GC 的语言来说它最核心的逻辑就是一个已经没用的对象因为仍然存在从 GC Root 到它的引用链所以垃圾回收器无法判断它已经“没用”。整个问题可以压缩成业务上已经不用 ↓ 引用却还存在 ↓ 对象仍然可达 ↓ GC无法回收 ↓ 对象越来越多 ↓ 内存持续上涨 ↓ 最终OOM所以真正需要关注的从来不只是创建了多少对象而是这些对象用完以后 还有谁在引用它们缓存没有淘汰、监听器没有解绑、ThreadLocal 没清理、静态集合无限增长本质上其实都是同一件事对象该走的时候没有人把那根引用线剪断。想清楚“对象为什么还活着”基本就抓住了内存泄漏问题的核心。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门