ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

02-03-原理篇-大对象堆

02-03-原理篇-大对象堆 大对象堆LOH篇章02-原理篇阅读时间约 30 分钟前置知识了解分代 GC一、引言在 .NET 的分代 GC 体系中有一个特殊的存在——大对象堆Large Object Heap简称 LOH。它不遵循常规的分代规则却对应用性能有着深远影响。许多 .NET 开发者都曾遇到过 LOH 碎片化导致的OutOfMemoryException而 Unity 开发者在使用 IL2CPP 后端时也会间接受到 LOH 机制的影响。本章将深入剖析 LOH 的定义、分配策略、碎片化问题、压缩机制以及 .NET 5 引入的 POHPinned 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对象类型大小条件进入 LOHbyte[]≥ 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 Listbyte[] keepAlive new Listbyte[](); 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 对象直接算作 Gen2LOH 的 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 Listbyte[](); // 模拟网络服务器场景分配和释放不同大小的缓冲区 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.Listbyte[](); 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.Listbyte[](); 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.0LOH 不压缩碎片化严重.NET Framework 4.5.1支持手动 LOH 压缩.NET Core 1.0继承 4.5.1 的 LOH 压缩支持.NET 5引入 POHPinned Object Heap.NET 6LOH 性能优化减少分配锁竞争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 那样手动压缩 } } #endif6.3 对比表特性.NET LOHUnity Boehm阈值85000 bytes无所有对象同堆算法Mark-Sweep 可选 CompactMark-Sweep不压缩分代算作 Gen2不分代压缩支持手动触发不支持碎片化可控可压缩严重不可压缩POH.NET 5 支持不支持七、POHPinned Object Heap7.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 引入了POHPinned 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.AllocateArraybyte(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.AllocateArraybyte(64 * 1024, pinned: true); unsafe { fixed (byte* ptr ioBuffer) { // 传递给 Windows API ReadFile/WriteFile // 不需要 GCHandle不会影响 Gen0 } } } // 场景2网络缓冲区 static void NetworkExample() { // 固定网络缓冲区避免 GC 移动导致指针失效 byte[] netBuffer GC.AllocateArraybyte(128 * 1024, pinned: true); unsafe { fixed (byte* ptr netBuffer) { // 传递给 socket API } } } // 场景3Unity 中的原生插件交互 // 注意Unity 可能不支持 POH取决于 .NET 版本 static void UnityNativePluginExample() { // 如果 Unity 使用 .NET 5可以使用 POH // 否则需要回退到 GCHandle.Alloc(pinned) try { byte[] buffer GC.AllocateArraybyte(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 ConcurrentDictionaryint, ConcurrentBagbyte[] _pools new(); public byte[] Rent(int minimumSize) { // 找到合适的池 int bucketSize GetBucketSize(minimumSize); var pool _pools.GetOrAdd(bucketSize, _ new ConcurrentBagbyte[]()); 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 ConcurrentBagbyte[]()); 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 内置了ArrayPoolT可以避免 LOH 分配using System; using System.Buffers; class ArrayPoolDemo { static void Main() { // 使用 ArrayPool 而不是 new byte[] byte[] buffer ArrayPoolbyte.Shared.Rent(100_000); try { // 使用 buffer // 注意实际大小可能 ≥ 请求大小 Console.WriteLine($租借大小: {buffer.Length} bytes); // ArrayPool 内部会复用数组避免 LOH 分配 } finally { ArrayPoolbyte.Shared.Return(buffer); } // ArrayPool 也可以使用 MemoryPool using IMemoryOwnerbyte memory MemoryPoolbyte.Shared.Rent(100_000); Spanbyte 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 // 但只分配一次不是反复分配 } // 陷阱2ListT 内部数组扩容 static void ListExpansion() { // ❌ 错误List 内部数组可能扩容到 LOH var list new System.Collections.Generic.Listbyte(); for (int i 0; i 100000; i) { list.Add((byte)i); // 内部数组多次扩容最终可能进 LOH } // ✅ 正确预分配容量 var list2 new System.Collections.Generic.Listbyte(100000); for (int i 0; i 100000; i) { list2.Add((byte)i); } } // 陷阱3LINQ ToArray 产生大数组 static void LinqToArray() { var data new System.Collections.Generic.Listint(); for (int i 0; i 25000; i) data.Add(i); // ❌ 可能产生 LOH 分配 int[] array data.ToArray(); // 25000 × 4 100000 bytes → LOH // ✅ 如果需要频繁使用考虑池化 int[] pooled ArrayPoolint.Shared.Rent(25000); try { data.CopyTo(pooled); // 使用 pooled[0..data.Count] } finally { ArrayPoolint.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 算法效率。池化是最佳实践使用ArrayPoolT或自定义对象池避免频繁的 LOH 分配和释放。Unity vs .NET 的关键差异.NET 有专门的 LOH 处理大对象支持手动压缩和 POHUnity 的 Boehm GC 没有 LOH所有对象在同一个堆中碎片化更严重且不可压缩.NET 的ArrayPoolT是避免 LOH 分配的有效工具Unity 中需要自行实现对象池理解 LOH 的工作原理和优化策略是编写高性能 .NET 和 Unity 应用的关键技能。在下一章中我们将深入探讨 GC Roots 和对象图遍历——GC 如何确定哪些对象是存活的。
返回列表