简介
写 C# 时,new 一个对象很容易,但背后涉及 CLR 的对象分配、内存区域、GC 根、分代晋升和堆压缩。
托管堆是 CLR 管理托管对象的主要内存区域。它解决了手动 malloc/free 容易出错的问题,但并不意味着内存问题自动消失:
- 静态集合仍然引用对象,GC 就不能回收;
- 短时间大量分配,会造成频繁 GC;
- 大数组进入 LOH 后,反复分配可能造成碎片;
- 固定对象地址会限制 GC 压缩;
- 托管堆下降后,进程工作集不一定立刻下降。
一句话概括:
托管堆不是一块均匀的内存,而是 CLR 根据对象大小、生命周期和移动规则管理的一组区域。
本文重点讲清楚 SOH、LOH、POH、Gen0、Gen1、Gen2 的关系,并通过多个 Demo 观察对象代数、LOH、固定对象、池化和内存泄漏,最后结合 dotnet-counters、dotnet-gcdump、dotnet-dump 和 SOS 说明实际排查流程。
托管堆和线程栈
常见的入门说法是:
text
class 在堆上
struct 在栈上
这个说法过于简化。更准确的规则是:
引用类型实例通常位于托管堆;值类型会内联存储在它所属的位置。
例如:
csharp
public sealed class User
{
public int Age;
public string Name = string.Empty;
}
var user = new User();
可以粗略理解为:
text
线程栈上的 user
↓ 引用
托管堆上的 User 对象
├── Age:int,直接位于对象内部
└── Name:引用,指向另一个堆对象
如果值类型是某个堆对象的字段,值类型数据也属于这个堆对象的一部分。数组中的值类型元素同样存放在数组对象内部。因此,判断内存位置不能只看 class 或 struct 关键字。
托管堆的整体结构
现代 .NET 的托管堆可以先按用途理解为:
text
托管堆
├── SOH:Small Object Heap,小对象堆
│ ├── Gen0
│ ├── Gen1
│ └── Gen2
├── LOH:Large Object Heap,大对象堆
└── POH:Pinned Object Heap,固定对象堆
SOH
普通的小型引用类型对象和数组通常分配在 SOH。SOH 使用分代回收:
- Gen0:新对象最常见的起点;
- Gen1:年轻代回收后仍然存活的过渡区;
- Gen2:长期存活对象所在的老年代。
Gen0 和 Gen1 经常合称为短生命周期代。一次年轻代 GC 主要处理这两个区域,不必每次都完整扫描 Gen2。
LOH
达到运行时大对象阈值的对象会进入 LOH,例如:
csharp
var buffer = new byte[100_000];
var values = new double[20_000];
工程中经常用约 85,000 字节作为判断参考,但这是 CLR 实现细节,不应当当成业务契约。对象头、数组长度等额外信息也会影响实际分配位置。
LOH 中的对象按 Gen2 参与回收。LOH 默认不频繁压缩,因为移动大对象成本较高;代价是空洞和碎片可能逐渐增多。
POH
.NET 5 引入 POH,用于放置需要固定地址的对象。固定对象常见于:
- P/Invoke 和原生库互操作;
- GCHandle.Alloc(obj, GCHandleType.Pinned);
- 某些底层 IO 和网络缓冲区。
POH 不是更快的堆,而是把固定对象单独放置,减少固定对象对普通堆压缩的干扰。
对象是怎么分配的
小对象通常通过线程分配上下文快速分配:
text
allocation pointer → [连续可用空间]
↓ new 对象
allocation pointer 向后移动
只要空间足够,分配主要是边界检查、写入对象头、移动指针,不需要遍历空闲链表。分配上下文不足时,CLR 会申请新的区域;空间压力继续增大时,就可能触发 GC。
对象创建后通常还会完成:
- 写入类型相关的 MethodTable 指针;
- 初始化字段和数组长度;
- 将引用字段初始化为空;
- 对数组内存执行清零;
- 返回托管引用。
对象头布局属于运行时实现细节,不应在业务代码中依赖固定字节数。
GC 如何判断对象是否存活
GC 不根据变量名判断对象,也不会因为对象很久没访问就直接删除。判断依据是可达性。
text
GC Roots
├── 线程栈和寄存器中的托管引用
├── 静态字段
├── GCHandle
├── JIT 暂存引用
└── 运行时内部句柄
↓
可达对象
如果从任何 GC Root 都找不到某个对象,该对象才是可回收候选。
例如:
csharp
var item = new byte[1024];
item = null;
真正可能被回收的是原来的 byte\[\] 实例,而不是变量名 item。只要没有其他引用路径,数组就会变得不可达。
静态集合很容易把对象意外留住:
csharp
private static readonly List<byte[]> Cache = new();
Cache.Add(new byte[1024 * 1024]);
只要 Cache 还存在,数组就通过静态字段连接到 GC Root。手动执行 Full GC 也不能解除这条引用链。
分代回收为什么有效
分代 GC 建立在一个实用经验上:
绝大多数新对象活不久,少数对象会活很久。
| 代 | 典型对象 | 回收特点 |
|---|---|---|
| Gen0 | 请求中的临时对象 | 回收频繁,范围较小 |
| Gen1 | 熬过年轻代回收的对象 | 作为过渡区 |
| Gen2 | 缓存、单例、长期状态 | 回收较少,成本较高 |
| LOH | 大数组、大字符串 | 按 Gen2 参与回收 |
| POH | 固定对象 | 单独处理固定约束 |
对象存活后通常会向更高代晋升,但不是每个对象都严格走完 Gen0、Gen1、Gen2。大对象一般直接进入 LOH。
GC 一次回收大概做了什么
一次 GC 可以粗略分成:
- 协调或暂停应用线程;
- 从 GC Roots 标记可达对象;
- 找出不可达对象占用的空间;
- 对需要压缩的区域移动存活对象;
- 修正被移动对象的引用;
- 更新代际边界和分配指针;
- 恢复应用线程。
SOH 通常会压缩,LOH 默认不频繁压缩,POH 和被 pin 的对象需要遵守地址稳定约束。GC 本质上是在吞吐量、暂停时间、内存占用和碎片之间做平衡。
Demo 1:观察对象代数和 GC 指标
创建控制台项目:
shell
dotnet new console -n ManagedHeapDemo
cd ManagedHeapDemo
将 Program.cs 改为:
csharp
using System;
using System.Runtime;
var small = new byte[1024];
var large = new byte[100_000];
Console.WriteLine("GC 模式:" +
(GCSettings.IsServerGC ? "Server GC" : "Workstation GC"));
Console.WriteLine("小对象初始代:" + GC.GetGeneration(small));
Console.WriteLine("大对象初始代:" + GC.GetGeneration(large));
Console.WriteLine("托管堆估算:" + GC.GetTotalMemory(false) + " bytes");
GC.Collect(0, GCCollectionMode.Forced, blocking: true);
GC.WaitForPendingFinalizers();
Console.WriteLine("Gen0 GC 后小对象代:" + GC.GetGeneration(small));
Console.WriteLine("Gen0 回收次数:" + GC.CollectionCount(0));
Console.WriteLine("Gen1 回收次数:" + GC.CollectionCount(1));
Console.WriteLine("Gen2 回收次数:" + GC.CollectionCount(2));
var info = GC.GetGCMemoryInfo();
Console.WriteLine("GC 堆大小:" + info.HeapSizeBytes + " bytes");
Console.WriteLine("已提交内存:" + info.TotalCommittedBytes + " bytes");
Console.WriteLine("碎片估算:" + info.FragmentedBytes + " bytes");
GC.KeepAlive(small);
GC.KeepAlive(large);
运行:
shell
dotnet run -c Release
输出中的代数不是跨版本固定结果。通常可以观察到:
- 小数组优先进入 SOH 的年轻代;
- 大数组通常进入 LOH,并按 Gen2 参与回收;
- 存活对象在回收后可能晋升;
- GetTotalMemory 只是托管内存估算,不是进程 RSS。
GC.GetGCMemoryInfo 返回的是一次快照,适合记录趋势,不适合单独证明内存泄漏。
Demo 2:观察短命对象和长期对象
csharp
using System;
using System.Collections.Generic;
var survivors = new List<byte[]>();
for (var i = 0; i < 10; i++)
{
survivors.Add(new byte[8_000]);
for (var j = 0; j < 10_000; j++)
{
_ = new byte[256];
}
GC.Collect(0, GCCollectionMode.Forced, blocking: true);
Console.WriteLine(
"第 " + i + " 轮,Gen0=" + GC.CollectionCount(0) +
",Gen1=" + GC.CollectionCount(1) +
",Gen2=" + GC.CollectionCount(2));
}
Console.WriteLine("保留对象代数:" + GC.GetGeneration(survivors[0]));
GC.KeepAlive(survivors);
大量临时对象通常带来较多 Gen0 回收;持续存活的对象则可能晋升到更高代。这个 Demo 使用强制 GC 只是为了让现象更容易观察,生产代码不应在请求路径中频繁调用 GC.Collect。
Demo 3:LOH 一次性压缩
csharp
using System;
using System.Runtime;
var buffers = new byte[20][];
for (var i = 0; i < buffers.Length; i++)
{
buffers[i] = new byte[100_000 + i * 10_000];
}
for (var i = 0; i < buffers.Length; i += 2)
{
buffers[i] = Array.Empty<byte>();
}
Console.WriteLine("释放部分 LOH 对象后:" + GC.GetTotalMemory(false));
GCSettings.LargeObjectHeapCompactionMode =
GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect(
GC.MaxGeneration,
GCCollectionMode.Forced,
blocking: true,
compacting: true);
Console.WriteLine("一次 LOH 压缩后:" + GC.GetTotalMemory(false));
CompactOnce 只是请求下一次完整 GC 尝试压缩 LOH,不代表每个运行时都得到相同结果。LOH 压缩可能带来明显暂停,不应在每个请求中执行。
更常见的优化方向是减少大对象分配、统一缓冲区大小和使用 ArrayPool。
Demo 4:ArrayPool 减少大数组分配
csharp
using System;
using System.Buffers;
var pool = ArrayPool<byte>.Shared;
var buffer = pool.Rent(100_000);
try
{
buffer[0] = 1;
Console.WriteLine("租用长度:" + buffer.Length);
}
finally
{
pool.Return(buffer, clearArray: true);
}
Rent 返回的数组长度可能大于请求值,因为池按桶复用数组。归还必须放在 finally 中,归还后不能继续读写数组。
ArrayPool 不是自动释放机制:
- 归还后不能继续使用数组;
- 数组可能包含上一次使用的残留数据;
- 敏感数据可以在归还时清零;
- 长时间占用数组会降低池的复用效果;
- 必须明确谁租用、谁归还。
Demo 5:固定对象和 GCHandle
csharp
using System;
using System.Runtime.InteropServices;
var buffer = new byte[4096];
var handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
var address = handle.AddrOfPinnedObject();
Console.WriteLine("固定地址:" + address);
buffer[0] = 42;
}
finally
{
handle.Free();
}
固定期间对象不能被普通压缩移动。固定时间越长,对 GC 越不友好。固定对象的代码必须使用 try/finally,避免异常路径遗忘 Free。
Demo 6:静态集合导致托管内存泄漏
csharp
using System;
using System.Collections.Generic;
using System.Threading;
public static class Program
{
private static readonly List<byte[]> LeakStore = new();
public static void Main()
{
Console.WriteLine("PID: " + Environment.ProcessId);
while (true)
{
LeakStore.Add(new byte[1024 * 100]);
if (LeakStore.Count % 100 == 0)
{
Console.WriteLine("对象数量:" + LeakStore.Count);
}
Thread.Sleep(50);
}
}
}
泄漏链路是:
text
GC Root
→ 静态字段 LeakStore
→ List<byte[]>
→ byte[] 对象
Full GC 只能回收不可达对象,不能解除静态集合的引用。修复方式是清理不再需要的元素、限制集合容量,或使用带过期和淘汰策略的缓存。
如何观察托管堆
使用 C# API
csharp
GC.GetTotalMemory(false);
GC.GetGeneration(objectInstance);
GC.CollectionCount(0);
GC.GetGCMemoryInfo();
GC.GetAllocatedBytesForCurrentThread();
GC.GetTotalAllocatedBytes();
| API | 说明 |
|---|---|
| GetTotalMemory | 当前托管内存的近似值 |
| GetGeneration | 对象当前所在代 |
| CollectionCount | 某一代 GC 的累计次数 |
| GetGCMemoryInfo | GC 堆和内存压力快照 |
| GetAllocatedBytesForCurrentThread | 当前线程累计分配量 |
| GetTotalAllocatedBytes | 进程累计分配量 |
累计分配量不是当前存活内存。程序可以累计分配很多对象,但因为对象短命,当前堆仍然不大。
使用 dotnet-counters
shell
dotnet tool install --global dotnet-counters
dotnet-counters ps
dotnet-counters monitor --process-id <PID> --counters System.Runtime
重点观察:
- gc-heap-size:托管堆大小趋势;
- alloc-rate:分配速率;
- gen-0-gc-count、gen-1-gc-count、gen-2-gc-count:各代回收次数;
- time-in-gc:应用时间中用于 GC 的比例;
- LOH 相关指标:大对象是否持续增长;
- 工作集和进程内存:判断问题是否超出托管堆。
判断框架可以写成:
text
alloc-rate 高 + GC 后堆能下降
→ 分配很多,但对象大多短命
堆持续增长 + Full GC 后仍不下降
→ 怀疑长生命周期引用或缓存失控
托管堆不大 + 进程内存很高
→ 检查原生内存、线程栈、映射文件或运行时开销
使用 dotnet-gcdump
shell
dotnet tool install --global dotnet-gcdump
dotnet-gcdump collect -p <PID> -o heap.gcdump
dotnet-gcdump 适合观察托管堆对象图、类型数量和引用关系,但不是完整进程 Dump,不能替代所有 SOS 分析。
使用 dotnet-dump 和 SOS
shell
dotnet tool install --global dotnet-dump
dotnet-dump collect -p <PID> -o app.dmp
dotnet-dump analyze app.dmp
进入分析环境后:
text
> dumpheap -stat
> dumpheap -type System.Byte[]
> dumpobj <对象地址>
> gcroot <对象地址>
> eeheap -gc
排查顺序通常是:
text
dumpheap -stat
↓ 找数量或大小异常的类型
dumpheap -type 或 -mt
↓ 找具体实例
dumpobj
↓ 看对象字段
gcroot
↓ 找静态字段、句柄、线程或缓存引用
WinDbg 中命令前需要加感叹号:
text
!dumpheap -stat
!gcroot <对象地址>
托管堆、工作集和进程内存
排查内存时,下面三个指标不能混为一谈:
| 指标 | 含义 |
|---|---|
| 托管堆大小 | GC 管理的对象空间及相关数据 |
| 私有字节 / 提交内存 | 进程提交或独占的虚拟内存 |
| 工作集 | 当前驻留在物理内存中的进程页面 |
可能出现托管堆下降,但工作集没有马上下降。原因包括 CLR 保留堆空间、操作系统暂时保留页面、线程栈、JIT 内存、原生库分配和内存映射。
因此不能只看任务管理器里的一个数字。至少需要同时观察托管堆、分配速率、GC 次数、工作集和私有字节。
Server GC 和 Workstation GC
.NET 提供两类主要 GC 模式:
- Workstation GC 更关注桌面应用和交互响应;
- Server GC 面向服务器吞吐,通常使用多个 GC 线程和堆;
- Server GC 不等于每个请求都更快;
- GC 模式要结合 CPU 核数、并发量、延迟目标和部署环境评估。
查看当前模式:
csharp
using System;
using System.Runtime;
Console.WriteLine(GCSettings.IsServerGC);
Console.WriteLine(GCSettings.LatencyMode);
切换 GC 模式前,应通过压测观察吞吐、P95/P99 延迟、暂停时间和内存占用。
托管堆优化方向
减少不必要的分配
常见分配热点包括:
- 循环中重复创建大数组;
- 字符串拼接产生大量中间对象;
- LINQ 链生成多个迭代器和临时集合;
- 装箱把值类型转换成对象;
- JSON 序列化反复创建大对象图;
- 日志未启用时仍然构造复杂字符串。
管理对象生命周期
长期存活对象需要明确所有权:
- 静态集合必须有淘汰策略;
- 事件订阅需要对应解绑;
- Timer、回调和后台任务不能意外持有请求对象;
- 缓存需要容量、过期时间和淘汰机制;
- 单例不能无限持有短生命周期对象。
大对象优先考虑复用
ArrayPool、MemoryPool 和 ObjectPool 可以减少重复分配,但池化会引入归还和清理责任。使用池化后,必须定义谁租用、谁归还、归还后能否继续访问。
不要把 GC.Collect 当常规优化
手动 GC 适合少量明确场景,例如批处理阶段结束后的诊断实验。在线请求路径中频繁调用,通常会增加暂停时间和 CPU 消耗。
常见误区
Gen2 大就一定是泄漏
缓存、单例和正常应用状态本来就可能长期存在。需要通过多次采样、Full GC 前后对比和 gcroot 引用链判断。
托管堆就是所有进程内存
线程栈、JIT 代码、运行时数据、原生库、内存映射和模块加载都可能占用进程内存。
LOH 出现就一定是性能问题
大对象进入 LOH 是正常行为。问题通常出在频繁分配、大小变化过大、长期占用或碎片严重。
固定对象越多越稳定
固定对象只是地址稳定,不代表性能更好。固定会限制 GC 移动对象,应尽量缩短固定时间。
任务管理器内存没有下降就是 GC 失败
CLR 可能保留堆空间,操作系统也可能暂时保留页面。需要同时观察托管堆、工作集、私有字节和原生内存。
总结
托管堆是 CLR 管理的一组对象存储区域:
text
SOH
├── Gen0:新对象和短命对象
├── Gen1:过渡代
└── Gen2:长期存活对象
LOH:大对象
POH:固定对象
排查托管内存问题时,可以按这条路线:
text
dotnet-counters 看趋势
↓
判断是分配过快,还是对象留存过久
↓
dotnet-gcdump 看类型和对象图
↓
dotnet-dump + SOS 看对象和 GC Root
↓
回到缓存、事件、Timer、Task 和集合生命周期
最重要的结论:
- GC 回收的是不可达对象,不是看起来没用的变量;
- 小对象使用分代回收,大对象和固定对象有专门处理区域;
- GetTotalMemory 不等于进程实际内存;
- Gen2、LOH 变大需要结合趋势和引用链分析;
- 减少分配、缩短对象生命周期,通常比强制 GC 更有效;
- 托管代码不需要手动 free,但仍然可能因为错误引用产生内存泄漏。
参考资料: