Sunday, March 25, 2007

.net 内存

在.NET程序中正确使用String类型

在实际程序中,String类型用得非常广泛,然而,由于.NET对String类型变量的独特管理方式,使用不当,会严重影响程序的性能。我们分几个方面来谈这个问题:


1 了解String数据的内存分配方式


编写一个控制台应用程序,输入以下测试代码:

class Program
{
static void Main(string[] args)
{
String s = "a";
s = "abcd";
}
}

使用.NET Framework 2.0 SDK提供的ildasm.exe工具查看生成的MSIL指令:

01 .method private hidebysig static void Main(string[] args) cil managed
02 {
03 .entrypoint
04 // 代码大小 14 (0xe)
05 .maxstack 1
06 .locals init ([0] string s)
07 IL_0000: nop
08 IL_0001: ldstr "a"
09 IL_0006: stloc.0
10 IL_0007: ldstr "abcd"
11 IL_000c: stloc.0
12 IL_000d: ret
13 } // end of method Program::Main

简要解释一下上述MSIL指令代码:
第06句给局部变量s分配一个索引号(索引号从0开始,如函数中有多个局部变量,其索引号按在函数中出现的顺序加一)。
在编译时编译器会将代码中的两个字串"a"和"abcd"写入到程序集的元数据(metadata)中,此时,这两个字串被称为"字串字面量(string literal)"。
第08句使用ldstr指令为字串对象"a"分配内存,并将此对象引用压入到线程堆栈中。
第09句使用stloc指令从线程堆栈顶弹出先前压入的对象引用,将其传给局部变量s(其索引号为0)。
同样的过程对"abcd"重复进行一次,所以这两句简单的代码

String s = "a";
s = "abcd";

将会导致CLR使用ldstr指令分配两次内存。
根据上述分析,读者一定明白了String变量的内容是只读的,给其赋不同的值将会导致内存的重新分配。因此,为提高程序性能,编程时应尽量减少内存的分配操作。
下面对代码中常见的字串用法进行分析,从中读者可以知道如何避免严重影响程序性能的字串操作。

2 尽量少使用字串加法运算符

请看以下两段代码:

(1) String s1 = "ab";
s1+="cd";
(2) String s1="ab"+"cd";

这两段代码运行结果一样,但速度一样快吗?
请看第(1)段代码生成的MSIL指令:

.locals init ([0] string s1)
IL_0000: nop
IL_0001: ldstr "ab"
IL_0006: stloc.0
IL_0007: ldloc.0
IL_0008: ldstr "cd"
IL_000d: call string [mscorlib]System.String::Concat(string,
string)
IL_0012: stloc.0
IL_0013: ret

再看第(2)段代码生成的指令:

.locals init ([0] string s1)
IL_0000: nop
IL_0001: ldstr "abcd"
IL_0006: stloc.0
IL_0007: ret

可以很清楚地看到,第(1)段代码将导致String类的Concat()方法被调用(实现字串加法运算)。对于第(2)段代码,由于C#编译器聪明地在编译时直接将两个字串合并为一个字串字面量,所以程序运行时CLR只调用一次ldstr指令就完成了所有工作,其执行速度谁快就不言而喻了!


3 避免使用加法运算符连接不同类型的数据

请看以下代码:

String str = "100+100=" + 200;
Console.Writeline(str);

生成的MSIL指令为:

.maxstack 2
.locals init ([0] string str)
IL_0000: nop
IL_0001: ldstr "100+100="
IL_0006: ldc.i4 0xc8
IL_000b: box [mscorlib]System.Int32
IL_0010: call string [mscorlib]System.String::Concat(object,
object)
IL_0015: stloc.0
IL_0016: ldloc.0
IL_0017: call void [mscorlib]System.Console::WriteLine(string)
IL_001c: nop
IL_001d: ret

可以清晰地看到,这两句C#代码不仅导致了String类的Concat()方法被调用(IL_0010),而且还引发了装箱操作(IL_000b)!
Concat()方法会导致CLR为新字串分配内存空间,而装箱操作不仅要分配内存,还需要创建一个匿名对象,对象创建之后还必须有一个数据复制的过程,代价不菲!
改为以下代码:

String str = "100+100=";
Console.Write(str);
Console.WriteLine(200);

生成的MSIL指令为:

.maxstack 1
.locals init ([0] string str)
IL_0000: nop
IL_0001: ldstr "100+100="
IL_0006: stloc.0
IL_0007: ldloc.0
IL_0008: call void [mscorlib]System.Console::Write(string)
IL_000d: nop
IL_000e: ldc.i4 0xc8
IL_0013: call void [mscorlib]System.Console::WriteLine(int32)
IL_0018: nop
IL_0019: ret

可以看到,虽然多了一次方法调用(Console.Write)方法,但却避免了复杂的装箱操作,也避免了调用String.Concat()方法对内存的频繁分配操作,性能更好。


4.在循环中使用StringBuilder代替String实现字串连接


在某些场合需要动态地将多个子串连接成一个大字串,比如许多复杂的SQL命令都是通过循环语句生成的。这时,应避免使用String类的加法运算符,举个简单的实例:

String str ="";
for (int i = 1; i <= 10; i++)
{
str += i;
if(i<10)
str += "+";
}

上述代码将生成一个字串:1+2+…+10。
有了前面的知识,读者一定知道这将导致进行10次装箱操作,19次字串内存分配操作(由String.Concat()方法引发),由于生成的MSIL指令太长,此处不再列出,请读者自行用ildasm.exe工具查看上述代码生成的MSIL指令。
改为以下代码,程序性能会好很多:

//预先分配1K的内存空间
StringBuilder sb = new StringBuilder(1024);
for (int i = 1; i <= 10; i++)
{
sb.Append(i);
if(i<10)
sb.Append("+");
}
String result = sb.ToString();

通过使用ildasm.exe工具查看生成的MSIL代码,发现虽然上述代码生成的MSIL指令比前面多了7条,但却避免了耗时的装箱操作,而且内存分配的次数也少了很多。当循环的次数很大时,两段代码的运行性能差异很大。




评论(0) |
20:47:34 | 固定链接 | 23 |
正在加载...

2007年1月1日星期一
.NET值类型变量"活"在哪个堆栈中?

不管是什么语言编的.NET程序,最后都会被各自的编译器编译成MSIL。当程序运行时,.NET JIT编译器从程序集中读入IL指令并将其动态编译为可被本地CP
U执行的机器指令再执行。
程序集中的IL代码以二进制方式存在,人阅读起来相当不便,正如传统的Win32程序可以被反汇编成汇编程序,.NET程序集中的IL代码也可以被反汇编成易于阅读的IL汇编程序。如果您愿意的话,可以用任意一个文本编辑器直接撰写IL汇编源代码,然后使用ilasm.exe程序将其编译为包含二进制形式的IL指令。CLR只能执行二进制的IL指令。
.NET SDK的另一个工具ildasm.exe可以用于将一个程序集反汇编为IL程序,在学习.NET时,这个工具非常有用,可以展示出高级语言(如C#和VB.NET)编写的程序是如何被CLR执行的。
然而,相比C#和VB.NET的资料满天飞,MSIL的技术资料少得可怜。我能够查阅的只有MSDN中有关IL指令的文档(还只是针对Reflection.Emit名字空间中的类的),以及一本由Serge Lidin著的《inside Microsoft .NET IL assembler》, Serge Lidin是汇编器ilasm.exe工具的主要开发者,因此,他的书应具有相当的权威性,然而,这位技术牛人的写作水平实在不敢恭维,整本书象是一本参考手册。此书国内引进了中文版,然而翻译得很不好。幸运的是其光盘中附上了英文原版,实乃国人之大幸。
IL可以看成是一个"面向对象的汇编语言",它提供了许多指令直接对对象进行操作,比如newobj指令创建对象,box指令进行装箱等
IL指令的一个最重要特性是它是基于堆栈的。几乎每一条指令都要与堆栈打交道:或者向堆栈中Push一些数据,或者从中Pop一些数据。
请看以下C#代码段:
class Program
{
static void Main(string[] args)
{
int i = 100;
int j = 200;
int reslut = i + j;
}
}
C#编译器将生成以下IL指令,其功能我在注释中有详细说明:
.method private hidebysig static void Main(string[] args) cil managed
{
.entrypoint
// 代码大小 15 (0xf)
.maxstack 2
.locals init ([0] int32 i,
[1] int32 j,
[2] int32 reslut)
IL_0000: nop
IL_0001: ldc.i4.s 100 //将100压入堆栈
IL_0003: stloc.0 //从堆栈中弹出先前压入的100,传给局部变量i
IL_0004: ldc.i4 0xc8 //将200压入堆栈
IL_0009: stloc.1 //从堆栈中弹出先前压入的200,传给局部变量j
IL_000a: ldloc.0 //将局部变量i的值压入堆栈
IL_000b: ldloc.1 //将局部变量j的值压入堆栈
IL_000c: add //连继弹出两个整数,相加得300,又压入堆栈
IL_000d: stloc.2 //从堆栈中弹出结果,保存到局部变量reslut中
IL_000e: ret //返回指令
} // end of method Program::Main
可以看到,所有的指令都涉及到堆栈。
然而,我在研究IL汇编程序的时候,却被"堆栈"两个字弄糊涂了。
几乎所有的C#书,都说值类型变量是生存在堆栈中,当函数结束时会自动销毁。那么,这里的堆栈与上述IL代码中的堆栈是不是一回事?
请看上述IL程序中有一个MaxStack指令,查看资料,得知其含义是为evaluation stack保留两个槽(slot),注意,这里的堆栈英文原文是evaluation stack,MSDN中文版译为"计算堆栈",slot可用于存放值对象,大小是可变的。换句话说,evaluation stack中的每一个slot可以存放一个值对象(对象引用也可看成是一种"特殊"的值变量,其值代表内存地址)或各种CLR直接支持的基本类型数据。
从上述IL程序中可以很明显地看到,局部变量i,j和result绝不会生存于evaluation stack,因为它只有2个slot,而我们有3个变量。那它们"活在"在哪儿?
IL程序中引人注目的一句是locals init指令,这提醒我们函数拥有另一块内存区域专用于存放局部变量,所以,声明为局部变量的值类型并不"活"在evaluation stack中。那么,为何所有的C#书(包括大名鼎鼎的Jeffrey Richter所著之《.NET框架程序设计》)都说值类型变量"活"在堆栈中?此堆栈在哪?至少有一点可以肯定,这个堆栈不会指的是evaluation stack。
用ildasm.exe查看程序集清单(manifest),发现其中有一句:
.stackreserve 0x00100000
上述语句让CLR在装入程序集时保存1M的堆栈空间,这个空间供托管进程的托管线程使用,称为线程堆栈(Thread Stack)。既是线程堆栈,自然与线程相关,由于.NET托管进程可以创建多个托管线程,因此,每个线程也应该有自己的堆栈(Jeffrey Richter说也是1M,查看也是这位老先生写的《Windows核心编程》,说在Win2000在创建线程时其堆栈大小是可调整的)。
.NET下每个托管线程都对应着一个线程函数,因此函数中定义的局部变量是在它拥有的线程堆栈中分配,而IL程序中的maxstack指令则从这一个1M的线程堆栈中再划出一块空间来作为evaluation stack。
考虑一下函数调用的问题。
IL使用call和callvirt两条指令调用特定类型所提供的方法。这就有一个函数参数传送的问题。以call指令为例,MSDN说在调用call指令之前,要将所有的实参压入evaluation stack,然后call指令再将其弹出,之后控制才会转到被调用的函数,而当被调用的函数执行完毕时,ret指令负责"将函数的返回值"从"被调用者的堆栈"(callee's evaluation stack)复制到"调用者堆栈"(caller evaluation stack)中。您看MSDN文档中居然又出现了两个堆栈,是否有点晕了吗?
查看Serge Lidin的书,他给出了这样一个图:
如上图所示:CLR会给每一个被调用的方法分配三块内存,除了上面讲到的两块(Evaluation stack和局部变量表Local Variable table),还有一块是参数表(Argument table)。
问题终于明晰了,call指令完成的工作应该是这样的:
调用者按要调用函数的参数准备好实参,将它们压入"自己的"evaluation stack中,然后,call指令执行,它从调用者的evaluation stack弹出这些参数,放入被调用函数的Argument Table中。一切准备工作就绪,这时才开始执行被调用函数的第一条IL指令。
当被调用函数执行完毕,如果有返回值,这个值应该被放在被调用函数自己的evaluation stack中(因为IL指令总是与堆栈打交道),然后,ret指令(每个函数最后一定是这条指令)将其弹出,再压入调用者的evaluation stack中,完成这一工作之后,执行流程转回到调用者。
因此,线程每调用一个函数,将导致图中所示的三块区域在1M的线程堆栈中分配给调用函数,对于递归调用的情况,后调用的函数占用的内存区域将"压"在其调用者内存区域之上,每执行完一个函数,对应的栈顶指针移动一个位移(大小刚好等于此函数先前所占用的内存),从而导致这些内存被释放,其中的局部变量不再有效。
分析.NET程序的IL指令还会得到一些有趣的结果,后面我会有更多的文章与网友们进行技术交流。
注:由于手头的资料不足,此文所述内容仅是本人对CLR内部运行机理的一个推测,如有错误,敬请指正。by the way,望有网友能提供更多的MSIL技术资料信息,在此谢谢了。:-)
转载请注明作者及出处。

0 Comments:

Post a Comment

<< Home