1. 数组大局观
数组是一个引用类型,也就是意味着数组的内存分配在托管堆上,并且我们在栈上维护的是他的指针而并非真正的数组。
接下来我们分析下数组的元素,其中的元素无外乎是引用类型和值类型。
当数组中的元素是值类型时,,不同于int i;这样的代码。数组会根据数组的大小自动把元素的值初始化为他的默认值。例如:
static void Main(string[] args) { int[] intArray = new int[3]; foreach(int i in intArray) { Console.WriteLine(i); } DateTime[] dtArray = new DateTime[3]; foreach (DateTime i in dtArray) { Console.WriteLine(i); } }
结果如下:
当数组中的元素是引用类型时,实际上数组中的元素是一个指向对象实际内存空间的指针,占用4Bytes的空间。
2. 谈谈零基数组
从学C语言时起,相信老师就会对我们讲,数组的第一个索引是0,而不是1。但是在C#中,我们可以去构造一个非零基数组,在这一节,我们就来把这个说透。
在常规意义上,我们初始化一个数组,都默认是零基数组,这也使得数组成为了字符串后再一个初始化时特殊的类型。正如我们知道的一样,初始化一个字符串时,对应的IL指令是newstr,同样,初始化一个零基数组对应的IL指令是newarr。
当我们希望构造一个非零基数组时,我们可以以下的语句来做到:
static void Main(string[] args) { Array intArr = Array.CreateInstance(typeof(Int32), new int[] { 5 }, new int[] { 1 }); Console.WriteLine(intArr.GetValue(1).ToString()); Console.WriteLine(intArr.GetValue(0).ToString()); }
得到的测试结果便如下:
于是便证明,我们初始化了一个非零基数组。此外,延伸一下,我们还应该通过这个记住以下两个方法:
static void Main(string[] args) { Array intArr = Array.CreateInstance(typeof(Int32), new int[] { 5 }, new int[] { 1 }); Console.WriteLine(intArr.GetLowerBound(0)); Console.WriteLine(intArr.GetUpperBound(0)); }
得到的测试结果如下:
3. 谈谈效率问题
相信会有好多阴谋论者说,C#是个类型安全的语言,也就是意味着我循环时每次访问一次数组的元素,那么就要检查一次该索引是否会造成数组越界,于是就造成了一定的性能损失。那么在这里,我们就把这个问题说透。
我们在这里把数组分成零基数组,非零基数组,多维数组,交错数组四种情况来分别讨论这个问题。
零基数组是.NET中提倡使用的类型,并且初始化时提供了特殊的IL指令newarr则充分说明了他在.NET中的特殊性,自然.NET Framework也会为其提供很大的优化待遇。在循环访问数组时,如这样的代码:
static void Main(string[] args) { int[] intArr = new int[5]; for (int i = 0; i < 4; i++) { //Some Method } }
JIT编译器只会在循环开始之前检查一次4和intArr.GetUpperBound的大小关系,之后便不会对其进行干预。也就是说JIT编译器只对其检查一次安全,因此带来的性能损失是非常小的。
而对于非零基数组,我们来比较这样两段代码:
static void Main(string[] args) { Array intArr = Array.CreateInstance(typeof(Int32), new int[] { 5 }, new int[] { 1 }); Console.WriteLine(intArr.GetValue(1).ToString()); Console.WriteLine(intArr.Length); // int[] intArr1 = new int[5]; Console.WriteLine(intArr1[1]); Console.WriteLine(intArr1.Length); }
其实两者创建的几乎是相同的数组,调用的也几乎是一样的方法,但是我们看下IL却会发现两者有着惊人的不同,首先是非零基数组的IL:
接下来是零基数组的:
我们可以发现,对于非零基数组中的大部分操作,.NET Framework都提供了对应的IL指令,我们也可以理解为.NET Framework为其提供了特殊的优化。
当然,实际上,正如CLR via C#所说的一样:.NET Framework对应非零基数组没有任何方面的优化,每次访问都需要检查其上限和下限与索引之间的关系。效率的损耗是必然的。
事实上,当我们测试这样一段代码时,也会发现其实零基数组和非零基数组的区别是很大的:
static void Main(string[] args) { Array intArr = Array.CreateInstance(typeof(Int32), new int[] { 5 }, new int[] { 1 }); Console.WriteLine("intArr的?类à型í是?:o{0}", intArr.GetType()); // int[] intArr1 = new int[5]; Console.WriteLine("intArr1的?类à型í是?:o{0}", intArr1.GetType()); }
得到的结果如下:
接下来我们再来简单地说下多维数组和交错数组。
多维数组和非零基数组一样,都没有受到.NET Framework的特殊优待。
而交错数组,其实就是数组中的数组,因此效率实际上取决于数组中的数组是零基数组还是非零基数组。
那接下来的一节,我们来具体探讨一下交错数组和多维数组的区别和应用。
4. 多维数组和交错数组
考虑到两个词的翻译问题,在这里给出两个词的英文:
多维数组:Multi-dimensional Array。
交错数组:Jagged Array。
好,下面步入正题。
首先从二者的内存分布说起。
多维数组是一个整体的数组,因此他在内存中占据一个整体的托管堆内存块。
而交错数组实际上是数组中的数组,因此我们用二维交错数组来举例,其内存如图所示:
也就是说,如果是一个3*100的数组,也就是说需要初始化101次数组,当数组的元素更加多的时候,那创建和垃圾回收将带来巨大的效率损失。
因此,也就是说:交错数组的效率瓶颈在于创建和销毁上,而并非类型安全检查上。
于是,我们就可以得出这样的结论:
当一次创建,多次访问时,我们应该创建交错数组。
当一次创建,一次访问时,我们应该创建多维数组。
5. 用代码改善效率
上面说到了,访问非零基数组和多维数组的效率是比较低的,对于非零基数组,我们的应用比较少,但是多维数组,相信每个人都或多或少有着一定的应用,那么面对其性能问题,我们该怎么办呢?
我们先来想想,多维数组的访问,性能瓶颈在安全检查上。在C语言中,为什么没有这样的问题,对,因为C语言不会做这样的检查。于是,相信聪明的大家都会想到不安全代码。
改善多维数组以及非零基数组的效率问题,我们就用不安全代码。
static unsafe void Main(string[] args) { int[,] intArr = new int[3, 3]; for (int i = 0; i < 3; i++) { for (int j = 0; j < 3; j++) { intArr[i, j] = i * 3 + j; } } fixed (int* p = &intArr[0, 0]) { for (int i = 0; i < 3; i++) { int baseOffset = i * 3; for (int j = 0; j < 3; j++) { Console.WriteLine(baseOffset + j); } } } }
这里,我们又见到了C语言中熟悉的指针,相信不需要多加介绍了。这里唯一需要注意的就是fixed,由于在垃圾回收时采用的是代机制+压缩机制,因此其内存地址很可能发生改变,因此我们应该讲数组的内存地址锁住,防止我们访问到其他的内存地址而造成我们读取数据的错误。
6. 对零基数组的精益求精
当然,即使是零基数组,我们依然在托管堆上为其分配了内存空间。如果对性能要求极高,我们知道创建一个对象也是有着一定的时间损耗,其中包括分配内存空间,同步块索引,以及指向下一块内存空间的指针等一系列复杂的操作。那么我们就放弃掉托管堆这个东东,而直接在栈中来创建这个数组,这样又省去了很多时间,从而达到了和C语言相同的效果,代码如下:
static unsafe void Main(string[] args) { int* intArr=stackalloc int[10]; for (int i = 0; i < 10; i++) { intArr[i] = i; } for (int i = 0; i < 10; i++) { Console.WriteLine(intArr[i]); } }
这样,效率就进一步提高了,对于二维数组,我们一样可以如此创建。其代码与C语言完全等同。我在这里就不继续演示了。
7. 总结
在全文中,主要是对数组的各个方面做一个比较简略地介绍,其中包括数组的基础知识,分类,以及效率性能问题,最后就是用不安全代码来访问创建数组来提高性能。
不过最后说一句,在实际工作中,如果对性能没有特别高的要求,则没必要用不安全代码来操作数组,因为其很可能因为你的一些失误而带来其他的一些安全问题,并且对代码的可读性也是个比较大的伤害,这就有些得不偿失了。
1. 写在前面的
在前文中,我主要介绍了数组的一些相关知识,希望加深各位对Array的理解,不过,看过Ivony…同学的回复,我发觉自己离说透还有很大的距离,于是就有了下面的文章。在本文中,我也主要来围绕Ivony…同学提出的几点问题来作以说明,问题如下:
A、数组在托管堆内部是怎么存放的?数组元素的位置是连续的么?
B、非零基数组可以和零基数组转换么?
C、int[]与System.Array的关系到底是同一类型?还是基类与派生类的关系?
D、ldelem不检查下标越界么?
E、多维数组可以和零基数组转换么?
F、Array.Copy和CopyTo与手动拷贝性能有多大差距?
G、数组的协变是怎么做到的?
H、数组是如何实现泛型接口(如IList<T>)的?
I、多维数组每一维度长度必须相等么?必须零基么?
J、数组的Length属性到底指示的是什么?
2. 数组内存详解
在这里,我们依然把数组分为零基数组和非零基数组来讨论。
首先来看零基数组的内存分配,废话少说,我们先来看测试代码:
static unsafe void Main(string[] args) { int[] intArr = new int[3]; intArr[0] = 1; intArr[1] = 2; intArr[2] = 3; }
代码本身很简单,接下来单步执行向下看,首先我们来查看一下源代码的汇编代码:
int[] intArr = new int[3]; 00000035 mov edx,3 0000003a mov ecx,61CD4192h 0000003f call FFFB2140 00000044 mov dword ptr [ebp-44h],eax 00000047 mov eax,dword ptr [ebp-44h] 0000004a mov dword ptr [ebp-40h],eax intArr[0] = 1; 0000004d mov eax,dword ptr [ebp-40h] 00000050 cmp dword ptr [eax+4],0 00000054 ja 0000005B 00000056 call 624B6B29 0000005b mov dword ptr [eax+8],1 intArr[1] = 2; 00000062 mov eax,dword ptr [ebp-40h] 00000065 cmp dword ptr [eax+4],1 00000069 ja 00000070 0000006b call 624B6B29 00000070 mov dword ptr [eax+0Ch],2 intArr[2] = 3; 00000077 mov eax,dword ptr [ebp-40h] 0000007a cmp dword ptr [eax+4],2 0000007e ja 00000085 00000080 call 624B6B29 00000085 mov dword ptr [eax+10h],3 }
在这里,我们就可以清晰地发现,在0x0000005b,0x00000070和0x00000085中,mov操作的目标地址之间是相隔4个Bytes的,也就是一个整数位。接下来我们来进一步证实。
当我们为数组分配过内存地址后,打开即使窗口查看数组所在的内存地址。
接下来打开内存窗口还查看0x015cc790内存块的数据:
以上是对数组赋值前的情况,赋值后的内存数据如下:
在这里可以更清晰地看出,数组元素之间差的正好是4个Bytes,也就是一个整数位。由此,我们可以得出结论。零基数组的元素在内存中是连续排布的。
接下来我们来看一下非零基数组:
由于空间所限,过程如上,就不再发,截图证明:
总之,当我们在托管堆中为数组分配内存时,数组占据一段连续的内存空间。
我们知道,当我们在托管堆中初始化一个对象时,每个对象都需要维护一个指针,该指针的作用是指向下一块空闲内存空间,由于对数组的操作经常是循环遍历等操作,这样如果把数组分配到一个连续的内存空间有一下两个好处:
A. 减少内存碎片
B. 节省内存,不需要维护指针
C. 基地址不需要发生变化,只需要改变偏移量即可,在一定程度上也提高了访问的效率。
接下来,我们还需要来补充一下数组在栈上分配内存的情况:
还记得上文中提到的这个关键字吧,stackalloc,就是他了。补充一下,在上文的回复中,有人问到说栈空间上分配的内存是不是也被垃圾回收器回收?这里的栈空间和C语言中的栈一样,没有垃圾回收器,每个变量都有他自己的作用域,当出了作用域后,变量自动销毁,具体的函数执行过程,请参看《深入理解计算机系统》。
3. 再论零基数组和非零基数组
我们先来看这样一段代码:
static void Main(string[] args) { int[,] intArr = (int[,])(Array.CreateInstance(typeof(Int32), new int[] { 3,4 }, new int[] { 1,1 })); intArr[2, 3] = 1; }
这段代码没有问题,我们将Array显式地转换成了强类型的二维数组,然后直接访问索引对其复制。
但是我们知道,对弱类型的Array而言,我们不能通过其下标访问他的元素,而只能通过SetValue和GetValue来获得值,但是我们看到SetValue和GetValue访问和设置的值的类型都是Object,这就意味着我们需要对其进行一次装箱或者拆箱。那么我们有没有办法也生成一个强类型的非零基数组呢?
在上文中,我们提到过,.NET Framework的几种数组类型:
一维零基数组:System.Int32[]。一维非零基数组:System.Int32[*]。多维数组:System.Int32[,]。
那么也就是说,我们是否能通过这样的代码来把Array转换成一维非零基数组呢?
static void Main(string[] args) { int[*] intArr = (int[*])(Array.CreateInstance(typeof(Int32), new int[] { 3}, new int[] { 1 })); }
事实证明是错误的。在CLR via C#中Jeffery有这样一段话:
“C# does not allow you to declare a variable of type string[*],and therefore it is not possible to user C# syntax to access a single-dimensional ,non-zero-based array.”
这段话翻译成中文的意思就是:C#不允许声明一个string[*]类型的变量,因此,我们能够使用C#语法来访问一个非零基一维数组。
通过以上的解释,我们也许又额外明白了一点,Array究竟是数组类型,还是数组类型的基类?
通过上面的一些代码,我们不妨又把数组重新分类为“强类型数组”和“弱类型数组”。而Array就属于弱类型数组。为什么Array是所有数组类型的基类,我没想出办法来如何证明,只是看到Jeffery说了这样一句话:
“All Arrays are Implicitly Derived from System.Array”。
我想这句话可以说明问题了,不过还是希望各位大侠指点如果证明这一点。