Array数组语义、连续存储与运行时实现系列C# 与常用数据结构源码剖析 · 数据结构-线性篇语言与运行时边界C# 12、.NET 8源码观察固定为dotnet/runtime的v8.0.0tag适用范围公共语义可用于符合相应规范的 .NET 实现CoreCLR 私有布局、JIT 代码形态和 GC 策略不能直接套用到 Unity Mono、IL2CPP 或未来版本一、先建立正确模型数组同时属于语言、CLI 与运行时T[]看起来只是“长度固定、按下标访问的一段元素”但它同时处在三个层次C# 语言层规定数组创建、初始化、索引、协变以及foreach等语义CLI 类型系统与 IL认识向量数组、数组元素类型以及newarr、ldelem、stelem等指令具体运行时负责对象分配、元素寻址、边界检查、GC 扫描、写屏障和批量搬运并由 JIT/AOT 后端选择机器代码。这三个层次不能混写。比如“越界必须失败”是公共安全语义“某个循环的边界检查会不会被 .NET 8 RyuJIT 消掉”是优化观察“长度字段在对象起点后的固定字节偏移”则是 CoreCLR 私有实现不是应用可以依赖的 ABI。数组之所以是一切集合的基石不是因为所有集合都直接继承它而是因为连续存储非常适合实现动态数组、环形队列、哈希桶、堆和排序缓冲区。ListT、QueueT、StackT等类型都可以在数组上增加容量、逻辑长度或首尾索引但数组本身仍有独立而严格的契约长度创建后不变元素可按索引读写所有元素具有同一数组元素类型。本文采用以下证据标记公共契约可由 C#、CLI 或 .NET API 文档保证业务代码可以依赖v8.0.0 实现观察对固定源码 tag 的阅读结论只用于解释和定位概念伪代码帮助理解控制流不声称与源码逐行一致实验观察必须记录 SDK、运行时、架构、构建模式和 Unity 后端后才有意义。二、三种数组不是同一种形状2.1 SZArray最常用的一维零基数组int[]、string[]与Enemy[]都是 SZArray即 single-dimensional, zero-based array在 CLI 记法中也称 vector。它的公共特征是Rank 1下界为零合法索引是[0, Length)长度创建后不能改变元素初始化为default(T)T[]可转换为System.Array并在适用时实现IEnumerableT、ICollectionT、IListT等接口。接口实现不意味着数组变成可增长集合。通过IListT读取、覆盖已有位置通常可行Add、Insert、Remove等改变长度的操作会失败因为ICollectionT.IsReadOnly描述的是接口能否增删并不等价于“元素不可写”。这是一个常见但容易忽略的契约差异。int[] values { 10, 20, 30 }; IListint listView values; listView[1] 99; // 合法覆盖已有槽位 // listView.Add(40); // 运行时抛出 NotSupportedException数组长度固定在 .NET 8 CoreCLR 中SZArray 的泛型集合接口支持与SZArrayHelper、运行时生成的数组类型接口映射有关。这里的“辅助类型”是固定版本实现线索不是说数组对象真的包含一个SZArrayHelper实例也不能据此推导对象布局。2.2 矩形多维数组一个对象、多个维度int[,] board new int[3, 4]是 rank 为 2 的矩形数组。每一行长度相同总元素数是各维长度的乘积。公共 API 提供Rank维数GetLength(dimension)某一维长度GetLowerBound(dimension)/GetUpperBound(dimension)上下界Length所有维度的元素总数。C# 常规new int[3, 4]创建零下界矩形数组但Array.CreateInstance可以创建非零下界数组。因此对任意Array写通用算法时不能假定每维都从零开始。Array matrix Array.CreateInstance( typeof(int), lengths: new[] { 2, 3 }, lowerBounds: new[] { 1, -1 }); matrix.SetValue(42, 1, -1); Console.WriteLine(matrix.GetLowerBound(0)); // 1 Console.WriteLine(matrix.GetValue(1, -1)); // 42多维数组按行主序排列最右边的维度变化最快。这个顺序影响遍历的空间局部性但“某后端下[,]一定比[][]慢多少”不是公共结论。索引计算、内联和范围检查优化会随 CoreCLR、Mono、IL2CPP、CPU 与构建设置变化。2.3 交错数组数组中的元素仍是数组引用int[][] rows是“元素类型为int[]的 SZArray”。外层连续存放的是内层数组引用不是所有int组成的一块连续矩形int[][] rows { new[] { 1, 2 }, new[] { 3, 4, 5 }, Array.Emptyint() };交错数组允许每行不同长度也允许某个元素为null。访问rows[i][j]要先取得内层数组引用再对内层做索引矩形数组则在一个对象中保存同形数据。选择时应看语义地图网格天然矩形可用[,]或手动展平邻接表天然不规则适合交错或其他结构。不要先凭“听说更快”决定形状。三、元素布局、默认值与 GC 扫描3.1 连续的是元素槽位不等于对象都在一起从逻辑和运行时实现角度数组元素槽位连续排列。值类型元素内联在槽位中引用类型元素的槽位保存托管引用引用指向的对象仍可分散在托管堆上。struct Point { public int X, Y; } Point[] points new Point[100]; // Point 值内联 string?[] names new string?[100]; // 连续的是引用槽位结构体也可能含有托管引用struct Entry { public int Id; public object? Payload; }Entry[]的Entry仍内联但 GC 必须识别并扫描每个元素中的Payload槽位。因而“值类型数组不需要 GC 扫描”是错的。更准确的边界是运行时按元素类型的 GC 描述识别托管引用位置不含托管引用的元素数据无需作为引用逐槽追踪包含引用的类、接口、数组或结构体则必须追踪。给引用槽赋值还涉及写屏障。写屏障不是 C# 可见的“额外 API 调用”而是运行时维护分代 GC 记忆集等信息的机制。单个stelem.ref、Array.Copy、Array.Clear和排序移动引用时都必须走符合 GC 要求的路径不能把引用数组当任意字节块用memcpy搬运。3.2 不要写死对象头与元素起始偏移应用层可依赖Length和按元素类型解释的索引却不应依赖以下数字对象头多少字节、长度字段在哪、元素数据从哪个偏移开始、数组总大小怎样对齐。它们受运行时、位数、压缩引用、GC 模式、元素对齐和未来实现变化影响。固定到dotnet/runtime v8.0.0时可以从 CoreCLR 的数组类型构造、分配 helper 和 GC 描述生成逻辑理解为何运行时能快速得到元素大小与引用布局但这些 C 结构和偏移仍是实现内部协议。需要与原生代码交互时应使用受支持的固定、封送、SpanT、MemoryMarshal或 Unity 对应原生容器接口而不是手算对象地址。3.3 分配一定初始化但成本不只有“申请地址”托管新数组的元素具有默认值这是公共语义数值为零、bool为false、引用为null、结构体递归取得默认状态。大数组的创建成本因此至少与需要初始化的内存量相关值类型构造函数不会被自动逐元素调用。数组分配还可能带来对齐和大小计算、堆空间消耗、后续 GC 扫描、存活对象晋升以及最终回收。一次分配是否触发 GC、触发哪一代、暂停多久都取决于当时堆状态不能从“数组很大”直接推出“每次都触发完整 GC”。3.4 LOH 是目标运行时策略不是数组语义在常见 .NET 8 CoreCLR 配置中达到大对象阈值的对象通常进入 LOH常见资料会提到约 85,000 字节量级但阈值针对整个对象大小并非“元素负载正好 85 KB”的永久公共常量。运行时版本、配置和实现都可能改变策略。LOH 通常与第 2 代收集协同默认压缩策略也与小对象堆不同运行时提供按需压缩等能力。但以下推断都不成立“一进入 LOH 就立即触发 Gen 2 GC”“LOH 永远不压缩”“小于某固定字节数就没有 GC 成本”“Unity 的 Mono/IL2CPP 也采用完全相同阈值和代际策略”。工程上应使用目标进程的GC.GetGCMemoryInfo、dotnet-counters、PerfView/TraceEvent 或诊断器观察分配与收集Unity 则使用目标 Editor/Player 版本的 Profiler、Memory Profiler 和平台构建验证。四、创建与索引从 IL 到机器码的责任边界4.1newarr专门创建 SZArray下面是 C# 编译到 IL 的典型形状具体局部变量编号和短指令选择由编译器决定// C#: int[] values new int[100]; ldc.i4.s 100 newarr [System.Runtime]System.Int32 stloc.0newarr接收元素类型和运行时长度创建一维零基数组。负长度在 C# 常量场景可能先被编译器拒绝运行时还要验证长度与总大小是否可表示、分配是否成功。矩形多维数组不是简单重复newarr其 IL 通常通过相应数组类型的特殊构造入口创建。new T[n]的概念流程可以写成下面的伪代码概念伪代码不是 CoreCLR v8.0.0 逐字源码 validate length and checked total size resolve runtime array type for element T allocate a managed array object through the GC allocator initialize runtime-required metadata and logical length ensure element storage observes default(T) return the managed reference在v8.0.0阅读实现时应联动查看 CoreCLR VM 中数组类型/分配 helper、JIT 对数组操作的导入以及System.Private.CoreLib的System.Array托管入口。只摘一个 helper 就声称覆盖完整流程通常会漏掉快慢路径、异常路径和 GC 协作。4.2ldelem、stelem、ldelema各做什么数组索引的 IL 会根据读取、写入、取地址以及元素类型选择具体形式ldelem.*读取元素stelem.*写入元素ldelema取得元素的托管地址常见于按引用访问结构体ldlen取得 SZArray 长度结果是 native unsigned integer 语义C#LengthAPI 暴露为int。每次索引都必须满足非空和范围安全引用数组写入还要满足运行时元素类型兼容并执行写屏障。JIT 可以在证明安全时合并或消除重复边界检查但“数组访问无边界检查”从来不是语言契约。static long Sum(int[] values) { long sum 0; for (int i 0; i values.Length; i) sum values[i]; return sum; }上述规范循环是范围分析容易识别的候选但最终机器码仍取决于 .NET 8 的具体补丁版本、Tiered Compilation、PGO、CPU 架构和构建模式。应查看 Release 代码的 JIT 反汇编不能把一段示意汇编当成所有平台的固定产物。复杂索引、别名、调用、倒序或多数组共同循环都可能让检查保留。4.3 展平索引要同时检查乘法与语义游戏网格常把二维坐标展平到一维数组static int Offset(int x, int y, int width, int height) { if ((uint)x (uint)width || (uint)y (uint)height) throw new ArgumentOutOfRangeException(); return checked(y * width x); }(uint)模式把负数与超过上界合并为一次无符号检查checked防止极端尺寸下乘法环绕。若数据来自文件或网络先验证width * height是否可表示再分配。性能优化不能以绕过不可信尺寸验证为代价。五、数组协变读视图看似方便写入必须动态守门5.1 只有引用类型数组参与这种协变如果存在引用转换Derived - BaseC# 允许Derived[] - Base[]string[] words new string[2]; object[] objects words; objects[0] safe; objects[1] new object(); // ArrayTypeMismatchException变量objects的静态类型是object[]但对象的运行时类型仍是string[]。若允许任意object写入之后从words[1]读取就会破坏类型安全所以运行时必须拒绝不兼容的引用。ArrayTypeMismatchException正是在暴露类型允许、真实数组类型不允许写入时出现。值类型数组不按这种方式协变int[]不能转换为object[]因为元素是内联int不是连续的 object 引用。泛型集合也默认不协变Liststring不能转换成Listobject。只读泛型接口可按其声明的 variance 规则转换但那是另一套契约。5.2 不要把每次写入描述成固定的昂贵调用从公共语义看协变路径必须保证写入安全从实现看JIT/运行时可以根据静态类型、值类型和具体数组类型选择快慢路径。不能笼统声称“每次引用数组写入都调用某个 MethodTable 函数”也不能给出无环境的固定百分比。更重要的是 API 设计风险接收object[]的方法可能拿到string[]即使签名看起来允许写任意对象。若方法只读使用ReadOnlySpanobject、IEnumerableobject或带约束的泛型接口表达意图若方法需要写应尽量保持精确元素类型。static void FillT(T[] destination, T value) { Array.Fill(destination, value); }泛型签名让调用方与目标数组共享T减少“静态视图比真实存储更宽”的空间。不过反射、dynamic、不安全转换或恶意比较器仍可能制造失败泛型并不等于所有运行时检查消失。六、Array.Copy、Clear与Resize相似 API所有权完全不同6.1Array.Copy是带类型规则的重叠安全复制Array.Copy可以在同一数组内部移动重叠区间语义效果等同于先保护源数据再复制而不是天真地从左到右覆盖。它还要验证源、目标、秩、索引、长度和元素类型兼容性。int[] a { 1, 2, 3, 4, 5 }; Array.Copy(a, 0, a, 1, 4); // a { 1, 1, 2, 3, 4 }v8.0.0的实现会根据源目标类型关系和元素是否含引用选择批量搬运或逐元素转换等路径。概念决策树如下概念伪代码省略全部异常细节 validate arrays, ranks, indexes and length classify element-type relationship if representation-compatible bulk copy is legal: move bytes with overlap-safe runtime primitive honor GC barriers when destination contains references else if element-wise cast/unbox/widen path is supported: copy one element at a time with checks else: throw an array/type-related exception这不是说所有值类型都能相互按位复制也不是说所有引用复制都等价于裸memmove。不同元素类型间的装箱、拆箱、引用赋值和某些基本类型转换都有独立规则。发生异常时不应假定目标数组保持事务式不变需要原子语义的业务代码应先验证或复制到临时缓冲成功后再发布。复杂度通常是 O(n)n 为复制元素数实际吞吐受元素大小、引用写屏障、类型转换、缓存层级和内存带宽影响。与手写循环谁更快必须在目标环境测量。6.2Array.Clear写入的是defaultArray.Clear(array, index, length)把指定范围恢复为默认值。引用槽变为null无引用值类型通常归零含引用结构体则既要清数据又要正确更新引用状态。范围之外的元素不变。object?[] cache { new object(), new object(), new object() }; Array.Clear(cache, 1, 2); // cache[0] 保留cache[1] 与 cache[2] 为 null清空引用槽可以解除数组对对象的强引用但对象何时回收仍由可达性与 GC 决定。清零普通内存也不自动构成密码学安全擦除编译器、运行时、复制品和硬件都会影响敏感数据处理应使用专门安全 API 与威胁模型。6.3Array.Resize不是原地扩容数组长度不可变所以Array.Resize(ref array, newSize)的本质是必要时分配新数组、复制公共前缀再把调用者的变量改为指向新数组。其他别名仍指向旧数组。int[] current { 1, 2, 3 }; int[] alias current; Array.Resize(ref current, 5); current[0] 99; Console.WriteLine(alias.Length); // 3 Console.WriteLine(alias[0]); // 1扩容时旧数组和新数组可能在一段时间内同时存活所以峰值内存不是“最终容量”这么简单。若从 n 逐次增长到 n1总复制量会形成 O(n²)需要动态增长时应使用按容量策略扩展的ListT或预估最终尺寸。缩小时被截断元素不再属于新数组但旧数组若仍被别名引用它们依然存在。七、排序与查找算法契约比算法名字更重要7.1.NET 8 Array.Sort的公共承诺Array.Sort提供整数组、区间、键值对数组、IComparerT和ComparisonT等重载。业务代码应依赖这些事实按指定或默认比较器原地排序通常为 O(n log n) 比较量级不保证稳定相等键的原相对顺序可能改变比较器必须能为参与元素建立自洽顺序比较器或元素比较抛异常时数组可能已经部分重排。在固定v8.0.0源码中泛型排序入口可沿System.Collections.Generic.ArraySortHelperT等类型追踪典型通用路径采用 introspective sort 思路快速排序式分区递归深度过大时切换堆排序小分区使用插入排序。某些受支持的基本类型和比较方式存在专门优化路径因此不能把一段通用IntroSort代码当成所有重载的唯一执行路径也不应编造某版本“刚刚引入”的历史。结构化实现摘要基于 v8.0.0非逐字源码 Sort(span/range, comparer) - select specialized or general helper - for a non-trivial general range: partition while depth budget remains heap-sort fallback when budget is exhausted insertion-sort small partitions“不稳定”不是说结果随机而是说比较器认为相等的项没有顺序保证。需要稳定性时可用稳定排序方案并验证额外索引/缓冲分配LINQOrderBy有稳定排序契约但它返回新序列语义、通常需要额外存储不能当原地Array.Sort的免费替代。7.2 错误比较器会破坏算法前提sealed class BadComparer : IComparerint { public int Compare(int x, int y) x y ? 0 : 1; }这个比较器让Compare(a,b)与Compare(b,a)都为正缺少反对称性。排序 API 可能抛出包装后的比较异常也可能产生没有业务意义的次序不要把“算法能返回”当作比较器正确。可靠比较器至少要考虑反对称、传递、相等一致性、null规则以及减法比较造成的整数溢出。// 不推荐x - y 可能溢出 // Array.Sort(values, (x, y) x - y); Array.Sort(values, static (x, y) x.CompareTo(y));7.3BinarySearch必须复用同一排序关系二分查找的前提不是“看起来有序”而是数组区间已按同一个比较器语义排序。若未找到返回值是负数按位取反~result得到插入位置若存在重复值不保证返回第一个或最后一个匹配项。StringComparer comparer StringComparer.OrdinalIgnoreCase; string[] ids { b, A, c }; Array.Sort(ids, comparer); int result Array.BinarySearch(ids, B, comparer); if (result 0) { int insertionIndex ~result; Console.WriteLine($应插入到 {insertionIndex}); }对 n 个元素比较次数是 O(log n)但单次比较可能很贵例如文化相关字符串比较。维护有序数组的插入仍需移动后缀 O(n)所以“查找快”不等于“动态更新快”。大量动态更新可考虑树、哈希或批量收集后统一排序。八、枚举、接口与可见性直接对T[]使用foreach时C# 编译器能采用数组专用枚举形状通常接近按索引循环。若先向上转型为IEnumerableT、IEnumerable或Array则可能走不同接口和枚举器路径带来虚调用、装箱或分配是否被 JIT 去虚拟化必须实测。static int Direct(int[] values) { int sum 0; foreach (int value in values) sum value; return sum; } static int ThroughInterface(IEnumerableint values) { int sum 0; foreach (int value in values) sum value; return sum; }两者语义相近调用形状不同。不要据此宣称接口版本必然分配泛型特化、逃逸分析、去虚拟化和调用上下文都会影响结果。用分配诊断与反汇编验证。数组没有ListT那样用于检测结构修改的版本号因为它不能改变长度。枚举期间另一个线程仍可修改元素枚举器不会给你快照也不会自动抛“集合已修改”。因此数组本身不提供并发一致性多线程共享需要明确的只读发布、锁、原子操作或双缓冲协议。九、复杂度、缓存与峰值内存操作典型时间复杂度额外空间/重要条件创建new T[n]O(n) 初始化量级新数组总大小需可表示索引读写O(1)范围检查引用写入有 GC/类型规则Array.Copyn 项O(n)可重叠类型转换路径可能逐项Array.Clearn 项O(n)写入defaultArray.ResizeO(min(old,new))分配新数组时存在峰值重叠Array.SortO(n log n)原地、不稳定比较器成本关键Array.BinarySearchO(log n)必须已按同一比较器排序线性查找O(n)连续访问通常局部性较好复杂度只描述随规模增长的趋势。数组的优势通常来自连续元素槽位、较少间接寻址和硬件预取但引用数组还要追随对象引用超大工作集会越过 CPU cache含大结构体的复制成本也更高。遍历矩形或展平网格时按连续存储顺序访问通常更友好行主序数据让最右维/横坐标放在内层循环。不过现代性能还受向量化、分支、NUMA、线程争用和数据布局影响。AoS结构体数组适合一起使用字段SoA多个字段数组可能更适合只扫描单一字段或 SIMD哪种更好应由实际访问模式和测量决定。峰值内存常被低估。Resize、排序的辅助结构、把数组ToArray、池保留和异步任务持有都会延长底层存储寿命。优化时同时观察分配速率、峰值、存活集、GC 暂停和缓存行为而不是只看一次方法耗时。十、Span、Memory 与 ArrayPool视图、生命周期、所有权10.1SpanT同步范围内的连续视图SpanT/ReadOnlySpanT可在不复制元素的情况下描述数组的一段int[] data Enumerable.Range(0, 100).ToArray(); Spanint middle data.AsSpan(20, 10); middle.Clear(); // 修改原数组的 20..29切片创建本身通常只建立视图不等于整个调用链“零分配”。SpanT是ref structC# 12 下不能任意装箱、存入普通堆对象、跨活跃await/yield或逃出底层存储寿命。它也不会赋予并发安全或所有权。10.2MemoryT可跨异步边界的描述不是所有者MemoryT/ReadOnlyMemoryT可存入普通对象并跨异步边界在同步片段取.Span处理。它通常仍只是对数组或其他 owner 的描述owner 被归还、释放或并发改写后view 的逻辑有效性也会失去。只读 view 只限制通过该 view 写无法阻止其他数组别名写。10.3ArrayPoolT租借可能减少分配也引入协议byte[] rented ArrayPoolbyte.Shared.Rent(requiredLength); try { Spanbyte active rented.AsSpan(0, requiredLength); Produce(active); Consume(active); } finally { ArrayPoolbyte.Shared.Return(rented, clearArray: true); }必须遵守Rent(n)返回的数组长度可能大于 n只能把逻辑有效长度交给下游内容不保证是默认值读取前先初始化Return后不得继续使用数组、Span、Memory 或原生指针不能重复归还也不能归还非该池租出的数组敏感数据和含引用元素要制定清理策略clearArray有成本异步 I/O 完成前不能提前归还。池化不是“消灭内存成本”池会保留部分数组可能放大峰值驻留、产生争用并增加 use-after-return 风险。只对测量表明分配/GC 是瓶颈且生命周期边界清晰的缓冲使用它。十一、Unity语义相似不等于实现相同11.1 Mono 与 IL2CPPUnity 支持的 C# 数组公共行为应以该 Unity 版本、API Compatibility Level 和脚本后端文档为准。Mono 后端运行 IL/JIT 或平台相关模式IL2CPP 把托管程序集转换为 C 并再经原生工具链构建两者的对象布局、GC、AOT 泛型、边界检查优化和调用成本都不能从 CoreCLRv8.0.0源码直接推出。尤其不要写成以下绝对结论“IL2CPP 下交错数组一定比多维数组快”“Unity 大数组也固定按 CoreCLR 的 LOH 阈值处理”“Editor Profiler 结果等于设备 Player”“AOT 后端一定消除/保留某次边界检查”。可靠实验矩阵至少记录Unity 版本、Editor/Player、Development/Release、Mono/IL2CPP、目标平台与 CPU、增量 GC 设置、代码剥离、Burst 版本和安全检查开关。游戏帧循环还要看 p95/p99 帧时和 GC spikes而不只看平均吞吐。11.2 Burst 与 Job System普通托管数组是 GC 管理对象不是 Burst Job 中通用的原生数据容器。高性能 Job 常使用NativeArrayT、NativeSliceT等 Unity Collections 类型并遵守 allocator、Dispose、Job 依赖和 safety handle 规则。某些静态只读托管数组或特定表达式可能受 Burst 版本的特殊支持但不能据此把任意T[]传入 Job。NativeArrayT也不是“更快数组”的无条件替代它具有非托管/原生生命周期、元素约束、边界与安全检查配置以及主线程和 Job 的依赖协议。短小主线程逻辑、托管 API 交互或引用类型数据仍可能更适合普通数组。选择依据是数据所有权、线程模型、Burst 可编译性与目标设备测量。十二、失败反例很多数组 Bug 不是越界12.1 协变把失败推迟到运行时static void PutMarker(object[] destination) { destination[0] new object(); } PutMarker(new string[1]); // 编译通过运行时失败修复方式是收紧元素类型、改成只读接口或用泛型让值与目标数组共享同一T。12.2 Resize 后旧别名仍然有效却不是同一存储缓存了数组引用的渲染器、Native 插件或异步任务不会因为另一个变量Resize而自动切换。发布新数组应采用明确的所有权与版本协议原生调用还要在合法生命周期内固定/复制不能保存会随 GC 移动的裸地址。12.3 用错误比较器排序再用默认比较器搜索string[] tags { beta, Alpha }; Array.Sort(tags, StringComparer.OrdinalIgnoreCase); // 错误排序关系与搜索关系不同 int index Array.BinarySearch(tags, alpha);必须向BinarySearch传入相同的StringComparer.OrdinalIgnoreCase。文化敏感比较还应固定文化策略避免服务环境与玩家设备差异。12.4 归还池后仍启动异步使用byte[] buffer ArrayPoolbyte.Shared.Rent(4096); Task pending stream.WriteAsync(buffer.AsMemory(0, 4096)).AsTask(); ArrayPoolbyte.Shared.Return(buffer); // 错I/O 可能尚未完成 await pending;正确做法是等待最后一个使用者完成后再归还并在异常、取消路径同样执行一次归还。12.5 把性能传言当成设计依据“foreach总比for慢”“数组访问等于裸指针”“Array.Copy 固定快若干倍”“大于阈值每帧必然 Full GC”都缺少环境和证据。性能结论必须能够被目标环境的反汇编、分配数据、GC trace 与统计测量共同支持。十三、可运行实验骨架先验证语义再测实现下面程序只依赖 .NET 8 BCL可验证数组形状、协变异常、重叠复制、Resize 别名和二分搜索返回值。它不是性能基准。!-- ArrayLab.csproj -- Project SdkMicrosoft.NET.Sdk PropertyGroup OutputTypeExe/OutputType TargetFrameworknet8.0/TargetFramework LangVersion12/LangVersion Nullableenable/Nullable Optimizetrue/Optimize /PropertyGroup /Projectusing System; Console.WriteLine($Runtime: {Environment.Version}); Console.WriteLine($64-bit process: {Environment.Is64BitProcess}); Console.WriteLine($Server GC: {System.Runtime.GCSettings.IsServerGC}); Array nonZero Array.CreateInstance( typeof(int), new[] { 2, 3 }, new[] { 1, -1 }); nonZero.SetValue(7, 2, 1); Console.WriteLine( $rank{nonZero.Rank}, lower({nonZero.GetLowerBound(0)},{nonZero.GetLowerBound(1)}), value{nonZero.GetValue(2, 1)}); try { object[] view new string[1]; view[0] new object(); } catch (ArrayTypeMismatchException ex) { Console.WriteLine(ex.GetType().Name); } int[] overlap { 1, 2, 3, 4, 5 }; Array.Copy(overlap, 0, overlap, 1, 4); Console.WriteLine(string.Join(,, overlap)); int[] resized { 1, 2, 3 }; int[] oldAlias resized; Array.Resize(ref resized, 5); Console.WriteLine($same{ReferenceEquals(resized, oldAlias)}, old{oldAlias.Length}, new{resized.Length}); int[] sorted { 1, 3, 5, 7 }; int result Array.BinarySearch(sorted, 4); Console.WriteLine($result{result}, insertAt{~result});性能实验建议用 BenchmarkDotNet 独立建项目不在单次Stopwatch输出上得出结论。至少包含for、直接数组foreach、经IEnumerableT枚举矩形、交错与手动展平并使用相同数据和访问顺序Array.Copy与等价循环区分纯值类型、引用类型和含引用结构体预分配、Resize、ListT容量增长和ArrayPoolT默认比较器、自定义比较器与不同数据分布下的 Sort/BinarySearch。报告必须写出 SDK/runtime 完整版本、OS、CPU、Release、Tiered PGO、Server/Workstation GC、数组规模、预热、迭代次数、误差与分配统计。研究 JIT 时加入反汇编诊断研究 GC 时加入 trace 和存活集不能仅凭GC.GetGeneration判断对象位于哪个物理堆或解释暂停原因。Unity 实验应另建 Player 测试不用 CoreCLR 结果替代。十四、固定版本源码阅读路线阅读dotnet/runtime v8.0.0时可按职责而非按单文件顺序追踪托管 API 契约与参数检查System.Private.CoreLib中的System.Array、泛型重载与平台分部SZArray 泛型接口SZArrayHelper及运行时数组接口映射排序/查找System.Collections.Generic.ArraySortHelperT、比较器辅助类型与 Span/MemoryExtensions 相关入口CoreCLR 数组类型与分配VM 的数组类型构造、数组 native/FCALL 入口、JIT helper 和对象分配路径JIT 导入与优化数组长度、元素访问、范围检查与 block operation 的 importer/optimizerGC 协作含引用元素的 GC 描述、写屏障与批量移动 helper。阅读时保留 tag、文件路径、方法名和 commit区分“源码里的 C# 实现”“runtime intrinsic/内部调用入口”“JIT 识别模式”和“概念解释”。不要从字段名反推出稳定 ABI也不要把某个平台 fast path 写成 API 保证。十五、审查清单与结论提交数组相关代码前逐项回答数据形状是规则矩形、不规则行还是一维连续流API 是否错误地暴露了数组协变写入口长度乘法、范围、下界和外部输入是否经过 checked 验证是否误把容量变化当原地操作遗漏旧数组别名和峰值内存排序与查找是否复用同一比较器是否需要稳定性或首个匹配含引用结构体、引用数组的清理与池归还是否覆盖异常路径Span/Memory/ArrayPool 的 owner、有效长度和最后使用者是否明确并发读写是否有发布、同步或快照协议LOH、JIT、GC、Mono、IL2CPP、Burst 结论是否标注目标版本与后端性能结论是否来自 Release 目标环境并同时检查时间、分配、GC 与峰值Array 的真正价值不只是 O(1) 下标而是它把固定长度、同质元素、连续槽位和运行时类型安全组合成一个基础存储契约。它可以非常接近硬件也始终处在托管类型系统和 GC 规则之内。掌握公共语义后再阅读固定版本源码解释 fast path最后用目标平台实验决定布局与所有权才能把数组从“最简单的数据结构”用成可靠的系统基础。下一篇List动态数组布局、扩容与操作成本