大对象堆(LOH)
篇章 :02-原理篇
阅读时间 :约 30 分钟
前置知识:了解分代 GC
一、引言
在 .NET 的分代 GC 体系中,有一个特殊的存在------大对象堆(Large Object Heap,简称 LOH) 。它不遵循常规的分代规则,却对应用性能有着深远影响。许多 .NET 开发者都曾遇到过 LOH 碎片化导致的 OutOfMemoryException,而 Unity 开发者在使用 IL2CPP 后端时也会间接受到 LOH 机制的影响。
本章将深入剖析 LOH 的定义、分配策略、碎片化问题、压缩机制,以及 .NET 5 引入的 POH(Pinned Object Heap),帮助读者理解并优化大对象堆的使用。
二、LOH 的定义与阈值
2.1 85000 字节阈值
在 .NET 中,大小 ≥ 85000 字节(约 83 KB)的对象会被直接分配到 LOH,而不是 Gen0。这个阈值从 .NET Framework 1.0 开始就固定为 85000 字节,至今未变。
using System;
class LOHThresholdDemo
{
static void Main()
{
// 84999 字节 → Gen0
var small = new byte[84999];
Console.WriteLine($"84999 bytes → Gen{GC.GetGeneration(small)}"); // Gen0
// 85000 字节 → LOH(算作 Gen2)
var large = new byte[85000];
Console.WriteLine($"85000 bytes → Gen{GC.GetGeneration(large)}"); // Gen2
// 注意:LOH 对象在 GetGeneration 中报告为 Gen2
// 但它们实际上在独立的 LOH 区域中
}
}
2.2 为什么是 85000 字节?
85000 字节这个阈值并非随意选择,而是基于以下考量:
- 性能权衡 :Copying 算法复制大对象的代价太高。85000 字节意味着一次
memcpy操作约 85KB,如果 Gen0 中有多个大对象,Copying 的开销会显著增加 STW 时间。 - 内存页对齐:85000 字节接近 2 个 4KB 内存页(8KB × 10),在内存管理上是一个合理的分界点。
- 历史原因:这个值在 .NET 1.0 时代确定,当时的设计目标是让大多数常见对象(字符串、小数组、小集合)都在 SOH 中分配。
2.3 哪些对象会进入 LOH
| 对象类型 | 大小条件 | 进入 LOH |
|---|---|---|
byte[] |
≥ 85000 bytes | ✅ |
int[] |
≥ 21250 个元素(21250 × 4 = 85000) | ✅ |
string |
≥ ~42500 个字符(UTF-16,每字符 2 bytes) | ✅ |
double[] |
≥ 10625 个元素(10625 × 8 = 85000) | ✅ |
| 自定义 struct 数组 | 元素大小 × 数量 ≥ 85000 | ✅ |
| 普通对象 | < 85000 bytes | ❌(进 Gen0) |
// 常见 LOH 分配场景
using System;
using System.Text;
class LOHAllocationExamples
{
static void Main()
{
// 场景1:大字节数组(网络缓冲区)
byte[] networkBuffer = new byte[65536]; // 64KB → Gen0
byte[] largeBuffer = new byte[131072]; // 128KB → LOH
// 场景2:大字符串
var sb = new StringBuilder();
for (int i = 0; i < 50000; i++) sb.Append('x');
string largeString = sb.ToString(); // ~100KB → LOH
// 场景3:大整数数组
int[] largeIntArray = new int[25000]; // 25000 × 4 = 100000 bytes → LOH
// 场景4:大 double 数组
double[] largeDoubleArray = new double[15000]; // 15000 × 8 = 120000 bytes → LOH
Console.WriteLine($"networkBuffer (64KB): Gen{GC.GetGeneration(networkBuffer)}");
Console.WriteLine($"largeBuffer (128KB): Gen{GC.GetGeneration(largeBuffer)}");
Console.WriteLine($"largeString (~100KB): Gen{GC.GetGeneration(largeString)}");
Console.WriteLine($"largeIntArray (100KB): Gen{GC.GetGeneration(largeIntArray)}");
Console.WriteLine($"largeDoubleArray (120KB): Gen{GC.GetGeneration(largeDoubleArray)}");
}
}
三、LOH 的分配策略
3.1 空闲链表分配
与 Gen0 的 Bump Allocation(指针碰撞分配)不同,LOH 使用 空闲链表(Free List) 进行分配:
LOH 内存布局:
[已分配 128KB] [空闲 64KB] [已分配 256KB] [空闲 128KB] [已分配 85KB] [空闲 2MB]
↑
空闲链表节点
分配请求 100KB:
→ 遍历空闲链表
→ 找到 128KB 空闲块(Best-Fit)
→ 分割为 [已分配 100KB] [空闲 28KB]
→ 28KB 碎片留在链表中
3.2 分配算法
.NET 的 LOH 分配使用近似 Best-Fit 策略:
-
遍历空闲链表,寻找第一个 ≥ 请求大小的空闲块
-
如果找到的块远大于请求大小,继续搜索更接近的块
-
如果没有合适的空闲块,向操作系统申请新内存
-
分配后,剩余空间作为新碎片加入空闲链表
// LOH 分配碎片化演示
using System;
using System.Collections.Generic;class LOHAllocationDemo
{
static List<byte[]> keepAlive = new List<byte[]>();static void Main() { Console.WriteLine($"初始堆大小: {GC.GetTotalMemory(false) / 1024 / 1024} MB"); // 分配多个大对象 for (int i = 0; i < 10; i++) { keepAlive.Add(new byte[100_000]); // ~100KB each → LOH } Console.WriteLine($"分配 10 个 100KB 对象后: {GC.GetTotalMemory(false) / 1024 / 1024} MB"); // 释放偶数索引的对象,制造碎片 for (int i = 0; i < 10; i += 2) { keepAlive[i] = null; } GC.Collect(); Console.WriteLine($"释放一半后: {GC.GetTotalMemory(false) / 1024 / 1024} MB"); // 尝试分配一个 500KB 的大对象 // 可能无法利用碎片空间(碎片不连续) keepAlive.Add(new byte[500_000]); Console.WriteLine($"分配 500KB 后: {GC.GetTotalMemory(false) / 1024 / 1024} MB"); // 堆大小可能增长,因为碎片无法满足 500KB 的连续请求 }}
3.3 LOH 不分代
LOH 中的对象虽然被报告为 Gen2,但 LOH 本身不分代:
- 新分配的 LOH 对象直接算作 Gen2
- LOH 的 GC 只在 Full GC 时发生
- LOH 默认不压缩(碎片不消除)
这意味着 LOH 对象的回收代价等同于 Gen2 回收------频率低但 STW 时间长。
四、LOH 的碎片化问题
4.1 碎片化成因
LOH 使用 Mark-Sweep 算法(默认不压缩),因此会产生内存碎片:
LOH 碎片化过程:
初始状态:
[AAAA] [BBBB] [CCCC] [DDDD] [EEEE] [FFFF] [GGGG] [HHHH]
释放 B、D、F、H 后:
[AAAA] [空闲] [CCCC] [空闲] [EEEE] [空闲] [GGGG] [空闲]
碎片化结果:
总空闲 = 4 块 × 100KB = 400KB
但最大连续空闲 = 100KB
→ 无法分配 200KB 的对象!
→ LOH 向 OS 申请新内存 → 堆不断增长
4.2 碎片化的危害
// LOH 碎片化导致 OOM 的典型场景
using System;
using System.Collections.Generic;
class LOHFragmentationDemo
{
static void Main()
{
var buffers = new List<byte[]>();
// 模拟网络服务器场景:分配和释放不同大小的缓冲区
for (int cycle = 0; cycle < 100; cycle++)
{
// 分配一批缓冲区
for (int i = 0; i < 20; i++)
{
int size = 85000 + i * 1000; // 85KB ~ 104KB
buffers.Add(new byte[size]);
}
// 释放一半(制造碎片)
for (int i = buffers.Count - 1; i >= 0; i -= 2)
{
buffers.RemoveAt(i);
}
GC.Collect();
if (cycle % 20 == 0)
{
Console.WriteLine($"Cycle {cycle}: 堆大小 = {GC.GetTotalMemory(false) / 1024 / 1024} MB");
}
}
// 堆大小持续增长,因为碎片无法被复用
Console.WriteLine($"最终堆大小: {GC.GetTotalMemory(false) / 1024 / 1024} MB");
}
}
4.3 检测 LOH 碎片化
// 使用 GC API 检测 LOH 碎片化
using System;
using System.Diagnostics;
class LOHFragmentationCheck
{
static void Main()
{
// 分配和释放制造碎片
var keep = new System.Collections.Generic.List<byte[]>();
for (int i = 0; i < 50; i++)
{
keep.Add(new byte[100_000]);
}
for (int i = 0; i < 50; i += 2)
{
keep[i] = null;
}
GC.Collect();
// 获取各代堆信息
for (int gen = 0; gen <= 2; gen++)
{
long heapSize = GC.GetGCMemoryInfo().HeapSizeBytes;
long fragmented = GC.GetGCMemoryInfo().FragmentedBytes;
long committed = GC.GetGCMemoryInfo().TotalCommittedBytes;
Console.WriteLine($"堆总大小: {heapSize / 1024 / 1024} MB");
Console.WriteLine($"已提交: {committed / 1024 / 1024} MB");
Console.WriteLine($"碎片: {fragmented / 1024 / 1024} MB");
Console.WriteLine($"碎片率: {(double)fragmented / committed * 100:F1}%");
}
}
}
五、LOH 压缩
5.1 默认不压缩
.NET 的 LOH 默认不执行压缩,原因有二:
- 移动大对象的代价高 :复制 85KB+ 的对象需要大量
memcpy操作 - 引用更新开销大:大对象可能被大量其他对象引用,更新所有引用代价高
5.2 手动触发压缩
从 .NET 4.5.1 开始,可以手动请求 LOH 压缩:
using System;
using System.Runtime;
class LOHCompactionDemo
{
static void Main()
{
// 制造 LOH 碎片
var keep = new System.Collections.Generic.List<byte[]>();
for (int i = 0; i < 100; i++)
{
keep.Add(new byte[100_000]);
}
for (int i = 0; i < 100; i += 2)
{
keep[i] = null;
}
GC.Collect();
var info = GC.GetGCMemoryInfo();
Console.WriteLine($"压缩前碎片: {info.FragmentedBytes / 1024 / 1024} MB");
// 启用 LOH 压缩(仅一次)
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();
info = GC.GetGCMemoryInfo();
Console.WriteLine($"压缩后碎片: {info.FragmentedBytes / 1024 / 1024} MB");
// 重置为默认模式
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.Default;
}
}
5.3 压缩的代价
LOH 压缩虽然能消除碎片,但代价很高:
| 指标 | 不压缩 | 压缩 |
|---|---|---|
| STW 时间 | 短 | 长(与 LOH 大小成正比) |
| CPU 开销 | 低 | 高(大量 memcpy) |
| 碎片消除 | 否 | 是 |
| 适用频率 | 每次 Full GC | 偶尔(如内存紧张时) |
最佳实践:不要在性能敏感的路径上启用 LOH 压缩。建议在场景切换、关卡加载等非关键路径时手动触发。
六、LOH 在不同 GC 中的实现
6.1 .NET Framework / .NET Core / .NET 5+
| 版本 | LOH 特性 |
|---|---|
| .NET Framework 1.0-4.0 | LOH 不压缩,碎片化严重 |
| .NET Framework 4.5.1+ | 支持手动 LOH 压缩 |
| .NET Core 1.0+ | 继承 4.5.1 的 LOH 压缩支持 |
| .NET 5+ | 引入 POH(Pinned Object Heap) |
| .NET 6+ | LOH 性能优化,减少分配锁竞争 |
6.2 Unity 中的大对象处理
Unity 的 Boehm GC 没有 LOH 概念,所有对象都在同一个堆中:
// Unity 中的大对象分配行为
#if UNITY_EDITOR
using UnityEngine;
using System;
public class UnityLargeObjectTest : MonoBehaviour
{
void Start()
{
// Boehm GC 不区分 LOH
// 大对象和小对象在同一个堆中
var large = new byte[1_000_000]; // 1MB
var small = new byte[100]; // 100 bytes
// 两者都在同一个堆中,没有 LOH 概念
// Boehm GC 使用 Mark-Sweep,不移动任何对象
Debug.Log($"堆大小: {GC.GetTotalMemory(false) / 1024 / 1024} MB");
// Unity 的 Boehm GC 也会碎片化
// 但无法像 .NET 那样手动压缩
}
}
#endif
6.3 对比表
| 特性 | .NET LOH | Unity Boehm |
|---|---|---|
| 阈值 | 85000 bytes | 无(所有对象同堆) |
| 算法 | Mark-Sweep + 可选 Compact | Mark-Sweep(不压缩) |
| 分代 | 算作 Gen2 | 不分代 |
| 压缩 | 支持手动触发 | 不支持 |
| 碎片化 | 可控(可压缩) | 严重(不可压缩) |
| POH | .NET 5+ 支持 | 不支持 |
七、POH(Pinned Object Heap)
7.1 固定对象的问题
在 .NET 5 之前,固定对象(Pinned Object)使用 GCHandleType.Pinned 或 fixed 语句来防止 GC 移动对象。固定对象会导致两个问题:
-
碎片化:如果固定对象在 Gen0 中,Copying 算法无法移动它,导致 Gen0 碎片化
-
效率降低:Gen0 的 Copying 算法需要跳过固定对象,增加复杂度
// 传统固定对象的问题
using System;
using System.Runtime.InteropServices;class PinnedObjectProblem
{
static void Main()
{
// 传统方式:在 SOH 中固定对象
var buffer = new byte[1024];
GCHandle handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);try { // 获取固定指针,传递给非托管代码 IntPtr ptr = handle.AddrOfPinnedObject(); Console.WriteLine($"固定对象地址: {ptr}"); // 问题:这个对象在 Gen0 中无法被 Copying 算法移动 // 如果 Gen0 GC 发生,这个对象会"卡"在原位 // 导致 Gen0 碎片化 } finally { handle.Free(); // 释放固定 } }}
7.2 POH 的设计
.NET 5 引入了 POH(Pinned Object Heap),将固定对象分配到独立的堆区域:
// .NET 5+ 使用 POH 分配固定对象
using System;
using System.Runtime.InteropServices;
class POHDemo
{
static void Main()
{
// .NET 5+ 支持 POH 分配
// 使用 GC.AllocateArray 的 pinned 参数
byte[] pinnedBuffer = GC.AllocateArray<byte>(1024, pinned: true);
// 这个数组直接分配在 POH 中
// 不会被 GC 移动,也不会影响 Gen0 的 Copying 算法
// 获取固定指针(不需要 GCHandle)
unsafe
{
fixed (byte* ptr = pinnedBuffer)
{
Console.WriteLine($"POH 对象地址: {(IntPtr)ptr}");
// 可以安全地传递给非托管代码
}
}
Console.WriteLine($"POH 对象所在代: {GC.GetGeneration(pinnedBuffer)}");
// POH 对象算作 Gen2,不会被移动
}
}
7.3 POH 的优势
| 优势 | 说明 |
|---|---|
| 不影响 Gen0 | 固定对象不在 Gen0,不影响 Copying 算法 |
| 无需 GCHandle | 分配即为固定,无需额外 GCHandle 开销 |
| 减少碎片化 | Gen0 不再有固定对象导致的碎片 |
| 简化 P/Invoke | 直接传递 POH 对象给非托管代码 |
7.4 POH 适用场景
// POH 典型场景:与非托管代码交互
using System;
using System.Runtime.InteropServices;
class POHUseCases
{
// 场景1:文件 I/O 缓冲区
static void FileIOExample()
{
// 分配固定缓冲区用于文件 I/O
byte[] ioBuffer = GC.AllocateArray<byte>(64 * 1024, pinned: true);
unsafe
{
fixed (byte* ptr = ioBuffer)
{
// 传递给 Windows API ReadFile/WriteFile
// 不需要 GCHandle,不会影响 Gen0
}
}
}
// 场景2:网络缓冲区
static void NetworkExample()
{
// 固定网络缓冲区,避免 GC 移动导致指针失效
byte[] netBuffer = GC.AllocateArray<byte>(128 * 1024, pinned: true);
unsafe
{
fixed (byte* ptr = netBuffer)
{
// 传递给 socket API
}
}
}
// 场景3:Unity 中的原生插件交互
// 注意:Unity 可能不支持 POH(取决于 .NET 版本)
static void UnityNativePluginExample()
{
// 如果 Unity 使用 .NET 5+,可以使用 POH
// 否则需要回退到 GCHandle.Alloc(pinned)
try
{
byte[] buffer = GC.AllocateArray<byte>(4096, pinned: true);
Console.WriteLine("POH 分配成功");
}
catch (MissingMethodException)
{
// Unity 不支持 POH,回退到传统方式
byte[] buffer = new byte[4096];
GCHandle handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);
Console.WriteLine("回退到 GCHandle 固定");
handle.Free();
}
}
}
八、LOH 优化最佳实践
8.1 池化大对象
最有效的 LOH 优化策略是 对象池化(Object Pooling),避免频繁分配和释放大对象:
using System;
using System.Collections.Concurrent;
// 大数组对象池
class LargeArrayPool
{
private readonly ConcurrentDictionary<int, ConcurrentBag<byte[]>> _pools = new();
public byte[] Rent(int minimumSize)
{
// 找到合适的池
int bucketSize = GetBucketSize(minimumSize);
var pool = _pools.GetOrAdd(bucketSize, _ => new ConcurrentBag<byte[]>());
if (pool.TryTake(out byte[]? array))
{
return array; // 复用已有数组
}
return new byte[bucketSize]; // 池为空时才分配
}
public void Return(byte[] array)
{
int bucketSize = GetBucketSize(array.Length);
var pool = _pools.GetOrAdd(bucketSize, _ => new ConcurrentBag<byte[]>());
pool.Add(array); // 归还到池中
}
private int GetBucketSize(int minSize)
{
// 按 2 的幂对齐
int size = 81920; // 从 80KB 开始(接近 LOH 阈值)
while (size < minSize) size *= 2;
return size;
}
}
// 使用示例
class PoolUsageDemo
{
private static LargeArrayPool _pool = new();
static void ProcessData()
{
// 从池中租借,而不是 new
byte[] buffer = _pool.Rent(100_000);
try
{
// 使用 buffer 处理数据
// ...
}
finally
{
_pool.Return(buffer); // 归还到池中
}
}
}
8.2 使用 ArrayPool
.NET 内置了 ArrayPool<T>,可以避免 LOH 分配:
using System;
using System.Buffers;
class ArrayPoolDemo
{
static void Main()
{
// 使用 ArrayPool 而不是 new byte[]
byte[] buffer = ArrayPool<byte>.Shared.Rent(100_000);
try
{
// 使用 buffer
// 注意:实际大小可能 ≥ 请求大小
Console.WriteLine($"租借大小: {buffer.Length} bytes");
// ArrayPool 内部会复用数组,避免 LOH 分配
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
// ArrayPool 也可以使用 MemoryPool
using IMemoryOwner<byte> memory = MemoryPool<byte>.Shared.Rent(100_000);
Span<byte> span = memory.Memory.Span;
// span 可以安全使用,超出作用域自动归还
}
}
8.3 避免不必要的 LOH 分配
// 常见的 LOH 陷阱和解决方案
using System;
using System.Text;
class LOHPitfalls
{
// 陷阱1:字符串拼接产生大字符串
static void StringConcatenation()
{
// ❌ 错误:可能产生 LOH 级别的大字符串
string result = "";
for (int i = 0; i < 50000; i++)
{
result += "x"; // 每次拼接都创建新字符串
}
// result 可能 > 85000 bytes → LOH
// ✅ 正确:使用 StringBuilder
var sb = new StringBuilder(50000); // 预分配容量
for (int i = 0; i < 50000; i++)
{
sb.Append('x');
}
string result2 = sb.ToString();
// StringBuilder 内部使用 char[],可能进 LOH
// 但只分配一次,不是反复分配
}
// 陷阱2:List<T> 内部数组扩容
static void ListExpansion()
{
// ❌ 错误:List 内部数组可能扩容到 LOH
var list = new System.Collections.Generic.List<byte>();
for (int i = 0; i < 100000; i++)
{
list.Add((byte)i); // 内部数组多次扩容,最终可能进 LOH
}
// ✅ 正确:预分配容量
var list2 = new System.Collections.Generic.List<byte>(100000);
for (int i = 0; i < 100000; i++)
{
list2.Add((byte)i);
}
}
// 陷阱3:LINQ ToArray 产生大数组
static void LinqToArray()
{
var data = new System.Collections.Generic.List<int>();
for (int i = 0; i < 25000; i++) data.Add(i);
// ❌ 可能产生 LOH 分配
int[] array = data.ToArray(); // 25000 × 4 = 100000 bytes → LOH
// ✅ 如果需要频繁使用,考虑池化
int[] pooled = ArrayPool<int>.Shared.Rent(25000);
try
{
data.CopyTo(pooled);
// 使用 pooled[0..data.Count]
}
finally
{
ArrayPool<int>.Shared.Return(pooled);
}
}
}
8.4 LOH 优化检查清单
| 检查项 | 说明 |
|---|---|
| 是否使用 ArrayPool | 大数组应使用 ArrayPool 而非 new |
| 是否预分配集合容量 | List、Dictionary 等应预分配容量 |
| 是否避免字符串拼接 | 大字符串使用 StringBuilder |
| 是否定期检查 LOH 碎片 | 使用 GC API 监控碎片率 |
| 是否在非关键路径压缩 LOH | 场景切换时手动压缩 |
| 是否使用 POH(.NET 5+) | 固定对象使用 POH 而非 GCHandle |
| 是否避免 LOH 中的短生命周期对象 | LOH 对象应长期复用 |
九、总结
大对象堆(LOH)是 .NET GC 体系中一个特殊而重要的设计:
-
85000 字节阈值:≥ 85000 字节的对象直接分配到 LOH,避免 Copying 算法复制大对象的开销。
-
Mark-Sweep 算法:LOH 默认使用 Mark-Sweep,不压缩,因此会产生碎片化。
-
碎片化是核心问题 :LOH 碎片化会导致堆不断增长,最终可能引发 OOM。可以通过
GCSettings.LargeObjectHeapCompactionMode手动压缩。 -
POH(.NET 5+):固定对象堆将固定对象从 Gen0 中分离,避免固定对象影响 Copying 算法效率。
-
池化是最佳实践 :使用
ArrayPool<T>或自定义对象池避免频繁的 LOH 分配和释放。
Unity vs .NET 的关键差异:
- .NET 有专门的 LOH 处理大对象,支持手动压缩和 POH
- Unity 的 Boehm GC 没有 LOH,所有对象在同一个堆中,碎片化更严重且不可压缩
- .NET 的
ArrayPool<T>是避免 LOH 分配的有效工具,Unity 中需要自行实现对象池
理解 LOH 的工作原理和优化策略,是编写高性能 .NET 和 Unity 应用的关键技能。在下一章中,我们将深入探讨 GC Roots 和对象图遍历------GC 如何确定哪些对象是"存活"的。