JVM 方法到底如何执行

前言

JVM内存模型.png

与上图类似的JVM内存模型图见过多次,仅从概念上去理解各个区域的作用,难有深刻印象。

当学习一个类如何存储,即JVM如何解析.Class文件,能知道方法区存在的意义。本文的目的则是学习JVM如何执行一个方法,如此对栈与程序计数器有更深刻的认识。

note 文中部分内容需要.Class文件知识,但总体上不妨碍理解
.Class参考

字节码基础

Java代码通过编译后,会将对应的函数方法转为字节码指令,如果了解.Class如何组成,可在对应方法表里的Code属性表查找到对应的一系列字节码指令。函数的执行本质上是数据运算与执行调度,因此可以用一系列的指令来进行描述。

字节码指令由一个字节长度表示,代表特定的操作含义,后面可以跟随零到多个必要参数。使用一个字节表示,意味着字节码的总数不可能操作 256条。

下面表列出了常用的数据类型对应的字节码指令,粗略看一眼就可以,需要的时候再具体查阅每条字节码的含义。

opcode byte short int long float doubt char Reference
Tipush bipush sipush
Tconst iconst lconst fconst dconst aconst
Tload iload lload fload dload alod
Tstore istore lstore fstore dstore astore
Tinc iinc
Taload baload saload iaload laload faload daload caload aaload
Tastore bastore sastore iastore lastore fastore dastore castore astore
Tadd iadd ladd fadd dadd
Tsub isub lsub fsub dsub
Tmul imul lmul fmul dmul
Tdiv idvi ldiv fdiv ddiv
Trem irem lrem frem drem
Tneg ineg lneg fneg dneg
Tncg ineg lneg fneg dneg
Tshl ishl lshl
Tshr ishr lshr
Tushr iushr lushr
Tadnd iadn land
Tor ior lor
Txor ixor lxor
i2T i2b i2s i2l i2f i2d
l2T l2i l2f l2d
f2T f2i f2l F2d
d2T d2i d2l D2f
Tcmp lamp
Tcmpl fcmpl dcmpl
Tcmpg fcmpg dcmpg
if_TcmpOP if_icmpOP if_acmpOP
Treturn ireturn lreturn freturn dreturn Return

字节码含义可参考此文

字节码用途大致分为9类,仅做简要介绍:

  • 加载和存储指令:用于将数据在栈帧中的局部变量表和操作数栈之间来回传输,如将一个局部变量加载到操作数栈Tload; 将一个数值从操作数栈存储到局部变量表Tstore
  • 运算指令:用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶,如加减对应为Tadd、Tsub
  • 类型转换指令:将两种不同的数值进行相互转换
  • 对象创建与访问指令: 创建类实例指令new,访问类字段getfield、putfield
  • 操作数栈管理指令:与操作数据结构的堆栈类似,如将操作数栈栈顶一个或两个元素出栈pop、pop2
  • 控制转移指令:可以让JVM有条件或无条件地从指定位置指令继续执行程序,如条件分支if系列;如无条件分支goto
  • 方法调用和返回指令:如invokevirtural 用于调用对象的实例方法、Treturn 返回值
  • 异常处理指令:throw语句抛出的异常,由athrow指令来实现
  • 同步指令:处理同步操作,如synchronized关键字由 monitorenter 和 monitorexit 指令来实现

栈帧基础

每执行调用一个方法,将用一个栈帧来支持此方法的执行。栈帧中存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等。每一个方法调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

在进行字节码指令操作时,需要确定数据归属到什么变量,需要局部变量表;需要对数据进行操作并存取,需要操作数栈;需要知道执行到哪,需要程序计数器;可能需要在运行时转化调用的具体方法,需要动态连接。

一个线程中的方法调用链可能会很长,很多方法同时处于执行状态,对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与这个栈帧相关联的方法称为当前方法。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。

栈帧结构如下图


栈帧结构图.jpg

图片来自 《深入理解Java虚拟机》

局部变量表

局部变量表用来存放方法参数和方法内部定义的局部变量,最大所需容量有max_locals表示,单位为Slot。

JVM没有指明一个 Slot占用的内存空间大小。Slot可以用32位或更小的物理内存来存放,也可以在64位虚拟机中使用64位的物理内存去实现一个Slot。对于64位的数据类型,虚拟机会以高位对齐的方式分配两个连续的Slot空间。Slot除了能存放基础数据类型外,还能存储reference和returnAddress,reference为一个对象实例的引用,能通过此引用直接或间接地查找到对象在Java堆中的数据存放的起始地址索引,以及直接或间接地查找到对象所属数据类型在方法区中的存储的类型索引。

Slot是可以重用的,如果在之后的执行区域里,局部变量如x不再使用,则x占用的Slot将会被清理再做他用。如果方法不是静态方法,一般第0个Slot为 “this”。

操作数栈

字节码指令进行操作时,将从操作数栈中写入和提取内容。操作数栈中元素的数据类型必须与字节码指令的序列严格匹配。任意时刻不会超过max_stacks。

动态连接

每个栈帧都包含一个指向运行时常量池中(位于方法区),该栈帧所属方法的引用,以支持方法调用过程中的动态连接。在源文件被编译成.Class文件后,.Class文件中常量池存有大量的符号引用。字节码指令进行方法调用时,会以指向方法的符号引用作为参数。这些符号引用一部分在类加载或第一次使用时转化为直接引用,称为静态解析。而另一部分将在每一次运行期间转化为直接引用,称为动态连接。

方法返回地址

一个方法执行后有两种方式退出。

  • 正常退出:遇到任意代表方法返回的字节码指令,将返回值返回给上层调用者
  • 异常退出:执行过程遇到了异常,并且没用在方法内进行处理,没有返回值。

不管哪一种方法退出,都需要返回到方法被调用的位置,让程序继续执行。返回地址位置,可以通过程序计数器来确定,将程序计数器的值指向下一条执行,令程序继续执行。

字节码运行

说了这么多,通过几个例子看字节码如何运行。

简单的运算

    public static int addAndDouble(int a, int b){
        return (a + b) * 2;
    }

函数将a和b相加然后乘以2,拿到结果返回。通过命令

javac fileName.java

编译出.Class文件,能拿到具体的字节码。
通过命令

javap -verbose class文件
能对.Class进行分析。

上面代码转成的字节码以及字节码指令为:


addAndDouble字节码指令.jpg

addAndDouble()需要的操作数栈深为stack=2,局部变量表深度为locals=2,参数args_size=2个(因为是static,不包含this),字节码指令流为 1A 1B 60 06 68 AC 。字节码命令前0、1、2等代表的是在指令在指令流中开始的位置。

0: iload_0   // 将第一个局部变量压入栈,也就是代码里的a
1: iload_1   // 将第二个局部变量压入栈,也就是代码里的b
2: iadd      // 将栈顶的两个元素取出相加并入栈, 即a+b,暂用c表示
3: iconst_2  // 将常数2压入栈
4: imul      // 将栈顶的两个元素取出相乘并入栈,即 c * 2 ,暂用d表示
5: ireturn   //  将栈顶元素即d返回

过程用下图表示


相加再乘2代码字节码运行实例.png

同步方法和条件语句

代码为

    public void syncFunction(int a){
        if (a == 2){
            synchronized (this){
                a++;
            }
        }
    }

代码目的仅是为看同步操作和条件语句如何执行,转出的字节码指令流和字节码指令为:


条件判断和同步.jpg

syncFunction()是有两个参数的,第一个为this,第二个则为传来的a,对于操作数栈和局部变量表的操作与之前没有区别,只需注意在有this时存于局部变量表第一位。astore命令时从操作数栈取出数据存入局部变量。

”2:“ 为代码if翻译出的字节码指令,当满足条件时从"5:"处继续执行指令,如果不满足条件则跳转到 "22:" 处。字节码指令是可以带参数的,“2:”的下一条指令从"5:"开始,if翻译出的指令占了三个字节,为 A0 00 14,其中A0代表if_icmpne指令,0x0014 为参数,十进制值为20,即要跳转的指令位置,当不满足if条件时跳转到字节码指令流第“2 + 20”处的指令,也就是“22:”处的指令。因为方法占用的最大字节为65535,因此用两位字节表示跳转位置足够。

“8:” ~ “13:” 是同步代码里的正常运行指令,简单了解即可。

异常调用

代码为

    public void exceptionFunction(){
        try {
            File file = new File("");
            file.getName();
        } catch (Exception e){
            e.printStackTrace();
        }
    }

转出的字节码指令流和字节码指令为:


异常方法字节码示例.jpg

如果没有异常发生,执行到“15:”的字节码指令后,会跳转到“23:”,意味着“18:” ~ "20:" 表示catch部分的执行。代码实例中,执行“11:”会发生异常,进入catch部分。

字节码执行小结

以上三个例子抛砖引玉说明字节码指令的执行,其它情况可以用类似方法分析。只要弄明白栈帧里局部变量表、操作数栈、返回地址、程序计数器的作用,以及字节码指令含义和携带参数含义,就可以知道字节码是怎样执行的。比如上一张图片中, “6:”出invokespecial命令,携带一个参数,参数指向的是常量池中的一个方法描述符,通过这些信息可以知道此命令是调用File的初始化函数创建对象。

字节码指令的执行可以简述为:

  • 运算中需要的额外数据存储,需要局部变量表,
  • 对操作数进行运算,需要操作数栈
  • 程序计数器记录指令执行到哪

方法调用

上面部分说明了方法是怎样执行的,在执行之前,JVM需知道具体要调用哪个方法,可以通过解析和分派完成。

解析

.Class文件
中存储的都是符号引用,不是直接引用。因而在类加载的解析阶段,会将一部分符号引用转化为直接引用,前提是在程序真正运行之前就有一个可以确定的调用版本,这个过程称为 “解析” 。

执行方法调用的字节码指令有:

  • invokestatic: 调用静态方法
  • invokespecial: 调用实例构造器<init>方法、私有方法和父类方法
  • invokevirtual: 调用所有的虚方法
  • invokeinterface: 调用接口方法,会在运行时再确定一个实现次接口的对象
  • invokedynamic: 在运行时才能确定调用的具体方法,由调用点限定符确定

能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,有 静态方法、私有方法、实例构造器、父类方法 以及被标识为 final的虚方法,没有任何手段可以覆盖或隐藏以上方法。

也因此解析是静态过程,在编译期间就可以确定,在类装载的解析阶段能把涉及到的符号引用全部转变为可以确定的直接引用。

分派

分派可以分为静态分派和动态分派,可以通过“重载”和“重写”一探究竟。无论如何,目的是看虚拟机如何选择正确的目标方法。

静态分派

代码例子

public class StaticDispatch {
    static class Parent{ }

    static class Child extends Parent{ }

    public void call(Parent parent){
        System.out.println("call parent");
    }

    public void call(Child child){
        System.out.println("call child");
    }

    public static void main(String[] args) {
        Parent parent = new Child();
        StaticDispatch dispatch = new StaticDispatch();
        dispatch.call(parent);
    }
}

实际会输出 “call parent”。 对于 Parent parent = new Child() 来说,前面的Parent称为静态变量,后面的Child称为实际变量。静态变量和实际变量在程序中都可以发生一些变化,区别是静态变量的变化仅仅发生在使用时,变量本身的静态类型不会改变,在编译期可知。JVM在确定重载版本时,是通过静态变量作为依据的。


静态分派.jpg

对StaticDispatch的call()方法选取重载版本翻译成字节码指令时,选择的是Parent的版本。

动态分派

动态分派则不同,需要确定运行时确定的数据类型,否则就乱了套。
代码如下:

public class DynamicDispatch {
    static class Parent{
        public void hello(){
            System.out.println("hello parent");
        }
    }

    static class Child extends Parent{
        @Override
        public void hello(){
            System.out.println("hello child");
        }
    }

    public static void main(String[] args) {
        Parent parent = new Child();
        parent.hello();
    }
}

执行结果输出为 “hello child”,下图为字节码解析图:


动态分派.jpg

字节码指令翻译出来的静态类型为Parent,但在方法执行是,调用的是实际类型为Child的方法。在"8: " 处,将代码新建的parent变量压入栈,然后“9: ”处调用invokevirtual指令,调用的hello()方法归属于parent(实际类型为Child),parent也称为方法的接收者(Receiver)。

invokevirtual指令的运行过程分为以下步骤:

  1. 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C
  2. 如果在类型C中找到与常量中的描述符和简单名称都相符的方法(也就是上图红圈右边部分),则进行权限校验,通过则返回这个方法的直接引用,结束查找;否则,返回java.lang.IllegalAccessError异常
  3. 否则按照继承关系从上往下按照步骤2查找
  4. 如果都没有找到合适的方法,抛出java.lang.AbstracMethodError异常

因此,上面代码的hello()方法的实际接收者类型为Child。invokevirtual指令在运行期确定接收者的实际类型,是将常量池中的方法描述符指向了实际的直接引用上,这个过程是重写的本质。运行期根据实际类型确定方法执行版本的过程就是动态分派。

多分派与单分派

分派除了能以静态分派与动态分派区分外,还能以多分派和单分派区分,两者的区别在于选取方法是,参照的“宗量”,按照一个宗量选取的称为单分派,按照多个宗量选取的称为多分派。方法的接受者、方法的参数称为方法的宗量,也可以不贴切地理解为依据。

public class MultiDispatch {

    static class Cigarette{} // 烟
    static class Toy{} // 玩具

    static class Parent{
        public void choice(Cigarette cigarette){
            System.out.println("parent choice cigarette");
        }
        public void choice(Toy toy){
            System.out.println("parent choice toy");
        }
    }

    static class Child extends Parent{
        public void choice(Cigarette cigarette){
            System.out.println("Child choice cigarette");
        }
        public void choice(Toy toy){
            System.out.println("Child choice Toy");
        }
    }

    public static void main(String[] args) {
        Parent parent = new Parent();
        parent.choice(new Cigarette());
        Parent child = new Child();
        child.choice(new Toy());
    }
}

运行结果为

parent choice cigarette
Child choice Toy

多分派.jpg

在编译时期,也就是静态分派时期。选择目标方法的依据有亮点:静态类型、方法参数。因此翻译成的字节码指令invokevirtual的指令参数均指向了 Parent.choice(),一个指向的是常量符号引用是 Parent.choice(Cigarette),另一个是Parent.choice(Toy)。根据两个宗量进行原则,因此Java里的静态分派数据多分派。

在运行时期,也就是动态分派过程。执行choice()方法时,需要确定接收者的实际类型,因为要执行的方法已被确认,无需关心,因此参数的静态类型、实际类型都不会对方法的选择构成影响。只有接收者的实际类型会构成影响。因此动态分派属于单分派类型,只以一个宗量作为选择。

总结

JVM 方法的执行可以总结为以下几点:

  1. 方法调用时,根据字节码指令的不同,以解析和分派确定目标方法
  2. invokestatic、invokespecial指令和以final声明的方法可以以解析确认目标方法,invokevirtual、invokeinterface、invokedynamic则以分派方式确认目标方法
  3. 分派需要考虑的宗量为:接收者的静态类型和实际类型,参数的静态类型。静态分派时考虑接受者的静态类型和参数的静态类型;动态分派考虑接受者的实际类型
  4. 方法代码最终转换为紧凑的字节码指令流,不同的字节码值代表不同的指令,后面可能会带有0到多个参数,查表可以知道对象的字节码含义以及参数含义,进而进行指令操作
  5. 字节码指令执行时,需要程序计数器记录执行到的指令,以能执行下一条指令;执行过程需要存储数据,借助局部变量表存储;指令执行时需要对操作数进行运算,通过操作数栈进行存取
  6. return系列指令代表一个方法正常执行结束,栈帧出栈,下一栈帧,也就是方法的调用这个方法的方法继续从它的程序计数器指向的下一条指令继续执行

参考

《深入理解 Java 虚拟机》—— 第 8 章
Java字节码分析

推荐阅读更多精彩内容