(一)C语言内存分配
C 语言内存分配的框图如下,一个正常的执行代码, 操作系统需要给他分配一段内存区域,这一大块内存区域还要分为几个小区域。
- 文本段(Text segment)
- 初始化数据段(Initialized data segment)
- 未初始化数据段(Uninitialized data segment)
- 栈(Stack)
- 堆(Heap)
- 文字常量区
1. 文本段
文本段,也称为代码段,是目标文件或内存中包含可执行指令的程序的一部分。作为存储区域,文本段可以放置在堆或堆栈下方,以防止堆和堆栈溢出覆盖它。通常,文本段是可共享的,因此对于频繁执行的程序(例如文本编辑器,C 编译器,shell 等),只需要一个副本就可以在内存中。此外,文本段通常是只读的,以防止程序意外修改其指令。文本段可以看做这段代码的大脑,需要怎么执行,做什么,都把数据保存在这个位置了。
2.初始化数据段
初始化数据段,通常简称为数据段。数据段是程序的虚拟地址空间的一部分,其包含由程序员初始化的全局变量和静态变量。请注意,数据段不是只读的,因为变量的值可以在运行时更改。该段可以进一步分类为初始化的只读区域和初始化的读写区域。
3.未初始化的数据段
未初始化的数据段,通常称为“bss”段,以古代汇编运算符命名,代表“由符号启动的块”。此段中的数据在程序启动之前由内核初始化为算术 0 执行。未初始化的数据从数据段的末尾开始,包含初始化为零或在源代码中没有显式初始化的所有全局变量和静态变量。
初始化与未初始化的数据段可以合起来称为全局区(静态区),程序结束后由系统释放。
4.栈:
栈区域由编译器自动分配释放,一般存放函数的参数值、局部变量的值等。
栈区域传统上与堆区域相邻并向相反方向增长;当栈指针遇到堆指针时,可用内存耗尽(因为理论上这个两个家伙是不可能相遇的)。(使用现代大地址空间和虚拟内存技术,它们几乎可以放置在任何地方,但它们通常仍会朝着相反的方向发展)
栈区域包含程序栈,LIFO结构,通常位于存储器的较高部分。在标准的PC x86计算机体系结构上,它向零地址发展;在其他一些架构上,它朝着相反的方向发展。“栈指针”寄存器跟踪堆栈的顶部;每次将值“推”到栈上时都会调整它。
5. 堆
堆是通常发生动态内存分配的段。堆区域从BSS 段的末尾开始,并从那里增长到更大的地址。堆区域由malloc, realloc和free管理,可以使用brk和sbrk系统调用来调整其大小;它们也可以使用mmap实现,将不连续的虚拟内存区域保留到进程的“虚拟地址空间”中。堆区域由进程中的所有共享库和动态加载的模块共享。
6. 常量区
常量字符串存放于此,程序结束后由系统释放。
7. 示例
int a = 0; // 全局初始化区
char *p1; // 全局未初始化区
int 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"优化成一个地方。
}
8. 堆与栈的区别
(1)申请方式
- 栈:由系统自动分配。e.g. 声明在函数中一个局部变量“int b;”,系统自动在栈中为b开辟空间。
- 堆:需要程序员自己申请,并指明大小,但指针本身是存储于栈中的。
(2)申请后系统的响应
- 栈:只要栈的剩余空间大于所申请空间,系统将为程序提供内存,否则将报异常提示栈溢出。
- 堆:首先应该知道操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序,另外,对于大多数系统,会在这块内存空间中的首地址处记录本次分配的大小,这样,代码中的delete语句才能正确的释放本内存空间。另外,由于找到的堆结点的大小不一定正好等于申请的大小,系统会自动的将多余的那部分重新放入空闲链表中。
(3)申请大小的限制
- 栈:在Windows下,栈是向低地址扩展的数据结构,是一块连续的内存的区域。这句话的意 思是栈顶的地址和栈的最大容量是系统预先规定好的,如果申请的空间超过栈的剩余空间时,将提示overflow。因此,能从栈获得的空间较小。
- 堆:堆是向高地址扩展的数据结构,是不连续的内存区域。这是由于系统是用链表来存储的空闲内存地址的,自然是不连续的,而链表的遍历方向是由低地址向高地址。堆的大小受限于计算机系统中有效的虚拟内存。由此可见,堆获得的空间比较灵活,也比较大。
(4)申请效率的比较:
- 栈:由系统自动分配,速度较快。但程序员是无法控制的。
- 堆:由new分配的内存,一般速度比较慢,而且容易产生内存碎片,不过用起来最方便。另外,在WINDOWS下,最好的方式是用VirtualAlloc分配内存,他不是在堆,也不是在栈是直接在进程的地址空间中保留一块内存,虽然用起来最不方便,但是速度快,也最灵活。
(5)堆和栈中的存储内容
- 栈:在函数调用时,第一个进栈的是主函数中后的下一条指令(函数调用语句的下一条可执行语句)的地址,然后是函数的各个参数,在大多数的C编译器中,参数是由右往左入栈的,然后是函数中的局部变量。注意静态变量是不入栈的。 当本次函数调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的地 址,也就是主函数中的下一条指令,程序由该点继续运行。
- 堆:一般是在堆的头部用一个字节存放堆的大小。堆中的具体内容由程序员安排。
(6)存取效率的比较
char s1[] = "aaa"; // aaa是在运行时刻赋值的;
char *s2 = "bbb"; // bbb是在编译时就确定的;
但是,在以后的存取中,在栈上的数组比指针所指向的字符串(例如堆)快。
e.g.
int main()
{
char a = 1;
char c[] = "1234567890";
char *p ="1234567890";
a = c[1];
a = p[1];
return 0;
}
对应的汇编代码
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读取字符,显然慢了。
9. 异常对象
执行throw语句时,throw表达式将作为对象被复制构造为一个新的对象,称为异常对象。异常对象放在内存的特殊位置,该位置既不是栈也不是堆,在window上是放在线程信息块TIB中。
栈展开、RAII
栈展开就是从异常抛出点一路向外层函数寻找匹配的catch语句的过程,寻找结束于某个匹配的catch语句或标准库函数terminate。这里重点要说的是栈展开过程中对局部变量的销毁问题。我们知道,在函数调用结束时,函数的局部变量会被系统自动销毁,类似的,throw可能会导致调用链上的语句块提前退出,此时,语句块中的局部变量将按照构成生成顺序的逆序,依次调用析构函数进行对象的销毁。例如下面这个例子:
//一个没有任何意义的类
class A
{
public:
A() :a(0){ cout << "A默认构造函数" << endl; }
A(const A& rsh){ cout << "A复制构造函数" << endl; }
~A(){ cout << "A析构函数" << endl; }
private:
int a;
};
int main()
{
try
{
A a ;
throw a;
}
catch (A a)
{
;
}
return 0;
}
程序将输出:
定义变量a时调用了默认构造函数,使用a初始化异常变量时调用了复制构造函数,使用异常变量复制构造catch参数对象时同样调用了复制构造函数。三个构造对应三个析构,也即try语句块中局部变量a自动被析构了。然而,如果a是在自由存储区上分配的内存时:
int main()
{
try
{
A * a= new A;
throw *a;
}
catch (A a)
{
;
}
getchar();
return 0;
}
程序运行结果:
同样的三次构造,却只调用了两次的析构函数!说明a的内存在发生异常时并没有被释放掉,发生了内存泄漏。
RAII机制有助于解决这个问题,RAII(Resource acquisition is initialization,资源获取即初始化)。它的思想是以对象管理资源。为了更为方便、鲁棒地释放已获取的资源,避免资源死锁,一个办法是把资源数据用对象封装起来。程序发生异常,执行栈展开时,封装了资源的对象会被自动调用其析构函数以释放资源。C++中的智能指针便符合RAII。关于这个问题详细可以看《Effective C++》条款13.
(二)Java的内存管理和垃圾回收机制
JVM内存结构分为:方法区(method),栈内存(stack),堆内存(heap),本地方法栈(java中的jni调用),结构图如下所示:
1. 堆内存(heap)
所有通过new创建的对象的内存都在堆中分配,其大小可以通过-Xmx和-Xms来控制。
操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序,另外,对于大多数系统,会在这块内存空间中的首地址处记录本次分配的大小,这样代码中的delete语句才能正确的释放本内存空间。但由于找到的堆结点的大小不一定正好等于申请的大小,系统会自动的将多余的那部分重新放入空闲链表中。这时由new分配的内存,一般速度比较慢,而且容易产生内存碎片,不过用起来最方便。另外,在WINDOWS下,最好的方式是用VirtualAlloc分配内存,它不是在堆,也不是在栈,而是直接在进程的地址空间中保留一块内存,虽然这种方法用起来最不方便,但是速度快,也是最灵活的。堆内存是向高地址扩展的数据结构,是不连续的内存区域。由于系统是用链表来存储的空闲内存地址的,自然是不连续的,而链表的遍历方向是由低地址向高地址。堆的大小受限于计算机系统中有效的虚拟内存。由此可见,堆获得的空间比较灵活,也比较大。
off-heap:
off-heap叫做堆外内存,将你的对象从堆中脱离出来序列化,然后存储在一大块内存中,就像它存储在磁盘上一样,但它仍在RAM中。对象在这种状态下不能直接使用,它们必须首先反序列化,不属于老年代和新生代,不受垃圾回收的影响。序列化和反序列化将会影响部分性能(所以考虑使用FST-serialization),但使用堆外内存能够防止GC导致的暂停。
2. 栈内存(stack)
在Windows下, 栈是向低地址扩展的数据结构,是一块连续的内存区域。这句话的意思是栈顶的地址和栈的最大容量是系统预先规定好的,在WINDOWS下,栈的大小是固定的(是一个编译时就确定的常数),如果申请的空间超过栈的剩余空间时,将提示overflow。因此,能从栈获得的空间较小。只要栈的剩余空间大于所申请空间,系统将为程序提供内存,否则将报异常提示栈溢出。 由系统自动分配,速度较快。但程序员是无法控制的。
堆内存与栈内存需要说明:
基础数据类型直接在栈空间分配,方法的形式参数,直接在栈空间分配,当方法调用完成后从栈空间回收。引用数据类型,需要用new来创建,既在栈空间分配一个地址空间,又在堆空间分配对象的类变量。方法的引用参数,在栈空间分配一个地址空间,并指向堆空间的对象区,当方法调用完成后从栈空间回收。局部变量new出来时,在栈空间和堆空间中分配空间,当局部变量生命周期结束后,栈空间立刻被回收,堆空间区域等待GC回收。方法调用时传入的literal参数,先在栈空间分配,在方法调用完成后从栈空间收回。字符串常量、static在DATA区域分配,this在堆空间分配。数组既在栈空间分配数组名称,又在堆空间分配数组实际的大小。
如:
3. 本地方法栈(java中的jni调用)
用于支持native方法的执行,存储了每个native方法调用的状态。对于本地方法接口,实现JVM并不要求一定要有它的支持,甚至可以完全没有。Sun公司实现Java本地接口(JNI)是出于可移植性的考虑,当然我们也可以设计出其它的本地接口来代替Sun公司的JNI。但是这些设计与实现是比较复杂的事情,需要确保垃圾回收器不会将那些正在被本地方法调用的对象释放掉。
4. 方法区(method)
又叫静态存储区,它保存方法代码(编译后的java代码)和符号表。存放了要加载的类信息、静态变量、final类型的常量、属性和方法信息。JVM用持久代(Permanet Generation)来存放方法区,可通过-XX:PermSize和-XX:MaxPermSize来指定最小值和最大值。
简而言之:
- 堆区:只存放引用数据类型的成员变量的引用,线程共享;
- 栈区:存放方法局部变量的引用、基本数据类型的成员变量的引用、执行环境上下文、操作指令区,线程不共享;
- 方法区:存放class文件和静态数据,线程共享。
e.g.
class A{
private String a = "aa"; // 变量a存放于堆区
public boolean methodB(){
String b = "bb"; // 变量b存放于栈区
final String c = "cc"; // 变量c存放于栈区
}
}
5. 常量池:
JVM为每个已加载的类型维护一个常量池,常量池就是这个类型用到的常量的一个有序集合。包括直接常量(基本类型,String)和对其他类型、方法、字段的符号引用。池中的数据和数组一样通过索引访问。由于常量池包含了一个类型所有的对其他类型、方法、字段的符号引用,所以常量池在Java的动态链接中起了核心作用。常量池存在于堆中。
有关字符串池的内容可参考博客——字符串池:java字符串池(string pool)和字符串堆(heap)内存分配;String.intern()方法:String.intern() 方法有什么作用?
6. 垃圾回收机制
堆里聚集了所有由应用程序创建的对象,JVM也有对应的指令比如 new, newarray, anewarray和multianewarray,然并没有向 C++ 的 delete,free 等释放空间的指令,Java的所有释放都由 GC 来做,GC除了做回收内存之外,另外一个重要的工作就是内存的压缩,这个在其他的语言中也有类似的实现,相比 C++ 不仅好用,而且增加了安全性,当然她也有弊端,比如性能这个大问题。
参考文献:
【1】C语言内存分配
【2】堆和栈的区别
【3】JVM工作原理和流程
【5】方法区和常量池
【6】C++ 异常机制分析