内存泄漏的底层原理:从JVM内存模型到引用链

说实话,很多同学一听到「内存泄漏」就头大。我当年也一样,看着监控面板上那条不断攀升的曲线,心里直发毛。但后来我发现,只要把底层原理吃透了,内存泄漏其实没那么玄乎。

今天我们就从JVM内存模型开始,一步步拆解。你想想看,一个对象从出生到死亡,到底经历了什么?为什么有些对象明明没用了,GC就是回收不掉?

JVM内存模型回顾

先画个重点。JVM的内存区域,我习惯把它分成两大块:线程私有和线程共享。

区域 是否共享 存什么 会不会OOM
程序计数器 私有 当前线程执行的字节码行号 不会
虚拟机栈 私有 局部变量表、操作数栈、动态链接 会(StackOverflowError)
本地方法栈 私有 Native方法调用 会
堆 共享 对象实例、数组 会(最常见)
方法区 共享 类信息、常量、静态变量 会

堆和方法区是GC的主战场。尤其是堆,90%的内存泄漏都发生在这里。我在项目中遇到过好几次,堆内存持续增长,最后直接OOM,服务挂了。一查,全是某个缓存对象没释放。

核心要点:内存泄漏的本质,就是堆里的对象「该回收却没被回收」。GC认为它还活着,但实际上已经没人用了。

对象生命周期与可达性分析

一个Java对象从出生到被回收,大概经历这几个阶段:

  1. 创建:new关键字,在堆里分配内存
  2. 使用:被引用,被调用方法
  3. 不可达:没有任何GC Roots能到达它
  4. 可回收:GC标记,执行finalize()(如果有)
  5. 真正死亡:内存被回收

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提供了四种引用类型,它们的「生存能力」完全不同。我画了张图,方便你理解:

四种引用类型对比 强引用 永不回收 软引用 OOM前回收 弱引用 下次GC就回收 虚引用 随时回收 回收条件:从左到右越来越容易被回收 典型使用场景 new Object() 静态变量 局部变量 缓存(内存敏感) 图片缓存 SoftReference WeakHashMap ThreadLocal WeakReference NIO Direct Buffer

具体来说:

  • 强引用:最常见的引用。只要强引用还在,GC永远不会回收。比如 Object obj = new Object()。内存泄漏的元凶,往往就是强引用没断开。
  • 软引用:内存够用就不回收,内存不够就回收。适合做缓存。我建议用 SoftReference 来包装那些「丢了也不心疼」的大对象。
  • 弱引用:只要GC一运行,不管内存够不够,直接回收。典型的应用是 WeakHashMap 和 ThreadLocal 的内部实现。
  • 虚引用:最弱的引用。你甚至拿不到它的引用对象。主要用于跟踪对象被回收的状态,比如NIO的DirectBuffer回收。

实战建议:如果你在写缓存,优先考虑软引用或弱引用。我见过太多人直接用HashMap做缓存,结果内存爆了。换成WeakHashMap或者Guava的Cache,世界就清净了。

总结一下

内存泄漏的根源,说白了就是不该活的对象,因为强引用链的存在,被GC误判为「活着」。你想想看,一个对象明明业务上已经没用了,但GC Roots还能通过引用链找到它,那它就永远占着内存。

排查的时候,我一般先看堆内存里哪些对象占得最多,然后顺着引用链往上找,看是谁还引用着它。大部分时候,都是静态集合、未关闭的资源、或者注册了没反注册的监听器。

嗯,这一章的内容就到这儿。记住一句话:引用链不断,内存泄漏不散。

可可精品资料库