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。
数组之所以是一切集合的基石,不是因为所有集合都直接继承它,而是因为连续存储非常适合实现动态数组、环形队列、哈希桶、堆和排序缓冲区。List<T>、Queue<T>、Stack<T>等类型都可以在数组上增加容量、逻辑长度或首尾索引,但数组本身仍有独立而严格的契约:长度创建后不变,元素可按索引读写,所有元素具有同一数组元素类型。
本文采用以下证据标记:
- 公共契约:可由 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,并在适用时实现IEnumerable<T>、ICollection<T>、IList<T>等接口。
接口实现不意味着数组变成可增长集合。通过IList<T>读取、覆盖已有位置通常可行,Add、Insert、Remove等改变长度的操作会失败,因为ICollection<T>.IsReadOnly描述的是接口能否增删,并不等价于“元素不可写”。这是一个常见但容易忽略的契约差异。
int[] values = { 10, 20, 30 }; IList<int> 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.Empty<int>() };交错数组允许每行不同长度,也允许某个元素为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++ 结构和偏移仍是实现内部协议。需要与原生代码交互时,应使用受支持的固定、封送、Span<T>、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 -> Base,C# 允许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 引用。泛型集合也默认不协变:List<string>不能转换成List<object>。只读泛型接口可按其声明的 variance 规则转换,但那是另一套契约。
5.2 不要把每次写入描述成固定的昂贵调用
从公共语义看,协变路径必须保证写入安全;从实现看,JIT/运行时可以根据静态类型、值类型和具体数组类型选择快慢路径。不能笼统声称“每次引用数组写入都调用某个 MethodTable 函数”,也不能给出无环境的固定百分比。
更重要的是 API 设计风险:接收object[]的方法可能拿到string[],即使签名看起来允许写任意对象。若方法只读,使用ReadOnlySpan<object>、IEnumerable<object>或带约束的泛型接口表达意图;若方法需要写,应尽量保持精确元素类型。
static void Fill<T>(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写入的是default
Array.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 逐次增长到 n+1,总复制量会形成 O(n²);需要动态增长时应使用按容量策略扩展的List<T>或预估最终尺寸。缩小时被截断元素不再属于新数组,但旧数组若仍被别名引用,它们依然存在。
七、排序与查找:算法契约比算法名字更重要
7.1.NET 8 Array.Sort的公共承诺
Array.Sort提供整数组、区间、键值对数组、IComparer<T>和Comparison<T>等重载。业务代码应依赖这些事实:
- 按指定或默认比较器原地排序;
- 通常为 O(n log n) 比较量级;
- 不保证稳定,相等键的原相对顺序可能改变;
- 比较器必须能为参与元素建立自洽顺序;
- 比较器或元素比较抛异常时,数组可能已经部分重排。
在固定v8.0.0源码中,泛型排序入口可沿System.Collections.Generic.ArraySortHelper<T>等类型追踪;典型通用路径采用 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 : IComparer<int> { 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# 编译器能采用数组专用枚举形状,通常接近按索引循环。若先向上转型为IEnumerable<T>、IEnumerable或Array,则可能走不同接口和枚举器路径,带来虚调用、装箱或分配;是否被 JIT 去虚拟化必须实测。
static int Direct(int[] values) { int sum = 0; foreach (int value in values) sum += value; return sum; } static int ThroughInterface(IEnumerable<int> values) { int sum = 0; foreach (int value in values) sum += value; return sum; }两者语义相近,调用形状不同。不要据此宣称接口版本必然分配;泛型特化、逃逸分析、去虚拟化和调用上下文都会影响结果。用分配诊断与反汇编验证。
数组没有List<T>那样用于检测结构修改的版本号,因为它不能改变长度。枚举期间另一个线程仍可修改元素,枚举器不会给你快照,也不会自动抛“集合已修改”。因此数组本身不提供并发一致性:多线程共享需要明确的只读发布、锁、原子操作或双缓冲协议。
九、复杂度、缓存与峰值内存
| 操作 | 典型时间复杂度 | 额外空间/重要条件 |
|---|---|---|
创建new T[n] | O(n) 初始化量级 | 新数组;总大小需可表示 |
| 索引读写 | O(1) | 范围检查;引用写入有 GC/类型规则 |
Array.Copyn 项 | O(n) | 可重叠;类型转换路径可能逐项 |
Array.Clearn 项 | O(n) | 写入default |
Array.Resize | O(min(old,new)) | 分配新数组时存在峰值重叠 |
Array.Sort | O(n log n) | 原地、不稳定;比较器成本关键 |
Array.BinarySearch | O(log n) | 必须已按同一比较器排序 |
| 线性查找 | O(n) | 连续访问通常局部性较好 |
复杂度只描述随规模增长的趋势。数组的优势通常来自连续元素槽位、较少间接寻址和硬件预取;但引用数组还要追随对象引用,超大工作集会越过 CPU cache,含大结构体的复制成本也更高。
遍历矩形或展平网格时,按连续存储顺序访问通常更友好:行主序数据让最右维/横坐标放在内层循环。不过现代性能还受向量化、分支、NUMA、线程争用和数据布局影响。AoS(结构体数组)适合一起使用字段,SoA(多个字段数组)可能更适合只扫描单一字段或 SIMD;哪种更好应由实际访问模式和测量决定。
峰值内存常被低估。Resize、排序的辅助结构、把数组ToArray、池保留和异步任务持有都会延长底层存储寿命。优化时同时观察:分配速率、峰值、存活集、GC 暂停和缓存行为,而不是只看一次方法耗时。
十、Span、Memory 与 ArrayPool:视图、生命周期、所有权
10.1Span<T>:同步范围内的连续视图
Span<T>/ReadOnlySpan<T>可在不复制元素的情况下描述数组的一段:
int[] data = Enumerable.Range(0, 100).ToArray(); Span<int> middle = data.AsSpan(20, 10); middle.Clear(); // 修改原数组的 20..29切片创建本身通常只建立视图,不等于整个调用链“零分配”。Span<T>是ref struct,C# 12 下不能任意装箱、存入普通堆对象、跨活跃await/yield或逃出底层存储寿命。它也不会赋予并发安全或所有权。
10.2Memory<T>:可跨异步边界的描述,不是所有者
Memory<T>/ReadOnlyMemory<T>可存入普通对象并跨异步边界,在同步片段取.Span处理。它通常仍只是对数组或其他 owner 的描述;owner 被归还、释放或并发改写后,view 的逻辑有效性也会失去。只读 view 只限制通过该 view 写,无法阻止其他数组别名写。
10.3ArrayPool<T>:租借可能减少分配,也引入协议
byte[] rented = ArrayPool<byte>.Shared.Rent(requiredLength); try { Span<byte> active = rented.AsSpan(0, requiredLength); Produce(active); Consume(active); } finally { ArrayPool<byte>.Shared.Return(rented, clearArray: true); }必须遵守:
Rent(n)返回的数组长度可能大于 n,只能把逻辑有效长度交给下游;- 内容不保证是默认值,读取前先初始化;
Return后不得继续使用数组、Span、Memory 或原生指针;- 不能重复归还,也不能归还非该池租出的数组;
- 敏感数据和含引用元素要制定清理策略;
clearArray有成本; - 异步 I/O 完成前不能提前归还。
池化不是“消灭内存成本”:池会保留部分数组,可能放大峰值驻留、产生争用并增加 use-after-return 风险。只对测量表明分配/GC 是瓶颈且生命周期边界清晰的缓冲使用它。
十一、Unity:语义相似不等于实现相同
11.1 Mono 与 IL2CPP
Unity 支持的 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 常使用NativeArray<T>、NativeSlice<T>等 Unity Collections 类型,并遵守 allocator、Dispose、Job 依赖和 safety handle 规则。某些静态只读托管数组或特定表达式可能受 Burst 版本的特殊支持,但不能据此把任意T[]传入 Job。
NativeArray<T>也不是“更快数组”的无条件替代:它具有非托管/原生生命周期、元素约束、边界与安全检查配置,以及主线程和 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 = ArrayPool<byte>.Shared.Rent(4096); Task pending = stream.WriteAsync(buffer.AsMemory(0, 4096)).AsTask(); ArrayPool<byte>.Shared.Return(buffer); // 错:I/O 可能尚未完成 await pending;正确做法是等待最后一个使用者完成后再归还,并在异常、取消路径同样执行一次归还。
12.5 把性能传言当成设计依据
“foreach总比for慢”“数组访问等于裸指针”“Array.Copy 固定快若干倍”“大于阈值每帧必然 Full GC”都缺少环境和证据。性能结论必须能够被目标环境的反汇编、分配数据、GC trace 与统计测量共同支持。
十三、可运行实验骨架:先验证语义,再测实现
下面程序只依赖 .NET 8 BCL,可验证数组形状、协变异常、重叠复制、Resize 别名和二分搜索返回值。它不是性能基准。
<!-- ArrayLab.csproj --> <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net8.0</TargetFramework> <LangVersion>12</LangVersion> <Nullable>enable</Nullable> <Optimize>true</Optimize> </PropertyGroup> </Project>using 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、经IEnumerable<T>枚举;- 矩形、交错与手动展平,并使用相同数据和访问顺序;
Array.Copy与等价循环,区分纯值类型、引用类型和含引用结构体;- 预分配、
Resize、List<T>容量增长和ArrayPool<T>; - 默认比较器、自定义比较器与不同数据分布下的 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.ArraySortHelper<T>、比较器辅助类型与 Span/MemoryExtensions 相关入口; - CoreCLR 数组类型与分配:VM 的数组类型构造、数组 native/FCALL 入口、JIT helper 和对象分配路径;
- JIT 导入与优化:数组长度、元素访问、范围检查与 block operation 的 importer/optimizer;
- GC 协作:含引用元素的 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:动态数组布局、扩容与操作成本