页面关了,内存却不降反升:我在 C# 里订阅了事件却忘了退订,埋下的那个悄无声息的内存泄漏
这是一个潜伏了很久、最后才暴露的内存泄漏。我做了一个客户端程序,里面有很多个"页面"会被反复地打开、关闭。每个页面在打开时,都会去订阅一个全局的、长期存在的"数据中心"对象的某个事件(比如"数据更新了"),好在数据变化时刷新自己。功能上一切正常。可程序用久了之后,我发现一个诡异的现象:随着用户不断地打开、关闭页面,程序的内存占用,一直在缓慢地、只升不降地往上涨——明明那些页面都已经"关闭"了,可它们占用的内存,却怎么也不释放,越积越多,最后把内存撑爆。
我用内存分析工具一查,真相浮出水面、也让我恍然大悟:那些"已经关闭"的页面对象,竟然一个都没有被垃圾回收(GC)!它们明明已经"关闭"、用户也看不到了,却像幽灵一样,全都还驻留在内存里、迟迟不肯离去。我顺着它们的"引用链"一查,揪出了那个把它们"拴住"的元凶——是那个全局的"数据中心"对象,它通过"事件",牢牢地引用着每一个曾经订阅过它的页面!原来,在 C# 里,当一个对象 A 订阅了对象 B 的事件时,B 的内部,会持有一个对 A 的引用(通过那个事件的委托);而只要 B 还引用着 A,A 就不可能被 GC 回收(GC 只回收"没人引用"的对象)。我的页面(A),订阅了长期存在的数据中心(B)的事件;可页面关闭时,我忘了去退订这个事件——于是,那个长寿的数据中心,就一直、永远地引用着那个本该被销毁的页面,导致页面永远无法被 GC 回收。每打开一个页面,就泄漏一个;开开关关无数次,就泄漏了无数个,内存自然只升不降。
故障现场:被事件"拴住"、永远不死的页面
我把出问题的代码,简化一下。问题就藏在"订阅了、却没退订"上:
// 全局的、长期存在的"数据中心"(比如一个单例)
public class DataCenter
{
public static DataCenter Instance = new DataCenter(); // 单例, 长期存在
public event EventHandler DataUpdated; // 一个"数据更新了"的事件
public void Update() => DataUpdated?.Invoke(this, EventArgs.Empty);
}
// 一个会被反复打开/关闭的"页面"
public class Page
{
public Page()
{
// 打开页面时, 订阅数据中心的事件
DataCenter.Instance.DataUpdated += OnDataUpdated; // ← 订阅了!
// ✗ 但是, 页面关闭时, 没有【退订】这个事件!
}
private void OnDataUpdated(object sender, EventArgs e)
{
// 数据变了, 刷新页面 ...
}
// 我以为页面"关闭"(没人引用它了), 它就会被 GC 回收。可是 ——
}
// 内存泄漏的过程:
// 1. 打开 Page A → A 订阅了 DataCenter 的事件
// → 此时 DataCenter.DataUpdated 内部, 持有了一个对 A 的引用!
// 2. 关闭 Page A → 我这边不再引用 A 了(以为它会被回收)
// 3. 但是! DataCenter(长寿的单例)还引用着 A(通过那个没退订的事件)!
// 4. → A 还"被引用着" → GC 不会回收 A → A 泄漏了!
// 5. 开开关关 1000 次 → 1000 个 Page 全泄漏 → 内存爆!
看清这个引用关系,我才明白那些页面是怎么"阴魂不散"的。问题的核心,是 C# 里"事件订阅"背后的一个引用关系:当对象 A(页面)订阅了对象 B(数据中心)的事件时,B 会在内部,通过那个事件的委托,持有一个对 A 的强引用(因为事件触发时,B 需要能"找到" A、调用 A 的那个处理方法)。这个引用关系,平时没什么问题;可它和"对象生命周期"一结合,就埋下了内存泄漏的隐患:垃圾回收(GC)的规则是,只回收那些"没有任何对象引用它"的对象。我的页面 A,在"关闭"后,虽然我这边(UI 框架那边)不再引用它了,看起来应该被回收;可是!那个长期存在的数据中心 B,因为 A 当初订阅了它的事件、而关闭时又没有退订,所以 B 依然牢牢地引用着 A!于是,从 GC 的角度看,A 还"被一个活着的对象(B)引用着",所以,GC 就不会、也不能回收 A——A 就这样,被那个比它长寿得多的数据中心,通过一个没退订的事件,永远地"拴"在了内存里,无法被释放。而这个泄漏,是"累积性"的:用户每打开一个页面,就有一个页面订阅了数据中心、又在关闭时忘了退订,于是就泄漏一个;开开关关一千次,就泄漏一千个"幽灵页面"——它们都已经"关闭"、用户都看不到了,却全都还赖在内存里、被数据中心拴着、永远不死。内存,就这样被这些泄漏的幽灵页面,一点点地、悄无声息地,蚕食殆尽。而这一切的根源,仅仅是因为我"订阅了事件、却忘了退订"。
第一件事:搞懂事件订阅为什么会导致内存泄漏
定位到根源,我必须把"事件订阅导致内存泄漏"的原理,彻底想透:核心,是要理解三件事的结合:第一,"事件订阅",会让"发布者"持有一个对"订阅者"的引用;第二,"GC 回收",只回收"没人引用"的对象;第三,当"发布者"比"订阅者"长寿、且订阅者没退订时,这个引用就会"拖住"订阅者,让它无法被回收,从而泄漏。
"事件订阅 → 内存泄漏"的原理(三件事的结合):
# 事实1: 订阅事件 = 发布者持有对订阅者的引用
# page.Event += handler → 发布者的事件委托里, 存了一个指向 page 的引用。
# (因为事件触发时, 发布者要能"调到" page 的 handler 方法)
# 方向: 发布者 ——引用——> 订阅者
# 事实2: GC 只回收"没有被任何活对象引用"的对象
# 一个对象, 只要还"被某个活着的对象引用着", GC 就不会回收它。
# 事实3: 泄漏发生的条件 = "发布者长寿 + 订阅者短命 + 没退订"
# - 发布者(数据中心): 长期存在(单例/静态/全局)
# - 订阅者(页面): 本该短命(开了又关)
# - 订阅者订阅了发布者的事件, 关闭时却没退订
# → 长寿的发布者, 一直引用着本该死掉的订阅者
# → 订阅者"被引用着", GC 回收不了它 → 泄漏!
# 关键判断: "谁的命更长?"
# - 发布者 比 订阅者 长寿 → 危险! (订阅者会被发布者拖住, 泄漏)
# - 发布者 和 订阅者 寿命相同(一起生死) → 没事(一起被回收)
# - 订阅者 比 发布者 长寿 → 没事(发布者先死, 引用也没了)
核心: 内存泄漏, 不是"内存用错了", 而是"该被回收的对象, 被一个引用拖住、回收不了"。
而事件订阅, 正是一种"容易被忽略的、隐藏的引用"——它悄悄地把订阅者, 拴在了发布者身上。
原理终于清晰了。"事件订阅导致内存泄漏",是三件事结合的产物:事实1:订阅事件,等于发布者持有了对订阅者的引用——page.Event += handler 这行代码,会让发布者的事件委托内部,存一个指向 page 的引用(因为事件触发时,发布者要能"调到" page 的那个处理方法);引用的方向,是"发布者 → 订阅者"。事实2:GC 只回收"没有被任何活对象引用"的对象——一个对象,只要还"被某个活着的对象引用着",GC 就不会回收它。事实3:泄漏发生的条件,是"发布者长寿 + 订阅者短命 + 没退订"——发布者(数据中心)长期存在,订阅者(页面)本该短命、开了又关,可订阅者订阅了发布者的事件、关闭时却没退订;于是,那个长寿的发布者,就一直引用着本该死掉的订阅者,导致订阅者"被引用着"、GC 回收不了、泄漏了。而这里有一个判断"会不会泄漏"的关键问题:"谁的命更长?"——如果发布者比订阅者长寿,那就危险(订阅者会被发布者拖住、泄漏,这是我的情况);如果发布者和订阅者寿命相同(一起生死),就没事(一起被回收);如果订阅者比发布者长寿,也没事(发布者先死,引用也就没了)。这让我深刻地认识到:内存泄漏,本质上,不是"内存用错了",而是"本该被回收的对象,被一个引用'拖住'了、回收不了"。而"事件订阅",正是一种极其容易被忽略的、隐藏的引用——它在你不经意间,悄悄地,把订阅者,'拴'在了发布者的身上;一旦发布者长寿、订阅者又没退订,这根'拴绳',就成了导致订阅者无法被回收、内存泄漏的、那根看不见的'绞索'。
第二件事:正解——订阅了就要退订,用 -= 解除引用
搞懂了根因——"订阅没退订,发布者一直拴着订阅者"——正解就清晰了:核心原则是"有订阅,就要有退订"——在订阅者(页面)的生命周期结束时(关闭/销毁),主动地退订它当初订阅的事件(用 -=),解除发布者对它的引用。这样,订阅者就不再被发布者拴着,GC 就能正常地回收它了。最规范的做法,是实现 IDisposable,在 Dispose 里退订。
// 正解: 订阅了就要退订, 在销毁时用 -= 解除引用
public class Page : IDisposable // ← 实现 IDisposable
{
public Page()
{
DataCenter.Instance.DataUpdated += OnDataUpdated; // 订阅
}
private void OnDataUpdated(object sender, EventArgs e) { /* ... */ }
public void Dispose()
{
// ★ 关键: 在销毁时, 退订事件! 解除发布者对自己的引用!
DataCenter.Instance.DataUpdated -= OnDataUpdated; // ← 退订! 用 -=
// 现在: 数据中心不再引用这个页面了 → 页面可以被 GC 回收了 → 不泄漏!
}
}
// 使用时, 确保 Dispose 被调用(关闭页面时):
// using (var page = new Page()) { ... } // using 块结束自动 Dispose
// 或在页面的 Closed/Unloaded 事件里, 手动调 Dispose / 退订
// 核心铁律: "成对出现"——
// 有 += (订阅), 就必须有对应的 -= (退订);
// 而且要保证 -= 用的是【同一个方法引用】(别用 lambda, 否则 -= 退不掉! 见下)
// ✗ 反例: 用 lambda 订阅, 就退不掉了! (因为 -= 时找不到那个匿名的 lambda)
// DataCenter.Instance.DataUpdated += (s, e) => Refresh(); // 订阅了
// DataCenter.Instance.DataUpdated -= (s, e) => Refresh(); // ✗ 退不掉! 是新的lambda
// → 用 lambda 订阅, 想退订, 要先把 lambda 存到一个变量里:
// EventHandler handler = (s, e) => Refresh();
// DataCenter.Instance.DataUpdated += handler; // 订阅
// DataCenter.Instance.DataUpdated -= handler; // ✓ 用同一个 handler 变量退订
这个正解的核心,是一条朴素的铁律:"有订阅(+=),就必须有对应的退订(-=)"——它们要"成对出现"。在订阅者(页面)的生命周期结束时(关闭、销毁),主动地用 -= 退订它当初订阅的事件,这就解除了发布者对它的那个引用;一旦这个引用解除了,订阅者就不再"被拴着"了,GC 就能正常地、顺利地回收它,内存泄漏也就不存在了。而最规范、最不容易遗漏的做法,是让订阅者实现 IDisposable 接口,把"退订"的逻辑,写在 Dispose() 方法里;然后,在订阅者该被销毁时(比如 using 块结束、或页面的 Closed 事件里),确保 Dispose() 被调用——这样,"退订"就和"销毁"绑定在了一起,不容易忘。这里还有一个极其隐蔽、必须注意的细节:如果你是用 lambda(匿名函数)订阅的事件,那你退不掉它!因为 -= 退订时,需要找到"和订阅时同一个"的方法引用,而你写的两个 lambda(即使代码一样),是两个不同的匿名对象,-= 根本找不到、退不掉。所以,如果用 lambda 订阅、又想能退订,就必须先把那个 lambda 存到一个变量里,订阅和退订都用这同一个变量。一句话:订阅和退订要成对出现、用同一个方法引用;最好用 IDisposable 把退订和销毁绑定;用 lambda 订阅要小心退不掉。
下面这张图,对比了"订阅没退订泄漏"和"订阅退订成对"两条路径:
这张图的对比很清楚:左边红色那条,订阅了却没退订,长寿的发布者仍引用着订阅者、GC 回收不了它,反复打开就累积泄漏;右边绿色那条,销毁时用 -= 退订、解除了发布者对订阅者的引用,订阅者不再被引用、GC 正常回收、不泄漏。两条路的根本分野,在于你有没有在订阅者销毁时,解除那个"拴住它"的事件引用。
第三件事:内存泄漏的其它"隐藏引用"
填平了"事件没退订"这个坑,我警觉起来,排查了 C#(乃至有 GC 的语言里)其它几种容易造成内存泄漏的"隐藏引用":
// 内存泄漏的其它"隐藏引用"(都是"被某个长寿对象拖住、回收不了"):
// 隐藏引用1: 静态字段/集合, 一直持有对象, 永不释放
public static List AllPages = new List();
// 往这个静态 List 里加了 Page, 却忘了移除 → Page 永远被静态 List 引用 → 泄漏
// 隐藏引用2: 闭包(lambda)意外捕获了大对象
void Setup() {
var bigData = LoadHugeData(); // 一个大对象
timer.Elapsed += (s, e) => DoSomething(); // lambda 捕获了它所在的作用域
// → 如果 lambda 被长期持有, 它捕获的 bigData 也跟着不被释放
}
// 隐藏引用3: 缓存只进不出, 无限增长
static Dictionary cache = new(); // 缓存
// 只往里加、从不清理/过期 → 内存无限增长 → 泄漏 (要用带过期/容量上限的缓存)
// 隐藏引用4: 没释放非托管资源(虽然 GC 管托管内存, 但管不了非托管!)
// 文件句柄、数据库连接、网络连接、GDI 对象 ...
// → 要用 using / Dispose 显式释放(GC 不会及时帮你释放它们)
// 隐藏引用5: 长生命周期对象 引用 短生命周期对象 (事件是其中一种)
// 通用规律: 凡是"长寿对象引用了短寿对象, 且没解除引用"→ 短寿对象泄漏
// 核心: 在有 GC 的语言里, 泄漏 = "对象本该被回收, 却被某个引用拖住了"。
// 排查泄漏, 就是去找: "这个本该死的对象, 到底被谁(什么引用链)拖住了?"
这一排查,让我对"有 GC 的语言里的内存泄漏",有了全局的认识。很多人以为"有了 GC,就不会内存泄漏了"——这是一个误解。GC 能自动回收"没人引用"的对象,但它无法回收那些"还被某个引用拖住"的对象;而内存泄漏,恰恰就发生在"一个本该被回收的对象,被一个你没注意到的引用拖住了"的时候。这种"隐藏的引用",形式多样:隐藏引用1(静态集合):往一个静态的 List/Dictionary 里加了对象、却忘了移除,对象就被这个长寿的静态字段永远引用着。隐藏引用2(闭包捕获):一个被长期持有的 lambda,会让它捕获的变量(可能是个大对象)也跟着无法释放。隐藏引用3(缓存只进不出):一个只加不清的缓存,会无限增长。隐藏引用4(非托管资源):文件句柄、数据库连接这些"非托管资源",GC 是管不了的,必须用 using/Dispose 显式释放。隐藏引用5(长引用短):这是最通用的规律——凡是"长生命周期的对象,引用了短生命周期的对象,且没解除引用",短寿对象就会泄漏(事件订阅,正是其中一种)。这些坑共同指向一个核心认识:在有 GC 的语言里,内存泄漏的本质,是"对象本该被回收、却被某个引用拖住了";而排查内存泄漏,本质上,就是去回答一个问题——"这个本该死掉的对象,到底是被谁(被哪条引用链)给拖住了?"理解了这一点,你就能从'内存泄漏的现象'(内存只升不降),顺着引用链,去定位'拖住对象的那个隐藏引用',并解除它。
第四件事:怎么"发现"和"排查"内存泄漏?
这次泄漏潜伏了很久才被发现,让我意识到:除了"预防",还得有"发现和排查"它的手段。我把"如何排查内存泄漏"也系统地学了一遍:
排查内存泄漏的手段:
# 第1步: "发现"泄漏 —— 看内存曲线
# - 监控进程内存占用, 画成曲线。
# - 正常: 内存在一个范围内"锯齿状"波动(用了又被 GC 回收)。
# - 泄漏: 内存"阶梯状/持续地, 只升不降"地往上爬 → 警报!
# - 关键现象: 做一个"会回到初始状态"的操作(如反复开关页面),
# 内存却回不到操作前的水平 → 几乎可以断定有泄漏。
# 第2步: "定位"泄漏 —— 用内存分析工具抓"堆快照"
# - 工具: Visual Studio 诊断工具 / dotMemory / WinDbg ...
# - 抓两个时间点的"堆快照(heap snapshot)", 对比:
# 哪些对象的数量, 在"本该被回收"后, 反而增多了? → 那就是泄漏的对象。
# - 我这次: 对比发现 Page 对象的数量, 随着开关只增不减 → 锁定 Page 泄漏。
# 第3步: "找根因" —— 看泄漏对象的"引用链(GC Root 路径)"
# - 选中那个泄漏的对象, 看工具给出的"它被谁引用着"(到 GC Root 的路径)。
# - 顺着这条引用链, 就能看到: "哦, 它被 DataCenter 的事件引用着!"
# - → 找到了那个"拖住它、不让它被回收"的隐藏引用。
# 第4步: "解除引用" —— 修复
# - 找到隐藏引用后, 解除它(退订事件、从静态集合移除、释放资源...)。
# 核心: 排查内存泄漏的三步 ——
# 发现(看曲线只升不降) → 定位(堆快照对比, 找出泄漏的对象) →
# 找根因(看引用链, 找出拖住它的引用) → 解除引用。
这一学习,让我对排查内存泄漏,有了一套清晰的方法。排查内存泄漏,大致分四步:第1步,"发现"泄漏——看内存曲线:监控进程内存、画成曲线,正常时它应该是"锯齿状波动"(用了又被回收),而泄漏时它会"只升不降地往上爬";一个尤其有用的判断方法是,做一个"本该回到初始状态"的操作(比如反复开关页面),如果操作后内存回不到操作前的水平,就几乎可以断定有泄漏。第2步,"定位"泄漏——用内存分析工具(如 Visual Studio 诊断工具、dotMemory)抓"堆快照":对比两个时间点的堆快照,看哪些对象的数量,在"本该被回收"后反而增多了——那就是泄漏的对象(我这次,正是这样锁定了 Page 对象只增不减)。第3步,"找根因"——看泄漏对象的"引用链":选中那个泄漏的对象,看工具给出的"它被谁引用着"(到 GC Root 的路径),顺着这条引用链,就能看到"它被 DataCenter 的事件引用着"——找到那个"拖住它、不让它被回收"的隐藏引用。第4步,"解除引用"——修复:找到隐藏引用后,解除它(退订事件、从集合移除、释放资源)。这套"发现(看曲线)→ 定位(堆快照对比)→ 找根因(看引用链)→ 解除引用"的方法,是排查内存泄漏的标准流程。它的核心,是把'内存只升不降'这个抽象的现象,一步步地,具体化为'是哪个对象在泄漏''它被哪条引用链拖住了',从而精准地定位、修复。掌握了这套方法,内存泄漏这种'看不见摸不着'的疑难杂症,就变得有迹可循、可以被系统地攻克了。把这四步整理成一张表:
| 步骤 | 做什么 | 用什么 |
|---|---|---|
| 发现 | 看内存只升不降 | 内存监控/曲线 |
| 定位 | 找出泄漏的对象 | 堆快照对比 |
| 找根因 | 看谁引用着它 | 引用链/GC Root 路径 |
| 解除引用 | 退订/移除/释放 | 修改代码 |
第五件事:GC 帮你"自动回收",但不帮你"管理引用"
这次踩坑,纠正了我对"垃圾回收(GC)"的一个根深蒂固的误解。我把这个认知的纠正,沉淀了下来:
关于 GC 的认知纠正: GC 自动"回收", 但不自动"管理引用"
# 我的误解(很多人都有):
# "有了 GC, 我就再也不用操心内存了, 内存泄漏是 C/C++ 那种手动管内存才有的事。"
# → 大错特错!
# GC 真正做的, 和不做的:
# GC 做的: 自动地, 把"没有任何引用指向它"的对象, 回收掉。(省去手动 free)
# GC 不做的: 它【不会】帮你"判断一个对象是不是'逻辑上'该死了"——
# 只要还有引用指向它, GC 就认为它"还活着、还有用", 绝不回收。
# → 所以, 如果你"逻辑上不要它了, 却还残留着一个引用指向它"(如没退订的事件),
# GC 就会"误以为它还有用", 永远不回收它 → 泄漏!
# 所以, 有 GC 的语言里, 内存管理的重心, 从"管理内存"变成了"管理引用":
# - 你不用再手动 free 内存了(GC 帮你做);
# - 但你仍然要管理好"引用"—— 确保"逻辑上不需要的对象, 没有残留的引用拖着它"。
# - 内存泄漏, 在 GC 语言里, 就是"残留的、本不该有的引用"造成的。
# 误区澄清:
# "GC 语言不会内存泄漏" → 错! 会泄漏, 只是泄漏的形式变了(从"忘了free"变成"残留引用")
# "GC 会立刻回收没用的对象" → 不一定, GC 有自己的时机; 且它只回收"没引用的"
# "弱引用(WeakReference)" → 一种"不阻止 GC 回收"的引用, 可用于缓存等避免泄漏的场景
核心: GC 自动化的, 是"回收"这个动作; 而"管理好引用关系"这个责任,
依然在你肩上。 内存泄漏, 在 GC 时代, 是"引用管理"的失败。
这层认知的纠正,是这次踩坑给我最深刻的收获。我曾有一个根深蒂固的误解(很多人都有):"有了 GC,我就再也不用操心内存了,内存泄漏是 C/C++ 那种手动管内存才有的事。"——这是大错特错的。要纠正它,关键是要分清 GC "做什么"和"不做什么":GC 做的,是自动地把"没有任何引用指向它"的对象回收掉(替你省去了手动 free 的麻烦);但 GC 不做的,是"判断一个对象是不是逻辑上该死了"——只要还有任何一个引用指向它,GC 就一律认为它"还活着、还有用",绝不回收。所以,如果你"逻辑上已经不需要某个对象了,却还残留着一个引用(比如那个没退订的事件)指向它",GC 就会"误以为它还有用",永远不回收它——这就是泄漏。这让我领悟到一个深刻的转变:在有 GC 的语言里,内存管理的重心,从"管理内存"(手动 free)变成了"管理引用"——你不用再手动释放内存了,但你仍然要管理好"引用",确保"逻辑上不再需要的对象,没有残留的引用拖着它"。内存泄漏,在 GC 语言里,本质上,就是一次"引用管理"的失败:你留下了一个本不该有的、残留的引用,把一个本该死掉的对象,给拖住了。所以,几个误区要澄清:"GC 语言不会内存泄漏"是错的(会,只是形式从"忘了 free"变成了"残留引用");"GC 会立刻回收没用的对象"也不准确(GC 有自己的时机,且只回收"没引用的")。归根结底:GC 自动化的,只是"回收"这个动作;而"管理好引用关系"这个责任,依然牢牢地,在你这个程序员的肩上。理解了'GC 管回收、你管引用'这个分工,你才能真正地,在 GC 时代,写出不泄漏的代码。把"对 GC 的误解"和"对 GC 的正解"对比成一张表:
| 维度 | 误解 | 正解 |
|---|---|---|
| GC 的作用 | 全自动管内存, 我不用操心 | 只自动回收"没引用"的对象 |
| 会不会泄漏 | 有 GC 就不泄漏 | 会, 残留引用就泄漏 |
| 我的责任 | 什么都不用管 | 管理好引用关系 |
| 泄漏的形式 | (以为没有) | 本不该有的残留引用 |
| 排查方向 | (不知道) | 找拖住对象的引用链 |
一张"订阅事件会不会泄漏"的决策图
把这次踩坑沉淀成一张图。每当你订阅一个事件时,照着它判断:
这张图的核心判断:订阅事件时,先问"发布者是不是比订阅者长寿"——是(单例/静态/全局),就危险、会泄漏订阅者,必须在订阅者销毁时退订(-=);最好用 IDisposable 把退订和销毁绑定,用 lambda 订阅要先存变量。把"订阅长寿发布者的事件,就一定要在销毁时退订"变成本能,那个"事件泄漏"的坑就再也碰不到你。
我立下的几条事件与内存规矩
这次"事件没退订导致内存泄漏"的事故后,我给自己立了几条规矩:
- 订阅退订成对:有
+=订阅,就必须有对应的-=退订,尤其是订阅长寿对象(单例/静态)的事件。 - 用 IDisposable 绑定退订:让订阅者实现
IDisposable,在Dispose里退订,并确保销毁时调用 Dispose。 - lambda 订阅要存变量:用 lambda 订阅事件,要先把它存到变量里,退订时用同一变量,否则退不掉。
- 判断谁更长寿:订阅前判断"发布者会不会比订阅者长寿",长寿发布者的事件是泄漏高危区。
- 警惕各种隐藏引用:静态集合、闭包捕获、只进不出的缓存,都可能拖住对象造成泄漏,要留意。
- 非托管资源显式释放:文件、连接等非托管资源用
using/Dispose显式释放,GC 管不了它们。 - 监控内存 + 会排查:监控内存曲线(只升不降就警报),会用堆快照和引用链排查泄漏。
这几条里,第一条"订阅退订成对"是直接根治这次 bug 的核心。而贯穿所有规矩的那条主线,是对"善始善终、有进有出"的责任意识。我这次栽跟头,根子上是我只做了"开始"(订阅事件),却没做"结束"(退订事件)——我建立了一个连接(订阅),却没有在它该断开时,负责任地把它断开。这其实和很多资源管理的坑(忘了关连接、忘了释放锁、忘了停 goroutine)是同源的:都是只管了'获取/建立',却忘了'释放/解除'。事件订阅,本质上也是一种'建立了一种关系/引用';而但凡你'建立'了一种关系,你就有责任,在它不再需要时,负责任地'解除'它——有 += 就有 -=,有 open 就有 close,有 lock 就有 unlock,有 subscribe 就有 unsubscribe。这种'有始有终、有建立就有解除'的对称性与责任感,是写出不泄漏、不留烂摊子的代码的根本。我那个泄漏,正是因为我打破了这份对称——只'建立'了订阅,却忘了'解除'它。
写在最后:你建立的每一种联系,都要负责到底
这次被"事件泄漏"教育的经历,给我一个超越内存管理本身的、颇有哲理的启示:我们在编程(乃至生活)里,会不断地'建立'各种各样的'联系'——订阅一个事件、打开一个连接、获取一把锁、注册一个回调、引用一个对象……'建立联系'往往是容易的、自然的;可每一个被建立的联系,都像一根'线',把两个东西'拴'在了一起;而如果你只顾着'建立',却忘了在它不再需要时'解除'这根线,那么,这些'残留的、本该解除却没解除的联系',就会日积月累,变成各种各样的问题——内存泄漏、资源耗尽、状态混乱……'建立一种联系'是一种行动,而'在它不再需要时解除它'则是一种责任;只享受'建立'的便利、却逃避'解除'的责任,迟早会被那些残留的联系所拖累。我那个内存泄漏,正是无数个"建立了订阅、却没解除"的残留联系,累积而成的——每一个被关闭的页面,都因为那根没解除的"订阅之线",而无法真正地离去。
想通这一点,我对"有始有终地管理好每一种联系"这件事,有了更深的体会。无论是代码里的资源(连接、锁、订阅、引用),还是更广义的各种'关系',它们都遵循一个朴素的规律:有'建立',就该有'解除';有'进',就该有'出';有'获取',就该有'释放'。这种'对称性',不是一种束缚,而是一种'负责任'的体现——它意味着,你为你所建立的每一种联系,都负责到了它的'终点',而不是建立了就甩手不管、任由它残留、累积、最终成为隐患。一个成熟的工程师(乃至一个成熟的人),其可靠之处,很大程度上,就体现在这份'善始善终'的对称感上:他建立的连接,会被妥善地关闭;他获取的资源,会被及时地释放;他注册的回调,会在合适的时机被注销——他不留烂摊子,因为他为他建立的每一种联系,都负责到了底。
所以,如果你也想写出不留隐患、干净可靠的代码(乃至成为一个有始有终、负责任的人),我想把这次踩坑最想说的话送给你:对你所建立的每一种'联系',都怀有一份'负责到底、在它不再需要时妥善解除'的责任心。订阅了事件,就想着在销毁时退订;打开了连接,就想着用完关闭;获取了锁,就想着用完释放;注册了回调,就想着适时注销;建立了任何一种引用、关系、连接,都问自己一句:"这根线,该在什么时候、由谁来解开?"因为'建立'的便利,常常诱使我们忽略'解除'的责任;可正是那些'建立了、却忘了解除'的残留联系,会一点点地,把你的系统(乃至你的生活)拖入泄漏、混乱与失控。'有建立,就有解除;有始,就要有终'——这份对称的、负责到底的意识,看似朴素,却是区分'干净可靠'与'隐患重重'的、一道根本的分水岭。那些被一根没解除的"订阅之线"拖住、无法离去的"幽灵页面",最终教给我的,正是这份"为你建立的每一种联系负责到底"的箴言——它让我懂得,在代码的世界里,真正的可靠,不只在于你能漂亮地"建立"起多少东西,更在于,你是否为你建立的每一样东西,都准备好了、并执行了,那个善始善终的"解除";因为唯有有进有出、有始有终,一个系统,才能长久地、干净地、不被自己制造的残留所拖累地,稳健运行下去。
—— 别看了 · 2026