.NET中数组的隐秘特性

** .NET中数组的隐秘特性 **

** 背景知识
** Array类是所有数组类型的基类,上一篇文章《.NET中String类的隐秘特性》中提到:数组的长度不是固定不变的,是可变的。
首先了解一些相关的概念:
数组元素:数组包含的值;
数组长度:数组可以包含的元素的个数;
维度数:数组的维度总数;
下限:数组指定的维度的起始索引。多维数组每个维可以有不同的下限。
运行时有两种不同的数组实现--SZ数组和普通数组。SZ数组是以0为下限的一维数组;普通数组指多维的或者下限不为0的数组。有时候我们称呼多维数组为MD数组。由于SZ数组较常用,微软对它的性能进行了极大的优化。下面的表详细列出了SZ数组与MD数组的区别。

SZ 数组MD 数组
定义一维的,以 0 为下限的数组多维的,或者下限不为 0 的数组
C# 语法

Object[]

Object[][] ( 交错数组 ) | Object[,] --- 二维数组
是否兼容 CLS | 兼容(交错数组除外) | 不兼容
IL 优化 | 使用专用的 IL 指令来操作这些数组,比如: ldlen , stelem 等等 | 在 1.0 版本,没有专用的 IL 指令,对数组的所有操作都是通过方法调用来实现
方法优化 | 基元类型数组有专用的方法,这些方法在操作一些值类型数组时不用反复的装箱,所以具有较高的性能 | 在 1.0 版本,引用类型和值类型数组使用同样的方法。值类型在方法调用时被反复地装箱和拆箱,造成了极大的性能冲击
基本长度(不包括 8 字节的方法表指针和对象头) |

值类型数组 — 4 字节

引用类型数组 — 8 字节 |

值类型数组 — 4+8*rank( 维度数 )

引用类型数组 — 8+8*rank( 维度数 )

JIT 优化 | JIT 编译器消除了范围检查 | JIT 编译器没有对它进行优化。 CLR 将会执行额外的代码对每一维进行范围检查

表中的一些内容在文章后面进行了比较详细的讲述。从表中我们可以清楚地看到,SZ数组性能要远远优于MD数组,交错数组可以看作数组元素是SZ数组的SZ数组,当然在性能上它要优于MD数组。不过要记住一点,交错数组不兼容CLS,因此它不能在不同的语言编写的代码之间传递。
** 数组的IL优化 **
using System;
namespace abc
{
class Class1
{
[STAThread]
static void Main(string[] args)
{
int[] A=new int[5];
int[,] C=new int[5,5];
A[0]=1;
C[0,0]=1;
}
}
}
上面代码的IL代码如下:
.method private hidebysig static void Main(string[] args) cil managed
{
.entrypoint
.custom instance void [mscorlib]System.STAThreadAttribute::.ctor() = ( 01 00 00 00 )
// Code size 29 (0x1d)
.maxstack 4
.locals ([0] int32[] A,
[1] int32[0...,0...] C)
IL_0000: ldc.i4.5
IL_0001: newarr [mscorlib]System.Int32
IL_0006: stloc.0
IL_0007: ldc.i4.5
IL_0008: ldc.i4.5
IL_0009: newobj instance void int32[0...,0...]::.ctor(int32,
int32)
IL_000e: stloc.1
IL_000f: ldloc.0
IL_0010: ldc.i4.0
IL_0011: ldc.i4.1
IL_0012: stelem.i4
IL_0013: ldloc.1
IL_0014: ldc.i4.0
IL_0015: ldc.i4.0
IL_0016: ldc.i4.1
IL_0017: call instance void int32[0...,0...]::Set(int32,
int32,
int32)
IL_001c: ret
} // end of method Class1::Main
对比一下给SZ数组和MD数组付值的IL代码:给数组A付值使用stelem.i4 指令,而给多维数组付值则必须调用Set方法。

** 数组内部字段 **
SZ数组和MD数组都包含有下面2个内部字段。

变量

|

类型

|

描述

---|---|---

Array Length

|

int

|

数组中实际的元素个数

Element Type

|

Type

|

从源代码看,这一字段只在数组包含“指针”的情况下才被使用。这里,“指针”指的是对象的引用,不是非托管代码中的指针

除了上面两个字段外,MD数组还包含下面两个字段。

变量

|

类型

|

描述

---|---|---

Bounds[rank]

|

int[]

|

数组某一维的元素个数

LowerBound[rank]

|

int[]

|

数组某一维的下限。合法的索引应该满足条件: lowerBounds[i] <= index[i] < lowerBounds[i] + bounds[i]

通过下面的例子和图示,你可以更好的了解这些内部字段。下面的非托管代码的主要目的是确定上述的内部字段在内存中的分布。
using System;
namespace ABC
{
class Class1
{
[STAThread]
static void Main(string[] args)
{
int[] A=new int[5];
byte[] a=GetBytes(A,4);
//输出数组A的长度(Array Length)
Console.WriteLine(BitConverter.ToInt32(a,0));

int[,,] B=new int[2,3,4];
// 4+8rank(维度数): 4+38=28
byte[] b=GetBytes(B,28);
//分别输出数组B的长度,第1,2,3维的长度和第1,2,3维的下限
for (int i=0;i<28;i=i+4)
Console.WriteLine(BitConverter.ToInt32(b,i));
Console.ReadLine();
}
static byte[] GetBytes(int[] array,int count)
{
unsafe
{
byte[] b=new byte[count];
byte *pb;
fixed (int *p=&array[0])
pb=(byte *)p;
pb=pb-count;
for (int i=0;i<count;i++)
{
b[i]=*pb++;
}

return b;
}
}
static byte[] GetBytes(int[,,] array,int count)
{
unsafe
{
byte[] b=new byte[count];
byte *pb;
fixed (int *p=&array[0,0,0])
pb=(byte *)p;
pb=pb-count;
for (int i=0;i<count;i++)
{
b[i]=*pb++;
}
return b;
}
}
}
}
根据上面代码的运行结果,我们可以确定数组的内部字段在内存中的分布,如图1所示(我在许多条件下测试了数组各内部字段在内存中的分布,发现它们的排列顺序总是如图)。


图1

对普通数组的访问必须检查几个内部成员,这会对性能造成一定的影响。一般地,我们有两种办法来优化普通数组的性能:一种是使用交错数组;另一种是使用非安全代码访问。

** 数组类型与分类 **
如果两个数组有着相同的维度数和相同的元素类型,我们认为这两个数组具有相同的类型,与C/C++不同,这里每一维的上限和下限不予考虑,下面的代码说明了这点。一些方法(比如Array.Copy)在操作多维数组时,它们在内部将多维数组看作一个一维数组(数组长度是各维长度的总和)。
Array A=Array.CreateInstance(typeof(int),new int[2]{2,2},new int[2]{-1,-1});
Array B=Array.CreateInstance(typeof(int),new int[2]{3,3},new int[2]{-10,-2});
if (A.GetType().Equals(B.GetType()))
Console.WriteLine("数组A与B属于同一类型");
具有不同维度数的交错数组属于不同的类型,比如:
int[][] A=new int[2][];
int[][][] B=new int[2][][];
A与B是不同的类型。道理比较显然,我们可以认为交错数组的元素是数组,A与B的元素类型是不一样的,所以A与B属于不同的类型。比较有意思的是,基类Array类型调用Type.IsArray()方法返回值是false,调用Type.GetElementType()方法返回值是null。
除了基本长度外,数组还包含了一些数据,如图1所示。值类型数组包含的是未装箱的结构(连续排列),引用类型数组则包含了指向引用对象的指针(连续排列)。另外,引用类型数组在指针数据块之前还有一个元素类型字段(ElementType)。读者也许会认为:通过数组的方法表可以获得有关元素类型的信息,这个字段显得有点多余了。其实不然,通过这个字段,可以迅速地获得类型信息,另外,这对于数组的其他特性,比如数组变异(Array Covariance),是非常重要的(后面会详细讲述这点)。
如果数据是值类型,那么元素的长度与相应的值类型一样,引用类型则占用IntPtr.Size个字节。IntPtr.Size在Win32系统中是4个字节,在64位系统中是8个字节。依据微软的文档记录,IntPtr.Size与Void *指针的本地字节数相同,但是在非Win32的Rotor包(比如Mac和Unix),不管CPU是什么,IntPtr.Size总是8个字节。

类型

|

元素的字节长度

---|---

bool

|

1

byte

|

1

short

|

2

int

|

4

long

|

8

float

|

4

double

|

8

decimal

|

16

string

|

IntPtr.Size

object

|

IntPtr.Size

interface

|

IntPtr.Size

你不能通过反射来访问数组的内部字段,那是不是需要使用非安全代码来访问内部字段?在这里,没有这个必要,因为Array的内部字段都通过公共方法和属性公开了。比如:GetLength()方法返回数组中指定维度的元素个数。相关的更详细的内容可以参考MSND。
上面提到了两种数组的分类:SZ数组和MD数组;值类型数组和引用类型数组。在代码中我们该如何判断它们?
下面的代码用来判断数组是否SZ数组:
if (array.Rank==1 && array.GetLowerBound(0)==0){}
下面的代码用来判断数组是否值类型数组:
if ((elementType = array.GetType().GetElementType()) && elementType.IsSubclassOf(typeof(ValueType)) && elementType != typeof(Enum) && elementType != typeof(ValueType)){}
有意思的是,Enum[]或者ValueType[]都不是值类型数组,它们包含的元素是指向装箱值类型的引用。
** 动态的ArrayList类
** ArrayList类是处理动态数组的一个很有用的类,除此之外,它还可以用来封装集合类。
ArrayList类允许创建一个内部数组对象并对数组进行直接的修改。没有显式设置ArrayList容量的情况下,使用默认容量(16),ArrayList创建的数组的长度是16。下面表中列出的是ArrayList类的四个内部成员。

变量

|

类型

|

描述

---|---|---

_items

|

Object[]

|

内部数组

_size

|

int

|

ArrayList 实例实际包含的元素数

_version

|

int

|

在每次对 ArrayList 进行修改后, _version 都会递增。

_defaultCapacity

|

int

|

常量字段,表示默认容量

一个ArrayList实例共占用20字节的内存(8字节的对象开销内存+12字节的实例信息),这不包括内部数组(_items)占用的空间。
给ArrayList添加新元素时(比如调用AddRange方法),需要超出ArrayList的初始容量,ArrayList将会自动扩大容量。ArrayList的容量

Published At
Categories with Web编程
Tagged with
comments powered by Disqus