文章目录
- 内存管理
-
- 托管堆
-
- [1. SOH (Small Object Heap) 小对象堆](#1. SOH (Small Object Heap) 小对象堆)
-
- [特性:分代回收(Generational GC)](#特性:分代回收(Generational GC))
- [2. LOH(Large Object Heap)大对象堆](#2. LOH(Large Object Heap)大对象堆)
- [3. SOH vs LOH 对比表](#3. SOH vs LOH 对比表)
- [4. 为什么要区分它们?](#4. 为什么要区分它们?)
- [5. 为什么要把它们都放在"托管堆"里?](#5. 为什么要把它们都放在“托管堆”里?)
- [6. 什么是不属于托管堆的?](#6. 什么是不属于托管堆的?)
- [7. 总结](#7. 总结)
- 非托管堆
-
- [1. 定义](#1. 定义)
- [2. 为什么需要非托管堆?](#2. 为什么需要非托管堆?)
- [3. 托管堆 vs 非托管堆](#3. 托管堆 vs 非托管堆)
- [4. 内存布局全景图](#4. 内存布局全景图)
- 5. 在 C#中如何操作?
- [6. Glossary](#6. Glossary)
- 什么是垃圾回收(GC)?
-
- 1.核心定义
- 2.核心原理
- 3.关键特性
- [4.GC 的分代回收机制是怎样的?](#4.GC 的分代回收机制是怎样的?)
- [5. GC模式的选择](#5. GC模式的选择)
-
- [Workstation GC (工作站模式)](#Workstation GC (工作站模式))
- [Server GC (服务器模式)](#Server GC (服务器模式))
- [Concurrent GC(并发GC)](#Concurrent GC(并发GC))
- 配置方式:如何切换?
-
- [A:修改项目文件 `.csproj` (推荐)](#A:修改项目文件
.csproj(推荐)) - [B:修改运行时配置文件 `runtimeconfig.json`](#B:修改运行时配置文件
runtimeconfig.json)
- [A:修改项目文件 `.csproj` (推荐)](#A:修改项目文件
- 什么是终结器(Finalizer)?
-
- 1.核心定义
- 2.核心特性
- 3.如何被"自动"调用的?
- 4.using/Finalizer两个自动的区别
- [5.终结器 (Finalizer) 的"副作用"](#5.终结器 (Finalizer) 的“副作用”)
- IDisposable的作用
-
- 1.核心定义
- [2.核心规范(Dispose 模式)](#2.核心规范(Dispose 模式))
- 3.常见的资源类型清单
- [using 语句如何实现资源释放?](#using 语句如何实现资源释放?)
- 什么是内存泄漏?如何避免?
-
- 1.核心定义
- 2.常见泄漏场景
-
- [A. 静态集合永久持有 (Static Collection)](#A. 静态集合永久持有 (Static Collection))
- [B. 事件订阅未取消 (Event Leak)](#B. 事件订阅未取消 (Event Leak))
- [C. 非托管资源未释放 (Unmanaged Leak)](#C. 非托管资源未释放 (Unmanaged Leak))
- [D. 闭包捕获 (Closure Capture)](#D. 闭包捕获 (Closure Capture))
- E.**长生命周期对象(单例)引用短生命周期对象**
- 3.内存泄漏诊断流程
- 4.如何避免?
-
- [策略1:严格遵守 `IDisposable` 模式](#策略1:严格遵守
IDisposable模式) - 策略2:弱引用与弱事件 (WeakReference)
- 策略3:事件解绑(取消订阅)
- 策略4:利用分析工具定位
- [策略1:严格遵守 `IDisposable` 模式](#策略1:严格遵守
- 强/弱引用
-
- [1. 强引用(Strong Reference)"死不撒手"](#1. 强引用(Strong Reference)"死不撒手")
- [2. 弱引用(Weak Reference)"若即若离"](#2. 弱引用(Weak Reference)"若即若离")
- [3. 强弱引用对比](#3. 强弱引用对比)
- [4. 为什么需要弱引用?](#4. 为什么需要弱引用?)
-
- [A. 大对象缓存 (Object Caching)](#A. 大对象缓存 (Object Caching))
- [B. 事件订阅导致的泄漏 (Event Handler Leak)](#B. 事件订阅导致的泄漏 (Event Handler Leak))
- [5. 示例](#5. 示例)
- [6. 短弱引用 vs 长弱引用](#6. 短弱引用 vs 长弱引用)
- [7. 总结](#7. 总结)
- [ArrayPool 的作用是什么?](#ArrayPool 的作用是什么?)
-
- [1. 核心矛盾:频繁分配大数组的代价](#1. 核心矛盾:频繁分配大数组的代价)
- [2. ArrayPool 的解决之道:池化 (Pooling)](#2. ArrayPool 的解决之道:池化 (Pooling))
- [3. 核心 API 演示](#3. 核心 API 演示)
- [4. ArrayPool 的关键特性](#4. ArrayPool 的关键特性)
- [5. 对比](#5. 对比)
- [什么是固定缓冲区(Fixed Buffer)?](#什么是固定缓冲区(Fixed Buffer)?)
- [stackalloc 关键字的作用](#stackalloc 关键字的作用)
- 总结
内存管理
本文是**.NET 内存管理与性能优化**。它从基础的自动化管理(GC)讲到手动资源释放IDisposable,最后触及了追求极致性能的内存技巧(栈分配与对象池)。
其核心逻辑拆解为三个维度:"谁来管" 、"怎么管" 以及 "怎么压榨性能"。
| 维度 | 核心对象 | 类比 | 解决的问题 |
|---|---|---|---|
| 自动管理 | GC (Garbage Collector) | 勤杂工:自动清理客人吃完的餐桌。 | 防止内存泄漏,让开发者专注于业务。 |
| 显式释放 | IDisposable / Finalizer | 贵重餐具:用完必须马上还回保险柜。 | GC 只管内存,不管文件句柄、数据库连接等外部资源。 |
| 性能压榨 | ArrayPool / stackalloc | 预制件 / 快餐纸盒:减少清洗压力。 | 降低 GC 的工作量,提升响应速度。 |
托管堆
要理解 .NET 的内存布局,我们可以把 托管堆(Managed Heap) 想象成一个大型仓库。为了管理效率,仓库被划分为两个主要区域:SOH(小对象堆) 和 LOH(大对象堆)。
它们的管理策略完全不同,就像快递公司处理"普通包裹"和"超大件特货"的区别。
1. SOH (Small Object Heap) 小对象堆
这是 .NET 内存管理的主战场,绝大多数对象(如字符串、小结构体、类实例)都住在这里。
特性:分代回收(Generational GC)
SOH 内部被划分为三代:Gen 0 、Gen 1 和 Gen 2。
- 第一性原理: "越年轻的对象,死得越快"。
- 运作流程: 新对象分配在 Gen 0。如果 Gen 0 满了,触发 GC。活下来的对象被"晋升"到 Gen 1,再活下来就去 Gen 2。
- 压缩机制(Compaction): 当 SOH 回收时,GC 会移动活着的物体,把它们挤在一起,填补空隙。这确保了内存总是连续的,分配新对象时只需移动一个指针(速度极快)。
2. LOH(Large Object Heap)大对象堆
专门存放"大家伙"的特区。
定义
- 入场门槛: 对象大小 > 85,000 字节(约 83 KB)。
- 对象类型: 主要是巨大的数组(如
double[]、byte[])或极大的字符串。 - 地位: LOH 在逻辑上被视为 Gen 2 的一部分。这意味着只有在发生 Full GC(完全回收) 时,LOH 才会得到清理。
为什么它很特殊?
- 不压缩(默认): 移动一个 100MB 的影像数组非常耗时。为了性能,GC 回收 LOH 时只是把空间标记为"空闲",而不移动对象。
- 内存碎片: 因为不压缩,LOH 容易像"蜂窝煤"一样布满空洞。如果新申请的大对象塞不进这些空洞,即使总内存够用,也会抛出 OOM(Out of Memory)。
- 分配代价: 在 LOH 分配对象前,CLR 必须扫描空闲列表寻找足够大的连续空间,这比 SOH 的指针移动要慢得多。
3. SOH vs LOH 对比表
| 特性 | SOH (Small Object Heap) | LOH (Large Object Heap) |
|---|---|---|
| 对象大小 | < 85,000 字节 | > 85,000 字节 |
| 内存代龄 | 分为 Gen 0, Gen 1, Gen 2 | 属于 Gen 2 |
| 回收频率 | 极高(尤其是 Gen 0) | 极低(仅 Full GC 时) |
| 是否压缩 | 是(整理碎片) | 否(默认不移动对象) |
| 分配性能 | 极快(移动指针即可) | 较慢(需要搜索空闲空间) |
| 典型案例 | 临时变量、ViewModel、小对象 | 图像像素 Buffer、大数据集、长字符串 |
4. 为什么要区分它们?
理解这两个区域的差异是为了解决两类核心问题:
- 防止"早逝"的对象进入 LOH: 如果你频繁创建临时的、巨大的
byte[](比如每秒处理一帧影像),它们会直接进入 LOH 并在那里堆积。由于 LOH 很难被触发回收,这会导致你的程序内存占用迅速飙升,最终诱发昂贵的 Full GC。 - 解决内存碎片化:
- 如果你在 LOH 中不断分配不同大小的对象(10MB, 2MB, 5MB...),回收后留下的空隙可能无法满足后续大对象的连续空间需求。
5. 为什么要把它们都放在"托管堆"里?
核心原因在于 GC 的统一调度。
虽然 SOH 和 LOH 的回收算法不同,但它们共享同一个**根搜索(Root Tracing)**过程。当 GC 开始工作时:
- 它会从"根对象"(栈变量、静态变量等)出发,遍历整个托管堆。
- 无论一个对象是在 SOH 还是 LOH,只要它还在被引用,GC 就会标记它为"存活"。
- 只有这样,CLR 才能确保类型安全(Type Safety)和内存安全,防止你在操作 LOH 里的影像 Buffer 时,SOH 里的某个引用对象被意外回收。
6. 什么是不属于托管堆的?
- 栈内存 (Stack): 存放值类型(
int,struct)、方法参数和局部变量。它随方法调用自动进出,不归 GC 管。 - 非托管堆 (Unmanaged Heap / Native Heap): * 通过
Marshal.AllocHGlobal或直接调用 C++ 的malloc分配的内存。- 体感: GC 完全看不见这块区域。如果你在这里申请了内存却没手动
Free,就会发生真正的内存泄漏。
- 体感: GC 完全看不见这块区域。如果你在这里申请了内存却没手动
- 代码堆 (Code Heap): 存放由 JIT 编译器生成的机器代码。

7. 总结
- SOH 是动态的、自动整理的"快餐区"。
- LOH 是静态的、容易产生碎片的"重型仓库"。
ArrayPool 的本质意义就是:在 LOH 里占几个坑位,一直复用它们,不让 GC 频繁跑进这个重型仓库里去打扫卫生。
非托管堆
非托管堆(Unmanaged Heap / Native Heap) 是不受 .NET 运行时(CLR)管辖的内存区域。如果说托管堆是"带管家的公寓",那非托管堆就是"自助式营地"------所有的搭建和拆除必须由你亲力亲手完成。
1. 定义
非托管堆是操作系统分配给进程的标准内存区域。在 .NET 程序中,它主要用于存放那些不需要或不能被 GC 追踪的数据。
- 分配方式: 不使用
new关键字。而是通过 Windows API(如HeapAlloc)或 C 运行库函数(如malloc)分配。在 C# 中,通常使用Marshal.AllocHGlobal或NativeMemory.Alloc。 - 回收机制: 零自动回收 。如果你不手动调用
Free或Delete,这块内存直到进程结束前都会一直占着。这就是最典型的内存泄漏(Memory Leak)来源。
2. 为什么需要非托管堆?
既然 .NET 的托管堆那么方便,为什么还要碰非托管堆?
- 高性能/零 GC 压力: 托管堆的对象会被 GC 扫描和移动。如果你有几十 GB 的影像数据(如超高分辨率的 CT 序列),放在托管堆会产生巨大的 GC 停顿(STW)。非托管堆里的数据对 GC 是"隐形"的,完全不产生负担。
- 与 C/C++ 库交互(P/Invoke): 很多软件底层驱动(探测器、显卡加速库)是用 C++ 写的。它们只能识别非托管内存的指针(
IntPtr/void*),无法直接读取被 GC 挪来挪去的托管对象。 - 精确控制生命周期: 托管对象的销毁时机由 GC 决定(不确定性)。在某些实时性要求极高的场景(如射线出束控制),你需要内存"说释放就释放"。
3. 托管堆 vs 非托管堆
| 特性 | 托管堆 (Managed Heap) | 非托管堆 (Unmanaged Heap) |
|---|---|---|
| 管理者 | CLR (垃圾回收器) | 操作系统 / 开发者 |
| 分配关键字 | new | Marshal.AllocHGlobal, malloc |
| 释放方式 | GC 自动处理 | 必须手动释放 (Marshal.FreeHGlobal) |
| 地址稳定性 | 会移动(压缩碎片) | 固定不变 |
| 访问方式 | 引用 (Reference) | 指针 (Pointer / IntPtr) |
| 安全风险 | 极低(类型安全) | 高(容易导致野指针、越界、崩溃) |
4. 内存布局全景图

5. 在 C#中如何操作?
在处理开发中,你可能会遇到类似下面的代码:
csharp
using System.Runtime.InteropServices;
// 1. 在非托管堆申请 1MB 内存
IntPtr buffer = Marshal.AllocHGlobal(1024 * 1024);
try
{
// 2. 对这块内存进行操作(比如填充图像像素)
unsafe
{
byte* ptr = (byte*)buffer.ToPointer();
ptr[0] = 255;
}
}
finally
{
// 3. 必须手动释放!否则就是内存泄漏
Marshal.FreeHGlobal(buffer);
}
6. Glossary
- 句柄 (Handle): 操作系统分配的资源标识符(如文件句柄、窗口句柄),本质上是指向非托管资源的引用。
- P/Invoke (Platform Invocation Services): .NET 调用非托管 DLL(如 Win32 API 或 C++ 动态库)的技术。
- 野指针 (Wild Pointer): 指向已经释放或无效地址的指针,是非托管开发中最常见的崩溃原因。
- Blittable Types (平铺类型): 在托管和非托管内存中具有相同表示的数据类型(如
int,byte,double),可以直接跨界传递而不需要转换。
什么是垃圾回收(GC)?
1.核心定义
GC(Garbage Collection)是**.NET自动内存管理机制**,负责回收托管堆中不再被引用的对象内存,避免手动管理内存的泄漏 / 野指针问题。
2.核心原理
- 可达性分析:通过 "根对象"(静态变量、栈引用、寄存器引用)遍历引用链,标记 "可达对象"(正在使用),未标记的为 "不可达对象"(可回收);
- 回收流程:
- 标记:标记所有可达对象;
- 清理:释放不可达对象内存;
- 压缩:移动可达对象,整理堆内存(减少碎片,仅 Gen0/Gen2 回收时执行);
- 触发时机 :堆内存不足时自动触发、手动调用
GC.Collect()(不推荐)、程序退出时。
3.关键特性
- 仅管理托管堆(引用类型),非托管资源(文件句柄、数据库连接)需手动释放;
- 后台线程执行,回收时短暂暂停托管线程(.NET Core 后并发 GC 减少暂停时间)。
4.GC 的分代回收机制是怎样的?
.NET GC 基于 "大部分对象存活时间短" 的规律,将托管堆分为 3 代,实现高效回收:
| 代 | 说明 | 回收策略 |
|---|---|---|
| 第 0 代(Gen0) | 新建的短生命周期对象(如临时变量),容量最小 | 最频繁回收,速度最快,Gen0 堆满时触发 |
| 第 1 代(Gen1) | Gen0 回收后存活的对象,过渡代 | 较少回收,过滤中等生命周期对象,减少 Gen2 压力 |
| 第 2 代(Gen2) | Gen1 回收后存活的对象(长生命周期,如静态对象) | 很少回收,开销最大(遍历对象多) |
- Gen0 堆满 → 触发 Gen0 回收 → 存活对象晋升到 Gen1;
- Gen1 堆满 → 触发 Gen1+Gen0 回收 → 存活对象晋升到 Gen2;
- Gen2 堆满 → 触发全量回收(Gen2+Gen1+Gen0) → 存活对象留在 Gen2。
5. GC模式的选择
Workstation GC (工作站模式)
- 设计目标: 响应性优先(Low Latency)。
- 运行机制: 全程序只有一个 GC 线程。它运行在触发 GC 的那个用户线程上,优先级与普通线程相同。
- 内存布局: 只有一个托管堆。
- 体感: 像一个"兼职保洁",平时不碍事,打扫时动静小,适合 UI 交互频繁的任务(如 WPF/Avalonia 界面)。
Server GC (服务器模式)
- 设计目标: 吞吐量优先(High Throughput)。
- 运行机制: 系统有多少个 CPU 核心,就创建多少个 专用的 GC 线程(高优先级)和多少个独立的托管堆。
- 内存布局: 堆被切分成多块,每个核心管一块,并行回收。
- 体感: 像一个"专业拆迁队",人多力量大,清理速度极快,但启动时会瞬间霸占所有 CPU 资源,导致短暂的系统"全静止"(STW)。
Concurrent GC(并发GC)
除了上述两种模式,还有一个开关叫 Concurrent GC。
- 如果开启: GC 在标记对象时,用户线程可以继续运行(不完全 STW)。
- 在 Linux/Avalonia 客户端,建议用 Workstation + Concurrent。
- 在 算法服务器 上处理大批量 CT 序列,建议用 Server + Concurrent。
配置方式:如何切换?
你可以在三个地方进行配置,优先级从高到低排列:
A:修改项目文件 .csproj (推荐)
这是最常用、最直观的方法。
- ServerGarbageCollection = true:开启 Server GC(服务器模式)
- ServerGarbageCollection = false:开启 Workstation GC(工作站模式)
xml
<PropertyGroup>
<ServerGarbageCollection>true</ServerGarbageCollection>
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
</PropertyGroup>
B:修改运行时配置文件 runtimeconfig.json
如果程序已经编译好了,你可以直接修改输出目录下的这个 JSON 文件。
json
{
"runtimeOptions": {
"configProperties": {
"System.GC.Server": true,
"System.GC.Concurrent": true
}
}
}
| 维度 | Workstation GC | Server GC |
|---|---|---|
| 堆的数量 | 1 个 | 等于 CPU 核心数 |
| GC 线程数 | 1 个 | 等于 CPU 核心数 |
| CPU 占用 | 较低且平稳 | 瞬间极高 (Spike) |
| 内存限制 | 堆较小,更省内存 | 堆较大,为了并行效率会预占更多内存 |
| 适合场景 | 客户端、控制台 | 处理后台服务、服务端 |
什么是终结器(Finalizer)?
1.核心定义
终结器(Finalizer,析构函数)是类中的~类名()方法,用于 GC 回收对象前释放非托管资源(如文件句柄、COM 对象)。
在处理非托管资源(如 IntPtr 指向的硬件句柄)时,存在两个场景:
- 理想情况: 开发者很自律,用完后主动调用了
Dispose()(好比离开房间随手关灯)。 - 糟糕情况: 开发者忘了调用,或者代码异常崩溃了(好比人走了灯还亮着)。
终结器 (Finalizer) 就是那个"自动感应开关",它在对象被销毁前最后巡视一遍,发现灯还亮着就强制关掉。
2.核心特性
- 语法 :
~MyClass() { ... },编译器转换为Finalize()方法,继承自Object.Finalize(); - 执行时机:由 GC 的终结器线程调用,时机不确定,无法手动调用;
- 生命周期延长:终结器执行后,对象需下一次 GC 才会被真正回收。
结合IDisposable
csharp
public class FileHandler : IDisposable
{
private IntPtr _fileHandle; // 非托管句柄
// 终结器:仅释放非托管资源
~FileHandler()
{
Dispose(false);
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this); // 无需再执行终结器 ,告诉 GC:我已经手动释放了,别把这哥们儿扔进终结队列了!
}
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
// 释放托管资源(其他IDisposable对象)
}
// 释放非托管资源
if (_fileHandle != IntPtr.Zero)
{
CloseHandle(_fileHandle);
_fileHandle = IntPtr.Zero;
}
}
[System.Runtime.InteropServices.DllImport("kernel32.dll")]
private static extern bool CloseHandle(IntPtr hObject);
}
// 正确姿势 1:使用 using (推荐)
using (var handler = new FileHandler())
{
// 离开作用域时,自动调用 Dispose() -> Dispose(true)
// 非托管句柄被立即关闭,性能最高
}
// 正确姿势 2:手动销毁
var handler2 = new FileHandler();
handler2.Dispose(); // 同上
// 错误姿势(虽然能跑,但不好):
var handler3 = new FileHandler();
handler3 = null;
// 此时句柄没关。直到下一次 Full GC 触发时,
// 终结器线程才会慢吞吞地调用 ~FileHandler() -> Dispose(false)
// 句柄才会被关掉。
| 参数 disposing | 触发源 | 任务:释放托管资源? | 任务:释放非托管资源? |
|---|---|---|---|
| true | 开发者手动调用 Dispose() | 是 (因为还在主线程,安全) | 是 |
| false | GC 终结器线程自动调用 | 否 (其他对象可能已先被回收,不安全) | 是 |

3.如何被"自动"调用的?
这个过程不需要你写任何代码,是由 .NET 运行时(CLR)在幕后操控的:
- 标记: 当 GC 扫描托管堆时,发现
FileHandler对象已经没有强引用了(可以回收了)。 - 分拣: GC 检查该类是否有终结器(即是否有
~FileHandler())。如果有,GC 不会立刻释放它,而是把它塞进一个特殊的队列------终结队列(Finalization Queue)。 - 异步执行: .NET 有一个专门的、低优先级的终结器线程(Finalizer Thread) 。它会像清洁工一样,扫描这个队列,一个个执行里面的
~FileHandler()方法。 - 彻底消失: 只有等终结器运行完了,这个对象才会在下一次 GC 中被彻底从内存抹除。
4.using/Finalizer两个自动的区别
| 特性 | using / Dispose() 的自动 | ~Finalizer() 的自动 |
|---|---|---|
| 触发者 | 编译器 (在代码结束处插桩) | GC 垃圾回收器 (在内存回收时) |
| 时机 | 确定性 (离开大括号那一刻) | 不确定性 (可能几秒后,可能几分钟后) |
| 线程 | 当前工作线程 (主线程或你的任务线程) | 特殊的后台终结器线程 |
| 开销 | 极低 | 较高 (会让对象在内存里多活一代) |
| 定位 | 第一选择 (主动关门) | 保底方案 (救火队员) |
5.终结器 (Finalizer) 的"副作用"
它会拖累 GC
- 核心逻辑: 带有终结器的对象在第一次被 GC 发现时不会被回收,而是会被放入一个"终结队列 (Finalization Queue)",由专门线程处理后再在下一次 GC 时回收。
- 后果: 对象的生命周期强行被延长了一代(例如从 Gen 0 变成了 Gen 1)。

IDisposable的作用
1.核心定义
IDisposable接口规范手动释放资源的行为,弥补 GC 仅回收托管堆内存的不足,支持释放:
- 托管资源:其他实现
IDisposable的对象; - 非托管资源:文件句柄、数据库连接、COM 对象等。
2.核心规范(Dispose 模式)
- 实现
IDisposable接口的Dispose()方法; - 提供
Dispose(bool disposing)虚方法:disposing = true:手动调用Dispose(),释放托管 + 非托管资源;disposing = false:终结器调用,仅释放非托管资源;
- 在
Dispose()中调用GC.SuppressFinalize(this),避免 GC 执行终结器。
csharp
// 多线程环境下(比如你的影像系统同时处理多路信号),Dispose 可能会被触发多次,我们需要增加"幂等性"保护
public class AdvancedHandler : IDisposable
{
private bool _disposed = false; // 状态锁:防止重复释放
public void DoWork()
{
// 每次操作前先检查是否已销毁
if (_disposed) throw new ObjectDisposedException(nameof(AdvancedHandler));
// ... 业务代码
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return; // 如果已经释放过,直接返回
if (disposing)
{
// 1. 释放托管资源
}
// 2. 释放非托管资源 (无论如何都要执行)
_disposed = true; // 标记已销毁
}
}
3.常见的资源类型清单
哪些东西需要 IDisposable
| 资源类别 | 具体示例 | 必须 Dispose 吗? |
|---|---|---|
| 文件/流 | FileStream, MemoryStream, StreamWriter | 是(锁定文件句柄) |
| 网络连接 | HttpClient (特殊), TcpClient, Socket | 是(占用端口) |
| 数据库 | SqlConnection, DbContext | 是(连接池溢出) |
| 图形/UI | Bitmap, Brush, Pen (GDI+ 时代) | 是(图形上下文有限) |
| 互斥锁 | ReaderWriterLockSlim, SemaphoreSlim | 是(防止死锁) |
using 语句如何实现资源释放?
核心原理
using是try-finally的语法糖,确保实现IDisposable的对象即使发生异常,也能调用Dispose()释放资源。
语法转换
csharp
using (FileHandler handler = new FileHandler())
{
handler.DoSomething();
} // <--- 作用域在这里结束
// 编译器替你生成的代码(繁琐但安全):
FileHandler handler = new FileHandler();
try
{
handler.DoSomething();
}
finally
{
// 无论 try 块里是否发生异常(Exception),
// 只要 handler 不为 null,就强制执行 Dispose()
if (handler != null)
{
((IDisposable)handler).Dispose();
}
}
扩展用法
csharp
// using声明:作用域结束时自动释放
using var fs = new FileStream("test.txt", FileMode.Open);
fs.WriteByte(0x01);
// 方法结束时,fs自动调用Dispose()
什么是内存泄漏?如何避免?
1.核心定义
.NET 内存泄漏是指不再使用的对象仍被根对象引用,导致 GC 无法回收,内存占用持续升高。
2.常见泄漏场景
在软件开发中,以下四种情况是内存泄漏的"四大天王":
A. 静态集合永久持有 (Static Collection)
现象: 你为了方便,定义了一个 static List<ImageBuffer>。 后果: 静态变量的生命周期与进程一样长。除非你手动 Remove,否则加进去的对象永远不会被回收。
B. 事件订阅未取消 (Event Leak)
现象: 长寿命的对象(如全局 HardwareService)订阅了短寿命对象(如弹出的 SettingsView)的事件。 后果: 发布者会持有一个指向订阅者的强引用。即便你关掉了窗口,只要不执行 -= 取消订阅,这个窗口对象就永远留在内存里。
C. 非托管资源未释放 (Unmanaged Leak)
现象: 使用了 IntPtr、Socket、FileStream 或调用了 C++ 写的底层库,但没有实现 IDisposable 或没有调用 Dispose()。 后果: GC 只管托管内存。那些底层的句柄(Handle)和原生内存块会一直堆积,直到耗尽系统资源。
D. 闭包捕获 (Closure Capture)
现象: 在异步方法或 Lambda 表达式中引用了外部的大对象。 后果: 匿名类会生成一个隐藏字段持有该对象,导致对象的生命周期被强行延长到异步任务结束(甚至更久)。
E.长生命周期对象(单例)引用短生命周期对象
这是一个非常经典且隐蔽的内存泄漏(Memory Leak)陷阱。在 .NET 开发中,这被称为"引用悬挂"或"长引短"。
从第一性原理 出发,GC 的回收逻辑是:只要一个对象可以从"根(Root)"被追踪到,它就必须活着。 单例(Singleton)作为静态持有的长生命周期对象,本身就是一个"根"或者紧贴着"根"。
问题的本质:生命周期的"强行同化"
当你把一个短生命周期的对象(比如一个只显示 5 秒的 ImageViewer)赋值给一个单例(比如整个程序运行期间都存在的 GlobalCache)的一个字段时,这个短命对象的寿命就被强行延长到了和单例一样长。
3.内存泄漏诊断流程

4.如何避免?
策略1:严格遵守 IDisposable 模式
- 规则: 凡是实现了
IDisposable的类,必须使用using或手动调用Dispose()。 - 体感: 看到
FileStream、Bitmap、HttpClient(部分场景)时,条件反射写下using。
策略2:弱引用与弱事件 (WeakReference)
- 场景: 如果你必须在一个长寿命对象里引用短寿命对象(如缓存)。
- 方案: 使用
WeakReference<T>。这样当 GC 发现内存不足时,可以随时把这些对象收走,而不会被你的引用"拽住"。
策略3:事件解绑(取消订阅)
- 习惯: 在 WPF/Avalonia 的
Unloaded事件中,或者在类的Dispose方法里,确保把所有+=过的事件都=掉。
策略4:利用分析工具定位
- 工具: 使用 Visual Studio 的 Memory Usage 或 dotMemory。
- 方法: 执行"打开窗口 -> 关闭窗口"操作 10 次,观察内存波谷。如果波谷不断抬升,直接看 "存活对象 (Survived Objects)",重点找那些本该消失却还在的对象。
强/弱引用
1. 强引用(Strong Reference)"死不撒手"
这是我们最常用的引用方式。当你写 var myObj = new MyClass(); 时,你就创建了一个强引用。
只要强引用存在,垃圾回收器(GC)就绝对不会回收该对象。它被认为是"可达的(Reachable)"。
- 形象类比: 强引用就像你手里牵着气球的绳子。只要你牵着绳子,无论风(GC)怎么吹,气球都不会飞走。
- 副作用: 如果你忘了放手(比如把对象加进了静态列表
static List但没移除),气球就永远占用空间,导致内存泄漏(Memory Leak)。
2. 弱引用(Weak Reference)"若即若离"
弱引用允许你引用一个对象,但不阻止 GC 回收该对象。
弱引用不会在 GC 的可达性分析中增加计数。如果一个对象只剩下弱引用指向它,那么在下一次 GC 触发时,这个对象会被直接回收。
- 形象类比: 弱引用就像你看着天上的气球,但手里没有绳子。你可以看到它,甚至在它没飞走前去抓住它;但如果风(GC)一吹,它就飞走了,你拦不住。
- 状态转换: 使用弱引用时,必须先检查对象是否还在。如果还在,你需要将其临时转为"强引用"才能操作。
WeakReference允许 GC 回收被引用的对象,同时保留 "对象未被回收时获取强引用" 的能力,解决强引用导致的内存泄漏。
- 弱引用不影响 GC 回收:对象仅被弱引用持有时,会被标记为可回收;
- 可获取强引用:通过
WeakReference.Target/TryGetTarget()获取(需检查是否为 null); - 分短 / 长弱引用:默认短弱引用(对象终结后 Target 为 null)。
弱引用缓存示例
csharp
public class WeakCache
{
private Dictionary<string, WeakReference<MyObject>> _cache = new();
public void Add(string key, MyObject obj)
{
_cache[key] = new WeakReference<MyObject>(obj);
}
public MyObject? Get(string key)
{
if (_cache.TryGetValue(key, out var weakRef) && weakRef.TryGetTarget(out var obj))
{
return obj; // 对象未回收
}
return null; // 对象已回收
}
}
3. 强弱引用对比
| 特性 | 强引用 (Strong Reference) | 弱引用 (Weak Reference) |
|---|---|---|
| GC 回收行为 | 只要引用在,绝对不回收 | 即便引用在,该收就收 |
| 对象可访问性 | 始终可以直接访问 | 访问前需检查 IsAlive 或 TryGetTarget |
| 生存周期 | 由开发者代码逻辑控制 | 由 GC 根据内存压力控制 |
| 典型语法 | MyClass obj = new MyClass(); | WeakReference weak = new(...); |
| 适用场景 | 绝大多数业务逻辑、核心对象 | 缓存 (Cache)、大对象监听、避免循环引用 |
- 强引用 (Strong Reference): 你拉着气球的绳子。只要你拉着,气球(对象)就不会飞走(被回收)。
- 弱引用 (Weak Reference): 你看着气球。你可以随时去抓它,但如果一阵风(GC)吹来,你拦不住它飞走。
4. 为什么需要弱引用?
弱引用主要帮你解决以下两个棘手问题:
A. 大对象缓存 (Object Caching)
假设你在开发影像系统,需要缓存一些高分辨率的 CT 图像(大对象)。
- 用强引用: 缓存越多,内存占用越高,最后 OOM。
- 用弱引用: 当系统内存充足时,缓存一直有效;当内存吃紧触发 GC 时,这些不常用的缓存会被自动清理,腾出空间给核心业务。
B. 事件订阅导致的泄漏 (Event Handler Leak)
这是 WPF/Avalonia 开发中最常见的坑。
- 问题: 如果长生命周期的
Service订阅了短生命周期的View的事件,Service会持有一个指向View的强引用。即便你关闭了窗口,View也无法被回收。 - 方案: 使用 弱事件模式 (Weak Event Pattern) 。
Service通过弱引用关联View,当View关闭后,GC 可以照常回收它。
5. 示例
在 C# 中,我们使用 WeakReference<T> 类来操作。
csharp
// 1. 创建一个强引用
ImageBuffer heavyImage = new ImageBuffer("CT_Scan_001.raw");
// 2. 创建一个指向该对象的弱引用
WeakReference<ImageBuffer> weakRef = new WeakReference<ImageBuffer>(heavyImage);
// 3. 切断强引用(现在 heavyImage 对象仅被弱引用持有)
heavyImage = null;
// ... 某段耗时操作后,或者手动触发了 GC ...
// GC.Collect();
// 4. 尝试从弱引用中找回对象
if (weakRef.TryGetTarget(out ImageBuffer? retrievedImage))
{
// 对象还没被回收,可以继续使用
Console.WriteLine("对象还在,成功复用。");
}
else
{
// 对象已经被 GC 收走了
Console.WriteLine("对象已飞走,需要重新加载。");
}
6. 短弱引用 vs 长弱引用
在 .NET 内部,弱引用还细分为两种,虽然日常开发很少手动区分,但面试或深挖原理时很有用:
- 短弱引用 (Short Weak Reference):
- 时机: 一旦对象被标记为可回收,弱引用的
Target立即变为null。 - 特点: 这是
WeakReference<T>的默认行为。
- 时机: 一旦对象被标记为可回收,弱引用的
- 长弱引用 (Long Weak Reference):
- 时机: 对象被回收后,如果对象有"终结器(Finalizer)",它会等终结器执行完后才变为
null。 - 特点: 允许你在对象"临终前"最后看它一眼。
- 时机: 对象被回收后,如果对象有"终结器(Finalizer)",它会等终结器执行完后才变为
7. 总结
- 强引用是"合同关系",白纸黑字,只要合同(引用)在,内存就得留着。
- 弱引用是"缘分关系",看缘分(GC 压力),缘分尽了(回收了),你就找不到它了。
给你的体感建议:
如果你发现程序内存一直涨,且 dotMemory 显示很多本该销毁的 View 依然存活,看看是不是哪里用了强引用(尤其是事件订阅)拽住了它们。此时,弱引用或显式的 Unsubscribe 就是你的救命稻草。
ArrayPool 的作用是什么?
它的核心作用是:化"申请与销毁"为"借用与归还"
ArrayPool是.NET 提供的数组池,用于复用数组,减少频繁创建 / 销毁大数组导致的 GC 压力和内存碎片。
1. 核心矛盾:频繁分配大数组的代价
在处理影像像素(如 1024 * 1024 的 byte[])时,如果你每次处理一帧都 new byte[1048576],会引发两个灾难:
- 分配开销 (Allocation Overhead): 这么大的数组直接进入 LOH。CLR 必须在 LOH 的空闲列表里搜索足够大的连续空间,这比普通小对象分配慢得多。
- GC 压力 (GC Pressure): * 数组用完即废,变成垃圾。
- LOH 的垃圾只能通过 Full GC (Gen 2) 回收。
- 频繁的 Full GC 会导致界面卡顿(STW),涉及到图像时会感觉到明显的掉帧。
2. ArrayPool 的解决之道:池化 (Pooling)
ArrayPool<T> 维护了一个内存桶(Buckets),预先准备好了一堆不同尺寸的数组。
- 租借 (Rent): 你不再
new,而是从池子里"借"一个。 - 复用 (Reuse): 数组在逻辑上属于你,但在物理上它一直存在于内存中,不会被 GC 回收。
- 归还 (Return): 你用完后把它放回池子,供下一个任务使用。
3. 核心 API 演示
csharp
// 1. 获取共享池实例(最常用)
var pool = ArrayPool<byte>.Shared;
// 2. 租借一个数组。
// 注意:你申请 1MB,池子可能会给你 1.2MB(它会返回 >= 你要求长度的数组)
byte[] buffer = pool.Rent(1024 * 1024);
try
{
// 3. 使用这个 buffer 处理影像数据
ProcessImage(buffer);
}
finally
{
// 4. 务必归还!
// clearArray: true 会清空数据(防泄密),但有额外性能开销。影像处理通常传 false。
pool.Return(buffer, clearArray: false);
}
频繁创建大数组(如byte[])会导致 Gen0 频繁回收,且堆内存产生碎片;ArrayPool<T>复用数组,避免重复分配。
csharp
// 获取共享数组池
var pool = ArrayPool<byte>.Shared;
// 租赁至少1024长度的数组(可能返回更大的数组)
byte[] buffer = pool.Rent(1024);
try
{
// 使用数组(如读取文件、网络数据)
for (int i = 0; i < 1024; i++) buffer[i] = (byte)i;
}
finally
{
// 归还数组(clearArray=true清空数据,避免数据泄露)
pool.Return(buffer, clearArray: true);
}
适用场景
高频创建 / 销毁大数组的场景:网络通信、文件读写、序列化 / 反序列化。
- 高频操作: 比如每秒刷新 30 帧的实时预览。
- 大尺寸: 只要数组大小接近或超过 85,000 字节(LOH 门槛)。
- 生命周期短: 拿到数据 -> 处理 -> 丢弃。
反例(不要用):
如果你租了一个数组,打算把它存在单例里用一整天,那就直接 new 一个长寿命数组即可,没必要进出池子。
4. ArrayPool 的关键特性
| 特性 | 说明 |
|---|---|
| 线程安全 | ArrayPool.Shared 是线程安全的,可以在多线程环境中并发借还。 |
| 内存自适应 | 池子会自动根据负载增加或减少内部保留的数组数量,防止空占内存。 |
| 分桶机制 | 它内部按 2^n 的尺寸分桶,请求 100字节可能会给你 128字节。 |
| 生命周期 | 被池化的数组通常会晋升到 Gen 2 并一直留在那里,从而绕过了频繁的回收过程。 |
5. 对比

什么是固定缓冲区(Fixed Buffer)?
1.普通数组 vs 固定缓冲区
在 C# 中,普通的数组是引用类型,即便它定义在结构体里,它在内存中也是分离的:
- 普通数组结构体: 结构体在栈上,但数组的数据在托管堆上。结构体只持有一个指向堆的指针。
- 固定缓冲区结构体: 数组的数据直接"长"在结构体内部。如果结构体在栈上,数组就在栈上;如果结构体在堆上,数组就在堆内连续的空间里。
2.核心定义
固定缓冲区是结构体中用fixed关键字声明的固定大小数组,用于不安全代码中创建值类型的内联数组,避免堆分配。
固定缓冲区必须满足以下严苛条件:
- 必须在
unsafe上下文中使用(因为它涉及直接指针操作)。 - 只能定义在
struct中 ,不能在class中。 - 只能是原始值类型 (如
bool,byte,char,short,int,long,float,double)。 - 长度必须是常量。
csharp
public unsafe struct DicomHeader
{
// 定义一个固定缓冲区,占用 128 字节
// 这 128 字节直接嵌入在 DicomHeader 结构体的内存布局中
public fixed byte RawData[128];
public int ProtocolVersion;
}
3.核心特性
- 内联存储:直接存储在结构体中(栈上),而非堆上;
- 不安全代码 :必须在
unsafe上下文使用,仅支持值类型数组; - 固定大小:数组长度为常量,不可动态修改。
csharp
// 包含固定缓冲区的结构体
public unsafe struct MyBuffer
{
// 100个byte的固定缓冲区,内联存储
public fixed byte Data[100];
}
// 使用固定缓冲区
unsafe void UseFixedBuffer()
{
MyBuffer buffer = new();
fixed (byte* ptr = buffer.Data)
{
for (int i = 0; i < 100; i++) ptr[i] = (byte)i;
}
}
| 特性 | 普通数组 (byte[]) | 固定缓冲区 (fixed byte[N]) |
|---|---|---|
| 分配位置 | 始终在托管堆 (Heap) | 随所属结构体(栈或堆) |
| 内存布局 | 间接引用(指针) | 直接内联(连续内存) |
| 安全级别 | 安全 (Safe) | 不安全 (Unsafe) |
| GC 压力 | 有(产生托管对象) | 无(属于结构体一部分) |
| 长度 | 动态可变 | 编译时固定 |
适用场景
与非托管代码交互(如 C/C++)、高性能内存操作(如协议解析)。

stackalloc 关键字的作用
1.核心定义
stackalloc用于在栈上分配数组内存(而非托管堆),避免 GC 回收,提升性能,仅适用于不安全代码。
2.核心特性
- 栈内存分配:方法执行完毕后自动释放,无需 GC,速度快;
- 不安全代码 :必须在
unsafe上下文使用,仅支持值类型数组; - 大小限制:栈空间有限(默认 1MB 左右),不可分配过大数组(避免栈溢出)。
csharp
unsafe void UseStackAlloc()
{
// 在栈上分配100个int的数组,返回指针
int* numbers = stackalloc int[100];
// 操作数组
for (int i = 0; i < 100; i++) numbers[i] = i * 2;
Console.WriteLine(numbers[50]); // 输出100
// 无需释放,方法结束后栈内存自动回收
}
3.适用场景
短生命周期、小尺寸数组操作(如加密算法、数据解析),追求极致性能且避免 GC 开销。
总结

多线程与异步编程核心
- Thread 是系统级线程(开销高、手动管理),Task 是.NET 抽象的逻辑任务(轻量、支持 async/await);
- 同步原语选型:进程内互斥用 lock/Monitor,限流用 SemaphoreSlim,跨进程用 Mutex/Semaphore;
- 死锁避免核心:异步全程 await、锁顺序一致、非 UI 场景加 ConfigureAwait (false)。
内存管理核心
- GC 分代回收:Gen0 高频回收短生命周期对象,Gen2 低频回收长生命周期对象;
- 资源释放:非托管资源通过 IDisposable+using 释放,避免内存泄漏;
- 高性能内存操作:大数组复用用 ArrayPool,栈上小内存用 stackalloc,内联数组用固定缓冲区。