起因于昨日的面试,问Java中int和Interger的区别,没答上来。故总结一下数据结构中堆、栈的相关知识。
堆、栈的词源
堆(heap)让人联想到土堆、草堆,有“杂乱无章、自由拜访”的意味,而在英语中本意为“杂乱拜访的物体”。栈(stack)在古代指存放货物的仓库(如“货栈”),货物通常按顺序堆叠存放,有“层层堆叠、顺序存取”的特点。内存管理中堆、栈的也是同样。
内存
要想理清堆、栈在内存管理中的区别,就首先应弄清此处讨论的内存是什么。内存在广义上包括随机存储器(RAM),只读存储器(ROM),以及高速缓存(CACHE)。
- 只读存储器(ROM),一般用于存放计算机的基本程序和数据(如bios),往往直接焊死在主板上,且工作过程中只能读出,不能更改。
- 高速缓存(CACHE)位于CPU与主存(RAM)之间,是一个读写速度比内存更快的存储器,用于解决CPU速率和主存访问速率差距过大的问题。
而在狭义上的内存往往指随机存储器(RAM),也就是用户可以自由插拔的内存条中的存储空间。它是CPU可以直接、快速访问数据的地方,一般的程序指令想要被执行就必须先被加载到RAM中。
但由于RAM的容量有限,且无隔离性(所有程序共享同一物理地址空间,程序 A 可随意读写程序 B 的内存),所以实际上程序并不会直接访问RAM的地址,而是访问虚拟地址,通过内存管理单元 (MMU) 动态翻译成RAM中的实际地址,并利用硬盘(或 SSD)上的一部分空间(称为页面文件或交换空间)来扩展可用的“内存”资源。为了区分虚拟地址空间和实际地址空间,就把RAM的实际地址空间称为物理内存,程序访问的虚拟地址空间称为虚拟内存。
所以虚拟内存并不是一个物理硬件,只是在程序的眼里,它在操作一个连续、独立且大小可能超过物理内存总容量的私有地址空间。对于程序员来说,也就不需要手动管理物理地址,而是只专注虚拟内存的管理。
所以在这里的内存管理实际指的是虚拟内存的管理。
内存管理
以32位CPU为例,最大寻址$2^{32}$,那么虚拟地址空间的地址范围就应该是0x00000000~0xFFFFFFFF,也就是提供4GB的虚拟内存。
原生程序(如 C/C++)
在程序的视角看,内存被划分为几个逻辑区域,从低地址(0x00000000)开始,至高地址结束(0xFFFFFFFF)结束,依次为代码段(Text Segment)、数据段(Data Segment)、BSS 段(Block Started by Symbol)、堆(Heap)、栈(Stack)。
- 代码段:只读,存放编译后的机器指令。
- 数据段:读写,已初始化的全局变量和静态变量。
- BSS段:读写,未初始化的全局变量和静态变量。且会隐式初始化为0。
- 堆:读写,从低地址向高地址,存放动态分配的内存(malloc/new 申请),需手动分配/释放(free/delete)。
- 栈:读写,从高地址向低地址,存放函数调用栈帧(局部变量、参数、返回地址),上下文数据(寄存器备份、异常处理链)。
在一段c/c++代码运行前,会先编译成机器码存入代码段,并将全局变量、静态变量等存入数据段或BSS段中。CPU会读取代码段中的机器码,基于指令和内存地址,动态的读取或修改其它段的数据。
一方面函数的调用强调严格的顺序和高效的逻辑关系,另一方面又在某些时候需要动态的内存空间和大内存需求(如处理运行时才能确定大小的数据),就干脆分成两部分,也就是栈和堆。所以栈和堆在本质上都是存储空间,只是设计目标不同,导致实现方式不同,最终承担的职责也不同。
值得一提的是,对于一行代码来说,可能会同时操作堆和栈:
1 | char* p = new char[100]; |
JVM
众所周知,Java 程序通过 JVM(Java 虚拟机) 运行,其内存结构其内存结构与原生程序(如 C/C++)也有显著区别。但归根结底是要通过操作系统的虚拟内存系统映射到物理内存才能使用,只是需要经过 JVM 的抽象层(如 GC、字节码解释器)封装。
示例:
当在java中创建对象时
1 | Object obj = new Object(); // 在 JVM 堆中分配内存 |
JVM 向操作系统申请虚拟内存页 → 操作系统将其映射到物理内存 → CPU 通过 MMU(内存管理单元)访问实际物理地址。
JVM中的内存结构主要分为线程私有区域(Thread-Local),和线程共享区域(Shared),栈和堆就分别存放在两个区域中(当然线程私有区域中又分为方法区、本地方法栈、程序计数器、堆栈、栈帧、对象引用、对象数据等等,栈和堆又可以划分成多个区域,但由于我自己还没搞懂,就先按下不表)。
- 栈(JVM Stack):存储方法参数和局部变量(基本类型 + 对象引用),存放计算过程的中间结果(如 i++ 的临时值),方法执行完毕后返回的位置。
- 堆(JVM Heap):存放对象实例,对象实例的属性值,对象实例的引用。
所以在大体上来说,JVM中的栈和堆,与原生程序中的栈和堆是很相似的。
那么回到int和Interger上来。Java中有两种数据类型:基本数据类型,有boolean、byte、int、char、long、short、double、float;引用数据类型 ,分为数组、类、接口。Java为每个基本数据类型都提供了对应的包装类,并在Java 5引入了自动装箱和自动拆箱,使其可以方便的相互转化。
Java中int往往存于栈中,而Integer往往存于堆中。但如果认为这是通过数据类型来判断的,就倒果为因了。从线程私有区域、线程共享区域的名字上就可以看出,实际上是通过上下文来区分的。
例如
1 | void method(){ |
a作为一个方法的局部变量存在,存放在栈中。
1 | class demo { |
在这里a作为一个类的成员变量存在,属于全局变量,存放在堆中。
只不过,由于类中常用Integer,方法中常用int,所以有这种错觉。归根结底是根据线程私有还是线程共享,或者说全局变量还是局部变量来区分。
至于这种代码习惯的产生有很多原因,例如Integer默认为null,而且可以用
List<Integer>
,而int只占用4字节,计算更高效。
由于Integer实际是一个类,所以在创建Integer实例的时候,实际获得的是地址。所以两个通过new生成的Integer变量永远是不相等的。
1 | Integer a = new Integer(1); |
但当int和Integer类型比较时,由于自动拆箱的机制存在,会自动把Integer转换成int,然后进行比较。
1 | Integer a = new Integer(1); |
由于JVM的垃圾回收机制,可以自动释放堆上不再使用的对象,所以程序员往往不需要主动释放对象。