1. 内存泄漏基础:什么是内存泄漏、内存泄漏的危害、常见的内存泄漏类型
大家好,我是你们的老朋友。今天咱们正式开始聊内存泄漏。
说实话,我做了十几年性能优化,见过最多的线上事故,一半以上都和内存泄漏有关。你想想看,一个服务跑着跑着突然挂了,查了半天发现是内存一点点被吃光——这种感觉,就像看着水龙头没关紧,水一滴一滴流走,最后整个屋子都淹了。
1.1 什么是内存泄漏?
先给个最直白的定义:内存泄漏,就是程序申请了内存,用完之后不还。
在 C 语言里,你 malloc 了一块内存,用完了没 free,这就是泄漏。在 Java 或 Go 里,虽然有了垃圾回收(GC),但如果你还持有对象的引用,GC 就没办法回收它——这同样是泄漏。
我遇到过最经典的案例:一个 Java 后端服务,每次请求都会往一个静态 HashMap 里放数据,但从来不移除。结果呢?运行 3 天后,堆内存从 2GB 涨到 8GB,然后 OOM 了。说白了,就是代码里埋了个「定时炸弹」。
核心要点:
- 内存泄漏 ≠ 立即崩溃,它是慢慢累积的
- 有 GC 的语言也会泄漏,只是表现形式不同
- 泄漏的本质是「对象无法被回收,且不再被使用」
1.2 内存泄漏的危害
很多人觉得:「泄漏就泄漏呗,重启一下不就好了?」
嗯,这话听起来没错,但代价太大了。我给大家列几个真实场景:
| 危害类型 | 具体表现 | 我见过的真实案例 |
|---|---|---|
| 性能下降 | GC 频繁触发,CPU 飙升,响应变慢 | 一个支付系统,GC 时间占到了 40% 的 CPU |
| 服务崩溃 | OOM,进程被系统杀掉 | 凌晨 3 点,核心交易服务挂了,全组被叫醒 |
| 数据丢失 | 进程异常退出,未持久化的数据丢失 | 缓存服务泄漏,重启后缓存全部清空,数据库被打爆 |
| 运维成本 | 需要频繁重启、扩容、监控 | 有个项目每周要重启 3 次,运维同事快疯了 |
我曾经接手过一个项目,线上每 6 小时就要重启一次。你想想看,一个电商平台,高峰期突然重启,用户正在下单呢,直接 502。这种体验,用户不骂你骂谁?
避坑指南:
我曾经以为「内存泄漏只是小问题,重启就能解决」。直到有一次,泄漏导致 GC 线程占满了 CPU,系统连重启脚本都执行不了——最后只能强制断电。从那以后,我再也不敢轻视任何一次内存泄漏。
1.3 常见的内存泄漏类型
聊完了危害,咱们看看实际工作中最常见的几种泄漏类型。我按语言分了类,但很多原理是相通的。
1.3.1 静态集合类泄漏
这是最常见的,没有之一。说白了,就是把对象往静态变量里一放,再也不管了。
// Java 示例:典型的静态集合泄漏
public class CacheManager {
private static final Map<String, Session> sessionCache = new HashMap<>();
public void addSession(String id, Session session) {
sessionCache.put(id, session); // 只增不减
}
}
我见过一个项目,用 HashMap 做缓存,但从来不清理过期数据。运行一周后,这个 Map 里存了上千万个对象,直接撑爆了堆内存。
1.3.2 未关闭的资源
数据库连接、文件流、网络连接——这些资源用完了不关,就是泄漏。
// 错误写法:忘记关闭流
public void readFile(String path) {
FileInputStream fis = new FileInputStream(path);
// 读取数据...
// 没有 close()!
}
嗯,这里要注意:很多人觉得「反正有 GC,它会帮我关」。错!GC 只回收内存,不帮你关文件句柄。文件句柄泄漏多了,系统会报「Too many open files」。
1.3.3 内部类持有外部引用
这个坑在 Java 里特别多。匿名内部类或非静态内部类,会隐式持有外部类的引用。
public class BigObject {
private byte[] data = new byte[1024 * 1024]; // 1MB
public void startTask() {
new Thread(new Runnable() {
@Override
public void run() {
// 这个 Runnable 隐式持有 BigObject 的引用
// BigObject 无法被回收
}
}).start();
}
}
我曾经排查过一个泄漏:一个 Activity 退出后,内存一直没释放。查了半天,发现是一个匿名线程持有了 Activity 的引用,导致整个 Activity 无法被回收。
1.3.4 缓存设计不当
缓存是好东西,但用不好就是泄漏。比如用 WeakHashMap 做缓存,结果 key 被回收了,value 还留在里面。
我的建议:
做缓存时,一定要考虑三个问题:
- 缓存什么时候过期?
- 缓存满了怎么办?
- 缓存里的数据会不会无限增长?
我个人习惯用 Guava Cache 或 Caffeine,它们自带过期策略和容量限制。
1.3.5 回调/监听器未注销
注册了监听器,但忘记注销。对象虽然不再使用了,但因为被监听器持有引用,无法回收。
// 注册了监听器,但忘记移除
button.addClickListener(new ClickListener() {
@Override
public void onClick() {
// 处理点击
}
});
// 页面销毁时,没有 removeClickListener()
我记得有一次排查 Android 应用的内存泄漏,发现一个 Fragment 退出后,它的 View 还活着。原因就是注册了一个全局的 BroadcastReceiver,但没在 onDestroy 里注销。
1.3.6 字符串/常量池泄漏
这个在 Java 里比较特殊。String.intern() 方法会把字符串放入常量池,如果滥用,常量池会越来越大。
// 危险操作:大量调用 intern()
for (String line : hugeFileLines) {
line.intern(); // 每一行都放入常量池,永不释放
}
我曾经见过一个日志解析程序,每解析一行日志就调用一次 intern(),结果运行 2 小时后,永久代(PermGen)满了,直接 OOM。
1.4 小结
好了,这一章的内容就这些。咱们总结一下:
- 内存泄漏 = 申请了内存,用完了不还
- 危害 = 性能下降 → 服务崩溃 → 数据丢失 → 运维噩梦
- 常见类型 = 静态集合、未关资源、内部类、缓存、监听器、字符串常量池
下一章,我会带大家深入 JVM 内存模型,看看内存到底是怎么分配的。只有搞懂了底层原理,你才能真正理解泄漏是怎么发生的。
咱们下节课见。