我的服务运行越久内存涨得越凶,最后内存泄漏到崩溃,排查发现是一堆本该被回收的对象因为订阅了事件却没取消订阅、被发布者死死攥着无法回收,我对着 C# 事件订阅导致的内存泄漏这个坑排查了大半天的复盘
这是一个让我对"有了 GC 就不会内存泄漏"这个错觉彻底清醒的 C# 坑。它隐蔽在:C# 有垃圾回收(GC),我一直以为"对象没用了 GC 自然会回收,根本不用操心内存";可这次,一堆本该被回收的对象,却被一根我没注意到的"引用线"死死拴住、GC 怎么也回收不了,内存就这么一点点泄漏到崩溃。
现象是:服务运行时间越长,内存占用涨得越凶,最后 OOM 崩溃;重启后又从低位开始涨。需求场景很常见——一些短生命周期的对象(比如临时的处理器、视图模型),订阅了一个长生命周期对象(比如全局服务)的事件:
// 长生命周期的全局服务(它有一个事件)
public class GlobalService {
public event EventHandler DataChanged; // 一个事件
public void NotifyChange() => DataChanged?.Invoke(this, EventArgs.Empty);
}
// 短生命周期的对象, 订阅了全局服务的事件(有问题的版本)
public class TempHandler {
private byte[] bigData = new byte[10_000_000]; // 假设它占用不少内存
public TempHandler(GlobalService service) {
service.DataChanged += OnDataChanged; // ★ 订阅事件 += , 但从不 -= 取消!
}
private void OnDataChanged(object sender, EventArgs e) { /* 处理 */ }
}
// 业务里, 不断创建 TempHandler, 用完就"丢弃"(以为GC会回收):
for (...) {
var handler = new TempHandler(globalService);
// ... 用完, handler 离开作用域, 我以为它会被GC回收
}
// 实际: 这些 handler 【一个都回收不了】! 内存持续泄漏!
我盯着内存分析器(profiler)里那一长串"本该消失却还活着"的 TempHandler 实例,百思不得其解。这些 handler 用完就离开了作用域,按理说没人引用它们了、GC 早该回收它们了;可它们就是顽固地活着、越积越多,每个还拖着 10MB 的 bigData,内存自然蹭蹭往上涨。明明有 GC,为什么这些"没用了"的对象就是回收不掉?是谁还在"抓着"它们不放?
第一件事:看清真相——事件订阅让发布者持有订阅者的引用,不取消就一直被抓着
我去深入理解了 C# 事件(event)的本质和 GC 的回收原理,才彻底明白这个"对象回收不掉"之谜——当你用 += 订阅一个事件时,发布者(GlobalService)内部会持有一个指向订阅者(TempHandler)的引用(通过那个委托);GC 回收一个对象的前提是"没有任何存活的对象引用它";而只要你不用 -= 取消订阅,这个"发布者→订阅者"的引用就一直存在——长生命周期的发布者,就这样把短生命周期的订阅者死死攥住,让它永远无法被 GC 回收。
事件订阅导致内存泄漏的真相
# 1. GC 回收对象的前提: 这个对象【没有任何存活的引用指向它】(不可达)。
# - 只要还有"活着的对象"引用它, GC 就【不会】回收它(认为它还有用)。
# 2. 事件订阅 += 做了什么:
# service.DataChanged += handler.OnDataChanged;
# - 这让 service 的 DataChanged 事件, 内部【持有一个委托】,
# 而这个委托【引用了 handler 这个对象】(因为OnDataChanged是它的方法)!
# - → 即: service 【间接引用了】 handler。
# 3. 于是内存泄漏发生:
# - service 是【长生命周期】的(全局服务, 一直活着);
# - handler 本是【短生命周期】的(用完该回收);
# - 但因为 service 通过事件【一直引用着】 handler(没-=取消),
# - → handler 永远"可达"(被活着的service引用) → GC 永远不回收它!
# - → 你以为handler用完没人要了, 其实service还死死攥着它。
# - → 不断创建handler又不取消订阅 → 它们全都泄漏、越积越多。
# 4. 关键: 引用方向是 "发布者 → 订阅者"!
# - 是长命的发布者, 攥住了短命的订阅者(而非反过来);
# - 所以"短命对象订阅长命对象的事件"是泄漏高发场景。
# 5. 为什么有GC还会泄漏:
# - GC 解决的是"没人引用的对象"的回收; 它无法判断"这个引用是不是你'忘了'解除的";
# - 只要存在引用(哪怕是你忘了取消的事件订阅), 在GC看来对象就"还有用"。
# - → "内存泄漏"在有GC的语言里, 主要表现为"对象被意外地、长期地引用着无法回收"。
# 核心: 事件 += 让发布者持有对订阅者的引用; 长命发布者引用短命订阅者、又不-=取消, 订阅者就永远
# 可达、GC回收不掉, 造成泄漏; 有GC不等于不会泄漏——意外的长期引用就是GC语言里的泄漏。
真相大白,我恍然大悟。原来 GC 回收一个对象的前提是"没有任何存活的引用指向它";而 += 订阅事件时,发布者(service)内部会持有一个委托,这个委托引用了订阅者(handler)——也就是说,service 间接引用了 handler。于是泄漏发生:service 是长生命周期的(全局服务一直活着),handler 本是短生命周期的(用完该回收);但因为 service 通过事件一直引用着 handler(没 -= 取消),handler 就永远"可达"、GC 永远不回收它——你以为 handler 用完没人要了,其实 service 还死死攥着它;不断创建 handler 又不取消订阅,它们全都泄漏、越积越多。关键在于引用方向是"发布者→订阅者"——是长命的发布者攥住了短命的订阅者,所以"短命对象订阅长命对象的事件"是泄漏高发场景。而为什么有 GC 还会泄漏:GC 解决的是"没人引用的对象"的回收,它无法判断"这个引用是不是你忘了解除的"——只要存在引用(哪怕是你忘了取消的事件订阅),在 GC 看来对象就"还有用"。所以"内存泄漏"在有 GC 的语言里,主要表现为"对象被意外地、长期地引用着,无法回收"。
第二件事:正解——用完取消订阅(-=),配合 IDisposable,或用弱事件
搞懂了原理,正解就清晰了:订阅了事件,就要在不用时取消订阅(-=);用 IDisposable 把"取消订阅"和对象生命周期绑定;或用弱事件模式让发布者不强引用订阅者。
// ====== 正解一(基础): 用完取消订阅 -= ======
public class TempHandler : IDisposable {
private readonly GlobalService service;
public TempHandler(GlobalService service) {
this.service = service;
service.DataChanged += OnDataChanged; // 订阅
}
private void OnDataChanged(object sender, EventArgs e) { /* 处理 */ }
// ★ 实现 IDisposable, 在 Dispose 里取消订阅
public void Dispose() {
service.DataChanged -= OnDataChanged; // ★ 取消订阅! 解除发布者对自己的引用
}
}
// 用: using (var handler = new TempHandler(service)) { ... } // using自动调Dispose
// 或手动: handler.Dispose(); → 取消订阅后, 没人引用handler了, GC就能回收它
// ====== 关键原则: 订阅(+=)和取消(-=)要成对出现 ======
// - 谁订阅, 谁负责取消; 在对象"生命周期结束/不再需要"时取消订阅。
// - 像 new/delete、open/close、lock/unlock 一样, += 也要有配对的 -=。
// ====== 正解二: 用 IDisposable + using 把生命周期管起来 ======
// 让订阅者实现IDisposable, 在Dispose取消订阅; using/框架的生命周期钩子自动调Dispose。
// (WPF: 在Unloaded/OnDetached里取消; ViewModel: 在Cleanup里取消)
// ====== 正解三: 弱事件模式(WeakEvent) ======
// 让发布者对订阅者持【弱引用】(weak reference): 弱引用不阻止GC回收订阅者。
// - .NET 有 WeakEventManager; 或自己用 WeakReference 实现弱事件。
// → 这样即使忘了-=, 订阅者也能被回收(发布者的弱引用不算"强引用")。
// (代价: 实现复杂些; 适合"难以确定何时取消订阅"的场景)
// ====== 正解四: 用匿名委托订阅时尤其当心 ======
// service.DataChanged += (s, e) => { ... }; // 匿名委托没法直接 -= !
// → 如果要取消, 要先把委托存进变量, 才能 -=:
// EventHandler h = (s, e) => {...}; service.DataChanged += h; ... service.DataChanged -= h;
// 核心: += 订阅要有配对的 -= 取消; 用IDisposable+using把取消和生命周期绑定; 难管理时用弱事件;
// 匿名委托订阅要存变量才能取消; 牢记"长命对象的事件, 别让短命对象订阅了又不取消"。
修复的核心,是"+= 订阅要有配对的 -= 取消,并和生命周期绑定"。正解一(基础):用完 -= 取消订阅——让订阅者实现 IDisposable,在 Dispose() 里 service.DataChanged -= OnDataChanged 取消订阅、解除发布者对自己的引用;用 using 自动调 Dispose,取消后没人引用 handler,GC 就能回收。关键原则:订阅(+=)和取消(-=)要成对出现——谁订阅谁负责取消,像 new/delete、open/close 一样,+= 也要有配对的 -=。正解二:用 IDisposable + using 把生命周期管起来(WPF 在 Unloaded、ViewModel 在 Cleanup 里取消)。正解三:弱事件模式——让发布者对订阅者持弱引用(不阻止 GC 回收),.NET 有 WeakEventManager,即使忘了 -= 订阅者也能被回收(代价是实现复杂些)。正解四:匿名委托订阅尤其当心——+= (s,e)=>{...} 没法直接 -=,要取消得先把委托存进变量。归根结底:+= 订阅要有配对的 -= 取消;用 IDisposable+using 把取消和生命周期绑定;难管理时用弱事件;匿名委托要存变量才能取消;牢记"长命对象的事件别让短命对象订阅了又不取消"。
第三件事:GC 语言里其他常见的内存泄漏
排查后我把 GC 语言(C#/Java 等)里其他常见的内存泄漏也系统梳理了一遍。
GC 语言里其他常见的内存泄漏
# 1. 事件订阅没取消(本文): 发布者引用订阅者。→ -= / 弱事件 / IDisposable。
# 2. 静态集合越加不减: static List/Map 不断add不remove, 里面的对象永不回收。
# → 静态集合要有清理机制/容量上限。
# 3. 缓存无上限/无过期: 缓存只进不出, 占满内存。→ 设容量上限/LRU/过期。
# 4. 长生命周期对象持有短生命周期对象引用: 同事件, 长命的攥着短命的。
# 5. 没关闭的资源(连接/流/定时器): Timer不Dispose会一直跑、引用回调对象。
# 6. ThreadLocal没清理: 线程池线程复用, ThreadLocal里的值不清会累积。
# 7. 闭包/委托意外捕获大对象: lambda捕获了一个大对象, 委托存活期间它也存活。
# 8. 集合里对象不再用却没移除: 用对象做key/value放进集合, 用完不remove。
# 共同根源: GC回收的是"不可达(没人引用)"的对象; 内存泄漏=对象"本该没用了, 却仍被
# 某个存活对象意外地、长期地引用着"→ GC认为它还有用、不回收。
# 核心: 有GC≠不会泄漏; GC语言的泄漏="意外的长期引用"——事件没取消、静态集合越加不减、
# 缓存无上限、资源没关闭、ThreadLocal没清; 要主动解除不再需要的引用、给集合/缓存设上限。
排查让我把 GC 语言的其他泄漏也梳理清了。一、事件订阅没取消(本文)。二、静态集合越加不减(static List/Map 不断 add 不 remove)。三、缓存无上限/无过期(设上限/LRU/过期)。四、长命对象持有短命对象引用。五、没关闭的资源(Timer 不 Dispose 一直跑)。六、ThreadLocal 没清理(线程池复用累积)。七、闭包/委托意外捕获大对象。八、集合里对象不再用却没移除。它们的共同根源是:GC 回收的是"不可达(没人引用)"的对象;内存泄漏=对象"本该没用了,却仍被某个存活对象意外地、长期地引用着"→ GC 认为它还有用、不回收。核心是:有 GC ≠ 不会泄漏;GC 语言的泄漏=意外的长期引用;要主动解除不再需要的引用、给集合/缓存设上限。下面这张图,是这次事件泄漏的成因与解法:
第四件事:GC 语言内存泄漏的几种典型模式对照表
这次踩坑后,我把 GC 语言里内存泄漏的几种典型模式整理成一张表。
| 模式 | 谁引用了谁 | 解法 |
|---|---|---|
| 事件订阅没取消 | 发布者→订阅者 | -= / 弱事件 / IDisposable |
| 静态集合越加不减 | 静态字段→对象 | 清理/上限/弱引用 |
| 缓存无上限/过期 | 缓存→对象 | LRU/容量上限/TTL |
| 未关闭的资源/定时器 | 运行时→回调对象 | Dispose/close/cancel |
| ThreadLocal没清 | 线程→值 | 用完remove |
| 闭包捕获大对象 | 委托→捕获的对象 | 别捕获不必要的大对象 |
这张表把 GC 语言泄漏的模式钉清了。核心是:所有这些泄漏,本质都是同一个模式——"某个生命周期长的东西(发布者/静态字段/缓存/线程),意外地、长期地引用着一个本该被回收的对象",让 GC 无法回收它;辨别泄漏,关键是找出"到底是谁在引用这个本该消失的对象"那根引用线。它给我的最大启发是:在有 GC 的语言里,排查内存泄漏的核心动作,是用内存分析器(profiler)找到"泄漏对象的引用链(GC Roots 到它的路径)"——即"从一个'永远存活的根'(静态字段、活跃线程、全局服务)出发,经过哪些引用,最终'抓住'了这个本该被回收的对象";找到了这根引用链,就找到了泄漏的根源,只要剪断它(取消订阅、清理集合、移除缓存),对象就能被回收。这其实揭示了 GC 语言内存管理的一个本质:有 GC,不意味着你完全不用管内存;它把"手动 free 内存"的负担,转换成了"管理对象的引用关系、确保不再需要的对象不被意外引用"的负担;"内存管理"在 GC 语言里,变成了"引用关系管理"——你要确保,当一个对象不再需要时,所有指向它的"不必要的引用"都被解除了。把 GC 语言的内存管理理解为"引用关系管理"、用 profiler 找引用链并剪断它——是排查这类泄漏的核心方法。
第五件事:GC 给我们的"幻觉"与真相
这次最大的认知冲击,是 GC 给我的"不用管内存"的幻觉被打破了。
| 关于内存的认知 | GC给的幻觉 | 真相 |
|---|---|---|
| 对象回收 | 没用了自动回收, 不用管 | "没人引用"才回收, 引用要你管 |
| 内存泄漏 | 有GC就不会泄漏 | 意外的长期引用照样泄漏 |
| 资源管理 | GC会处理一切 | 非内存资源(连接/文件/事件)GC管不了/不及时 |
| 开发者的责任 | 完全不用操心内存 | 要管理引用关系、及时解除引用 |
这张表道出了 GC 的"幻觉与真相"。核心是:GC 极大地减轻了内存管理的负担(不用手动 free),但它没有、也不可能完全消除这个负担;它给了我们"完全不用管内存"的幻觉,而真相是:我们仍然要管理"对象之间的引用关系",确保不再需要的对象及时被解除引用。它给我的深刻启发是:任何"自动化/帮你管理某件事"的机制(GC 管内存、ORM 管 SQL、框架管生命周期),都不是"魔法",而是"在某个范围内、按某种规则"帮你处理;如果你完全不理解它的规则和边界,把它当成"万能、永不出错"的魔法,就会在它"规则之外或边界处"栽跟头(GC 的规则是"回收不可达对象",边界是"它管不了'你忘了解除的引用'和'非内存资源'")。这让我对"自动化机制"有了更成熟的态度:享受自动化带来的便利,但始终理解"它到底帮我做了什么、它的规则是什么、什么是它管不了而仍需我负责的";不要因为有了自动化,就彻底放弃对那件事的理解和责任——而要清楚地知道"自动化的边界在哪,边界之外仍是我的责任"。看破"自动化=完全不用管"的幻觉、理解 GC 的规则与边界并守住边界外的责任——是这个内存泄漏坑,带给我的关于"如何依赖自动化机制"的更深思考。
第六件事:写订阅/持有引用的代码时,我现在的判断习惯
现在每当我写"订阅事件"或"让一个对象持有另一个对象的引用"的代码,我都会按这张图先想清楚:
这张图的精髓,是"发布者比订阅者长命就警惕,订阅时就想好在哪取消、确保 += 和 -= 配对"。生命周期一样长一般安全;发布者比订阅者长命就警惕、订阅时就想好在哪取消——有明确结束时机就 IDisposable 里 -=、难确定就用弱事件,确保 += 和 -= 配对。长期服务还要监控内存趋势、用 profiler 查泄漏。这套习惯,让我写订阅代码时,从"+= 订阅就完事"变成了"订阅时就想好怎么取消、谁攥着谁"——核心始终是:事件订阅让发布者引用订阅者,长命攥短命又不取消就泄漏;+= 要配对 -=、绑定生命周期。
我立下的几条规矩
这场"事件订阅导致内存泄漏"的事故,换来了我写 C# 时,刻进骨子里的几条铁律:
- 有 GC 不等于不会内存泄漏。意外的长期引用就是 GC 语言的泄漏。
- += 订阅让发布者持有对订阅者的引用。长命攥短命就泄漏。
- += 要有配对的 -=。像 new/delete 一样成对,谁订阅谁取消。
- 用 IDisposable + using 把取消订阅和生命周期绑定。
- 难管理时用弱事件模式。发布者持弱引用,不阻止 GC。
- 匿名委托订阅要存变量才能取消。否则没法 -=。
- GC 语言的内存管理 = 引用关系管理。及时解除不再需要的引用。
附:一段能复现并验证事件泄漏的实验
口说无凭。下面这段代码,用弱引用 + GC,亲手验证"订阅不取消则回收不掉、取消后能回收":
using System;
class GlobalService {
public event EventHandler DataChanged;
}
class Handler {
public Handler(GlobalService svc) { svc.DataChanged += OnChanged; }
private void OnChanged(object s, EventArgs e) { }
}
class Program {
// 创建一个Handler订阅事件, 返回它的弱引用(弱引用不阻止GC回收)
static WeakReference CreateAndSubscribe(GlobalService svc) {
var h = new Handler(svc); // 订阅了 svc 的事件, 没有取消
return new WeakReference(h); // 返回弱引用; 局部变量h离开作用域后, 看它能否被回收
}
static void Main() {
var svc = new GlobalService(); // 长生命周期的服务
Console.WriteLine("=== 1. 订阅了不取消 ===");
var weak = CreateAndSubscribe(svc);
GC.Collect(); // 强制GC
GC.WaitForPendingFinalizers();
GC.Collect();
// weak.IsAlive: handler还活着吗?
Console.WriteLine(" GC后 handler 还活着吗: " + weak.IsAlive);
// → True! 虽然局部变量h没了, 但svc的事件还引用着它 → 没被回收 → 泄漏!
// (说明: 因为没有取消订阅, svc.DataChanged 仍持有对handler的引用,
// handler对GC来说仍"可达", 所以GC不回收它——这就是泄漏的铁证。)
Console.WriteLine("\n 对比: 如果在Handler里实现Dispose并 svc.DataChanged -= OnChanged,");
Console.WriteLine(" 调用Dispose取消订阅后再GC, weak.IsAlive 就会是 False(被回收了)。");
}
}
// 核心: 跑一遍, 亲眼看到——订阅了不取消订阅时, 即使局部引用没了、强制GC, handler仍然
// weak.IsAlive==True(没被回收, 因被事件引用着); 这就用弱引用+GC确凿地证明了事件泄漏。
这段实验代码,是我这次踩坑后写下的"泄漏证明器"。它最巧妙的设计,是用了 WeakReference(弱引用)——弱引用本身不阻止 GC 回收对象,所以它成了一个完美的"探针":我创建一个 Handler 订阅了事件、然后让指向它的强引用(局部变量)消失,再强制 GC,最后通过 weak.IsAlive 检查"这个 Handler 到底被回收了没"。结果是 True(还活着)——这就确凿地证明了:即使没有任何"明面上的"强引用、即使强制 GC,这个 Handler 也没被回收,因为 svc 的事件还在暗中引用着它。这正是我想用这段代码,留给每个用 GC 语言的人的核心方法:要验证"某个对象到底会不会被 GC 回收、是不是泄漏了",一个精巧的实验是——用 WeakReference 持有它(弱引用不影响回收)、然后清除所有强引用、强制 GC、再检查 weak.IsAlive:如果还活着(IsAlive=True),说明有"看不见的强引用"在拖住它(泄漏);如果被回收了(False),说明引用确实都解除了。因为"对象有没有被回收"这件事,平时是看不见、摸不着的;而 WeakReference + 强制 GC + IsAlive 这套组合,能把它变成一个可以明确观测的布尔值,让"泄漏与否"从猜测变成确凿的实验结果;这是验证内存泄漏(尤其这种"被意外引用"型泄漏)最直接的"白盒"手段。用 WeakReference+强制 GC+IsAlive 把"对象是否被回收"变成可观测的实验——这份习惯,是我确诊一切"意外引用型内存泄漏"最可靠的法门。
写在最后
回头看,这场由"事件没取消订阅"引发的、内存泄漏到崩溃的事故,真正教给我的,远不止"记得 -="这一个技巧。它让我对"自动化解放了我们,但也容易让我们交出本不该交出的责任与理解"这件事,有了一次深刻的体会。我栽跟头,根源是 GC 这个伟大的自动化机制,给了我一种"内存这件事,我完全不用操心了"的过度的安全感。我把"不用手动 free 内存",错误地理解成了"完全不用思考内存、不用思考对象的生死"——我以为只要一个对象"逻辑上没用了",它就会自动消失,从没想过"它在内存里到底还被谁引用着、它在 GC 眼里到底还活不活"。于是,当一根我没注意到的引用线(事件订阅)把它拴住时,我对此毫无察觉,因为我早已"不思考内存"了。这让我领悟到一个深刻的认知:强大的自动化/抽象工具,在解放我们于繁琐的同时,也有一个微妙的副作用——它容易让我们连同那件事的"理解"和"必要的责任"一起交出去;我们享受着"不用管"的便利,却也因此丧失了对那件事的感知和警觉;而当那件事在自动化的边界之外出问题时(GC 管不了你忘了解除的引用),我们因为早已"不去想它"而后知后觉、束手无策。这其实是一个关于"人与自动化"的普遍警示:对每一个"替我们自动处理某事"的工具(GC、ORM、框架、乃至 AI),我们都要警惕"过度信赖导致的能力与责任的交出";正确的姿态是:用它来免去繁琐,但不放弃对那件事的基本理解和必要的警觉——知道它在替我做什么、它的边界在哪、边界之外我仍需负责;"用自动化,但不被自动化麻痹",是这个时代每个工程师都需要的清醒。用 GC 免去手动管理内存的繁琐、但不放弃对"对象引用关系"的理解与责任——这,是我用一次内存泄漏的事故,换来的、关于 C#、关于 GC、也关于如何清醒地与一切自动化工具相处的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次写下 += 订阅事件 时,顺手就想一句"这个订阅,我会在哪里 -= 取消呢?",那我对着那一长串本该消失却还活着的对象排查的这大半天,就值了。
—— 别看了 · 2026