首页 > 代码库 > 从内存使用的角度来理解.Net底层架构
从内存使用的角度来理解.Net底层架构
.NET的很多概念如果总是从语法的角度或许你永远都不会理解到底为什么他会这么架构,但是如果你换个角度或许这些都会迎刃而解。从底层理解.NET的架构你就离高手更近一步了。本文只是从个人角度来瞅一眼为什么.NET的架构,若有不对的地方,还请各位指正。OK, here we go.
C/C++等程序如何使用内存
技术毕竟是一个逐渐积累进步的过程,.NET的推出不乏抄袭其他语言的地方,但是他有自己独特的地方,至于为什么会独特肯定会有语言的过人之处。
1、预备知识
C/C++编译的程序占用的内存分为以下几个部分(这里和《数据结构》中的说的是两码事哦)
1.1 栈(stack)— 由编译器自动分配释放 ,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。
1.2 堆(heap) — 一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收。
1.3、全局区(静态区)(static)—,全局变量和静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域, 未初始化的全局变量和未初始化的静态变量在相邻的另 一块区域。 程序结束后由系统释。
1.4、文字常量区 —常量字符串就是放在这里的。 程序结束后由系统释放
1.5、程序代码区—存放函数体的二进制代码。
上代码:
//这是一个前辈写的,非常详细 //main.cpp int a = 0; 全局初始化区 char *p1; 全局未初始化区 main() { int b; 栈 char s[] = "abc"; 栈 char *p2; 栈 char *p3 = "123456"; 123456/0在常量区,p3在栈上。 static int c =0; 全局(静态)初始化区 p1 = (char *)malloc(10); p2 = (char *)malloc(20); 分配得来得10和20字节的区域就在堆区。 strcpy(p1, "123456"); 123456/0放在常量区,编译器可能会将它与p3所指向的"123456" 优化成一个地方。 }
2.理论知识
2.1 申请方式
stack: 由系统自动分配。 例如,声明在函数中一个局部变量 int b; 系统自动在栈中为b开辟空间
heap: 需要程序员自己申请,并指明大小,在c中malloc函数
如p1 = (char *)malloc(10);
在C++中用new运算符
如p2 = new char[10];
但是注意p1、p2本身是在栈中的。
2.2 申请后系统的响应
栈:只要栈的剩余空间大于所申请空间,系统将为程序提供内存,否则将报异常提示栈溢出。
堆:首先应该知道操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序,另外,对于大多数系统,会在这块内存空间中的首地址处记录本次分配的大小,这样,代码中的delete语句才能正确的释放本内存空间。
另外,由于找到的堆结点的大小不一定正好等于申请的大小,系统会自动的将多余的那部分重新放入空闲链表中。
2.3 申请大小的限制
栈:在Windows下,栈是向低地址扩展的数据结构,是一块连续的内存的区域。这句话的意是栈顶的地址和栈的最大容量是系统预先规定好的,在WINDOWS下,栈的大小是2M(也有的说是1M,总之是一个编译时就确定的常数),如果申请的空间超过栈的剩余空间时,将提示overflow。因此,能从栈获得的空间较小。
堆:堆是向高地址扩展的数据结构,是不连续的内存区域。这是由于系统是用链表来存储的空闲内存地址的,自然是不连续的,而链表的遍历方向是由低地址向高地址。堆的大小受限于计算机系统中有效的虚拟内存。由此可见,堆获得的空间比较灵活,也比较大。
2.4 申请效率的比较:
栈由系统自动分配,速度较快。但程序员是无法控制的。
堆是由new分配的内存,一般速度比较慢,而且容易产生内存碎片,不过用起来最方便. 另外,在WINDOWS下,最好的方式是用VirtualAlloc分配内存,他不是在堆,也不是在栈是直接在进程的地址空间中保留一块内存,虽然用起来最不方便。但是速度快,也最灵活。
2.5 堆和栈中的存储内容
栈:在函数调用时,第一个进栈的是主函数中后的下一条指令(函数调用语句的下一条可执行语句)的地址,然后是函数的各个参数,在大多数的C编译器中,参数是由右往左入栈的,然后是函数中的局部变量。注意静态变量是不入栈的。当本次函数调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的地址,也就是主函数中的下一条指令,程序由该点继续运行。
堆:一般是在堆的头部用一个字节存放堆的大小。堆中的具体内容由程序员安排。
2.6 存取效率的比较
char s1[] = "aaaaaaaaaaaaaaa";
char *s2 = "bbbbbbbbbbbbbbbbb";
aaaaaaaaaaa是在运行时刻赋值的;
而bbbbbbbbbbb是在编译时就确定的;
但是,在以后的存取中,在栈上的数组比指针所指向的字符串(例如堆)快。
比如:
#include
void main()
{
char a = 1;
char c[] = "1234567890";
char *p ="1234567890";
a = c[1];
a = p[1];
return;
}
对应的汇编代码
10: a = c[1];
00401067 8A 4D F1 mov cl,byte ptr [ebp-0Fh]
0040106A 88 4D FC mov byte ptr [ebp-4],cl
11: a = p[1];
0040106D 8B 55 EC mov edx,dword ptr [ebp-14h]
00401070 8A 42 01 mov al,byte ptr [edx+1]
00401073 88 45 FC mov byte ptr [ebp-4],al
第一种在读取时直接就把字符串中的元素读到寄存器cl中,而第二种则要先把指针值读到
edx中,再根据edx读取字符,显然慢了。
2.7小结
Stack由系统控制,特性是FIFO,越过使用域即被系统清理,立马分配给其他变量,它的分配空间有限,但是因为在内存上所以速度比较快,常用于值类型等较轻量的变量存储.
Heap由猿们自个儿控制,比较灵活,其主要存储引用类型.其主要是系统搜索内存的空闲链表来实现分配,可分配空间较大。它的速度不快主要效率损失在一些三方面:
a.虚拟内存存储
虚拟内存是WINDOWS为了弥补内存不足,系统从硬盘中匀出的一部分磁盘空间作为内存的补充,它与内存的速度不能相比。
b.寻址过程
链表决定了系统在分配空间的时候要逐个遍历。系统在分配空间的时候会产生很多不连续的空间,假使内存足够大,寻址过程就是个不小的消耗。另外由于引用类型的销毁实际上是Stack中指针的销毁,对应的Heap中的部分并未做处理,因此就要求猿们要手动写析构函数,释放可用空间,不至于系统Heap空间不足报Overflow。
c.读取损耗
引用类型的分配是在Stack中分配一个空间作为指针,再从Heap中分配一段连续空间作为其指向,因此在使用的时候首先要从Stack中读取Heap中的内容再进行操作。
堆和栈的区别可以用如下的比喻来看出:
使用栈就象我们去饭馆里吃饭,只管点菜(发出申请)、付钱、和吃(使用),吃饱了就走,不必理会切菜、洗菜等准备工作和洗碗、刷锅等扫尾工作,他的好处是快捷,但是自由度小。
使用堆就象是自己动手做喜欢吃的菜肴,比较麻烦,但是比较符合自己的口味,而且自由度大。
.NET的如何使用内存
上面我们说了,简单的轻量类型是直接放到Stack上的,这点.NET也是这么干的。传统程序是将引用类型放到了Heap上,但是有个很头疼的问题就是对于使用之后空间的处理问题,当电脑使用时间比较长之后,系统就会明显变慢,甚至会出现内存溢出。针对这个问题.NET提出了自己的解决方案:托管堆.意思是,微软为我们提供一个管理Heap的"保姆"兼"监工"。一方面,我们代码申请的空间受它检查,另一方面它会对申请之后我们不使用的Heap空间做处理。他使用的工具就是大名鼎鼎的GC(garbage collection),说的GC我们就顺便提一下他的工作过程吧。
基本算法很简单:
● 将所有的托管内存标记为垃圾
● 寻找正被使用的内存块,并将他们标记为有效
● 释放所有没有被使用的内存块
● 整理堆以减少碎片
我们看一下后面两个工作,再针对之前C/C++对Heap的处理方面是不是有点什么意会了呢?是的,它主要就是监控已经被抛弃的Heap块,然后回收。那么为什么还要进行整理呢?前面说过,Heap的寻址是很耗时的,但是GC将整理的结果排序一下,下次申请Heap的时候只要顺着从低地址向高地址直接找就是啦,而每块地址的大小也是有记录的,指针只要沿着每块的边界找,即可很快找到未被使用的块。GC会把可回收的Heap内存块分成3个Generation,新分配的对象会放到Generation 0 中,这个因为是刚被使用过,所以回收的效率最高,回收的内存也最多,至于那些“关系户”,还在被引用的Heap块,是少数,暂时不做处理,放到Generation 1中。Generation 0的尺寸很小(小到足以放进处理器的L2 cache中),当Generation 0很快被装满之后,GC就触发了回收操作,这个动作非常块,如此循环之后Generation 1 中也总有装满的那一刻,此时便出发了GC回收Generation 1的操作,Generation 1的容量比Generation 0 要大许多,当Generation 1中发现还有被引用的Heap块,经过此时的一次回收后这些不可回收的就会放到Generation 2中,Generation 2比Generation 1 容量还要大,对Generation 2的回收过程具有很高的开销,并且此过程只有在Generation 0和Generation 1的GC过程不能释放足够的内存时才会被触发。如果对Generation 2的GC过程仍然不能释放足够的内存,那么系统就会抛出OutOfMemoryException异常。
GC中回收内存的时候会将Heap内存压缩到一起,使其连续,想一想引用类型的内存分配过程,是不是有些地方有些不对劲?是的,Stack中指针也要调整的嘛。
GC做了那么多的工作,会不会拖慢程序执行的效率呢?微软认为,虽然GC额外的增加了程序的开销,但是他预防了Overflow,另外从其工作原理来看也相应的增加了内存分配的速度,所以还是值得大家拥有的。
GC并非万能,碰到另外两种情况它也很无奈:
1.大对象。通常,大对象具有很长的生存期。当一个大对象在.NET托管堆中产生时,它被分配在堆的一个特殊部分中,这部分堆永远不会被整理。因为移动大对象所带来的开销超过了整理这部分堆所能提高的性能。
2.外部资源(External Resources)
垃圾收集器能够有效地管理从托管堆中释放的资源,但是资源回收操作只有在内存紧张而触发一个回收动作时才执行。那么,类是怎样来管理像数据库连接或者窗口句柄这样有限的资源的呢?等待,直到垃圾回收被触发之后再清理数据库连接或者文件句柄并不是一个好方法,这会严重降低系统的性能。
所有拥有外部资源的类,在这些资源已经不再用到的时候,都应当执行Close或者Dispose方法。从Beta2(译注:本文中所有的Beta2均是指.NET Framework Beta2,不再特别注明)开始,Dispose模式通过IDisposable接口来实现。这将在本文的后续部分讨论。
需要清理外部资源的类还应当实现一个终止操作(finalizer)。在C#中,创建终止操作的首选方式是在析构函数中实现,而在Framework层,终止操作的实现则是通过重载System.Object.Finalize 方法。以下两种实现终止操作的方法是等效的:
~OverdueBookLocator()
{
Dispose(false);
}
和:
public void Finalize()
{
base.Finalize();
Dispose(false);
}
在C#中,同时在Finalize方法和析构函数实现终止操作将会导致错误的产生。
除非你有足够的理由,否则你不应该创建析构函数或者Finalize方法。终止操作会降低系统的性能,并且增加执行期的内存开销。同时,由于终止操作被执行的方式,你并不能保证何时一个终止操作会被执行。
带有终止操作的对象的垃圾收集过程要稍微复杂一些。当一个带有终止操作的对象被标记为垃圾时,它并不会被立即释放。相反,它会被放置在一个终止队列(finalization queue)中,此队列为这个对象建立一个引用,来避免这个对象被回收。后台线程为队列中的每个对象执行它们各自的终止操作,并且将已经执行过终止操作的对象从终止队列中删除。只有那些已经执行过终止操作的对象才会在下一次垃圾回收过程中被从内存中删除。这样做的一个后果是,等待被终止的对象有可能在它被清除之前,被移入更高一级的generation中,从而增加它被清除的延迟时间。
需要执行终止操作的对象应当实现IDisposable接口,以便客户程序通过此接口快速执行终止动作。IDisposable接口包含一个方法——Dispose。这个被Beta2引入的接口,采用一种在Beta2之前就已经被广泛使用的模式实现。从本质上讲,一个需要终止操作的对象暴露出Dispose方法。这个方法被用来释放外部资源并抑制终止操作,就象下面这个程序片断所演示的那样:
public class OverdueBookLocator: IDisposable
{
~OverdueBookLocator()
{
InternalDispose(false);
}
public void Dispose()
{
InternalDispose(true);
}
protected void InternalDispose(bool disposing)
{
if(disposing)
{
GC.SuppressFinalize(this);
// Dispose of managed objects if disposing.
}
// free external resources here
}
}
从内存使用的角度来理解.Net底层架构