Java 虚拟机系列文章目录导读:

深入理解 Java 虚拟机(一)~ class 字节码文件剖析
深入理解 Java 虚拟机(二)~ 类的加载过程剖析
深入理解 Java 虚拟机(三)~ class 字节码的执行过程剖析
深入理解 Java 虚拟机(四)~ 各种容易混淆的常量池
深入理解 Java 虚拟机(五)~ 对象的创建过程
深入理解 Java 虚拟机(六)~ Garbage Collection 剖析

在前面详细介绍了 class 字节码,以及 JVM 如何将 class 字节码加载到内存中。下面就来介绍下 JVM 如何执行 class 字节码的。

在执行字节码的时候肯定是需要进行内存分配的,比如执行一个方法,方法的局部变量、方法里 new 的对象等等,那么下面我们就来看下 Java 的内存区域。

1. Java 内存区域

Java 虚拟机所管理的内存包括下图所示的几个运行时数据区域(Run-time data area)

run-time data areas

1.1 程序计数器

Java 是支持多线程的,每个线程都有一个程序计数器(Program Counter Register,简称 PC 计数器),它是线程私有的。如果线程执行的是 Java 代码,它永远不会用来存储“当前指令”,而只是会存储代表当前程序位置的计数器,可以看作一个整数,也可以看作一个代码指针”(R大在知乎的问答)。如果线程执行的是 native 代码,那么程序计数器就是 undefined。程序计数器pc 就是程序执行的指示器,当线程获取到 CPU 资源时,能够恢复到正确的执行位置继续执行程序。

1.2 Java 虚拟机栈

Java 虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期和线程一样。创建线程时,虚拟机栈也一起被创建。虚拟机栈里面存储着栈帧(Frame),栈帧是用来存放局部变量表、操作数栈、动态连接、方法出口等信息(关于栈帧后面会详细介绍)。线程每执行一个方法都会创建一个栈帧放进虚拟机栈中(push),方法执行完毕则将栈帧从虚拟机栈中出栈(pop)。

1.3 本地方法栈

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

1.4 Java 堆

虚拟机一启动,Java 堆就会被创建,它是被所有线程共享的一个区域。Java 堆主要是用来存储 Java 对象的,所以垃圾回收器主要管理的区域就是堆区。

但是随着逃逸技术(Escape Analysis)的发展,对象内存分配也可能不在堆区。什么是 逃逸分析 呢?简单来说就是分析对象动态作用域:如果一个对象在方法中定义,它可能作为参数传递到另一个方法中去,称之为方法逃逸。如果对象可能被其他线程访问到,例如赋值给类变量或者可以在其他线程被访问的实例变量,称之为线程逃逸

如果能够分析出一个对象不会逃逸到其他方法或线程,那么可以将该对象分配到栈中而不是堆中。因为堆是所有线程共享的区域,垃圾回收器会对里面的对象进行管理,这都是需要耗费时间的。如果将对象分配在栈中,该对象就会随着栈帧的出栈而被销毁。如果不会逃逸的对象很多,也就是在栈中分配的对象比较多,那么大量的对象都会随着方法的结束而被自动销毁了,垃圾收集器的压力就会小很多,内存的利用率也会高很多,因为对象没用了会立马被销毁。

1.5 方法区

方法区(Method Area)也是多有线程共享的一个区域。逻辑上它是堆区的一部分。

方法区主要用来存放已被虚拟机加载的 class 的结构信息,如运行时常量池、字段和方法数据、方法的代码(包括实例构造器<init>和类构造器<clinit>)。

1.6 运行时常量池

运行时常量池(Run-Time Constant Pool)是方法区的一部分。运行时常量池就是 class 字节码文件的常量池部分在运行时的描述。因为 class 字节码文件中的常量它是存于字节码文件中,一旦该 class 字节码文件加载到内存中,则字节码文件的常量池部分,则放进运行时常量池。运行时常量包含这几种类型的常量:从编译期的 数字字面量 到运行时对方法和字段解析出来的 直接引用

运行时常量池有点类似其他流行语言的符号表(Symbol Table),但是它包含的范围又比符号表更广。

2. 方法调用

通过上面的介绍,我们对 Java 虚拟机运行时数据区有了一个大致的了解。我们都知道,写的代码绝大部分都在方法中,那么虚拟机是如何进行方法的调用呢?

这里的方法的调用并不是执行方法里面的代码,而是确定被调用方法的版本。比如一个类具有多态性,调用方法的时候就需要找到最终需要调用的方法。

方法调用在 class 字节码文件里存储的都是符号引用,需要在类加载阶段或者运行期间才能确定目标方法的直接引用。

那么什么样方法会在类加载的时候就会将符号引用解析成直接引用呢?这样的方法需要满足一个条件:运行时只会有一个可确定的版本。比如静态方法、私有方法等,因为这些方法无法被覆写。

2.1 方法调用指令

Java 虚拟机提供了 5 条方法调用的指令:

  • invokestatic:调用静态方法。
  • invokespecial:调用实例构造方法、私有方法和父类构造方法。
  • invokevirtual:调用所有虚方法(什么是虚方法后面在介绍)。
  • invokeinterface:调用接口方法,在运行时在确定一个实现此接口的对象。
  • invokedynamic:在运行时动态解析出调用点(CallSite)限定符所引用的方法,然后在执行该方法。前面 4 个用于方法调用的指令,分派的逻辑都是固化在虚拟机内部的, 而 invokedynamic 指令的分派由用户所设定的引导方法决定。(分派就是寻找合适的方法)

上面的指令也不要死记硬背。

invokestatic、invokeinterface 很简单从名字就知道它们分别用来调用 static 方法和接口方法的。

invokespecial 指令

其中 special特别的、专用的 意思,invokespecial 指令用来调用运行时只有一个版本的方法。所以 special 在这里你可以理解为 专用的 意思,因为这些方法不能被 override,也就没有多态性了,就是说这个方法是只给当前类来使用的,例如私有方法、构造方法。

需要注意的是,new 一个对象我们通常也认为是调用了这个类的构造方法。其实这样说不准确,实际上是 JVM 虚拟机是先通过 new 指令 创建好对象后,然后会自动帮我们调用对应的构造函数来对该对象进行初始化,这就是为什么构造函数编译后叫做 <init> 函数。

那什么时候我们会手动调用构造函数呢?

在构造函数中调用本类或父类的构造函数时会使用 invokespecial

public class Client {

    private Client(String name) {
        this(name, 18); // invokespecial
    }

    private Client(String name, int age) {
        // ...
    }
}

invokevirtual 指令

virtual虚拟的、虚的 意思。invokevirtual 指令用来调用具有多态性方法的,也就是说能够被覆写的方法。

Animal animal = new Dog();
animal.run();

例如上面的代码表面是执行 Animal 的 run 方法,运行的时候实际上执行的 Dog 的 run 方法,你说虚不虚,不实在嘛,所以它叫做 invokevirtual

其实哪些指令哪些方法,设计者都是经过了精密思考的。

final 方法

final 是不能被 override 的,也就是说没有多态性。根据上面我们讲的调用 final 方法应该是使用 invokespecial 指令。可实际上调用 final 方法是使用 invokevirtual 指令。下面举一个例子来说明这个问题:

public class Fruit {
    public String getName() {
        return "Fruit";
    }
}

public class Apple extends Fruit {

    // 覆写方法时加上 final
    @Override
    public final String getName() {
        return "Apple";
    }
}

public static void main(String[] args) {
    Fruit fruit = new Apple();
    fruit.getName();
}

从上面的代码可以看出父类 Fruit.getName 是非 final 的,但是子类在覆写的时候加上了 final 关键字。

Fruit fruit = new Apple();
fruit.getName();

fruit.getName() 表面调用了 Fruit.getName 方法(非 final),运行的时候实际上调用的是 Apple.getName 方法(final),所以是很难判断调用的方法是不是 final 的。Apple 加载后,它的 getName 也解析成为了直接引用,执行 invokevirtual 时在运行时常量池中发现已经存在了该方法的直接引用,就可以直接拿来用了。

2.2 方法重载解析

方法重载解析(Method Overload Resolution):意思是重载方法的调用在编译器就已经确定了调用哪个方法。

继续以上面的水果类为例:

public class MethodOverloadResolution {

    public void sell(Fruit fruit) {
        System.out.println("fruit...");
    }

    public void sell(Apple apple) {
        System.out.println("apple...");
    }

    public void sell(Banana banana) {
        System.out.println("banana...");
    }

    public static void main(String[] args) {
        Fruit apple = new Apple();
        Fruit banana = new Banana();

        MethodOverloadResolution dispatch = new MethodOverloadResolution();
        dispatch.sell(apple);
        dispatch.sell(banana);

    }
}

// 输出结果:

fruit...
fruit...

对于 Fruit apple = new Apple(); Fruit 称之为静态类型,静态类型在编译期可知,所以也可以叫做编译期类型。后面的 Apple 称之为实际类型,在运行期可知,所以也可以称之为运行时类型。

方法重载解析 是在编译期通过静态类型去匹配重载的方法。因为上面的静态类型都是 Fruit 所以调用的都是 sell(Fruit fruit) 这个方法。

上面的例子还是比较精确的情况,静态类型是 Fruit,在重载方法中有参数类型刚好是 Fruit 。

有的时候静态类型和方法参数类型不完全匹配,此时会编译器会找一个 最符合 的方法。

例如下面的代码:


public class MyPrinter {

    public void println(int x) {
        System.out.println("int:" + x);
    }

    public void println(short x) {
        System.out.println("short:" + x);
    }

    public void println(long x) {
        System.out.println("long:" + x);
    }

    public void println(float x) {
        System.out.println("float:" + x);
    }

    public void println(double x) {
        System.out.println("double:" + x);
    }

    public void println(Character x) {
        System.out.println("character:" + x);
    }

    public void println(Serializable x) {
        System.out.println("serializable:" + x);
    }
    
    public void println(char... x) {
        System.out.println("char...:" + x);
    }

    public static void main(String[] args) {
        MyPrinter printer = new MyPrinter();
        printer.println('a');
    }
}

输出结果为:int:97

编译器尝试找参数为 char 的重载方法,发现没有。发现有参数为 int 的重载方法,因为 char 也可以表示整型,将 char 类型转化成 int,所以最后调用的是参数为 int 的重载方法。

如果我们将参数为 int 的重载方法注释掉,运行结果为:long:97,它为什么没有找 short 呢?因为 char 转 short 不安全(char 是占 2 字节的无符号类型,short 是占 2 字节的有符号类型)。继续测试的话,会按照 char -> int -> long -> float -> double 的顺序进行类型转换,然后找到最合适的重载方法。

如果把所有的方法注释掉,只剩下一个 println(Character x),会发现会输出:character:a,可见除了编译器会自动类型转换,还会进行自动装箱来查找合适的方法。

如果只剩下 println(Serializable x) 方法,会输出:serializable:a,首先会将 char 自动装箱成 Character,Character 实现了 Serializable 接口,所以编译器就选定了该方法。

总的来说,如果找不到精确匹配的重载方法,编译器会:

  • 通过安全的类型转换来寻找(包括基本类型、父类子类)
  • 通过装箱或拆箱来寻找

提示:可变参数的重载方法是优先级最低的

更多关于 Method Overload Resolution 可以查看官方文档:Compile-Time Step 2: Determine Method Signature

2.3 动态分派

上面介绍的 方法重载解析 是在编译期确定方法调用的版本。动态分配是在运行时确定方法的版本。例如方法的覆写(override),也就是类的多态性相关。

我们还是以上面的水果类为例:

public class Client {
    public static void main(String[] args) {
        Fruit apple = new Apple();
        Fruit banana = new Banana();
        System.out.println(apple.getName());
        System.out.println(banana.getName());
    }
}

// 输出结果:

Apple
Fruit

对上面例子的输出结果,对于任何有点面向对象基础的读者,都是很容易理解的。那么 Java 虚拟机对多态的运行机制什么样的呢?下面我们展开详细讨论。

 public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class execute_bytecode/Apple
       3: dup
       4: invokespecial #3                  // Method execute_bytecode/Apple."<init>":()V
       7: astore_1
       8: new           #4                  // class execute_bytecode/Banana
      11: dup
      12: invokespecial #5                  // Method execute_bytecode/Banana."<init>":()V
      15: astore_2
      16: getstatic     #6                  // Field java/lang/System.out:Ljava/io/PrintStream;
      19: aload_1
      20: invokevirtual #7                  // Method execute_bytecode/Fruit.getName:()Ljava/lang/String;
      23: invokevirtual #8                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      26: getstatic     #6                  // Field java/lang/System.out:Ljava/io/PrintStream;
      29: aload_2
      30: invokevirtual #7                  // Method execute_bytecode/Fruit.getName:()Ljava/lang/String;
      33: invokevirtual #8                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      36: return
}

第 0 ~ 15 行的字节码主要是通过 new 指令创建 Apple、Banana 对象,调用对应的构造器进行初始化,然后分别将两个对象分别通过 astore_1astore_2 指令将两个对象放在局部变量表的第 1,2 个位置。

第 16 行字节码将 System.out 的结果 PrintStream 压入栈顶

第 19 行字节码将局部变量的第一个位置的值(也就是 Apple 实例对象)压入栈顶。

第 20 行字节码通过 invokevirtual 指令调用栈顶对象的 getName 方法,此时栈顶里的对象就是 Apple,但是第 20 行字节码注释为:

// Method execute_bytecode/Fruit.getName:()Ljava/lang/String;

注释上说 invokevirtual 指令指向的描述符是 Fruit.getName:() 。这就要从 invokevirtual 指令执行过程说起:

  • 1)将操作数栈顶指向的元素对象,记作 C
  • 2)如果在 C 中找到与常量池中的描述符和简单名称都相等的方法,则进行权限访问,如果通过则直接调用该方法;不通过。则抛出 java.lang.IllegalAccessError 异常。
  • 3)否则,按照继承关系从下往上一次对 C 的各个父类按照步骤 2 进行搜索。
  • 4)如果还是没有找到合适的方法,抛出 java.lang.AbstractMethodError 异常。

所以虽然第 20 行字节码指向的是常量池中的 Fruit.getName,但是 invokevirtual 指令使用的实际类型 Apple,从实际类型开始从下往上查找要执行的方法。

第 23 行字节码是调用 println 方法。那么问题来了,如果栈顶是 PrintStream,那么是可以调用 println 方法的,那么 println 方法的参数从哪里来呢?

如果栈顶是 apple.getName 方法返回值,那么就没有对象来调用 println 方法了。首先我们需要确定 invokevirtual 指令调用会将方法的返回值压入栈顶吗?

我们用一个简单的例子做一个实验:

public static void main(String[] args) {
    Fruit apple = new Apple();
    apple.getName();
}

javap 反编译结果:

public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class execute_bytecode/Apple
       3: dup
       4: invokespecial #3                  // Method execute_bytecode/Apple."<init>":()V
       7: astore_1
       8: aload_1
       9: invokevirtual #4                  // Method execute_bytecode/Fruit.getName:()Ljava/lang/String;
      12: pop
      13: return

可以看到 invokevirtual 执行完毕,通过 pop 指令将栈顶的值弹出栈(因为后面没有用到方法的返回值)。所以 invokevirtual 指令会将返回值压入栈顶的。

那么既然栈顶是 println 方法的参数,栈顶下面是 PrintStream 对象,栈的数据结构不是只能从栈顶拿数据吗?这个就要看 invokevirtual 指令的官方文档

invokevirtual

可见 invokevirtual 指定对应的操作数栈是:

..., objectref, [arg1, [arg2 ...]] →

右边的箭头就是栈的方向,objectref 就是调用方法实际对象,紧接着是方法的参数。可见虽然操作数栈顶不是 objectref,虚拟机会将 objectref 做标记,这是方法的所有者,称之为接受者(Receiver)。执行 invokevirtual 指令的时候会将 objectref 和后面的参数一起出栈,用来执行方法。

通过运行时的实际类型来确定方法的版本,称之为动态分派。

在现实情况中可能要比上面的代码要复杂些:不仅有覆写(Override),还有重载(Overload)。这个时候如何来选择方法呢?其实这个也很简单,首先编译时会通过 方法重载解析(Method Overload Resolution)来确定调用哪个方法版本,至于 Override,运行时根据实际类型来调用该方法。

2.4 虚拟机动态分派的实现

对于 Java 这个面向对象的语言来说,动态分派 是非常频繁的动作。为了性能考虑,虚拟机不会从类的方法元数据中去查找合适的方法。虚拟机通常会在方法区中为类创建一个虚拟方发表(Virtual Method Table 简称 vtable),如果是 invokeinterface 指令会用到接口方法表(Interface Method Table 简称 itable)。虚拟机使用虚拟方法表代替搜索方法元数据来提高性能。

例如下面代码:

public class Printer {

    public void print(Pager image){
        //...
    }

    public void print(Image image){
        //...
    }

}


class HP_Printer extends Printer{
    @Override
    public void print(Image image) {
        //...
    }

    @Override
    public void print(Pager image) {
        //...
    }
}

对应的虚拟方法表如下所示:

虚拟方法表

虚拟表中存放的是各个方法的实际入口地址,如果某个方法在子类没有被 Override,那么子类的虚方法表存的方法是父类相同方法的入口地址。

例如上面的 Printer 并没有覆写 Object 中的方法,所以 Printer 的虚拟方法表中关于 Object 里的方法存的都是 Object 方法的入口地址。 HP_Printer 的虚拟方法表也是如此。如果 Printer 覆写了父类的 toString 方法,那么 Printer 的虚拟方法表存的就是覆写的 toString 方法的入口地址,HP_Printer 存的就是 Printer 的 toString 的入口地址而不是 Object 的。

2.5 动态类型语言的支持

《深入理解 Java 虚拟机(一)~ class 字节码剖析》 介绍了 invokedynamic 指令的实现原理。在这里我们在做一些补充。

invokedynamic 指令是 Java 为了支持 “动态类型语言” 而做的一项改进。那么什么是动态类型语言呢?动态类型语言关键特征是它的类型检查的主体过程是在运行期而不是编译期。比如:Clojure、Erlang、Groovy、JavaScript、Lua、PHP、Ruby等等。在编译期就进行类型检查过程的语言就是静态类型语言(如C++、Java)。

下面举个例子来描述静态类型语言和动态类型语言在类型检查上的区别。

obj.println("chiclaim")

例如上面的代码,如果是在 Java 语言中,假定 obj 是 PrintStream 类型,那么 obj 要么是 PrintStream,要么是 PrintStream 的子类。否则无法调用 println 方法。作为 Java 程序员我想这个是很容易理解的。如果传递的 obj 和 PrintStream 类型没有任何关系,那么编译器在编译期就会报错。

那么如果上面的代码在动态类型语言 JavaScript 中则情况不一样了,不管 obj 是什么类型,只要 obj 有 println(string) 方法即可。在动态类型语言中 obj 本身是没有类型的,变量 obj 的值才具有类型。编译时最多只确定方法的名称、参数、返回值等信息,不会去确定方法调用者的类型。

由于 Java 虚拟机上运行的不止 Java 语言,还有其他的很多动态类型语言。为了支持动态类型, Java 1.7 加入了 invokedynamic 指令以及 java.lang.invoke 包。

2.5.1 java.lang.invoke 包

我们知道 Java 的方法调用主要是通过符号引用来确定目标方法。java.lang.invoke 包的推出提供了一种新的动态确定目标方法的机制,称之为 MethodHandler。

还是上面的 obj.println(“chiclaim”) 为例,obj 通过方法参数传递进来,不用管 obj 是 PrintStream 还是其他我们自定义的包含 println(string) 方法的类。 有了 MethodHandler 让这变成了可能。

首先我们定义一个类也具有 println 方法:

static class MyPrinter {
    public void println(String msg) {
        System.out.println(msg);
    }
}

然后定义一个 test 方法,相当于:obj.println(“chiclaim”),只不过 obj 对象是外面动态传递进来的:

public static void test(Object obj) throws Throwable {

    // 描述方法的返回值,参数类型
    MethodType methodType = MethodType.methodType(void.class, String.class);

    // 在指定类中查找符合给定的方法名称、方法返回值、参数类型的方法
    // 因为调用的是成员方法,成员方法都有一个隐含参数,也就是方法的调用者,通过 bingTo 传递过去
    MethodHandle methodHandle =  MethodHandles.lookup()
            .findVirtual(obj.getClass(), "println", methodType)
            .bindTo(obj);

    // 通过 MethodHandler 调用方法
    methodHandle.invokeExact("chiclaim");
}

最后测试下:

public static void main(String[] args) throws Throwable {
    test(System.out);      // PrintStream
    test(new MyPrinter()); // MyPrinter
}

// 输出结果:

chiclaim
chiclaim
2.5.2 java.lang.reflect 包

上面的案例我们同样可以使用 反射 来实现,如:

private static void test(Object obj) throws Throwable {
    Class clazz = obj.getClass();
    Method method = clazz.getDeclaredMethod("println", String.class);
    method.invoke(obj, "chiclaim");
}

但是 反射MethodHandler 还是有差别的:

  • 1)反射模拟的是 Java 代码层面的方法调用,而 MethodHandler 是在模拟字节码层面的方法调用。例如 MethodHandlers.lookup 中的 3 个方法 - findStatic、findVirtual、findSpecial 分别对应的是 invokeStatic、invokeVirtual/invokeinterface、invokespeical 字节码指令。
  • 2)反射中的 java.lang.Method 对象要比 java.lang.invoke.MethodHandler 对象所包含的信息要多。反射是重量级的,MethodHandler 是轻量级的。
  • 3)MethodHandler 和 invokedynamic 指令功能是类似的,都是为了支持动态类型。MethodHandler 是在 Java 语言层面支持,invokedynamic 指令是在虚拟机层面支持。

3. 方法执行

介绍完了方法调用,接下让我们深入方法内部,虚拟式如何执行方法的。我们知道方法的调用虚拟机会创建一个栈帧(Stack Frame)放进虚拟机栈中,方法执行完毕栈帧从虚拟机栈弹出。

下面我们就来介绍下栈帧的内部细节,栈帧主要由下面几个部分组成:

  • 局部变量表
  • 操作数栈
  • 动态连接
  • 方法返回地址

关于栈帧 Frame 更多信息可以查看:jvms-2.6

3.1 局部变量表

局部变量表(Local Variable Table)用于存放方法参数和方法内部定义的局部变量。在编译时就会确定局部变量表的大小(max_locals),在字节码中的 code 属性中。

局部变量表以变量槽(Variable Slot)为最小单位。每个 Slot 可以存放 boolean、byte、char、short、int、float、reference(32、64 bits 系统可能不一样 )或 returnAddress 类型的数据。

但是不能说每个 Slot 严格占用 4 字节,不同的操作系统和位数 Slot 大小可能不一样。但是需要保证每个 Slot 能够上面的数据类型。对于 8 字节的数据类型如 long、double 使用 2 个 Slot 来保存。

为了节省栈帧空间,局部变量表中的 Slot 是可以重用的。当变量超出局部变量的作用域,那么这个局部变量所占用的 Slot 可以被其他变量所使用。例如:

public void test() {
    int count = 0;
    for (int i = 0; i < 10; i++) {
        int num = i;
        count += num;
    }
    System.out.println(count);
}

// 通过 javap 反编译查看局部变量表(locals)大小:
stack=2, locals=4, args_size=1

在循环中定义 num 变量,循环 10 次,num 变量始终都会共用一个 Slot,局部变量表占用 Slot 情况如下所示:

Slot1 -> this // 每个实例方法都会隐含 this 参数
Slot2 -> conut
Slot3 -> i
Slot4 -> num

有些情况 Slot 复用会对垃圾回收产生影响。例如:

public static void main(String[] args) {
    {
        // 分配 64M 空间
        byte[] buff = new byte[1024 * 1024 * 64];
    }
    System.gc();
}

运行的时候加上 -verbose:gc 运行参数:

[GC (System.gc())  70784K->66384K(251392K), 0.0018971 secs]
[Full GC (System.gc())  66384K->66217K(251392K), 0.0106443 secs]

发现并没有回收掉已经超出了作用域的 buff 所占用的空间。因为局部变量表存在对 buff 数组对象的引用。如果 buff 占用 Slot 被其他变量占用,那么 buff 就可以被回收了,例如:

public static void main(String[] args) {
    {
        // 分配 64M 空间
        byte[] buff = new byte[1024 * 1024 * 64];
    }

    int i = 10; // 新增变量
    System.gc();
}

运行的 GC 情况:

[GC (System.gc())  70784K->66416K(251392K), 0.0007598 secs]
[Full GC (System.gc())  66416K->681K(251392K), 0.0049434 secs]

可以发现 66416K->681K,也就是说 buff 占用的空间被回收掉了。因为变量 i 复用了 buff 的 Slot,那么局部变量就没有对 buff 数组对象的引用了。

3.2 操作数栈

操作数栈(Operand Stack)是一个后入先出的栈结构。当方法开始执行的时候,方法的操作数栈是空的,在方法的执行过程中,字节码指令 会往操作数栈中写入和读取元素。

操作数栈的每个元素可以是任意的 Java 数据类型,4 字节的数据类型所占的栈容量为 1,8 字节的数据类型所占的栈容量为 2

操作栈的深度(max_stacks)也会在编译时确定,存放在字节码中的 code 属性中。

下面我们举一个例子,介绍方法执行过程中局部变量表和操作数栈的情况:

public static void main(String[] args) {
    int a = 10;
    int b = 15;
    int sum = a + b;

    float f1 = 1.1f;
    float f2 = 1.2f;
    float f3 = 1.3f;
    float fSum = f1 + f2 + f3;

    double d1 = 3.14;
}

通过 javap 反编译其字节码文件:

stack=2, locals=10, args_size=1
      0: bipush        10                    
      2: istore_1                            
      3: bipush        15                    
      5: istore_2                            
      6: iload_1                             
      7: iload_2                             
      8: iadd                                
      9: istore_3                            
     10: ldc           #2    // float 1.1f   
     12: fstore        4                     
     14: ldc           #3    // float 1.2f   
     16: fstore        5                     
     18: ldc           #4    // float 1.3f   
     20: fstore        6                     
     22: fload         4                     
     24: fload         5                     
     26: fadd                                
     27: fload         6                     
     29: fadd                                
     30: fstore        7                     
     32: ldc2_w        #5    // double 3.14d 
     35: dstore        8                     
     37: return

可以看出操作数栈为最大深度为 2,局部变量表大小为 10

为什么局部变量表大小为 10?静态方法 main 有 1 个参数 args,3 个 int 变量,4 个 float 变量,1 个 double 变量,因为 double 占 8 个字节,所有占用 2 个 Slot,所以局部变量表大小为:1 + 3 + 4 + 2 = 10

为了方便描述,我在每一行的指令后面都添加了操作数栈和局部变量表的内部细节,如下图所示:

局部变量表和操作数栈情况

3.3 动态连接

每个栈帧(Frame)都包含当前方法所在类的运行时常量池的引用。

该引用主要是为了支持方法在执行过程中的动态连接。例如在方法的执行过程中,需要执行另一个方法,此时可能需要进行动态连接,如果该方法是 private 或 static 方法那么该方法会在类加载的阶段解析成直接引用。如果方法是 virtual 方法,那么该方法在调用的时候再解析,称之为动态连接。这关于这部分的内容已经在上面的 方法调用 详解介绍过了。

3.4 方法返回地址

方法的退出有两种情况:

  • 正常退出(正常完成出口 Normal Method Invocation Completion)

    执行引擎遇到任意一个方法返回的字节码指令,会正常退出。这个时候可能会有返回值传递给上层的方法调用者。

  • 异常退出(异常完成出口 Abrupt Method Invocation Completion)

    在方法的执行的过程中遇到异常,并且这个异常没有在方法体内得到处理(不管是 Java 虚拟机内部产生异常,还是代码中使用 athrow 字节码指令产生的异常,只要在本方法的异常表没有搜索到匹配的异常处理器)

方法的退出,栈帧会从虚拟机栈弹出,因此退出时可能执行的操作有:恢复上层方法的局部变量和操作数栈,如果退出的方法有返回值,该返回值会压入调用者的操作数栈中,调整 PC 计数器的值以指向方法调用指令后面的一条指令。

基于栈的指令集与基于寄存器的指令集

Java 虚拟机是基于栈的指令集架构,这些指令依赖操作数栈进行工作。上面的通过 javap 反编译的例子可以出看出来。

与之对应的另一套常用的指令集架构是基于寄存器的指令集。这些指令依赖寄存器尽心工作。

两种指令集能同时存在,肯定各有优点。

基于寄存器架构指令集优点肯定是:快。

基于栈的指令集主要优点是:可移植。基于寄存器的指令集,由于程序直接依赖硬件寄存器,则不可避免地受到硬件的约束。基于用户程序不会直接使用寄存器,那么就可以由虚拟机实现来自行决定把一些访问最频繁的数据放到寄存器中获取更好的性能。

基于栈的指令集完成相同功能需要的指令数量一般会寄存器架构要多,指令的执行都依赖栈,栈的实现在内存中,那就意味着需要频繁地访问内存。相对于处理器来说,内存始终是执行速度的瓶颈。所以基于栈的指令集要比寄存器架构性能要低。

Reference


如果你觉得本文帮助到你,给我个关注和赞呗!

另外本文涉及到的代码都在我的 AndroidAll GitHub 仓库中。该仓库除了 Java虚拟机 技术,还有 Android 程序员需要掌握的技术栈,如:程序架构、设计模式、性能优化、数据结构算法、Kotlin、Flutter、NDK,以及常用开源框架 Router、RxJava、Glide、LeakCanary、Dagger2、Retrofit、OkHttp、ButterKnife、Router 的原理分析 等,持续更新,欢迎 star。

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐