4、内存泄漏检测原理:什么是内存泄漏、内存泄漏的常见原因、引用计数与可达性分析、GC Roots概念
聊到内存优化,有个绕不开的话题——内存泄漏。
说实话,我早年刚入行时,对内存泄漏的理解就停留在「内存没释放」这个层面。直到有一次线上服务频繁 Full GC,查了两天才定位到问题,才发现自己之前太天真了。今天咱们就把这块掰开揉碎讲清楚。
4.1 什么是内存泄漏?
先给个最直白的定义:内存泄漏,就是本该被回收的对象,因为被错误地引用着,导致 GC 无法回收。
你想想看,Java 有自动垃圾回收,按理说不用我们操心。但问题是,GC 只回收「不可达」的对象。如果一个对象已经没用了,但还有引用链连着它,GC 就认为它「还活着」,于是它就一直占着内存不走。
我习惯把内存泄漏比作「占着茅坑不拉屎」——对象已经没用了,却赖在堆里不走。日积月累,堆空间越来越小,GC 越来越频繁,最终 OOM。
核心要点:内存泄漏 ≠ 内存溢出。泄漏是原因,溢出是结果。泄漏多了,迟早溢出。
4.2 内存泄漏的常见原因
我在项目中遇到过不少内存泄漏的案例,总结下来,最常见的就这几类:
4.2.1 静态集合类持有对象
这是最经典的一种。比如你有一个 static List,往里面塞了一堆对象,用完了没清掉。静态变量的生命周期和 JVM 一样长,里面的对象永远可达。
public class CacheManager {
private static List<byte[]> cache = new ArrayList<>();
public void addData(byte[] data) {
cache.add(data); // 用完了没人 remove
}
}
嗯,这种代码我见过太多次了。说白了就是「只加不减」。
4.2.2 未关闭的资源
数据库连接、文件流、Socket、Cursor……这些资源用完后必须 close。如果不关,它们内部往往会持有一些对象引用,导致内存泄漏。
我曾经排查过一个线上问题,发现数据库连接池的连接数一直在涨,最后发现是某段代码里 try 完之后忘了在 finally 里 close。
避坑指南:我建议所有资源操作都用 try-with-resources 语法,Java 7 以后就支持了,能省不少心。
4.2.3 内部类持有外部类引用
非静态内部类会隐式持有外部类的引用。如果你在 Activity 里定义了一个非静态内部类,然后把这个内部类的实例传给了某个静态变量或长期存活的对象,那整个 Activity 就泄漏了。
public class MainActivity extends AppCompatActivity {
private static Handler sHandler;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// 非静态匿名内部类,持有外部 Activity 引用
sHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
// ...
}
};
}
}
这个例子我印象特别深。当时一个同事写的代码,Activity 退出后一直没释放,就是因为 Handler 被静态变量引着。
4.2.4 注册没反注册
广播接收器、EventBus、观察者模式……注册了就要反注册。否则对象虽然不再需要了,但事件总线还持有它的引用。
4.2.5 缓存设计不合理
很多人喜欢用 HashMap 做缓存,但从不考虑淘汰策略。缓存越积越多,最终变成内存泄漏。我建议用 WeakHashMap 或者 LRU 缓存。
4.3 引用计数与可达性分析
聊到内存泄漏检测,得先搞清楚 GC 是怎么判断对象「该不该回收」的。主流有两种算法:
4.3.1 引用计数法
思路很简单:每个对象维护一个计数器,被引用一次就 +1,引用失效就 -1。计数器为 0 时,说明没人用了,可以回收。
听起来很完美对吧?但有个致命问题——循环引用。
class Node {
Node next;
}
// 两个对象互相引用
Node a = new Node();
Node b = new Node();
a.next = b;
b.next = a;
// 此时 a 和 b 的引用计数都是 1,即使外部没人用了,它们也永远不会被回收
这就是为什么主流 JVM 不用引用计数法。Python 早期用过,但为了解决循环引用,还得额外搞个标记清除。
4.3.2 可达性分析
Java 用的就是这种。思路是:从一组「根对象」出发,沿着引用链往下走,能走到的对象就是「活的」,走不到的就是「死的」。
说白了,就是画一张对象引用图,然后从根节点做一次遍历。没被遍历到的节点,就是垃圾。
关键区别:引用计数是「数数」,可达性分析是「画图」。画图的方式天然不怕循环引用——两个互相引用的对象,如果从根节点出发找不到它们,那它们就是垃圾。
4.4 GC Roots 概念
GC Roots 就是可达性分析的「起点」。JVM 里哪些对象可以作为 GC Roots 呢?
| GC Roots 类型 | 说明 | 举例 |
|---|---|---|
| 虚拟机栈引用 | 栈帧中的局部变量表 | 方法里的局部对象 |
| 静态变量 | 方法区的静态属性 | static 字段引用的对象 |
| 常量引用 | 方法区中的常量池 | 字符串常量池中的引用 |
| JNI 引用 | Native 方法栈中的引用 | JNI 全局引用 |
| 活跃线程 | 所有正在运行的线程 | Thread 对象本身 |
我个人的理解是:GC Roots 就是「程序当前正在使用」的那些对象。 只要这些对象还活着,它们引用的对象就不能回收。
举个例子:
public void test() {
User user = new User(); // user 是局部变量,属于 GC Root
user.setName("张三");
// 方法执行完,user 出栈,不再作为 GC Root
// 如果此时没有其他引用指向这个 User 对象,它就会被回收
}
这里要注意一点:GC Roots 不是固定不变的。 每次 GC 时,JVM 都会重新确定当前的 GC Roots 集合。比如方法执行完了,局部变量出栈了,它就不再是 GC Root 了。
小技巧:分析内存泄漏时,用 MAT 或 JProfiler 查看 GC Roots 路径,就能知道某个对象为什么没被回收。我每次排查泄漏问题,第一件事就是看「GC Root 到目标对象的最短路径」。
4.5 总结一下
内存泄漏的本质,就是不该存活的对象,因为被 GC Roots 链引用着,导致无法回收。
常见的泄漏原因就那么几种:静态集合、未关闭资源、内部类、未反注册、缓存设计不当。
而检测泄漏的核心,就是理解可达性分析和 GC Roots。你只要搞清楚「哪些对象是根」,就能顺着引用链找到泄漏点。
嗯,这一章的内容就到这儿。下一章咱们聊聊具体的检测工具和实战案例,到时候我会拿几个我亲手修过的泄漏问题来拆解。