(拉钩)Android工程师进阶34讲-01:程序运行时,内存到底是如何进行分配的?

0. 前言

将Java的内存分为堆内存(Heap)和栈内存(Stack),这样划分体现了这两块区域时Java工程师最关注的内存区域。但是这样划分并不完全正确。

Java虚拟机在执行Java程序过程中,会把它所管理的内存划分成不同的数据区域。下面的图描述了一个HelloWorld.java文件被JVM加载到内存中的过程:

  • 1、HelloWorld.java文件首先需要经过编译器编译,生成HelloWorld.class字节码文件。
  • 2、Java程序访问HelloWorld这个类时,需要通过ClassLoader(类加载器)将HelloWorld.class加载到JVM的内存中。
  • 3、JVM的内存可以划分为若干个不同的数据区域,主要分为:程序计数器、虚拟机栈、本地方法栈、堆、方法区

1. 程序计数器(Program Counter Register)

Java程序是多线程的,CPU可以在多个线程中分配执行时间片段。当某一个线程被CPU挂起时,需要记录代码已经执行到的位置,方便CPU重新执行此线程时,知道从哪行命名开始执行。这就是程序计数器的作用。

程序计数器是虚拟机中的一块较小的内存空间,主要用户记录当前线程执行的位置。

如下图所示:每个线程都会记录当前方法执行到的位置,当CPU切换到某一个线程时,则根据程序计数器记录的数字,继续向下执行指令。

实际上除了上图演示的恢复线程操作之外,其他一些我们熟悉的分支操作、循环操作、跳转、异常处理等也都需要依赖这个计数器来完成

关于计数器还有几点需要格外注意:

  • 1、在Java虚拟机规范中,对程序计数器这一区域没有规定任何OutOfMemoryError情况。
  • 2、线程私有的,每条线程内部都有一个私有程序计数器。它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
  • 3、当一个线程正在执行一个Java方法的时候,这个计数器记录的是正在执行的虚拟字节码指令的地址。如果正在执行的是Native方法,这个计数器值为空(Undefined)。

2. 虚拟机栈

虚拟机栈也是线程私有的,与线程的生命周期同步。在Java虚拟机规范中,对这个区域规定了两种异常情况:

  • 1、StackOverflowError:当线程请求栈深度超过虚拟机栈所允许的深度时抛出。
  • 2、OutOfMemoryError:当Java虚拟机动态扩展到无法申请足够的内存时抛出。

在学习Java虚拟机过程中,常会看到一句话:

JVM是基于栈的解释器执行的, DVM是基于寄存器解释器执行的。

上面的“基于栈”指的就是虚拟机栈。虚拟机栈的初衷是用来描述Java方法执行的内存模型,每个方法被执行的时候,JVM都会在虚拟机中创建一个栈帧

2.1 栈帧

栈帧(Stack Frame),是用于支持虚拟机进行方法调用和方法执行的数据结构,每一个线程在执行某个方法时,都会为这个方法创建一个栈帧。

可以这样理解:一个线程包含多个栈帧,而每个栈帧内部包含局部变量、操作数栈、动态链接、返回地址等。如下图:

2.2 局部变量表

局部变量是变量值的存储空间,调用方法时传递的参数,以及在方法内部创建的局部变量都保存在局部变量表中。在Java编译成Class文件时,就会在方法的Code属性表中的max_locals数据项中,确定该方法需要分配的最大局部变量表的容量。如下代码:

1
2
3
4
5
public static int add(int k) {
int i = 1;
int j = 2;
return i + j + k;
}

使用javap-v反编译之后,得到如下字节码指令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static int add(int);
  descriptor: (I)I
  flags: ACC_PUBLIC, ACC_STATIC
  Code:
    stack=2, locals=3, args_size=1
      0: iconst_1
      1: istore_1
      2: iconst_2
      3: istore_2
      4: iload_1
      5: iload_2
      6: iadd
      7: iload_0
      8: iadd
      9: ireturn

上面的locals=3就是代表局部变量表的长度,也就是说经过编译之后,局部变量表的长度已经确定是3,分别保存参数k和局部变量ij

注意:系统不会为局部变量赋予初始值(实例变量和类变量都会被赋予初始值),也就是说不存在类变量那样的准备阶段。这一点会在后续的Class初始化中详细介绍。

2.3 操作数栈

操作数栈(Operand Stack),也称操作栈,它是一个后入先出栈。

和局部变量表一样,操作数栈的最大深度也在编译的时候写入方法的Code属性表中的max_stacks数据项中。栈中的元素可以是任意Java数据类型,包括longdouble

当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的。在方法执行的过程中,会有各种字节码指令被压入和弹出操作数栈(比如:iadd指令就是将操作数栈中栈顶的两个元素弹出,执行加法运算,并将结果重新压回到操作数栈中)。

2.4 动态链接

动态链接的主要目的是为了支持方法调用过程中的动态链接(Dynamic Linking)。

在一个class文件中,一个方法要调用其他方法,需要将这些方法的符号引用转化为其所在内存地址中的直接引用,而符号引用存在于方法区中。

在Java虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的就是为了支持调用过程中的动态链接。具体过程会在后面字节码中讲解。

2.5 返回地址

当一个方法执行后,只有两种方法可以退出这个方法:

  • 正常退出:指方法中的代码正常完成,或者遇到任意一个方法返回的字节码指令(如return)并退出,没有抛出任何异常。
  • 异常退出:指方法执行过程中遇到异常,并且这个异常在方法体内部没有得到处理,导致方法退出。

无论当前方法采用何种退出方式,在方法退出后都需要返回到方法被调用的位置,程序才能继续执行。而虚拟机栈中的“返回地址”就是用来帮助当前方法恢复到它的上层方法执行状态。

一般来说,方法正常退出时,调用者的PC计数值可以作为返回地址,栈帧中可能保存此计数值。而方法异常退出时,返回地址是通过异常处理器表确定的,栈帧中一一般不会保存此部分的信息。

2.6 实例讲解

下面用一个add()方法来演示,代码如下:

1
2
3
4
5
6
public int add() {
  int i = 1;
  int j = 2;
  int result = i + j;
  return result + 10;
}

使用javap命令来查看某个类的字节码指令,比如add()方法的代码,经过javap之后的字节码指令如下:

1
2
3
4
5
6
7
8
9
10
11
12
0: iconst_1    (把常量 1 压入操作数栈栈顶)
1: istore_1    (把操作数栈栈顶的出栈放入局部变量表索引为 1 的位置)
2: iconst_2    (把常量 2 压入操作数栈栈顶)
3: istore_2    (把操作数栈栈顶的出栈放入局部变量表索引为 2 的位置)
4: iload_1     (把局部变量表索引为 1 的值放入操作数栈栈顶)
5: iload_2     (把局部变量表索引为 2 的值放入操作数栈栈顶)
6: iadd        (将操作数栈栈顶的和栈顶下面的一个进行加法运算后放入栈顶)
7: istore_3    (把操作数栈栈顶的出栈放入局部变量表索引为 3 的位置)
8: iload_3     (把局部变量表索引为 3 的值放入操作数栈栈顶)
9: bipush 10   (把常量 10 压入操作数栈栈顶)
11: iadd       (将操作数栈栈顶的和栈顶下面的一个进行加法运算后放入栈顶)
12: ireturn    (结束)

从上面的字节码指令可以看到,其实局部变量表和操作数栈在代码执行期间是协同合作来达到某一运算效果的。接下来通过图示来看以上代码在执行期间,虚拟机栈的实际情况。

各指令代表的意思:

  • iconst和binpush:这两个指令都是将常量压入操作数栈顶,区别就是:当int取值-15采用iconst指令,取值-128127采用binpush指令。
  • istore:将操作数栈顶的元素放入局部变量表的某索引位置,比如istore_5表示将操作数栈顶的元素放到局部变量表下标为5的位置。
  • iload:将局部变量表中某下标上的值加载到操作数栈顶中,比如iload_2表示将局部变量表下标为2上的值压入到操作数栈顶。
  • iadd:表示加法运算,具体是将操作数栈顶最上方的两个元素进行相加操作,然后将结果重新压入栈顶。

首先在Add.java被编译成Add.class时,栈帧中需要多大的局部变量表,多深的操作数栈已经完全确定了,并且写入到了方法表的Code属性中。因此这会局部变量表的大小是确定的,add()方法中有三个局部变量,因此局部变量表的大小为3,但是操作数栈此时为空。

所以代码刚执行到add()方法时,局部变量表和操作数栈的情况如下:

iconst_1 把常量1压入操作数栈顶,如下:

istore_1 把操作数栈顶的元素出栈并放入局部变量表下标为1的位置,如下:

可以看到此时操作数栈重新变为空,并将出栈元素1保存到局部变量表中。

iconst_2 把常量2压入到操作数栈顶,如下:

istore_2 把操作数栈顶的元素出栈并放入到局部变量表下标为2的位置,如下:

接下来是iload_1和iload_2。分别表示将局部变量表下标为1和2的元素重新压入操作数栈中,如下:

然后进行iadd操作,将栈顶最上方的两个元素进行加法操作,然后将结果重新压入到操作数栈顶,如下:

istore_3 将操作数栈顶的元素出栈,保存到局部变量表下标为3的位置,如下:

iload_3 将局部变量表下标为3的元素重新压入到操作数栈顶,如下:

bipush 10 将常量10压入到操作数栈顶,如下:

再次执行iadd操作, 如下:

最后执行ireturn指令,将操作数栈顶的元素13返回给上层方法。至此add()方法执行完毕。局部变量表和操作数栈也会被销毁。

3. 本地方法栈

本地方法栈和虚拟机栈基本相同,只不过是针对本地(native)方法。开发中,如果涉及JNI接触本地方法栈会多一些,在有些虚拟机的实现中已经将二者合二为一(比如HotSpot)。

4. 堆

堆(Heap),是JVM所管理的内存最大的一块,该区域的唯一目的就是存放对象实例,几乎所有的对象的实例都在堆里面分配,因此它也是Java垃圾收集器(GC)管理的主要区域,有时也叫做“GC堆”。同时它也是所有线程共享的内存区域,因此被分配在此区域的对象如果被多个线程访问的话,需要考虑线程安全问题。

按照对象存储时间的不同,堆中的内存可以划分为新生代(Young)和老年代(Old),其中新生代又被划分为Eden和Survivor区。如图:

图中不同区域存放具有不同生命周期的对象,这样可以根据不同的区域使用不同的垃圾回收算法,从而更具有针对性,进而提高垃圾回收效率。

5. 方法区

方法区(Method Area),也是JVM规范中规定的一块运行时数据区。方法区主要是存储已经被JVM加载的类信息(版本、字段、方法、接口)、常量、静态变量、即时编译器编译之后的代码和数据。该区域和堆一样,也是被各个线程共享的内存区域。

注意:关于方法区,会跟“永久区”混淆。所以在这里对二者进行一下对比:

  • 方法区是JVM规范中规定的一块区域,但是并不是实际实现,切忌将规范跟实际实现混淆,不同的JVM厂商可以有不同版本的“方法区”的实现。
  • HotSpot在JDK 1.7以前使用“永久区”(或者叫Perm区)来实现方法区,在JDK 1.8以后“永久区”就已经被移除了,取而代之的是“元空间(metaspace)”的实现方式。

总结一下:

  • 方法区是规范层面的东西,规定了这一区域要存放哪些数据。
  • 永久区或者是metaspace是对方法区的不同实现,是实现层面的东西。

6. 异常再现

6.1 StackOverflowError 栈溢出异常

递归调用是造成StackOverflowError的一个常见场景,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class StackOver {
private int number;

public static void main(String[] args) {
StackOver so = new StackOver();
try {
so.method();
} catch (StackOverflowError e) {
System.out.println("栈容量已经溢出!");
}
}

public void method() {
number++;
method();
}
}

method()方法中,递归调用了自身,并且没有设置结束条件。运行上述代码时,则会产生StackOverflowError。

原因就是每调用一次method()方法时,都会在虚拟机栈中创建一个栈帧。因为是递归调用,method()方法并不会退出,也就不会销毁栈帧;所以必然会导致StackOverflowError。因此当使用递归时,要格外注意。

6.2 OutOfMemoryError 内存溢出异常

理论上,虚拟机栈、堆、方法区都有可能发生OutOfMemoryError。但实际项目中,大多数发生在堆中。如下:

1
2
3
4
5
6
7
8
public class HeapError {
public static void main(String[] args) {
ArrayList list = new ArrayList();
while (true) {
list.add(new HeapError());
}
}
}

在一个无限循环中,动态向ArrayList中添加新的HeapError对象。这样不断的占用堆的内存,当堆内存不够时,必然产生OutOfMemoryError。

7. 总结

对于JVM运行时内存布局,需要记住:上面介绍的5块内容都是在Java虚拟机规范中定义的规则,这些规则只是描述各个区域是负责做什么事情、存储什么样的数据、如何处理异常、是否允许线程间共享等。并不是虚拟机的“具体实现”,虚拟机的具体实现很多,比如Sun公司的HotSpot、JRocket、IBM J9,以及Android Dalvik和ART等。这些具体实现符合上面5中运行时数据区的前提下,各自会有不同的实现方式。

总的来说,JVM的运行时内存结构中一共有两个“栈”和一个“堆”,分别是:Java虚拟机栈和本地方法栈,以及“GC堆”和方法区。除此之外还有一个程序计数器。JVM内存中只有堆和方法区是线程共享的数据区域,其他区域都是线程私有的。并且程序计数器是唯一一个在Java虚拟机规范中没有任何OutOfMemoryError情况的区域。

  • Copyrights © 2019-2020 Tyler Liu

请我喝杯咖啡吧~

支付宝
微信