博客记录-day049-JVM栈帧结构、JVM运行时数据区

105 阅读23分钟

一、沉默王二-JVM

1、JVM栈帧结构

Java 的源码文件经过编译器编译后会生成字节码文件,然后由 JVM 的类加载器进行加载,再交给执行引擎执行。在执行过程中,JVM 会划出一块内存空间来存储程序执行期间所需要用到的数据,这块空间一般被称为运行时数据区。

栈帧(Stack Frame)是运行时数据区中用于支持虚拟机进行方法调用和方法执行的数据结构。每一个方法从调用开始到执行完成,都对应着一个栈帧在虚拟机栈/本地方法栈里从入栈到出栈的过程。

本地方法,也就是 native 方法,我们前面有详细地讲过,由 C/C++ 实现。

每一个栈帧都包括了局部变量表、操作数栈、动态链接、方法返回地址和一些额外的附加信息。

在编译程序代码时,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的 Code 属性之中。

一个线程中的方法调用链可能会很长,很多方法都处于执行状态。在当前线程中,位于栈顶的栈帧被称为当前栈帧(Current Stack Frame),与这个栈帧相关联的方法成为当前方法。执行引擎运行的所有字节码指令都是对当前栈帧进行操作,在概念模型上,栈帧的结构如下图所示:

1.1 局部变量表

局部变量表(Local Variables Table)用来保存方法中的局部变量,以及方法参数。当 Java 源代码文件被编译成 class 文件的时候,局部变量表的最大容量就已经确定了。

我们来看这样一段代码。

public class LocalVaraiablesTable {
    private void write(int age) {
        String name = "沉默王二";
    }
}

write() 方法有一个参数 age,一个局部变量 name。

然后用 Intellij IDEA 的 jclasslib 查看一下编译后的字节码文件 LocalVaraiablesTable.class。可以看到 write() 方法的 Code 属性中,Maximum local variables(局部变量表的最大容量)的值为 3。

按理说,局部变量表的最大容量应该为 2 才对,一个 age,一个 name,为什么是 3 呢?

当一个成员方法(非静态方法)被调用时第 0 个变量其实是调用这个成员方法的对象引用,也就是那个大名鼎鼎的 this 。调用方法 write(18),实际上是调用 write(this, 18)

点开 Code 属性,查看 LocalVaraiableTable 就可以看到详细的信息了。

第 0 个是 this,类型为 LocalVaraiablesTable 对象;第 1 个是方法参数 age,类型为整型 int;第 2 个是方法内部的局部变量 name,类型为字符串 String。

当然了,局部变量表的大小并不是方法中所有局部变量的数量之和,它与变量的类型和变量的作用域有关。当一个局部变量的作用域结束了,它占用的局部变量表中的位置就被接下来的局部变量取代了。

来看下面这段代码。

public static void method() {
    // 1
    if (true) {
        // 2
        String name = "沉默王二";
    }
    // 3
    if(true) {
        // 4
        int age = 18;
    }
    // 5
}
  • method() 方法的局部变量表大小为 1,因为是静态方法,所以不需要添加 this 作为局部变量表的第一个元素
  • ②的时候局部变量有一个 name,局部变量表的大小变为 1;
  • ③的时候 name 变量的作用域结束;
  • ④的时候局部变量有一个 age,局部变量表的大小为 1;
  • ⑤的时候局 age 变量的作用域结束;

来看下面的代码。

public void solt() {
    double d = 1.0;
    int i = 1;
}

用 jclasslib 可以查看到,solt() 方法的 Maximum local variables 的值为 4。

为什么等于 4 呢?带上 this 也就 3 个呀?

查看 LocalVaraiableTable 就明白了,变量 i 的下标为 3,也就意味着变量 d 占了两个槽。

1.2 操作数栈

同局部变量表一样,操作数栈(Operand Stack)的最大深度也在编译的时候就确定了,被写入到了 Code 属性的 maximum stack size 中。当一个方法刚开始执行的时候,操作数栈是空的,在方法执行过程中,会有各种字节码指令往操作数栈中写入和取出数据,也就是入栈和出栈操作。

来看下面这段代码。

public class OperandStack {
    public void test() {
        add(1,2);
    }

    private int add(int a, int b) {
        return a + b;
    }
}

OperandStack 类共有 2 个方法,test() 方法中调用了 add() 方法,传递了 2 个参数。用 jclasslib 可以看到,test() 方法的 maximum stack size 的值为 3。

这是因为调用成员方法的时候会将 this 和所有参数压入栈中,调用完毕后 this 和参数都会一一出栈。通过 「Bytecode」 面板可以查看到对应的字节码指令。

  • aload_0 用于将局部变量表中下标为 0 的引用类型的变量,也就是 this 加载到操作数栈中;
  • iconst_1 用于将整数 1 加载到操作数栈中;
  • iconst_2 用于将整数 2 加载到操作数栈中;
  • invokevirtual 用于调用对象的成员方法;
  • pop 用于将栈顶的值出栈;
  • return 为 void 方法的返回指令。

  • iload_1 用于将局部变量表中下标为 1 的 int 类型变量加载到操作数栈上(下标为 0 的是 this);
  • iload_2 用于将局部变量表中下标为 2 的 int 类型变量加载到操作数栈上;
  • iadd 用于 int 类型的加法运算;
  • ireturn 为返回值为 int 的方法返回指令。

操作数中的数据类型必须与字节码指令匹配,以上面的 iadd 指令为例,该指令只能用于整型数据的加法运算,它在执行的时候,栈顶的两个数据必须是 int 类型的,不能出现一个 long 型和一个 double 型的数据进行 iadd 命令相加的情况。

1.3 动态链接

每个栈帧都包含了一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接(Dynamic Linking)。

图片来源于网络,作者浣熊say

①、方法区是 JVM 的一个运行时内存区域,属于逻辑定义,不同版本的 JDK 都有不同的实现,但主要的作用就是用于存储已被虚拟机加载的类信息、常量、静态变量,以及即时编译器编译后的代码等。

②、运行时常量池(Runtime Constant Pool)是方法区的一部分,用于存放编译期生成的各种字面量和符号引用——在类加载后进入运行时常量池。

来看下面这段代码。

public class DynamicLinking {
    static abstract class Human {
       protected abstract void sayHello();
    }
    
    static class Man extends Human {
        @Override
        protected void sayHello() {
            System.out.println("男人哭吧哭吧不是罪");
        }
    }
    
    static class Woman extends Human {
        @Override
        protected void sayHello() {
            System.out.println("山下的女人是老虎");
        }
    }

    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        man.sayHello();
        woman.sayHello();
        man = new Woman();
        man.sayHello();
    }
}

Man 类和 Woman 类继承了 Human 类,并且重写了 sayHello() 方法。来看一下运行结果:

男人哭吧哭吧不是罪
山下的女人是老虎
山下的女人是老虎

这个运行结果很好理解,man 的引用类型为 Human,但指向的是 Man 对象,woman 的引用类型也为 Human,但指向的是 Woman 对象;之后,man 又指向了新的 Woman 对象。

从面向对象编程的角度,从多态的角度,我们对运行结果是很好理解的,但站在 Java 虚拟机的角度,它是如何判断 man 和 woman 该调用哪个方法的呢?

用 jclasslib 看一下 main 方法的字节码指令。

  • 第 1 行:new 指令创建了一个 Man 对象,并将对象的内存地址压入栈中。
  • 第 2 行:dup 指令将栈顶的值复制一份并压入栈顶。因为接下来的指令 invokespecial 会消耗掉一个当前类的引用,所以需要复制一份。
  • 第 3 行:invokespecial 指令用于调用构造方法进行初始化。
  • 第 4 行:astore_1,Java 虚拟机从栈顶弹出 Man 对象的引用,然后将其存入下标为 1 局部变量 man 中。
  • 第 5、6、7、8 行的指令和第 1、2、3、4 行类似,不同的是 Woman 对象。
  • 第 9 行:aload_1 指令将第局部变量 man 压入操作数栈中。
  • 第 10 行:invokevirtual 指令调用对象的成员方法 sayHello(),注意此时的对象类型为 com/itwanger/jvm/DynamicLinking$Human
  • 第 11 行:aload_2 指令将第局部变量 woman 压入操作数栈中。
  • 第 12 行同第 10 行。

注意,从字节码的角度来看,man.sayHello()(第 10 行)和 woman.sayHello()(第 12 行)的字节码是完全相同的,但我们都知道,这两句指令最终执行的目标方法并不相同。

究竟发生了什么呢?

还得从 invokevirtual 这个指令着手,看它是如何实现多态的。根据《Java 虚拟机规范》,invokevirtual 指令在运行时的解析过程可以分为以下几步:

  • ①、找到操作数栈顶的元素所指向的对象的实际类型,记作 C。
  • ②、如果在类型 C 中找到与常量池中的描述符匹配的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找结束;否则返回 java.lang.IllegalAccessError 异常。
  • ③、否则,按照继承关系从下往上一次对 C 的各个父类进行第二步的搜索和验证。
  • ④、如果始终没有找到合适的方法,则抛出 java.lang.AbstractMethodError 异常。

也就是说,invokevirtual 指令在第一步的时候就确定了运行时的实际类型,所以两次调用中的 invokevirtual 指令并不是把常量池中方法的符号引用解析到直接引用上就结束了,还会根据方法接受者的实际类型来选择方法版本,这个过程就是 Java 重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的过程称为动态链接

1.4 方法返回地址

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

  • 正常退出,可能会有返回值传递给上层的方法调用者,方法是否有返回值以及返回值的类型根据方法返回的指令来决定,像之前提到的 ireturn 用于返回 int 类型,return 用于 void 方法;还有其他的一些,lreturn 用于 long 型,freturn 用于 float,dreturn 用于 double,areturn 用于引用类型。
  • 异常退出,方法在执行的过程中遇到了异常,并且没有得到妥善的处理,这种情况下,是不会给它的上层调用者返回任何值的。

无论是哪种方式退出,在方法退出后,都必须返回到方法最初被调用时的位置,程序才能继续执行。一般来说,方法正常退出的时候,PC 计数器的值会作为返回地址,栈帧中很可能会保存这个计数器的值,异常退出时则不会。

PC 计数器:JVM 运行时数据区的一部分,跟踪当前线程执行字节码的位置。

方法退出的过程实际上等同于把当前栈帧出栈,因此接下来可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整 PC 计数器的值,找到下一条要执行的指令等。

1.5 附加信息

虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现。实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,成为栈帧信息。

1.6 小结

栈帧是 JVM 中用于方法执行的数据结构,每当一个方法被调用时,JVM 会为该方法创建一个栈帧,并在方法执行完毕后销毁。

  • 局部变量表:存储方法的参数和局部变量,由基本数据类型或对象引用组成。
  • 操作数栈:后进先出(LIFO)的栈结构,用于存储操作数和中间计算结果。
  • 动态链接:关联到方法所属类的常量池,支持动态方法调用。
  • 方法返回地址:记录方法结束后控制流应返回的位置。

栈帧是线程私有的,每个线程有自己的 JVM 栈。方法调用时,新栈帧被推入栈顶;方法完成后,栈帧出栈。

栈帧的局部变量表的大小和操作数栈的最大深度在编译时就已确定。栈空间不足时可能引发 StackOverflowError。理解栈帧对于深入理解 Java 程序的运行机制至关重要。

2、JVM运行时数据区

Java 源代码文件经过编译器编译后会生成字节码文件,经过加载器加载完毕后会交给执行引擎执行。在执行的过程中,JVM 会划出来一块空间来存储程序执行期间需要用到的数据,这块空间一般被称为运行时数据区,见下图。

根据 Java 虚拟机规范的规定,运行时数据区可以分为以下几个部分:

  • 程序计数器(Program Counter Register)
  • Java 虚拟机栈(Java Virtual Machine Stacks)
  • 本地方法栈(Native Method Stack)
  • (Heap)
  • 方法区(Method Area)

JDK 8 开始,永久代被彻底移除,取而代之的是元空间。元空间不再是 JVM 内存的一部分,而是通过本地内存(Native Memory)来实现的。也就是说,JDK 8 开始,方法区的实现就是元空间。

2.1 程序计数器

程序计数器(Program Counter Register)所占的内存空间不大,很小很小一块,可以看作是当前线程所执行的字节码指令的行号指示器。字节码解释器会在工作的时候改变这个计数器的值来选取下一条需要执行的字节码指令,像分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。

在 JVM 中,多线程是通过线程轮流切换来获得 CPU 执行时间的,因此,在任一具体时刻,一个 CPU 的内核只会执行一条线程中的指令,因此,为了线程切换后能恢复到正确的执行位置,每个线程都需要有一个独立的程序计数器,并且不能互相干扰,否则就会影响到程序的正常执行次序。

也就是说,我们要求程序计数器是线程私有的

《Java 虚拟机规范》中规定,如果线程执行的是非本地方法,则程序计数器中保存的是当前需要执行的指令地址;如果线程执行的是本地方法,则程序计数器中的值是 undefined。

为什么本地方法在程序计数器中的值是 undefined 的?因为本地方法大多是通过 C/C++ 实现的,并未编译成需要执行的字节码指令。

我们来通过代码以及字节码指令来看看程序计数器的作用。

public static int add(int a, int b) {
    return a + b;
}

字节码指令大致如下:

0: iload_0      // 从局部变量表中加载变量 a 到操作数栈
1: iload_1      // 从局部变量表中加载变量 b 到操作数栈
2: iadd         // 两数相加
3: ireturn      // 返回

现在,让我们逐步分析程序计数器是如何在执行这些指令时更新的:

  1. 初始状态:当方法开始执行时,PC 计数器设置为 0,指向第一条指令 0: iload_0

  2. 执行第一条指令

    • 执行 iload_0 指令,将局部变量表中索引为 0 的整数(即方法的第一个参数 a)加载到操作数栈顶。
    • 执行完成后,PC 计数器更新为 1,指向下一条指令 1: iload_1
  3. 执行第二条指令

    • 执行 iload_1 指令,将局部变量表中索引为 1 的整数(即方法的第二个参数 b)加载到操作数栈顶。
    • 执行完成后,PC 计数器更新为 2,指向下一条指令 2: iadd
  4. 执行第三条指令

    • 执行 iadd 指令,弹出操作数栈顶的两个整数(即 a 和 b),将它们相加,然后将结果压入操作数栈顶。
    • 执行完成后,PC 计数器更新为 3,指向下一条指令 3: ireturn
  5. 执行最后一条指令:

    • 执行 ireturn 指令,弹出操作数栈顶的整数(即 a + b 的结果),并将这个值作为方法的返回值。
    • 方法执行完成,控制权返回到方法调用者。

2.2 Java 虚拟机栈

Java 虚拟机栈(JVM 栈)中是一个个栈帧,每个栈帧对应一个被调用的方法当线程执行一个方法时,会创建一个对应的栈帧,并将栈帧压入栈中。当方法执行完毕后,将栈帧从栈中移除。

假设我们有一个简单的 add 方法,如下所示:

public int add(int a, int b) {
    int result = a + b;
    return result;
}

当 add 方法执行完毕后,对应的栈帧会从 JVM 栈中弹出。

Java 虚拟机栈的特点如下:

  • 线程私有:  每个线程都有自己的 JVM 栈,线程之间的栈是不共享的。
  • 栈溢出:  如果栈的深度超过了 JVM 栈所允许的深度,将会抛出 StackOverflowError,这个我们讲栈帧的时候讲过了。

也就是说,默认 1024 KB 的 JVM 栈可以执行 10885 次 testStackOverflowError 方法,而 256 KB 的 JVM 栈只能执行 1990 次 testStackOverflowError 方法,四五倍的样子。

2.3 本地方法栈

本地方法栈(Native Method Stack)与 Java 虚拟机栈类似,只不过 Java 虚拟机栈为虚拟机执行 Java 方法服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。

2.4 堆

堆是所有线程共享的一块内存区域,在 JVM 启动的时候创建,用来存储对象(数组也是一种对象)。

以前,Java 中“几乎”所有的对象都会在堆中分配,但随着 JIT 编译器的发展和逃逸技术的逐渐成熟,所有的对象都分配到堆上渐渐变得不那么“绝对”了。从 JDK 7 开始,Java 虚拟机已经默认开启逃逸分析了,意味着如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。

栈就是前面提到的 JVM 栈(主要存储局部变量、方法参数、对象引用等),属于线程私有,通常随着方法调用的结束而消失,也就无需进行垃圾收集;堆前面也讲了,属于线程共享的内存区域,几乎所有的对象都在对上分配,生命周期不由单个方法调用所决定,可以在方法调用结束后继续存在,直到不在被任何变量引用,然后被垃圾收集器回收。

简单解释一下 JIT 和逃逸分析。

常见的编译型语言如 C++,通常会把代码直接编译成 CPU 所能理解的机器码来运行。而 Java 为了实现“一次编译,处处运行”的特性,把编译的过程分成两部分,首先它会先由 javac 编译成通用的中间形式——字节码,然后再由解释器逐条将字节码解释为机器码来执行。所以在性能上,Java 可能会干不过 C++ 这类编译型语言。

为了优化 Java 的性能 ,JVM 在解释器之外引入了 JIT 编译器:当程序运行时,解释器首先发挥作用,代码可以直接执行。随着时间推移,即时编译器逐渐发挥作用,把越来越多的代码编译优化成本地代码,来获取更高的执行效率。解释器这时可以作为编译运行的降级手段,在一些不可靠的编译优化出现问题时,再切换回解释执行,保证程序可以正常运行。

逃逸分析(Escape Analysis)是一种编译器优化技术,用于判断对象的作用域和生命周期。如果编译器确定一个对象不会逃逸出方法或线程的范围,它可以选择在栈上分配这个对象,而不是在堆上。这样做可以减少垃圾回收的压力,并提高性能。

  • createAndCalculate 方法创建了一个 Point 对象,并调用它的 calculate 方法。
  • Point 对象在 createAndCalculate 方法中创建,并且不会逃逸到该方法之外。
  • 如果 JVM 的逃逸分析确定 Point 对象不会逃逸出 createAndCalculate 方法,它可能会在栈上分配 Point 对象,而不是在堆上。

堆我们前面已经讲过了,它除了是对象的聚集地,也是 Java 垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。从垃圾回收的角度来看,由于垃圾收集器基本都采用了分代垃圾收集的算法,所以堆还可以细分为:新生代和老年代。新生代还可以细分为:Eden 空间、From Survivor、To Survivor 空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。

堆这最容易出现的就是 OutOfMemoryError 错误,分为以下几种表现形式:

  • OutOfMemoryError: GC Overhead Limit Exceeded:当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生该错误。
  • java.lang.OutOfMemoryError: Java heap space:假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发该错误。和本机的物理内存无关,和我们配置的虚拟机内存大小有关!

2.5 元空间和方法区

方法区是 Java 虚拟机规范上的一个逻辑区域,在不同的 JDK 版本上有着不同的实现。在 JDK 7 的时候,方法区被称为永久代(PermGen),而在 JDK 8 的时候,永久代被彻底移除,取而代之的是元空间。

换句话说,方法区和永久代的关系就像是 Java 中接口和类的关系,类实现了接口,接口还是那个接口,但实现已经完全升级了。

JDK 7 之前,只有常量池的概念,都在方法区中。

JDK 7 的时候,字符串常量池从方法区中拿出来放到了堆中,运行时常量池还在方法区中(也就是永久代中)。

JDK 8 的时候,HotSpot 移除了永久代,取而代之的是元空间。字符串常量池还在堆中,而运行时常量池跑到了元空间。

为什么要废弃永久代,而使用元空间来进行替换呢?

旧版的 Hotspot 虚拟机是没有 JIT 的,而 Oracle 旗下的另外一款虚拟机 JRocket 是有的,那为了将 Java 帝国更好的传下去,Oracle 就想把庶长子 JRocket 的 JIT 技术融合到嫡长子 Hotspot 中。

但 JRockit 虚拟机中并没有永久代的概念,因此新的 HotSpot 索性就不要永久代了,直接占用操作系统的一部分内存好了,并且把这块内存取名叫做元空间。

元空间的大小不再受限于 JVM 启动时设置的最大堆大小,而是直接利用本地内存,也就是操作系统的内存。有效地解决了 OutOfMemoryError 错误。

当元空间的数据增长时,JVM 会请求操作系统分配更多的内存。如果内存空间足够,操作系统就会满足 JVM 的请求。那会不会出现元空间溢出的情况呢?

2.5.1 运行时常量池

在讲字节码的时候,我们详细的讲过常量池,它是字节码文件的资源仓库,先是一个常量池大小,从 1 到 n-1,0 为保留索引,然后是常量池项的集合,包括类信息、字段信息、方法信息、接口信息、字符串常量等。

运行时常量池,顾名思义,就是在运行时期间,JVM 会将字节码文件中的常量池加载到内存中,存放在运行时常量池中。

也就是说,常量池是在字节码文件中,而运行时常量池在元空间当中(JDK 8 及以后),讲的是一个东西,但形态不一样,就好像一个是固态,一个是液态;或者一个是模子,一个是模子里的锅碗瓢盆。

2.5.2 字符串常量池

字符串常量池我们在讲字符串的时候已经详细讲过了,它的作用是存放字符串常量,也就是我们在代码中写的字符串。依然在堆中。

OK,方法区(不管是永久代还是元空间的实现)和堆一样,是线程共享的区域

2.6 小结

来总结一下运行时数据区的主要组成:

  • PC 寄存器(PC Register),也叫程序计数器(Program Counter Register),是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的信号指示器。
  • JVM 栈(Java Virtual Machine Stack),与 PC 寄存器一样,JVM 栈也是线程私有的。每一个 JVM 线程都有自己的 JVM 栈(也叫方法栈),这个栈与线程同时创建,它的生命周期与线程相同。
  • 本地方法栈(Native Method Stack),JVM 可能会使用到传统的栈来支持 Native 方法的执行,这个栈就是本地方法栈。
  • 堆(Heap),在 JVM 中,堆是可供各条线程共享的运行时内存区域,也是供所有类实例和数据对象分配内存的区域。
  • 方法区(Method area),JDK 8 开始,使用元空间取代了永久代。方法区是 JVM 中的一个逻辑区域,用于存储类的结构信息,包括类的定义、方法的定义、字段的定义以及字节码指令。不同的是,元空间不再是 JVM 内存的一部分,而是通过本地内存(Native Memory)来实现的。
  • 运行时常量池,运行时常量池是每一个类或接口的常量在运行时的表现形式,它包括了编译器可知的数值字面量,以及运行期解析后才能获得的方法或字段的引用。简而言之,当一个方法或者变量被引用时,JVM 通过运行时常量区来查找方法或者变量在内存里的实际地址。

在 JVM 启动时,元空间的大小由 MaxMetaspaceSize 参数指定,JVM 在运行时会自动调整元空间的大小,以适应不同的程序需求。