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 还留在里面。

我的建议:

做缓存时,一定要考虑三个问题:

  1. 缓存什么时候过期?
  2. 缓存满了怎么办?
  3. 缓存里的数据会不会无限增长?

我个人习惯用 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 内存模型,看看内存到底是怎么分配的。只有搞懂了底层原理,你才能真正理解泄漏是怎么发生的。

咱们下节课见。