Skip to content

JVM 运行时数据区(上)

本文主要介绍运行时数据区中的PC寄存器、虚拟机栈以及本地方法栈。

这三种数据区都是线程私有的。

1. PC寄存器

PC寄存器的作用是存储下一条指令的地址,以便执行引擎根据PC寄存器中的地址找到指令并执行。

PC寄存器有以下特点:

  • 占用很少的内存空间,也是运行速度最快的存储区域;
  • 是线程私有的,PC寄存器的生命周期与线程的生命周期保持一致;
  • 任何时间线程都只有一个方法在执行,也就是当前方法。PC寄存器会存储当前线程正在执行的Java方法的JVM指令地址;如果正在执行本地(native)方法,那PC寄存器的值是未定义的(undefined);
  • 唯一一个在Java虚拟机规范中规定没有OutofMemoryError情况的区域;

为什么PC寄存器是线程私有的?因为在一个JVM实例中,多个线程不断切换,当某个线程拿到CPU控制权后,需要恢复上次的执行,而PC寄存器存储了下一条指令的地址,线程可以知道从哪开始继续执行。

2. 虚拟机栈

2.1 虚拟机栈概述

Java虚拟机栈(Java Virtual Machine Stacks),在线程创建时随之创建,随着线程的结束而销毁,主管Java程序的运行,参与方法的调用与返回。Java虚拟机栈的内部基本结构称为栈帧(Stack Frame),一个栈帧就对应着一次方法调用。

下面以一个例子演示虚拟机栈的基本认识,如下一个程序,在第17行打上断点,以调试模式启动:

java
public class StackTest {
    public static void main(String[] args) {
        StackTest stackTest = new StackTest();
        stackTest.method1();
    }
    public void method1(){
        int i = method2();
        System.out.println(i);
    }

    private int method2() {
        int sum = add(10, 20);
        return sum;
    }

    private int add(int num1, int num2) {
        return num1 + num2;
    }
}

image-20250323152843161

在调试界面左下角,虚拟机栈的可视化演示。我们可以看到,程序入口是main方法,所以先创建一个栈帧放入虚拟机栈,当调用method1()方法时,又创建一个栈帧入栈,直到程序断点的add()方法。

我们再在程序第10行打上断点,使程序恢复运行:

image-20250323153111380

发现左下角的虚拟机栈中,只有两个栈帧了。即方法调用结束后,会使得对应的栈帧出栈。

所以方法调用对应着栈帧入栈,方法调用结束对应着栈帧出栈。虚拟机栈最顶上的栈帧,就是正在执行的方法。

栈帧中包含四种结构:局部变量表、操作数栈、动态链接与返回地址。

2.2 虚拟机栈的常见异常

Java虚拟机规范允许Java虚拟机栈的大小是固定的或可以动态调整的:

  • 如果Java虚拟机栈的大小是固定的,那么当线程请求分配的栈容量超过允许的最大容量,Java虚拟机会抛出一个StackOverflowError异常;

    我们可以使用**-Xss<size>[g|m|k]**来设置Java虚拟机栈的大小。其中<size>表示大小,用数字表示;[g|m|k]表示单位。

    我们以无限递归为例演示StackOverflowError异常:

    java
    package data.area.stack;
    
    public class StackOverFlowErrorDemo {
        public static void main(String[] args) {
            method();
        }
    
        public static void method(){
            method();
        }
    }
    bash
    java -Xss512k data/area/stack/StackOverFlowErrorDemo

    结果如下(部分):

    txt
    Exception in thread "main" java.lang.StackOverflowError
      at data.area.stack.StackOverFlowErrorDemo.method(StackOverFlowErrorDemo.java:9)
      ...
  • 如果Java虚拟机栈的大小是可以动态调整的,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那么Java虚拟机会抛出一个OutofMemoryError异常;

    在Hot Spot虚拟机中,虚拟机栈的大小在运行时并不支持动态调整。

2.3 局部变量表(Local Variables)

局部变量表的作用是按顺序存储方法参数和定义在方法体内的局部变量。

每个栈帧包含一个名为局部变量表的数组,局部变量表的长度在编译时确定,并保存在方法的Code属性的maximum local variables数据项中,在方法运行期间,局部变量表的大小是不会改变的。例如:

image-20250323164645822

局部变量表中的基本元素称为插槽(slot),一个插槽可以保存一个 intfloatreference(引用)或 returnAddress 类型的值。longdouble类型的值需要两个插槽来保存。byteshortcharboolean类型的局部变量在存储前,会转换为int

32位JDK或64位JDK+压缩场景下,引用类型的值只占据1个插槽,64位非压缩场景下,引用类型的值占据2个插槽。

局部变量通过索引来寻址,第一个局部变量的索引是零。

下面在一个方法中定义不同类型的变量,并查看字节码的局部变量表,可以发现longdouble类型的变量占据两个插槽,其余类型的变量占据一个插槽:

image-20250323165423684

如果当前栈帧是由调用实例方法或构造方法而创建的,那么局部变量表索引为0的插槽,存储的是调用对象的引用,即this,其余方法参数或局部变量再按照顺序存储;如果当前栈帧是由静态方法调用而创建的,那么不存在this变量。

如下,方法method2是实例方法,所以局部变量表索引为0的位置存放的是this变量:

image-20250323171648175

接下来讲解一下局部变量表中各项的含义:

  • Nr.:表示行号,无太大意义;
  • 起始PC:表示定义该局部变量时的位置;
  • 长度:表示该局部变量的生命周期;
  • 序号:表示局部变量表的索引;
  • 名字:局部变量的名字;
  • 描述符:局部变量的类型;

局部变量表中的插槽是可以复用的,如果一个局部变量过了其作用域,那么在其作用域之后声明的新的局部变量,很有可能复用过期局部变量的插槽,从而达到节省资源的目的。例子如下:

image-20250323172855409

在代码中,变量c声明在变量b的作用域之后,所以变量c可以复用变量b的插槽。在局部变量表可视化视图中可以发现,变量b的序号是2,变量c的序号也是2。并且变量b的起始PC(4)加上长度(7)的值为11,小于变量c的起始PC(13),即在变量c定义的时候,变量b的作用域已经过期了。

2.4 操作数栈(Operand Stack)

操作数栈,主要用于保存计算过程的临时变量和中间结果

  • 操作数栈的深度在编译期即可确定,保存在方法的Code属性中的操作数栈最大深度,如下:

image-20250324191439479

  • 操作数栈中一个槽位也是4字节32bit,所以同局部变量表一样byteshortintfloatcharboolean、引用类型指针reference占用一个栈深度,doublelong占用两个栈深度;
  • 操作数栈是栈结构,不能像局部变量表一样通过下标索引访问数据,只能通过入栈push和出栈pop操作来完成数据访问;

以上面的例子说明操作数栈是怎么工作的:

txt
 0 bipush 10
 2 istore_1
 3 bipush 20
 5 istore_2
 6 iload_1
 7 iload_2
 8 iadd
 9 istore_3
10 return
java
public void test01(){
    int a = 10;
    int b = 20;
    int c = a + b;
}
  1. 首先,在调用方法时,创建一个栈帧,其中包含一个最大深度为2的操作数栈;

  2. 然后执行bipush 10指令,将10放入操作数栈中,如下图第1步;

  3. 然后执行istore_1指令,将栈顶元素放入局部变量表中索引为1的位置,如下图第2步;

  4. 然后执行bipush 20指令,将20放入操作数栈中,如下图第3步;

  5. 然后执行istore_2指令,将栈顶元素放入局部变量表中索引为2的位置,如下图第4步;

  6. 然后执行iload_1指令,将局部变量表中索引为1的变量值放入栈中,如下图第5步;

  7. 然后执行iload_2指令,将局部变量表中索引为2的变量值放入栈中,如下图第5步;

  8. 然后执行iadd指令,将栈顶的两个值相加后,将结果再次入栈,如下图第6步;

  9. 然后执行istore_3指令,将栈顶元素放入局部变量表中索引为3的位置,如下图第7步;

operand-stack

基于栈架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,虚拟机栈也是存在于内存中,这就意味着将需要更多的指令分派次数和内存读/写次数,频繁的执行内存读/写操作必然会影响执行速度,为了解决这一问题HotSpot JVM的设计者们提出了栈顶缓存(Top-of-Stack-Caching)技术,将栈顶元素(或栈顶周边)元素缓存到物理CPU的寄存器中,以此降低对内存的读写次数,提升执行引擎的执行效率。

操作数栈与寄存器映射: JVM 的解释器或即时编译器 (JIT) 会尝试将当前栈帧的操作数栈顶部的几个元素(通常是 1 到 3 个)映射到 CPU 的通用寄存器上。

指令执行优化: 当字节码指令需要操作这些栈顶元素时,可以直接从寄存器中读取,而无需访问内存中的操作数栈。同样,当指令产生结果需要压入栈顶时,如果对应的寄存器可用,结果也会先写入寄存器。

寄存器分配与管理: JVM 需要有效地管理和分配这些寄存器,以确保它们存储的是当前栈顶最活跃的元素。当操作数栈发生变化(例如,有新的值压入或弹出,或者需要访问栈中较深的元素)时,JVM 可能需要更新寄存器中的值,这可能涉及到寄存器到内存的交换 (spilling) 和内存到寄存器的加载 (filling) 操作。

2.5 动态链接(Dynamic Linking)

每个栈帧都包含一个指向该栈帧所属方法所在的类的运行时常量池的引用。这个引用在方法执行过程中用于支持动态链接

什么是动态链接?在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在字节码文件的常量池里。比如,在一个方法中调用另外的方法时,就是通过常量池中指向的符号引用来表示的。动态链接的作用就是将这些符号引用转换为调用方法的直接引用。例如,有下面的Java源代码:

java
package data.area.stack;

public class DynamicLinkTest {
    public void method1(){
        method2();
    }

    private void method2(){
    }
}

编译成字节码后代码如下:

txt
0 aload_0
1 invokespecial #2 <data/area/stack/DynamicLinkTest.method2 : ()V>
4 return

可以看到在第二行,执行了指令invokespecial #2(注:<>中的内容是注释,不是指令内容),其中#2就代表符号引用,我们可以在字节码文件中的常量池找到#2

image-20250325183054122

首先从常量名CONSTANT_Methodred_info上可以看出,#2表示的是一个方法;通过具体内容#3#17,我们可以看到#3表示类data/area/stack/DynamicLinkTest#17表示方法method2:()V

但是,在代码实际执行过程中,执行引擎不能根据data/area/stack/DynamicLinkTest.method2:()V找到方法代码,必须要有内存地址,所以就需要把#2转换为内存地址(例如0x000080012ac800),然后调用invokespecial #2时,才能找到具体的方法代码开始执行。将符号引用转换为在内存中的直接引用,就是链接。

在类加载系统中,链接的子阶段包括解析,这一步不就是把符号引用转换为直接引用吗,为什么在方法调用时还要进行转换呢?我们将链接(或者叫解析)分为两类:

  • 静态解析:发生在类加载的链接阶段,目标方法在编译时就已经确定,通常用于调用静态方法、私有方法、构造器、final方法和父类方法,统称为非虚方法。

    换句话说,非虚方法调用时,它应该在哪个对象或在哪个类上进行调用,编译时就确定(绑定)了,不会在运行时更改了,这也称为早期绑定。

    在字节码层面,这些调用会使用 invokestaticinvokespecial 指令,这些指令通常在解析阶段就能确定目标方法。

  • 动态解析:发生在程序运行时,目标方法需要在运行时根据对象的实际类型才能确定,主要用于调用虚方法,是实现多态性的关键机制。除了以上方法,其他方法统称为虚方法。

    在虚方法调用时,需要在运行时根据对象的实际类型来确定方法调用,这称为晚期绑定。

    在字节码层面,这些调用会使用 invokevirtual 指令。JVM 需要在运行时通过查找对象的实际类型,然后在该类型的方法表中找到匹配的方法进行调用。

代码示例:

java
class StaticResolutionExample {

    private static void staticMethod() {
        System.out.println("This is a static method.");
    }

    private void privateMethod() {
        System.out.println("This is a private method.");
    }

    public StaticResolutionExample() {
        System.out.println("StaticResolutionExample constructor.");
    }

    public void callMethods() {
        staticMethod(); // 调用静态方法
        privateMethod(); // 调用私有方法
        new StaticResolutionExample(); // 调用构造器
    }

    public static void main(String args[]) {
        StaticResolutionExample example = new StaticResolutionExample();
        example.callMethods();
    }
}
txt
 0 invokestatic #7 <data/area/stack/StaticResolutionExample.staticMethod : ()V>
 3 aload_0
 4 invokespecial #8 <data/area/stack/StaticResolutionExample.privateMethod : ()V>
 7 new #9 <data/area/stack/StaticResolutionExample>
10 dup
11 invokespecial #10 <data/area/stack/StaticResolutionExample.<init> : ()V>
14 pop
15 return
java
class Animal {
    public void makeSound() {
        System.out.println("Generic animal sound.");
    }
}

class Dog extends Animal {
    @Override
    public void makeSound() {
        System.out.println("Woof!");
    }
}

class Cat extends Animal {
    @Override
    public void makeSound() {
        System.out.println("Meow!");
    }
}

public class DynamicResolutionExample {
    public static void main(String args[]) {
        Animal animal1 = new Animal();
        Animal animal2 = new Dog();
        Animal animal3 = new Cat();

        animal1.makeSound(); // 调用 Animal 的 makeSound (动态解析)
        animal2.makeSound(); // 调用 Dog 的 makeSound (动态解析)
        animal3.makeSound(); // 调用 Cat 的 makeSound (动态解析)
    }
}
txt
 0 new #2 <data/area/stack/Animal>
 3 dup
 4 invokespecial #3 <data/area/stack/Animal.<init> : ()V>
 7 astore_1
 8 new #4 <data/area/stack/Dog>
11 dup
12 invokespecial #5 <data/area/stack/Dog.<init> : ()V>
15 astore_2
16 new #6 <data/area/stack/Cat>
19 dup
20 invokespecial #7 <data/area/stack/Cat.<init> : ()V>
23 astore_3
24 aload_1
25 invokevirtual #8 <data/area/stack/Animal.makeSound : ()V>
28 aload_2
29 invokevirtual #8 <data/area/stack/Animal.makeSound : ()V>
32 aload_3
33 invokevirtual #8 <data/area/stack/Animal.makeSound : ()V>
36 return

在程序运行过程中,执行动态链接是很频繁的,如果每次调用虚方法时都要进行符号引用转换为直接引用,那么程序运行效率是很低的,所以提出了虚方法表(Virtual Method Table)。

方法表会在类加载的链接阶段被创建并初始化,方法表通常存储在方法区(Method Area) 类模板中。每个加载到JVM中的类或接口都会拥有自己的方法表。

方法表中存储的是该类及其所有父类中非私有、非静态、非构造器的方法的引用(通常是指向方法字节码的指针)。

子类的方法表会继承父类的方法表,并且会将子类重写(Override)的方法放在与父类相同偏移量的位置上。这样,无论对象的实际类型是父类还是子类,都可以通过相同的偏移量找到对应的方法,从而实现多态。

我们以下面的例子说明方法表(去除Object类中的虚方法):

java
class Animal {
    public void eat(){
        System.out.println("Generic animal eating");
    }
    public void makeSound() {
        System.out.println("Generic animal sound.");
    }
}

class Dog extends Animal {
    @Override
    public void makeSound() {
        System.out.println("Woof!");
    }
}

class Cat extends Animal {
    @Override
    public void makeSound() {
        System.out.println("Meow!");
    }
  
  	public void run(){
        System.out.println("cat running");
    }
}

对于Animal来说,方法表如下:

索引方法名方法字节码地址
0eat0x000010000
1makeSound0x000020000

对于Dog来说,方法表如下(继承了父类的方法表):

索引方法名方法字节码地址
0eat0x000010000
1makeSound0x000030000

注意,由于Dog类重写了makeSound()方法,所以Dog类方法表中makeSound()的方法字节码地址与AnimalmakeSound()方法字节码地址不同,而eat()方法由于没有重写,所以两者的方法字节码地址相同。

对于Cat来说,方法表如下:

索引方法名方法字节码地址
0eat0x000010000
1makeSound0x000040000
2run0x000050000

注意Cat类多了一个run()方法,所以在方法表中也多了一项。

2.6 返回地址(Return Address)

当在一个方法A中调用另一个方法B时,会创建B方法对应的栈帧SF_B(StackFrame_B),并且栈帧SF_B中包含有一个结构:返回地址,返回地址指向调用方法A中的调用方法B的下条指令。

返回地址(Return Address) 是指当一个方法执行完毕后,程序需要返回到调用该方法的地方继续执行的指令的地址。

例如:

java
public class ReturnAddressTest {
    public void method1(){
        method2();
        int a = 1;
    }

    private void method2() {
    }
}

将上述Java代码编译为字节码后,method1字节码如下:

txt
0 aload_0
1 invokespecial #2 <data/area/stack/ReturnAddressTest.method2 : ()V>
4 iconst_1
5 istore_1
6 return

在执行第1条指令invokespecial #2时,会创建一个栈帧,并且将该栈帧的返回地址设置为4,这是因为当调用方法结束后,执行引擎可以根据返回地址,重新回到method1方法中下一条指令地址继续执行。

3. 本地方法栈

3.1 本地方法

本地方法是在Java中声明,在其他语言(例如C/C++)中实现的方法。在Java中,可以使用关键字native声明本地方法。

3.2 本地方法栈

本地方法栈和虚拟机栈类似,但虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用,本地方法栈也是线程私有的。

当某个线程调用一个本地方法时,它就进入了一个不受虚拟机限制的环境,它和虚拟机拥有同样的权限,本地方法可以访问虚拟机内部的运行时数据区,甚至可以直接从本地内存中分配人意数量的内存、使用本地处理器中的寄存器。

在Hotspot虚拟机中,直接将本地方法栈和虚拟机栈合二为一,称为混合栈(mixed stack)。

3.3 本地方法示例

以下示例在Windows系统下实现。

首先新建HelloService类,其中包含一个本地方法:

java
package com;

public class HelloService {
    public native void sayHello();
}

然后使用javac编译源文件,生成C++头文件:

bash
javac -h . HelloService.java

生成的头文件如下:

c++
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class com_HelloService */

#ifndef _Included_com_HelloService
#define _Included_com_HelloService
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     com_HelloService
 * Method:    sayHello
 * Signature: ()V
 */
JNIEXPORT void JNICALL Java_com_HelloService_sayHello
  (JNIEnv *, jobject);

#ifdef __cplusplus
}
#endif
#endif

将上述头文件放在单独的路径,这里放在E:\external_header文件夹下。

我们在Visual Studio中创建一个C++动态链接库项目,如下:

image-20250327111812691

然后在该项目属性中修改如下:

image-20250327163246715

在包含目录项增加三个目录:

  • E:\external_header:就是我们生成的头文件;
  • %JAVA_HOME%\include
  • %JAVA_HOME%\include\win32

然后编写C++代码:

c++
#include "pch.h"
#include <iostream>
#include "com_HelloService.h"

JNIEXPORT void JNICALL Java_com_HelloService_sayHello(JNIEnv*, jobject) {
  std::cout << "Hello from C++!" << std::endl;
  return;
}

最后生成动态链接库(注意生成64位的)。

在Java项目中,引入DLL,程序正确运行

image-20250327163657831

4. 其他

4.1 方法返回

我们将方法返回分为:正常返回和异常返回。

正常返回:这里主要讲解方法有返回值的情况。例如下面的代码案例:

java
public class NormalReturnTest {
    public void getSum(){
        int sum = add(10, 20);
    }
    
    public int add(int a, int b){
        int sum = a + b;
        return sum;
    }
}

当调用add()方法时,会创建一个对应的栈帧压入栈,当在add()执行到return语句时,正常返回,流程如下:

  • 计算出的返回值(sum)会被存储到当前栈帧(add方法)的操作数栈(Operand Stack) 的栈顶;
  • 将操作数栈顶的返回值(sum)弹出,将该值压入调用方方法(getSum方法)栈帧的操作数栈的栈顶;
  • 弹出被调用方法(sum)的栈帧,并将程序计数器(PC Register)设置为之前保存的返回地址,从而使程序能够回到调用方方法(getSum)继续执行;

总之,被调用方法的返回值会压入调用方方法的操作数栈栈顶。

异常返回

栈展开(Stack Unwinding): JVM会从当前方法开始,沿着方法调用栈向上查找能够处理该异常的 catch 块。

查找匹配的 catch 块: JVM会检查每一层调用方法中的 try-catch 块,看是否有 catch 块声明可以捕获该异常类型或其父类型。

执行 finally 块: 在栈展开的过程中,如果遇到包含 finally 块的 try 语句,那么该 finally 块中的代码会被执行,无论异常是否被捕获。

到达顶层栈帧: 如果JVM一直向上搜索到最顶层的栈帧(通常是 main 方法所在的线程的栈帧),仍然没有找到匹配的 catch 块,那么该异常就成为一个未捕获的异常(Uncaught Exception)

默认异常处理: JVM会调用默认的异常处理器(Default Exception Handler) 来处理这个未捕获的异常。

线程终止: 对于普通的Java应用程序,默认的异常处理器通常会将异常的堆栈跟踪信息(StackTrace)打印到控制台(标准错误流),然后终止当前线程的执行

程序可能继续运行: 如果该异常发生在某个非主线程中,那么只有该线程会终止,主线程以及其他线程可能会继续运行。如果异常发生在主线程中,通常会导致整个应用程序的退出。

4.2 方法调用

我们还是以下面的例子为例:

java
public class NormalReturnTest {
    public void getSum(){
        int sum = add(10, 20);
    }
    
    public int add(int a, int b){
        int sum = a + b;
        return sum;
    }
}

查看getSum方法的字节码指令:

txt
0 aload_0
1 bipush 10
3 bipush 20
5 invokevirtual #2 <data/area/stack/NormalReturnTest.add : (II)I>
8 istore_1
9 return

可以看到在执行invokevirtual #2指令之前,会先将对象、方法实参压入操作数栈中(aload_0就是this)。