C#数组进阶:引用语义、浅拷贝与性能陷阱全解析
发布时间:2026/10/1 15:11:13来源:尧图网络
C# 数组Array看起来是C#里最基础的数据结构之一但我在做了几年上位机和工业通信项目之后发现真正能把数组用明白的人并不多。很多人面试时能背出“数组是引用类型、长度固定”一到写代码还是会因为浅拷贝、默认值、多维数组性能这些问题翻车。这篇文章我就从自己踩过的坑出发把C#数组的底层行为、创建方式、常用API、实战场景和替代方案串起来讲一遍适合刚学C#的初学者也适合写过一段时间但没系统梳理过数组的老手。内容不追求大而全只讲你写业务代码时真正会用到的东西。1. 从一次协议解析事故说起为什么数组的“引用语义”总是让人栽跟头我以前做过一套上位机和PLC通信的中间层协议帧固定是byte[]承载。测试时一直发现一个问题前一帧的数据会“串”到后一帧里而且不是每次都出现断断续续非常难查。接线没问题波特率没问题CRC校验也对最后定位到代码时我才反应过来某个后台解析线程持有的是同一个byte[]的引用接收缓存一刷新之前“入队”的数据就跟着变了。这就是C#数组最反直觉的地方——数组是引用类型而不是值类型。很多从C/C转过来的人会下意识把数组当成一块独立的内存区域来拷贝但C#里数组变量更像是一个“遥控器”而不是电视本身。1.1 数组变量存的是“地址”不是“数据”看这段代码int[] a new int[] { 1, 2, 3 }; int[] b a; b[0] 99; Console.WriteLine(a[0]); // 输出 99int[] a并不是直接装下了1, 2, 3这三个数而是装下了“这三个数在堆内存中的地址”。int[] b a做的事情是把地址复制给b所以a和b指向的是同一个数组对象。你通过b修改元素a当然也会跟着变。对比一下值类型int x 1; int y x; y 2; Console.WriteLine(x); // 输出 1int是值类型y x会把1直接复制一份之后怎么改y都不影响x。数组就不一样哪怕数组里的元素是int这样的值类型数组本身依然是引用对象。这个“外层引用、内层值”的模型是很多bug的来源。1.2 这个特性会带来什么实战后果最常见的问题出现在“复用缓冲数组”的场景。比如用一个byte[] buffer去循环接收网络数据每次收到数据后触发一个事件把buffer传给事件处理器。表面看代码结构很清晰实际上所有事件处理器拿到的是同一个对象byte[] buffer new byte[1024]; while (true) { int len ReadPacket(buffer); OnPacketReceived(buffer, len); // 危险下一次循环会改写 buffer }下一次ReadPacket往buffer里写数据时上一次事件里还没来得及处理完的部分就被覆盖了。正确的做法是取出有效长度后立刻复制一份或者用只依赖offset和count的视图结构并且保证视图所在的外部数组不会被人乱改。还有人在做UDP监听时把接收回调里的同一个data字节数组压入队列等后台线程慢慢处理。结果队列里存了十份“数据”其实十份都是同一块内存的当前状态处理每条时看到的几乎都是一样的内容。这是典型的“保存了引用而不是保存了数据”的问题。1.3 深拷贝不是默认选项得自己来很多人以为Clone()或者ToArray()就能复制出完全独立的数据其实不一定。Person[] people1 new Person[] { new Person(A), new Person(B) }; Person[] people2 (Person[])people1.Clone(); people2[0].Name C; Console.WriteLine(people1[0].Name); // 输出 CClone()只是把数组对象本身复制了一遍数组里每个引用类型的元素仍然指向原来的对象。你可以理解成浅拷贝复制了一份“书架清单”清单是新的但书还是同一批书。想真正深拷贝得把每个元素也重新创建一份。在协议解析、缓存管理等场景我的习惯是如果这个数组将来会被复用或者被外部持有就直接拷贝真实需要的字节数不要偷懒传引用。这不是性能问题而是正确性问题。2. 创建数组的几种方式默认值、显式初始化、多维和交错数组数组的创建方式虽然简单但默认值、多维数组和交错数组这些细节到了实际项目里经常让人困惑。尤其是新手最容易在“声明了数组但没赋初值”这类事情上翻车。2.1 一维数组的创建方式与默认值陷阱C#里常见的一维数组创建方式有这几种int[] arr1 new int[5]; // 5个元素全是0 int[] arr2 new int[] { 1, 2, 3 }; int[] arr3 { 1, 2, 3 }; // 简写只能在声明时用 var arr4 new[] { 1, 2, 3 }; // 类型推断new int[5]看起来只是分配了5个位置其实C#会立刻给每个元素一个默认值。不同类型数组的默认值如下数组元素类型默认值说明int/double/float0数值类型boolfalse布尔类型stringnull引用类型自定义classnull每个元素都是空引用struct字段默认值组合结构体每一位都初始化为默认值enum0枚举底层值新手最容易踩的坑是引用类型数组string[] names new string[3]; Console.WriteLine(names[0].Length); // NullReferenceExceptionnames[0]是null不是空字符串所以你不能直接访问它的成员。要使用必须先给每个元素赋对象for (int i 0; i names.Length; i) { names[i] string.Empty; }这里的“数组元素默认值”不是一个无关紧要的知识点它直接决定了你的代码敢不敢在循环外直接访问元素。2.2 多维数组 vs 交错数组长得像内存不一样C#里有两种“二维数组”很容易混淆int[,] rectangular new int[3, 4]; // 矩形数组 int[][] jagged new int[3][]; // 交错数组int[,]是真正的多维数组矩形数组内存连续排布可以理解成一张3行4列的表格。访问方式是rectangular[1, 2]用GetLength(0)拿行数GetLength(1)拿列数Length返回的是总元素个数。int[][]是“数组的数组”。外层数组有3个元素每个元素本身又是一个int[]。访问方式是jagged[1][2]。它的好处是每一行长度可以不一样jagged[0] new int[] { 1, 2 }; jagged[1] new int[] { 3, 4, 5, 6 }; jagged[2] new int[] { 7 };实际项目中如果处理图像、矩阵、表格这类固定行列的数据用int[,]更直观如果处理“每一行的列数不同”的数据比如一个班级里每名学生选的课程数不一样用int[][]更合适。性能上没有绝对的谁快谁慢。矩形数组整体连续在某些顺序遍历场景下缓存命中好交错数组每一行是一块独立连续内存访问某个固定行反而更灵活。遇到具体性能瓶颈最好用自己的数据实测不要凭感觉拍板。2.3 字符串数组和“指针数组”的误区很多从C转过来的同学会问C#里的“指针数组”怎么用如果指的是C里那种常见写法char* arr[]C#里最接近的、也是日常最常用的其实是string[]或char[][]。string[] lines new string[] { hello, world }; char[][] charLines new char[][] { new char[] { h, i }, new char[] { A, B, C } };C#不是不能用unsafe指针去搞int*[]这种结构但在托管代码里数组本身已经被GC管理没有必要把C那套指针数组思路照搬过来。就算做P/Invoke调用原生DLL也尽量优先用byte[]、IntPtr或Marshal.AllocHGlobal去处理而不是手动搞一层指针数组。我见过有人非要写string*指针数组编译过不去还来问原因。其实问题不在语法而在设计思路C#的字符串本身就是不可变引用类型string[]已经等价于“指向字符串对象的一组引用”你需要的“指针数组存放字符串”这件事直接用string[]就够了。3. 数组最常见的操作遍历、排序、查找、复制、裁剪数组操作是每个C#开发者都躲不开的日常。这里我把最高频的几类操作串一遍同时讲清楚哪些方法会原地改数组哪些会生成新数组。这个区别非常影响代码行为却经常被忽略。3.1 遍历和元素替换for、foreach、Array.ConvertAll遍历数组最简单的方式是foreachforeach (var item in arr) { Console.WriteLine(item); }但要注意foreach的迭代变量是只读的你修改item不会写回数组。如果想把每个元素原地乘2必须用forfor (int i 0; i arr.Length; i) { arr[i] * 2; }如果你想保留原数组、生成一个新的转换结果可以用Array.ConvertAllint[] source { 1, 2, 3 }; string[] text Array.ConvertAll(source, x x.ToString());这个方法会遍历原数组并创建新数组逻辑上和手动写for循环基本一致但可读性更好。数组长度固定所以遍历时不需要考虑增删元素这点比ListT简单。3.2 排序去重查找Array.Sort、Array.IndexOf、LINQ 的选择数组原地排序最直接的是Array.Sortint[] arr { 3, 1, 2 }; Array.Sort(arr); // 结果是 1, 2, 3 Array.Reverse(arr); // 结果是 3, 2, 1Array.Sort直接修改原数组不产生新数组。如果你不想改动原数组用LINQint[] sorted arr.OrderBy(x x).ToArray();去重可以直接用LINQ的Distinctint[] unique arr.Distinct().ToArray();Distinct默认按元素的相等性判断对引用类型来说默认是比较引用不是你自定义的“业务相等”。如果需要按某个属性去重要传IEqualityComparerT或者先Select投影再Distinct。查找方法选择上int index Array.IndexOf(arr, 5); // 找到返回索引找不到返回 -1 var item Array.Find(arr, x x 2); // 返回第一个满足条件的元素找不到返回默认值 var all Array.FindAll(arr, x x 2); // 返回所有满足条件的元素组成新数组 bool exists Array.Exists(arr, x x 2);如果是已经排序好了的数组用Array.BinarySearch更快。但要注意BinarySearch找不到时返回的是负数补码不是简单的-1int pos Array.BinarySearch(sortedArr, 4); if (pos 0) { // 找到了 } else { int insertIndex ~pos; // 如果要插入这个位置可以保持有序 }很多新手会把BinarySearch的结果直接拿来当索引用结果数组越界。遇到这类“找不到也要给信息”的API先查文档再动手。3.3 复制和裁剪Copy、BlockCopy、ArraySegment 与范围语法复制数组最简单的是Array.Copyint[] src { 1, 2, 3, 4, 5 }; int[] dst new int[3]; Array.Copy(src, 1, dst, 0, 3); // 从 src[1] 开始复制3个到 dstsrc.CopyTo(dst, 0)也能用但没有Array.Copy灵活。从数组里切出一段C# 8开始有范围语法int[] tail src[2..]; // 从索引2到末尾返回新数组 int[] middle src[1..4]; // 返回 src[1], src[2], src[3]范围语法很舒服但它会分配新数组。如果每次处理一个超大byte[]都要切片频繁分配核心内存会给GC带来压力。不复制数组、只表达“我想看这一段”的方式有ArraySegmentT和SpanTArraySegmentbyte segment new ArraySegmentbyte(buffer, 4, 10); byte first segment[0]; // 实际是 buffer[4]ArraySegment只是一个结构体记录了数组引用、偏移和长度。它没有复制数据所以原数组被改写时这个切片里看到的内容也会变。关于它和SpanT的取舍我在第5章会详细说。还有一种专门用于原始字节复制的API叫Buffer.BlockCopy它按字节数复制适合把基元类型数组转成byte[]性能比逐个赋值好很多int[] ints { 1, 2, 3, 4 }; byte[] bytes new byte[ints.Length * sizeof(int)]; Buffer.BlockCopy(ints, 0, bytes, 0, bytes.Length);注意Buffer.BlockCopy只适用于基元类型比如int、float、byte不能用于string或自定义对象。做协议封包的时候这个函数几乎是神器。4. 真实项目里最容易踩的数组坑从上位机和图像处理说起这一章我不准备讲教科书上的数组知识而是结合真实项目里常见的场景讲几个我实际遇到过的坑和解决办法。尤其是byte[]的协议解析、图像角点排序、多路视频帧缓冲如果你也做上位机或视觉相关开发这些很值得看。4.1 byte[] 与协议解析别把接收缓冲区直接扔进队列串口或网络通信里最常见的接收模式是准备一个固定大小的byte[]然后不断往里面读数据。你可能会写出这么一段看似合理的代码byte[] workBuffer new byte[4096]; Listbyte[] pending new Listbyte[](); void OnDataReceived(byte[] data, int dataLength) { pending.Add(workBuffer); // 错你加了引用不是数据 }每次收到数据都把workBuffer加进列表最后会发现列表里所有元素都指向同一个数组值全是最后一包数据。正确做法是“复制有效长度”void OnDataReceived(byte[] data, int dataLength) { byte[] packet new byte[dataLength]; Array.Copy(data, 0, packet, 0, dataLength); pending.Add(packet); }如果你嫌频繁分配数组性能差可以用线程安全的缓冲池比如ArrayPoolbyte但先要保证“正确性”再谈性能。我自己踩过这个坑后定了一个规矩只要数组会被复用、会被别人持有永远不要默认“我只保存了引用没保存数据”。在处理分包和粘包时还会用到ArraySegmentbyte来标记当前包在接收缓冲区里的位置。但切记ArraySegment并不拥有数据它只是“视图”。如果你的缓冲区是复用的最终还是要拷贝出真正的有效载荷或者用内存池保证这段内存在这段生命周期内不再被写入。4.2 用Array.Sort给OpenCvSharp的角点排序做图像校正、透视变换的时候经常要从轮廓里拿到四个角点然后按“左上、右上、左下、右下”的顺序排好。很多新手喜欢自己写复杂的坐标比较逻辑其实用数组排序就能优雅解决。先按 y 坐标把角点分成上下两排再按 x 坐标排左右OpenCvSharp.Point[] corners GetCorners(); // 假设有4个点 Array.Sort(corners, (a, b) a.Y.CompareTo(b.Y)); var top new[] { corners[0], corners[1] }.OrderBy(p p.X).ToArray(); var bottom new[] { corners[2], corners[3] }.OrderBy(p p.X).ToArray(); OpenCvSharp.Point tl top[0], tr top[1]; OpenCvSharp.Point bl bottom[0], br bottom[1];这段代码的核心思路是用Array.Sort配合自定义比较器而不是自己写一堆if。OpenCvSharp里Point本身就是结构体直接比较X、Y很直观。如果你用的版本或者自定义类型不一样可以把比较器换成ComparisonT委托逻辑完全一样。做视觉项目时角点数组通常来自轮廓查找数量可能不止4个。如果数组很大尽量不要在回调里频繁OrderBy生成新数组能原地Array.Sort就原地Array.Sort。每次图像帧回调都是几十毫秒级别的事GC卡顿非常致命。4.3 多路摄像头和多路通信缓存用数组的数组区分通道在DShow、UVC这类多摄像头回调场景里每个摄像头通常会独立回调但有些硬件驱动会把多个摄像头的数据送到同一个回调。这时候我习惯用“数组的数组”来管理每路数据byte[][] frameBuffers new byte[cameraCount][]; public void OnFrameReceived(int cameraIndex, byte[] data, int length) { if (frameBuffers[cameraIndex] null || frameBuffers[cameraIndex].Length length) { frameBuffers[cameraIndex] new byte[length]; } Array.Copy(data, 0, frameBuffers[cameraIndex], 0, length); }这样每个摄像头都有自己的独立缓冲不会互相覆盖。如果只用单个byte[] workBuffer两个摄像头回调交错时数据必然串扰。这个例子看起来朴素但很好地体现了byte[][]的用法外层数组表示通道索引内层数组存放该通道的实际数据。视觉处理、多轴运动控制、多传感器采集里经常用这个模式。4.4 数组增加、分割和去重的常见写法与效率提醒C#的数组长度是固定的没有Add方法。如果你要在数组“后面加一个元素”只能重新创建更大的数组再复制int[] arr { 1, 2, 3 }; Array.Resize(ref arr, arr.Length 1); arr[^1] 4;Array.Resize内部其实是创建新数组然后把旧数据拷贝过去再把引用指向新数组。如果频繁使用效率很低。正确做法是需要动态增删的时候直接用ListT只有最终需要固定数组给外部API时再调用list.ToArray()。数组分割在.NET 6之后可以直接用Chunkint[] arr { 1, 2, 3, 4, 5, 6, 7, 8 }; var chunks arr.Chunk(3); // 分成 {1,2,3}, {4,5,6}, {7,8}每个chunk是一个新数组注意内存开销。数据量小无所谓量大的话最好自己写一个按offset、length遍历的方法避免同时产生多个数组对象。数组去重最省事的是Distinctint[] unique arr.Distinct().ToArray();如果去重的同时还想保留原数组顺序Distinct正好满足如果不在乎顺序也可以用HashSetint手动收集var set new HashSetint(arr); int[] unique set.ToArray();哈希的方式在大数据量下通常更快但会引入额外集合对象。每个项目约束不一样没有万能答案。5. 数组不是银弹什么时候该换成 List、Span 或 ArraySegment数组在C#里很强大但它不是万能的。有些场景用数组会写出又慢又绕的代码换成集合或视图结构会舒服很多。5.1 List 内部也是数组ListT的底层实现就是一个数组只是帮你做了动态扩容Listint list new Listint(); list.Add(1); list.Add(2);当内部数组容量不够时List会创建一个更大的新数组把旧数据拷贝过去。这个操作同样有成本所以如果预先知道元素数量最好指定容量Listint list new Listint(1000);数组和ListT的取舍我一般这么看场景更合适的选择长度固定、随机访问多数组需要频繁添加/删除末尾ListT需要频繁在中间插入/删除LinkedListT或专门数据结构传给原生DLLbyte[]或IntPtr方法返回值希望只读IReadOnlyListT或ReadOnlySpanT大批量原始字节复制byte[]Buffer.BlockCopy数组和ListT都支持索引访问但数组不需要检查版本号某些循环场景编译器可以做得更激进。不过这种微优化远没有代码清晰度重要不要过早纠结。5.2 Span 和 ArraySegment 视图而不是复制SpanT是C#里处理数组切片非常好用的结构byte[] buffer new byte[100]; Spanbyte header buffer.AsSpan(0, 4); Spanbyte payload buffer.AsSpan(4, 96);它不会分配新数组也不会复制数据。你通过Span修改元素原数组会同步变化。因为SpanT是ref struct它只能存在于栈上不能作为类的字段也不能跨越await使用。这点非常关键。ArraySegmentT也是“视图”但它可以存到字段里适合异步场景ArraySegmentbyte segment new ArraySegmentbyte(buffer, 4, 10);但同样它内部保存的是数组引用不是复制。如果原来的buffer被复用切片里的内容也会变。所以协议解析里要用好它们必须想清楚“谁拥有这段数据的生命周期”。在方法签名上我个人偏向用ReadOnlySpanbyte来表达“只读一段二进制数据”而不是直接传byte[]。ReadOnlySpan一眼就能告诉调用者我只负责读不会保存你的引用。这比写注释更可靠。5.3 给初学者的选型建议和性能直觉如果你刚开始用C#我建议先别急着上各种高级集合把数组的引用语义、默认值、复制行为彻底搞透。然后遇到“长度不确定”的需求时大胆用ListT遇到“只需要看一段数据”的需求时试试ReadOnlySpanT遇到“要把一个片段传给后台异步处理”的需求时要么用ArraySegmentT并明确生命周期要么老老实实复制出独立数组。下面这个表可以直接抄作业需求推荐方案固定数量、快速随机访问T[]动态数量、频繁增删末尾ListT解析超大文件或网络包只看不复制ReadOnlySpanT需要把视图存起来异步处理ArraySegmentT或复制批量转字节流byte[]Buffer.BlockCopy去重、集合比较HashSetT排序后需要新数组Array.Sort副本 或OrderBy最后分享一个我自己的体会写代码时不要默认“数组复制很贵”也不要默认“引用传递一定省”。实际项目里复制一个几百字节的协议帧成本微乎其微但复制一个几十兆的图像帧就可能拖垮一帧的处理时间。所以关键不是记结论而是搞清楚你的数据有多大、生命周期有多长、谁拥有它。如果你现在还在为数组的某个行为困惑我建议回去把“引用语义、浅拷贝、Span和ArraySegment”这三件事画张图很多问题不用问人就通了。
网站建设高端定制企业官网