程序运行时,内存到底是如何进行分配的?
虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干不同的数据区域,下面通过一张图体现了字节码
是如何被加载到这些区域来分析Java内存区域和这些区域分别存储了什么数据的,如图。
- HelloWorld.java 文件首先需要经过javac编译器编译,生成HelloWorld.class字节码。
- 当Java程序中要访问HelloWorld类时,需要经过ClassLoader 将 HelloWorld.class文件中的二进制数据,即字节码指令读入到内存中,将其放在运行时数据区的方法区中,然后虚拟机会在堆区创建一个 Class 对象,用来封装该类在方法区内的数据结构。
- Class对象封装了类的信息和指针等,Class 对象作为方法区类数据的访问入口,提供了访问方法区内的数据结构的接口。
在JVM规范中将内存划分为:程序计数器
,虚拟机栈
,本地方法栈
,堆
,方法区
。
程序计数器(Program Counter Register)
- 程序计数器是一块较小的内存空间,它是
当前线程所执行的字节码的行号指示器,字节码解释器工作时通过改变该计数器的值来选择下一条需要执行的字节码指令,分支、跳转、循环
等基础功能都要依赖它来实现。每条线程都有一个独立的的程序计数器,各线程间的计数器互不影响,因此该区域是线程私有
的。- 当线程在执行一个 Java 方法时,该计数器记录的是正在执行的字节码指令的地址,当线程在执行的是 Native 方法(调用本地操作系统方法)时,该计数器的值为空。另外,该内存区域是唯一一个在 Java 虚拟机规范中么有规定任何 OOM(内存溢出:OutOfMemoryError)情况的区域。
为什么需要程序计数器呢?
这是为了保证操作系统调度能够正确地进行线程切换,如:CPU时间片轮转机制会为每个线程分配时间片,当一个线程的时间片用完,或者其他线程提前抢夺 CPU 时间片时,当前线程就会挂起,而等到被挂起的线程再次获得CPU时间片时,就需要通过程序计数器来恢复到正确的指令位置,确保程序能够从正确位置开始执行,如下图:
如上图的,当线程1
执行到cp = 1时挂起了,如果线程1
再次获得CPU时间片,那么字节码解释器从cp=1 改变该计数器的值来选择下一条需要执行的字节码指令,即 cp = 2,这就是图大概的意思。
总结以下几点:
- 计数器是
线程私有
的,每条线程都有一个程序计数器,互不影响。 - 存放正在执行字节码指令的地址,字节码解释器通过给改变计数器的值来执行指令。
- 程序计数器是JVM内存中唯一没有规定OutMemoryError的区域。
虚拟机栈(Virtual Machine Stack)
虚拟机栈
也是线程私有的,它的生命周期也与线程相同。
虚拟机栈
描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会同时创建一个栈帧
。栈帧
是用于支持续虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素,栈帧存储了方法的本地变量表、操作数栈、动态连接和方法返回等信息。
对于执行引擎来讲,活动(active)线程中,只有栈顶的栈帧是有效的,称为当前栈帧,这个栈帧所关联的方法称为当前方法,执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
栈帧
用于存储局部变量表、操作数栈、动态链接、方法返回地址和一些额外的附加信息。在编译程序代码时,栈帧中需要多大的局部变量表(locals=4)、多深的操作数栈(stack=2)都已经完全确定了,并且写入了方法表的 Code 属性之中,如下图:
在虚拟机规范中,对这块区域规定了两种异常情况:
- 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常。
- 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。
虚拟机栈特点:
-
虚拟机栈
是线程私有的,生命周期与线程相同,随着线程的创建而创建。 -
虚拟机栈
描述的是Java方法执行的内存模型,栈帧是虚拟机栈的栈元素
。 -
栈帧
是用于支持续虚拟机进行方法调用和方法执行的数据结构。 -
虚拟机栈
内存不足时会抛出StackOverflowError和OutOfMemoryError异常。
总结
虚拟机是基于栈(操作数栈)的体系结构来执行 class 字节码的。线程创建后都会伴随着产生程序计数器和虚拟机栈,程序计数器存放下一条要执行的指令在方法内的偏移量,虚拟机栈中存放着栈帧,每个栈帧对应着方法的每次调用,而栈帧又是由局部变量表和操作数栈两部分组成,局部变量表用于存放方法中的局部变量和参数的值,操作数栈中用于存放方法执行过程中产生的中间结果。
栈帧(Stack Frame)
栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,每一个线程在执行某个方法时,都会为这个方法创建一个栈帧。每个方法在执行过程中,都会伴随着栈帧的创建、入栈和出栈。栈帧是用来存储局部数据和部分过程结果的数据结构,主要包含局部变量表、操作数栈、动态连接(指向当前方法所属类的运行时常量池的引用)、返回地址。
我们可以这样理解:随着线程的创建后都会伴随着产生程序计数器和虚拟机栈,虚拟机栈包含多个栈帧,而每个栈帧内部包含局部变量表、操作数栈、动态连接、返回地址等。如图所示为Java虚拟机栈的模型。
栈帧特点:
-
栈帧
是虚拟机栈的基本元素,每个线程都独有一个虚拟机栈,虚拟机栈中包含多个栈帧,每个栈帧内部包含局部变量表、操作数栈、动态连接、返回地址等。 -
栈帧
是用于支持续虚拟机进行方法调用和方法执行的数据结构。 - 方法在执行过程中,都会伴随着
栈帧
的创建、入栈和出栈。
局部变量表
局部变量表是一组变量值
存储空间,主要用于存储方法参数和方法内部创建的局部变量
都保存在局部变量表中。其中存储的数据的类型是编译期可知的各种包括各基础类型(byte、char、short、int、long、float、double、boolean)、对象引用(reference类型)和returnAddress类型(指向一条字节码指令的地址)
,其中长度为long和double类型的数据会占用两个局部变量空间(Slot),其余的数据类型只占用一个
。
局部变量表所需的内存空间在编译期间完成分配,即在Java源代码被编译成 Class 文件时,就确定了所需分配的最大局部变量表的容量。当进入一个方法时,这个方法需要在栈中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小,如下代码所示:
public class VMStack {
public int add(int i, int j) {
long longType = 2;
double doubleType = 1;
int result = 3;
result = i + j;
return result + 100;
}
public static int staticAdd(int i, int j) {
long longType = 6;
double doubleType = 8;
int result = 3;
result = i + j;
return result + 100;
}
}
使用javac -g 编译,然后使用javap -v 反编译之后,得到如下字节码指令:
上面的 locals=8 代表局部变量表长度是 8,也就是说经过编译之后,局部变量表的长度已经确定为8,分别保存:参数 i、j、局部变量 result、longType 、doubleType和this,为什么是8呢?这是用为long和double要分别占用2个槽(Slot)。而stack=2 表示操作数栈的最大深度为 2,而args_size = 3(默认是传this参数) 表示方法参数个数是3个(反射调用静态方法和实例方法的区别)。
局部变量表的容量以变量槽(Slot)为最小单位,虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从 0 开始到局部变量表最大的 Slot 数量,在方法执行时,虚拟机是使用局部变量表来完成参数值到参数变量列表的传递过程的,如果是实例方法(非static),则局部变量表中的第 0 位索引的 Slot 默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字 this 来访问这个隐含的参数。其余参数则按照参数表的顺序来排列,占用从1开始的局部变量 Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的 Slot
。
局部变量表特点:
- 局部变量表所需的内存空间在编译期间完成分配,如 stack、locals。
- 除了static方法外,第0个Slot固定存储指向方法所属对象的this指针。
- 除了long和double类型占用了连续2个Slot之外,其他类型都只占用了1个Slot。
- 局部变量表按照变量的声明顺序进行存储,方法参数优先然后再到方法内部定义的局部变量。
操作数栈
操作数栈的最大深度(stack)也是在编译的时候就确定了。当一个方法开始执行时,操作栈是空的,在方法的执行过程中,会有各种字节码指令(比如:加操作、赋值元算等)向操作栈中写入和提取内容,也就是入栈和出栈操作。
Java 虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。因此我们也称 Java 虚拟机是基于栈的,这点不同于 Android 虚拟机,Android 虚拟机是基于寄存器的。基于栈的指令集最主要的优点是可移植性强,主要的缺点是执行速度相对会慢些;而由于寄存器由硬件直接提供,所以基于寄存器指令集最主要的优点是执行速度快,主要的缺点是可移植性差。
举个例子:
public int add(int i, int j) {
int result = 3;
result = i + j;
return result + 100;
}
使用 javap -v 反编译之后,得到如下字节码指令:
public int add(int, int);
descriptor: (II)I
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=3
0: iconst_3 (把常量 3 压入操作数栈栈顶)
1: istore_3 (把操作数栈栈顶的出栈放入局部变量表索引为 1 的位置)
2: iload_1 (把局部变量表索引为 1 的值放入操作数栈栈顶)
3: iload_2 (把局部变量表索引为 2 的值放入操作数栈栈顶)
4: iadd (将操作数栈栈顶的和栈顶下面的一个进行加法运算后放入栈顶)
5: istore_3 (把操作数栈栈顶的出栈放入局部变量表索引为 3 的位置)
6: iload_3 (把局部变量表索引为 3 的值放入操作数栈栈顶)
7: bipush 100 (把常量 100 压入操作数栈栈顶)
9: iadd (将操作数栈栈顶的和栈顶下面的一个进行加法运算后放入栈顶)
10: ireturn (结束)
- iconst 和 bipush指令是将常量压入操作数栈顶,区别就是:当 int 取值 -1~5 采用 iconst 指令,取值 -128~127 采用 bipush 指令。
- istore 将操作数栈顶的元素放入局部变量表的某索引位置,比如 istore_3 代表将操作数栈顶元素放入局部变量表下标为3 的位置。
- iload 将局部变量表中某下标上的值加载到操作数栈顶中,比如 iload_2 代表将局部变量表索引为 2 上的值压入操作数栈顶。
- iadd 代表加法运算,具体是将操作数栈最上方的两个元素进行相加操作,然后将结果重新压入栈顶。
我们来看图更直观一些,方法参数 i = 6, j = 8;
最后执行 return 指令,将操作数栈顶的元素 124返回给上层方法。至此 add() 方法执行完毕。局部变量表和操作数栈也会相继被销毁。
操作数栈特点:
- 作数栈的最大深度(stack)在编译器可以确定其大小。
- 方法开始执行时,操作栈是空的,在方法的执行过程中,会有各种字节码指令入栈和出栈操作。
- Java 虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。
- 基于栈的指令集最主要的优点是可移植性强,主要的缺点是执行速度相对会慢些。
- 寄存器由硬件直接提供,所以基于寄存器指令集最主要的优点是执行速度快,主要的缺点是可移植性差。
动态连接
每个栈帧都包含一个指向运行时常量池(在方法区中,后面介绍)中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。Class 文件的常量池中存在有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用,一部分会在类加载阶段或第一次使用的时候转化为直接引用(如 final、static 域等),称为静态解析,另一部分将在每一次的运行期间转化为直接引用,这部分称为动态连接。
看一下代码:
public class DynamicLinking {
public void dynamicLinking() {
DynamicLinking2 dl = new DynamicLinking2();
dl.dLinking();
}
public void dynamicLinking2() {
int a = 23;
dynamicLinking();
}
}
用javap解析得到:
Constant pool:
#1 = Methodref #7.#16 // java/lang/Object."":()V
#2 = Class #17 // com/github/gradle/jvm/DynamicLinking2
#3 = Methodref #2.#16 // com/github/gradle/jvm/DynamicLinking2."":()V
#4 = Methodref #2.#18 // com/github/gradle/jvm/DynamicLinking2.dLinking:()V
#5 = Methodref #6.#19 // com/github/gradle/jvm/DynamicLinking.dynamicLinking:()V
#6 = Class #20 // com/github/gradle/jvm/DynamicLinking
#7 = Class #21 // java/lang/Object
#8 = Utf8
#9 = Utf8 ()V
#10 = Utf8 Code
#11 = Utf8 LineNumberTable
#12 = Utf8 dynamicLinking
#13 = Utf8 dynamicLinking2
#14 = Utf8 SourceFile
#15 = Utf8 DynamicLinking.java
#16 = NameAndType #8:#9 // "":()V
#17 = Utf8 com/github/gradle/jvm/DynamicLinking2
#18 = NameAndType #22:#9 // dLinking:()V
#19 = NameAndType #12:#9 // dynamicLinking:()V
#20 = Utf8 com/github/gradle/jvm/DynamicLinking
#21 = Utf8 java/lang/Object
#22 = Utf8 dLinking
{
public com.github.gradle.jvm.DynamicLinking();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
public void dynamicLinking();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=2, args_size=1
0: new #2 // class com/github/gradle/jvm/DynamicLinking2
3: dup
4: invokespecial #3 // Method com/github/gradle/jvm/DynamicLinking2."":()V
7: astore_1
8: aload_1
9: invokevirtual #4 // Method com/github/gradle/jvm/DynamicLinking2.dLinking:()V
12: return
public void dynamicLinking2();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=2, args_size=1
0: bipush 23
2: istore_1
3: aload_0
4: invokevirtual #5 // Method dynamicLinking:()V
7: return
}
可以看出:
- dynamicLinking2() 方法的字节码指令码中,
4: invokevirtual
指令引用的符号为#5
,最终解析成dynamicLinking:()V
。 - dynamicLinking()方法的字节码指令码中,
0: new
指令引用的符号为#2
,最终解析成com/jvm/DynamicLinking2
,4: invokespecial
指令引用符号#3
,最终解析成com/jvm/DynamicLinking2."
,":()V 9: invokevirtual
指令引用符号#4
,最终解析成com/jvm/DynamicLinking2.dLinking:()V
。
动态连接特点:
- 动态链接就是根据
符号引用
所表示名字,转换成对方法
、变量
或类型的直接引用,从而实现运行时绑定。 - 在class 文件中,一个方法要调用其他方法,需要将这些方法的
符号引用
转化为其所在内存地址中的直接引用,而符号引用存在于方法区
中。
方法返回地址
当一个方法被执行后,有两种方式退出该方法:执行引擎遇到了任意一个方法返回的字节码指令
或遇到了异常
,并且该异常没有在方法体内得到处理。
无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续往下执行。方法返回时都需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的 PC 计数器的值就可以作为返回地址,栈帧中很可能保存了这个计数器值,而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上等同于把当前栈帧出站,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,如果有返回值,则把它压入调用者栈帧的操作数栈中,调整 PC 计数器的值以指向方法调用指令后面的一条指令。
本地方法栈
本地方法栈和上面介绍的虚拟栈基本相同,只不过后者是为Java方法服务,而本地方法栈则为native方法服务。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError。
本地方法栈特点:
- 本地方法栈为虚拟机使用到的Native方法服务。
- 本地方法栈区域会抛出StackOverflowError和OutOfMemoryError
堆
Java 堆(Heap)是 Java 虚拟机所管理的内存中最大的一块内存,几乎所有的对象实例和数组都在堆分配内存,它也是 Java 垃圾收集器(GC)管理的主要区域,因此很多时候也被称为“GC堆”。
同时它也是所有线程共享
的内存区域,因此被分配在此区域的对象如果被多个线程访问的话,需要考虑线程安全问题。
根据 Java 虚拟机规范的规定,Java 堆可以处在物理上不连续的内存空间中,只要逻辑上是连续的即可。如果在堆中没有内存可分配时,并且堆也无法扩展时,将会抛出 OutOfMemoryError 异常。
按照对象存储时间的不同,堆中的内存可以划分为新生代(Young Generation)
、老年代(Old Generation)
,而新生代则又可以细分为Eden 区
、From Survivor 区
、To Survivor 区
。具体如下图所示:
JVM将堆区划分成这么多的区域,主要是为了提高垃圾收集器(GC)对对象进行管理的效率,这样可以根据不同的区域使用不同的垃圾回收算法
,从而更具有针对性,进而提高垃圾回收效率。
堆的特点:
- 堆内存是被所有线程共享的一块内存区域。
- Java中创建的对象几乎都存放在堆内存中。
- 堆内存不足时会抛出OutOfMemoryError异常
- 对象存储时间的不同,堆中的内存可以划分为
新生代(Young Generation)
、老年代(Old Generation)
,而新生代则又可以细分为Eden 区
、From Survivor 区
、To Survivor 区
方法区
方法区也是各个线程共享
的内存区域,它用于存储已经被虚拟机加载的类信息(版本、字段、方法、接口)、常量、静态变量、JIT编译后的代码等数据
。
方法区是 JVM 规范中规定的一块区域,但是并不是实际实现,切忌将规范跟实现混为一谈,不同的 JVM 厂商可以有不同版本的“方法区”的实现。
HotSpot 在 JDK 1.7 以前使用“永久区”(或者叫 Perm 区)来实现方法区,在 JDK 1.8 之后“永久区”就已经被移除了,取而代之的是一个叫作“元空间(metaspace)”的实现方式。
运行时常量池是方法区的一部分,Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Class文件常量池),用于存放编译器生成的各种字面量
和符号引用
,这部分内容将在类加载后存放到方法区的运行时常量池中。运行时常量池相对于 Class 文件常量池的另一个重要特征是具备动态性,Java 语言并不要求常量一定只能在编译期产生,也就是并非预置入 Class 文件中的常量池的内容才能进入方法区的运行时常量池,运行期间也可能将新的常量放入池中,如:String 类的 intern()方法。
根据 Java 虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。
方法区特点:
- 方法区也是线程共享的内存区域。
- 方法区是规范层面的东西,规定了这一个区域要存放哪些数据。
- 方法区主要存储被 JVM 加载的类信息、常量、静态变量、即时编译器编译后的代码和数据。
- 运行时常量池是方法区的一部分。
- 永久区或者是 metaspace 是对方法区的不同实现,是实现层面的东西。
总结
对于 JVM 运行时内存布局,我们要记住一点:上面介绍的这 5 块内容都是在 Java 虚拟机规范中定义的规则,这些规则只是描述了各个区域是负责做什么事情、存储什么样的数据、如何处理异常、是否允许线程间共享等。
千万不要将它们理解为虚拟机的“具体实现”,虚拟机的具体实现有很多,比如 Sun 公司的 HotSpot以及我们非常熟悉的 Android Dalvik 和 ART 等。这些具体实现在符合上面 5 种运行时数据区的前提下,又各自有不同的实现方式。