内存泄漏的底层原理:从JVM内存模型到引用链
说实话,很多同学一听到「内存泄漏」就头大。我当年也一样,看着监控面板上那条不断攀升的曲线,心里直发毛。但后来我发现,只要把底层原理吃透了,内存泄漏其实没那么玄乎。
今天我们就从JVM内存模型开始,一步步拆解。你想想看,一个对象从出生到死亡,到底经历了什么?为什么有些对象明明没用了,GC就是回收不掉?
JVM内存模型回顾
先画个重点。JVM的内存区域,我习惯把它分成两大块:线程私有和线程共享。
| 区域 | 是否共享 | 存什么 | 会不会OOM |
|---|---|---|---|
| 程序计数器 | 私有 | 当前线程执行的字节码行号 | 不会 |
| 虚拟机栈 | 私有 | 局部变量表、操作数栈、动态链接 | 会(StackOverflowError) |
| 本地方法栈 | 私有 | Native方法调用 | 会 |
| 堆 | 共享 | 对象实例、数组 | 会(最常见) |
| 方法区 | 共享 | 类信息、常量、静态变量 | 会 |
堆和方法区是GC的主战场。尤其是堆,90%的内存泄漏都发生在这里。我在项目中遇到过好几次,堆内存持续增长,最后直接OOM,服务挂了。一查,全是某个缓存对象没释放。
核心要点:内存泄漏的本质,就是堆里的对象「该回收却没被回收」。GC认为它还活着,但实际上已经没人用了。
对象生命周期与可达性分析
一个Java对象从出生到被回收,大概经历这几个阶段:
- 创建:new关键字,在堆里分配内存
- 使用:被引用,被调用方法
- 不可达:没有任何GC Roots能到达它
- 可回收:GC标记,执行finalize()(如果有)
- 真正死亡:内存被回收
GC怎么判断一个对象「该不该死」?用的是可达性分析算法。说白了,就是从一组根节点(GC Roots)出发,沿着引用链往下走。能走到的,就是活的;走不到的,就是死的。
我的一点经验:很多人以为引用计数法也能判断,但Java没用它。为什么?因为循环引用的问题。A引用B,B引用A,但外部没人引用它们,引用计数法就傻眼了。可达性分析完美解决了这个问题。
GC Roots与引用链
GC Roots是啥?我总结了一下,常见的GC Roots包括:
- 虚拟机栈中引用的对象(局部变量)
- 方法区中静态属性引用的对象(static变量)
- 方法区中常量引用的对象(final常量)
- 本地方法栈中JNI引用的对象
- 活跃线程(Thread对象)
引用链就是一条从GC Roots到目标对象的路径。举个例子:
// 伪代码
GC Root (main线程局部变量)
→ userService对象
→ userDao对象
→ connection对象(可达,不回收)
// 如果userService = null
// 那么userDao和connection都不可达,会被回收
我曾经排查过一个线上问题:一个静态Map里存了用户会话,用户退出后没移除。结果Map越来越大,GC Roots(静态变量)一直引用着它,所有会话对象都成了「活死人」。嗯,这就是典型的内存泄漏。
避坑指南:我曾经在项目里用了一个全局的ArrayList来缓存数据,想着方便。结果数据量一上来,GC根本回收不了。后来改成WeakHashMap,问题就解决了。所以,静态集合类一定要小心!
强/软/弱/虚引用对GC的影响
Java提供了四种引用类型,它们的「生存能力」完全不同。我画了张图,方便你理解:
具体来说:
- 强引用:最常见的引用。只要强引用还在,GC永远不会回收。比如
Object obj = new Object()。内存泄漏的元凶,往往就是强引用没断开。 - 软引用:内存够用就不回收,内存不够就回收。适合做缓存。我建议用
SoftReference来包装那些「丢了也不心疼」的大对象。 - 弱引用:只要GC一运行,不管内存够不够,直接回收。典型的应用是
WeakHashMap和ThreadLocal的内部实现。 - 虚引用:最弱的引用。你甚至拿不到它的引用对象。主要用于跟踪对象被回收的状态,比如NIO的DirectBuffer回收。
实战建议:如果你在写缓存,优先考虑软引用或弱引用。我见过太多人直接用HashMap做缓存,结果内存爆了。换成WeakHashMap或者Guava的Cache,世界就清净了。
总结一下
内存泄漏的根源,说白了就是不该活的对象,因为强引用链的存在,被GC误判为「活着」。你想想看,一个对象明明业务上已经没用了,但GC Roots还能通过引用链找到它,那它就永远占着内存。
排查的时候,我一般先看堆内存里哪些对象占得最多,然后顺着引用链往上找,看是谁还引用着它。大部分时候,都是静态集合、未关闭的资源、或者注册了没反注册的监听器。
嗯,这一章的内容就到这儿。记住一句话:引用链不断,内存泄漏不散。
可可精品资料库