C# 的垃圾回收器在回收过程中通常会移动存活对象,以便合并空闲空间,让后续分配只需移动分配指针即可完成。然而,当托管对象需要被传递给非托管函数,或者被 unsafe 代码直接操作时,对象的地址必须保持稳定,否则原生代码里保存的指针就会变成悬空指针。GCHandle.Alloc 配合 GCHandleType.Pinned 正是为此设计:它创建一个固定句柄,阻止 GC 移动目标对象。固定操作虽然简单,但它会改变 GC 对堆的整理策略,并增加并发回收时的扫描与调度成本。本文会从固定句柄的底层行为讲起,逐步拆解它对垃圾回收效率和多线程分配性能的影响。

GCHandle 固定对象的底层机制
GCHandle 是 .NET 运行时提供的一种强引用包装。GCHandleType 有 Normal、Weak、WeakTrackResurrection 和 Pinned 四种模式。Normal 相当于普通强引用,Weak 不阻止回收,而 Pinned 不仅保持对象存活,还会把对象标记为不可移动。创建固定句柄后,可以通过 AddrOfPinnedObject 获得对象在托管堆上的稳定地址,也可以将该地址转换为指针后传给非托管代码。
固定对象的实现并不只是设置一个标志位。GC 在标记和压缩阶段会遍历所有固定句柄,把句柄引用的对象当作根。压缩阶段计算对象新地址时,凡是命中固定句柄的对象都不能移动。对于小型对象堆(SOH)来说,一个固定对象就像钉在内存段里的一颗钉子:它前面的空闲空间无法被后续对象填充,它后面的对象也不能向前挤压,只能绕过它去寻找新的连续空间。
固定对象的地址虽然稳定,但它在代际提升上仍然遵循普通规则。一个 Gen0 对象如果被固定时间较长,它可能被提升到 Gen1 甚至 Gen2。一旦高代区域里存在大量固定对象,GC 在决定是否进行完全压缩时会更加困难,碎片化也会随着程序运行时间逐步累积。理解这一点,是分析固定对象对性能影响的前提。
固定对象对垃圾回收性能的影响
垃圾回收的效率很大程度上依赖于“压缩”操作。压缩会把存活对象移动到一段连续内存的起点,释放出完整空闲块。但如果堆中存在固定对象,压缩就必须以这些对象为边界,进行分段移动。结果就是原本一次线性扫描可以完成的工作,被拆成多个小区间;每个小区间之间留下的空洞无法合并,下一次分配时可能因为找不到足够大的连续空间而提前触发 GC。
固定对象还会影响分配指针的推进。现代 .NET 使用 bump pointer 分配策略,正常对象分配只需要移动指针。当堆碎片化严重时,CLR 需要在空闲列表里寻找合适的块,分配路径从 O(1) 退化为 O(n),并且多线程分配时对空闲列表的竞争也会增加。如果固定对象正好位于 Gen0 分配区域内,这个代价会被放大,因为 Gen0 的回收最频繁。
另一个容易被忽略的影响是固定句柄的释放时机。调用 Free 之后,对象并不会立刻恢复可移动状态,而是要等到下一次 GC 时重新计算。如果在两次 GC 之间频繁分配和释放固定句柄,运行时需要维护额外的句柄表,并产生句柄扫描成本。因此,即使固定对象本身很小,不必要的常驻 GCHandle 也可能拖累整体吞吐。
对并发 GC 与多线程分配的具体影响
工作站并发 GC 通常允许后台标记和清扫与用户线程同时进行,但由于压缩必须停止所有线程,固定对象越多,压缩阶段越难在短时间内完成。服务器 GC 虽然为每个逻辑处理器准备了独立堆,但固定对象仍然会破坏各自堆内的连续性。一旦某个处理器对应的堆出现严重碎片,分配线程不得不等待 GC,甚至发生“阻塞式回收”,导致吞吐量急剧下降。
在多线程环境中,固定句柄本身并不是线程安全的资源。如果多个线程同时调用 GCHandle.Alloc 或 Free,必须使用锁或其他同步机制,否则会破坏句柄表的内部状态。即便使用锁,固定句柄表也可能成为热点。更常见的情况是,多个线程同时固定不同对象并触发 GC,GC 需要扫描更多的固定句柄,后台标记工作可能被延长,用户线程的暂停时间随之增加。
代码示例展示了最典型的固定用法。下面这段代码在调用非托管 API 前固定一个托管字节数组,并在 finally 中释放句柄,避免异常路径导致泄漏。
byte[] buffer = new byte[4096];
GCHandle handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
IntPtr ptr = handle.AddrOfPinnedObject();
// 调用 NativeMethods.ReadFromDevice(ptr, buffer.Length)
}
finally
{
handle.Free();
}
这个写法正确但不够高效。如果 NativeMethods.ReadFromDevice 是一个高频率调用,反复分配和释放固定句柄会带来可观的句柄扫描成本。更推荐的方式是用 fixed 语句在栈上临时固定数组,fixed 语句会在方法执行期间阻止对象移动,离开作用域后自动恢复,开销通常低于 GCHandle。
替代方案与优化实践
如果数据只需要在极短时间内传递给非托管代码,优先使用 C# 的 fixed 语句。fixed 语句通过 IL 层的 pinned 局部变量实现固定,不需要分配单独句柄,生命周期由编译器保证。下面是一个使用 fixed 的示例,它适用于同步且短小的 P/Invoke 调用。
byte[] buffer = new byte[4096];
unsafe
{
fixed (byte* ptr = buffer)
{
// 直接在栈上使用 ptr,避免 GCHandle 分配
}
}
如果非托管调用是异步的,或者缓冲区需要跨多次回调存活,固定托管数组可能不合适。此时更推荐使用非托管内存或池化缓冲区。非托管内存不受 GC 管理,自然不存在对象移动问题;池化缓冲区虽然最终要归还,但可以在固定窗口内与 fixed 语句配合,减少频繁的大数组分配。下面是非托管内存的使用方式。
IntPtr unmanagedBuffer = Marshal.AllocHGlobal(4096);
try
{
// 将 unmanagedBuffer 交给非托管代码
}
finally
{
Marshal.FreeHGlobal(unmanagedBuffer);
}
对于需要频繁借用大块缓冲区的场景,可以使用 ArrayPool<byte>.Shared.Rent 租用缓冲区,配合固定语句短时固定,用完后 Return。这样既减少了分配压力,也把固定窗口压缩到最短。
最后要强调的是,任何固定操作都应该有明确的释放边界。如果确实需要长期固定对象,请固定尽量小的对象,并定期评估是否可以用非托管内存替代。固定对象不是免费的午餐,它换取的是地址稳定性,付出的代价是 GC 压缩能力下降、碎片增加以及并发回收时更长的暂停。理解这些影响,有助于在 P/Invoke、unsafe 代码和实时数据处理场景中做出更合理的取舍。