6.变量和对象存储在哪里?理解栈和堆
我们在编程过程中不断地定义各种类型的变量,在面向对象的语言中,我们还会经常通过new关键字生成对象。通过第3节的学习,我们已经理解了数据类型,但对于这些数据在内存中是如何存储的可能还存有疑问。变量和对象存储在哪里?答案是栈和堆。经常有人直接把内存区分为栈内存和堆内存,这种方法比较粗糙,内存区域的划分实际比这复杂得多,但这种说法可以反映出与变量和对象的分配关系最为密切的内存区域是这两块。通过学习本节,读者会对栈和堆形成深刻的理解,熟悉内存模型是对一个程序员的基本要求,也是非常重要的一个要求。
进程地址空间
在学习栈和堆之前,让我们先看一下Linux中的进程地址空间,从而对进程的内存布局有一个全局的认识。图6.1展示了Linux中的进程地址空间。
Linux操作系统的内存分为两大类,一类是内核空间,一类是用户空间,应用程序进程占用的内存在用户空间分配。一个Linux进程的地址空间分为图6.1中显示的几个主要区域。
图6.1 Linux中的进程地址空间
(1)栈:由操作系统自动分配和释放,用于维护函数调用上下文,存储函数的参数值、局部变量等。使用一级缓存,调用速度较快。
(2)堆:应用程序动态分配的内存区域,一般由程序员分配和释放(C/C++),若程序员不释放,程序结束时由系统释放(Java)。使用二级缓存,调用速度较慢。
(3)数据段:该内存区域用于存放程序数据,包括未初始化数据段(即均被初始化为0),初始化数据段。
(4)代码段:该段数据存放程序代码,具有执行权限,只读。
栈内存
栈在数据结构中是一种具有先进后出特点的有序队列,内存中的栈的操作方式类似于数据结构中的栈。将栈的操作方式比作一堆碗碟,我们拥有两种操作方式:可以在当前碗碟的顶部堆放一个新的碗碟,也可以将最顶上的碗碟取出,先堆进去的碗碟在最下面,最后才能取出。因此栈具有先进后出的特点,先入栈的元素后出栈。在碗碟的比喻中,这个栈的扩展方向是朝上的,而在Linux进程地址空间中,栈内存的扩展方向是自顶向下的,如图6.1所示。
想要理解栈内存的工作原理,必须首先了解栈帧(Stack Frame),栈帧保存了一个函数调用的所有相关信息,每一个函数从调用到执行完毕的过程,对应了一个栈帧在栈内存中入栈到出栈的过程。一个栈帧主要包括以下几部分内容:
(1)函数参数,该部分存储函数的实参。
(2)函数返回地址,前一个栈帧的指针。该部分存储恢复前一个栈帧所必需的数据。
(3)函数的局部变量。
(4)保存的上下文,即在函数调用前后需要保持不变的寄存器。
图6.2 栈帧的结构
图6.2展示了栈帧的结构,一个栈帧维护了一个函数调用的所有信息。一个栈帧维护了两个指针,分别是ebp寄存器和esp寄存器。ebp是栈帧指针,该值指向了函数栈帧的一个固定位置,不随函数的执行变化。esp是栈帧栈顶指针,始终指向栈顶,会随函数执行不断变化。因此,ebp可以用来唯一标识一个栈帧的位置。在图6.2中可以看到有一个地址保存了旧的ebp的值,该值就是为了让当前被调用函数执行完毕后能够找到调用函数的栈帧,从而找到调用函数的所有相关信息。从ebp正向偏移可以首先看到存放了函数的返回地址,函数的返回地址就是调用完该函数之后要执行的下一条指令的地址。再向上可以看到存放了函数实参。从ebp负向偏移可以看到存放了函数调用前后需要保持不变的寄存器,以及函数的局部变量。
一个函数A的调用及其栈帧形成的过程如下:首先将函数A的参数依次(C语言中依照反向压栈顺序)入栈,接着将当前指令的下一条指令的地址(即函数A的返回地址)入栈,下面就开始执行函数A,依次将函数A的局部变量入栈。当函数A执行完毕,ebp恢复为旧的ebp的值,函数A的栈帧被销毁,此时栈内存栈顶为调用A的函数的栈帧,所以函数的参数和局部变量的作用域仅仅存在于函数内部。本书的第7节有函数调用及其栈帧形成的具体示例,读者可以通过阅读第7节加深对栈内存工作机制的理解。
堆内存
由于栈帧的数据在函数返回的时候就被销毁了,函数内部的数据无法被传递到函数外部,仅仅用栈来存储数据是不能满足编程的需求的。因此,堆内存应运而生。
如图6.1所示,堆内存的空间从低地址向高地址扩展,堆的存储空间较栈要大得多。堆内存的空间都是动态分配的,由于大量使用new和delete,堆内存中更容易出现内存碎片。
程序员可以随时在堆内存中申请空间。在C++中,程序员通过new或malloc动态申请堆内存空间,而当程序员不再需要这片内存空间时,需要通过delete或free主动释放这片空间。由于程序员可能忘记释放内存这一操作,因此容易出现内存泄漏的问题,关于内存泄漏的定义读者可以阅读本书第10节。Java针对此问题作了改进,在Java中,程序员通过new申请堆内存空间,而当这一内存空间不再需要时,程序员无须主动释放,Java虚拟机会对堆内存中的对象实施垃圾回收机制,这些不再需要的内存会由Java虚拟机自行回收并得到再次利用。本书第10节还详细介绍了Java中的垃圾回收机制。
Java内存分区
接下来我们将学习Java的内存分区,分析Java示例代码中的各个变量和对象分别是如何存储的。
JVM运行时数据区如图6.3所示,JVM运行时会将它所管理的内存划分为若干不同区域,其中,Java堆内存与方法区是由所有线程共享的数据区,而虚拟机栈、本地方法栈、程序计数器是线程隔离的数据区,各线程之间互不影响,各自独立,这些区域是线程私有的内存。
图6.3 JVM运行时数据区
Java堆内存是JVM管理的内存中最大的区域,几乎所有的对象实例(通过new生成的对象)和数组都在这里被分配内存。Java堆内存是垃圾收集器管理的主要区域,因此这一区域细分为“新生代”和“老生代”,其中新生代又被进一步划分为Eden区、From Survivor区与To Survivor区。这样划分的目的是为了使JVM能够更好地管理堆内存中的对象,包括内存的分配以及回收。
方法区用于存储类信息、运行时常量、静态变量等。很多程序员将这一区域称为“永久代”,严格说这两者并不等价。这一区域的垃圾回收较少出现,但并非所有数据进入方法区就不会被回收了。运行时常量池是方法区的一部分,该区域用于存放编译期生成的各种字面量和符号引用。
程序计数器是一片较小的内存空间,该区域记录正在执行的虚拟机字节码的地址。JVM在切换线程时为了恢复到对应线程的执行位置,需要查看程序计数器。
Java虚拟机栈就是Java中的栈内存,该区域是线程私有的,生命周期与线程相同。Java虚拟机栈主要存储了函数的局部变量,包括各种基本数据类型和引用类型(不同于对象本身,对象本身存储在堆内存中),这些局部变量存在于函数对应的栈帧中,作用域为函数。
本地方法栈类似于Java虚拟机栈,区别在于,虚拟机栈执行的是Java方法,而本地方法栈执行的是Native方法服务。
变量和对象存储在哪里?
示例代码6.1
为了更好地理解本节一开始提出的问题“变量和对象存储在哪里”,我们不妨结合示例代码6.1来最后总结一下这个问题的答案。
在示例代码6.1的第4行,我们首先定义了一个int类型的局部变量num,根据本节的内容可知,该局部变量存储在main函数对应的栈帧中。
在示例代码6.1的第5行,我们先来看等号右边的内容,这里通过new Object()我们生成了一个Object类型的对象,该动态生成的对象存储在Java堆内存中。但第5行代码不止生成了对象,我们再来看等号左边的内容,这里定义了一个Object类型引用ref,值得注意的是,ref本身不是对象,而是一个引用。Thinking in Java一书将引用比作遥控器,将对象比作电视机,程序员所有对于电视机的操作都是通过遥控器实现的。在这一行代码中,ref引用实际上存储着等号右边生成的Object类型对象在堆内存中的地址,有了这个对象的地址,我们就能够操纵该对象了。引用类型不同于对象,引用存储在栈内存中,示例代码中ref引用存储在main函数对应的栈帧中。引用和对象的关系如图6.4所示。关于Java中引用与对象的更多知识,读者可以阅读本书第9节。
图6.4 引用和对象的存储关系