程序跑得越久,占用内存越高,重启之后又恢复正常——如果你见过这样一条单调爬升的曲线,那多半就是内存泄漏。这篇用“租房与酒店”的比喻,把栈、堆、C 的手动释放、C++ 的智能指针、Java 的垃圾回收和 Rust 的所有权串成一条线,讲清楚泄漏究竟在哪一步发生。

栈和堆:酒店与仓库

内存泄漏这个词听着很玄,其实它只跟堆有关。要弄明白它,得先把栈和堆这对兄弟分清楚。

栈:酒店房间,退房就清空

栈是 CPU 的“亲儿子”,硬件层面专门给了它一套服务。x86-64 架构里有两个寄存器专门管栈:rsp 指向栈顶,rbp 指向栈底。函数调用时把返回地址、参数、局部变量压进去——所谓“分配”,本质就是把 rsp 往下挪几个字节;函数返回时,leave 和 ret 两条指令一执行,栈指针复位,那块空间瞬间就没了。

用租房来打比方,栈就是住酒店。前台(编译器)直接在最高层给你开一间房,你退房的那一刻房间立刻清空,不需要保洁阿姨(垃圾回收)来打扫,也不会产生任何碎片,因为从头到尾只是指针的加减。栈是线程私有的,每个线程一份,Linux 下默认大小一般是 8MB。

代价是房间太少。递归写深了,或者函数里开一个几百 KB 的局部数组,把楼层用光就会撞上操作系统设的保护页,触发缺页中断,程序被直接击毙——这就是栈溢出,Java 里表现为 StackOverflowError,C 里通常是一个段错误。

堆:长租仓库,钥匙自己管

堆是另一套逻辑。malloc 和 new 其实只是用户态的库函数,底下通过 brk 或 mmap 向内核申请虚拟内存。为了少打扰内核,分配器(比如 glibc 的 ptmalloc)先向操作系统批发一大块,再做“二房东”零售给你,用空闲链表之类的结构管理这些内存块。

对应到比喻里,堆就是租仓库。你得自己找物业租格子,用完了自己还回去(free 或 delete);也可以请一个管理员(垃圾回收器)定期来清场。堆是所有线程共享的,所以分配器必须加锁,比栈慢不少;格子大小参差不齐,频繁租借还会留下内部碎片和外部碎片。

泄漏只发生在堆

栈的空间由函数调用自动收回,不需要你操心,所以栈上不会有泄漏,只会溢出。堆的空间什么时候还,取决于谁来负责——这个责任归属,正是后面四种语言最大的分歧。

高地址
  +------------------------+
  |  栈(向下增长)         |   局部变量、返回地址,函数返回即回收
  +------------------------+
  |                        |
  |  堆(向上增长)         |   malloc / new 分配,靠 free / delete / GC 回收
  |                        |
  +------------------------+
  |  全局 / 静态存储区       |   与程序同生共死
  +------------------------+
  |  代码区                 |   机器指令
  +------------------------+
低地址

内存泄漏的三个经典现场

严格说起来,泄漏不是“内存消失了”,而是这块内存既没法用,也没法还。下面三种现场,覆盖了绝大多数真实事故。

现场一:把钥匙弄丢了

手动管理内存的语言里,最纯粹的一种泄漏来自指针被覆盖。

#include "stdlib.h"

struct Node {
    int value;
    struct Node *next;
};

void leak_demo(void) {
    struct Node *n = malloc(sizeof(struct Node));
    n->value = 42;
    n = malloc(sizeof(struct Node));   /* 第一块内存的地址被覆盖,永远找不回来 */
    n->value = 43;
    /* 函数结束,两块内存都还挂在堆上,其中第一块已经无主 */
}

第一次 malloc 拿到一块内存,指针 n 记着它的门牌号。第二次 malloc 把 n 指向了新的地址,第一块内存的门牌号就此丢失。程序里再也没有任何变量知道它在哪里,于是既没人用得上它,也没人还得回去——分配器只能一直把它记在“已分配”的账上。这就是内存泄漏。

同一个家族里还有几个常见的变体:异常或提前 return 跳过了释放代码;用 new[] 分配的数组却用 delete(而不是 delete[])释放,元素析构函数没跑;把对象塞进全局容器,用完之后忘了移除引用。

现场二:环里的两个人

引用计数是另一套思路:给每个对象记一个数,有人指向它就加一,撤销指向就减一,减到零立刻回收。听起来很省心,没有停顿,回收也很及时。但它怕环。

        A  ────────▶  B
        ▲             |
        |             |
        └─────────────┘

外部已经没有任何引用指向 A 和 B
但 A 的计数仍是 1,B 的计数仍是 1,永远到不了 0

A 引用 B,B 引用 A,除此之外整个世界都已经把它们忘了。可它们互相攥着对方,引用计数永远不归零,谁也不会被回收。C++ 的 std::shared_ptr、Python 的引用计数、Objective-C 和 Swift 的 ARC 都栽在这个坑里,需要弱引用或者额外的环检测机制来兜底。

现场三:有垃圾回收,也能泄漏

那 Java 呢?虚拟机会自动回收,总该安全了吧。答案依然是会泄漏,原因在于垃圾回收器判断对象死活的标准是“可达性”,而不是“你还需不需要它”。只要一个对象还能从 GC Roots(栈上的局部引用、静态变量、JNI 引用等)顺着引用链走到,它就一律算活着。

public class UserCache {
    /* 静态字段的生命周期和程序一样长 */
    private static final java.util.ArrayList CACHE = new java.util.ArrayList();

    public void add(Object user) {
        CACHE.add(user);   /* 只进不出,缓存慢慢变成了垃圾桶 */
    }
}

静态集合和程序同生共死,往里塞的对象只要不删就永远可达。这类泄漏在监控上表现为老年代水位持续上升、Full GC 之后也降不下去。类似的还有线程池里排队的任务隐式持有外部对象、监听器注销时忘记反注册、ThreadLocal 用完不调用 remove。

还有一种更隐蔽的:内部类持有外部类的引用,外部类对象本该被回收,却因为内部类实例被长期存着而始终可达。所以自动管理语言里的泄漏,本质是“引用关系没理清”,而不是垃圾回收器失灵。

四种语言,四份答卷

同样是堆内存,四种语言给出了完全不同的答案。

C:责任全在程序员

C 的选择最直接:malloc 配 free,new 配 delete,编译器只负责生成代码,不负责检查你有没有配对。优点是零额外开销,堆里每一字节都由你掌控;缺点就是上面那两个现场,全靠人自觉。在一段几千行的长生命周期代码里,只要有一条异常路径没考虑到,泄漏就埋下了。

C++:用 RAII 和智能指针兜底

C++ 早年的处境和 C 一样,直到 C++11 把智能指针写进标准,思路才从“记得释放”变成“让编译器替你释放”。

class Buffer {
public:
    explicit Buffer(int n) : data(new char[n]) {}
    ~Buffer() { delete[] data; }   /* 析构即释放,离开作用域自动调用 */
private:
    char *data;
};

void use(void) {
    Buffer buf(1024);   /* 不用写 delete,作用域结束自动回收 */
}

这就是 RAII:资源在构造函数里获取,在析构函数里释放,把堆内存的生命周期绑到栈对象的生命周期上——栈上的东西是自动回收的,堆上的东西跟着它走,就一起自动了。三种智能指针分工明确:std::unique_ptr 独占所有权,禁止拷贝只允许移动,开销接近裸指针;std::shared_ptr 用引用计数实现共享所有权;std::weak_ptr 不增加计数,专门用来打断上面那个环。

要注意的是,std::shared_ptr 并不会因为“智能”就免疫现场二,成环照样泄漏。C++26 的技术工作已于 2026 年 3 月完成(标准号 ISO/IEC 14882:2026),内存安全仍是这门语言往后几个版本持续补课的方向,但在很长一段时间里,写得好不好还是取决于人。

Java:把判断交给虚拟机

Java 直接放弃手动释放,把“哪些对象是垃圾”这个问题交给运行时。主流 JVM 用的是可达性分析:从 GC Roots 出发遍历对象图,走不到的对象就是垃圾。为了让用户线程少停一会儿,现代收集器会并发标记并配合读写屏障修正并发期的引用变化,三色标记法里的白、灰、黑,说的就是对象在遍历过程中的三种状态。

收集器的选择也会影响你对“内存问题”的观感。G1 从 JDK 9 起是默认收集器,能通过最大暂停时间目标来调节回收节奏;长年打磨下来,它的原生内存开销已经比早期版本低了一个数量级。ZGC 走的是另一条路,用染色指针和读屏障把停顿压到亚毫秒级,JDK 21 引入分代模式之后吞吐量也补了上来,代价是多出一部分内存开销和少量吞吐量。

对比项G1ZGC
设计目标吞吐与延迟兼顾极致低延迟
暂停时间数十到数百毫秒亚毫秒级
适合堆大小中小堆为主大堆到超大堆
额外内存开销较小更大
典型场景常规 Web 服务、批处理网关、竞价、实时风控

只不过,再先进的收集器也只能回收“不可达”的对象。你在代码里留着一个静态集合,对它来说那是活得好好的对象,一概不动。

Rust:把检查提前到编译期

Rust 走了第三条路:既不要手动释放,也不要运行时回收,而是把内存管理的判断搬到编译期。它的核心是三条所有权规则:每个值有且仅有一个所有者;同一时刻只能有一个所有者;所有者离开作用域,值被自动丢弃。

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;              // 所有权移动,s1 从此失效
    println!("{}", s2);       // 正确
    // println!("{}", s1);    // 编译错误:use of moved value
}

s2 = s1 不是复制而是移动,编译器会直接拒绝你之后再用 s1——因为如果两个变量都持有同一个堆指针,离开作用域时就会释放两次,那是更严重的事故。想临时用别人的数据,就显式声明借用:不可变借用可以同时存在多个,可变借用同一时刻只能有一个,借用期间数据的生命周期也必须覆盖引用的使用范围,这就是借用检查器在把关。

释放的活儿交给 Drop,逻辑上等同于 C++ 的析构函数,在作用域结束时自动执行。于是 Rust 里既没有忘记释放导致的泄漏,也没有释放后使用、重复释放这一类问题,它们全在编译期就被拦住了。代价是你得顺着借用检查器的脾气写代码。截至 2026 年 10 月初,Rust 稳定版已经到 1.99,新一代借用检查器仍在预览通道里打磨,报错的友好度还在持续改善。

当然,Rust 也不是绝对安全:std::mem::forget 和 Box::leak 都能主动泄漏,往一个只增不减的容器里 push 同样会把内存吃光,这类属于逻辑性增长,任何语言都躲不掉。

一张表看清四种答卷

语言回收方式谁负责释放典型泄漏原因运行时开销
C手动管理程序员忘记 free,异常路径跳过释放几乎为零
C++RAII 加智能指针栈对象析构裸指针遗漏,shared_ptr 成环引用计数的原子操作
Java追踪式垃圾回收虚拟机静态集合、监听器、ThreadLocal 只进不出回收停顿与额外内存
Rust编译期所有权编译器插入的 Drop主动 forget,逻辑性只增不减无运行时回收开销

泄漏了到底怎么找

知道原理之后,排查手段其实是顺着责任归属推导出来的。

C 和 C++:Valgrind 与 AddressSanitizer

Valgrind 的 Memcheck 不修改源码,通过动态二进制插桩运行程序,为每个字节维护一个有效性位和一个可寻址位,程序退出时扫描整个地址空间,报告 definitely lost、indirectly lost、possibly lost 的内存块以及它们的分配调用栈。它的缺点是慢,通常把程序拖慢 10 到 50 倍。

AddressSanitizer 走编译器插桩路线:每 8 字节真实内存对应 1 字节影子内存,读到非法区域立刻报错并打印调用栈,配合 LeakSanitizer 就能在程序结束时列出未释放的内存。它的性能开销通常在 2 倍左右,更适合放进持续集成里天天跑。

clang -fsanitize=address -g -O1 -fno-omit-frame-pointer example.c -o example

./example   /* 直接运行,程序退出时会打印泄漏报告 */

Java:先看垃圾回收日志

jcmd、jstat 可以看各区回收频率和老年代水位,jmap 能导出堆快照,再配合内存分析工具看支配树上的大对象;JFR 则能持续记录分配与回收事件。判断标准要盯住“Full GC 之后老年代占用是否还在涨”,而不是看堆占用总量高不高——堆用得多不代表泄漏,降不下去才是。

Go 和 Rust:一个新增了工具,一个天然少

Go 1.27(2026 年 8 月 19 日发布)把 goroutine 泄漏分析做成了正式能力,挂上 net/http/pprof 之后,goroutineleak 端点直接可用。它的思路很巧妙:从垃圾回收的可达性视角出发,把那些永久堵在 channel 或同步原语上、同时再没有任何存活协程能解开它们的 goroutine 标出来。官方也提示这类分析只覆盖阻塞在并发原语上的那一类泄漏,适合低频周期采集而不是高频采样。

Rust 的情况不太一样,大部分内存问题在编译期就被拦住了,日常排查的重点反而落在逻辑性增长、第三方库和主动泄漏上。

把结论压缩成三句话

  • 栈不会泄漏,只会溢出;泄漏是堆的问题。
  • 泄漏的本质不是内存丢了,而是“地址丢了”或者“引用还在”——前者是手动管理忘了释放,后者是自动管理误判为仍在使用。
  • 四种语言其实是三种思路:信任程序员(C)、用类型系统兜底(C++)、用运行时兜底(Java)、把检查提到编译期(Rust)。没有免费的午餐,只有代价不同的取舍。

版本与事实速查

写技术文章最怕版本过时,把截至 2026 年 10 月的几个关键事实放在这里。

项目状态
Rust1.99 于 2026 年 10 月初发布,保持约 6 周一个稳定版的节奏
Go1.27 于 2026 年 8 月 19 日发布,goroutine 泄漏分析正式可用
JavaJDK 25 是 2025 年 9 月的 LTS;JDK 27 于 2026 年 9 月 15 日发布,属非 LTS 版本
C++C++26 技术工作于 2026 年 3 月完成,标准号为 ISO/IEC 14882:2026
GCC16.1 于 2026 年 4 月 30 日发布,默认 C++ 标准升到 gnu++20
检测工具Valgrind 3.22 系列;ASan 通常约 2 倍开销,Valgrind 常在 10 到 50 倍

这些内容都能在官方渠道查到:Rust 语言官网、Go 下载页、Oracle Java 下载页、cppreference、Valgrind 官网。

写在最后

回到那个租房的比喻。栈是酒店,你退房的那一刻房间就空了,从来不需要担心;堆是仓库,钥匙在谁手里,决定了这块空间能不能被归还。C 把钥匙交给你,C++ 用 RAII 把钥匙挂到栈对象的腰带上,Java 雇了个管理员定期巡楼,Rust 则在你还想再配一把钥匙的时候就拦住了你。

所谓内存泄漏,说到底就是一个丢了地址的格子,或者一个被误判为还在使用的引用。搞懂这一点,再看那些持续上涨的内存曲线,就不会觉得神秘了。

发表评论

验证码图片,点击可更换