JVM字节码执行引擎

物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上,而JVM的执行引擎是由自己实现。在不同的JVM实现里,执行引擎在执行Java代码时,可能有解释执行(通过解释器)和编译执行(通过即时编译器产生本地代码,JIT)。从最终结果来看,所有的JVM执行引擎都是一致的:输入字节码,处理过程是字节码解释的等效过程,输出执行结果。

运行时栈帧(Stack Frame)结构

栈帧是用于支持JVM进行方法调用和方法执行的数据结构,是内存区域划分中的虚拟机栈的栈元素。每一个方法从调用开始到执行完成,就对应一个栈帧在虚拟机栈从入栈到出栈的过程。编译代码时,栈帧需要多大的局部变量表、多深的操作数栈都已经完全确定,并写入方法表的code属性(max_locals和max_stack)中,因此一个栈帧分配多少内存,不会受程序运行期数据影响。一个线程的方法调用链可能很长,只有栈顶的栈帧是有效的,称为当前栈帧(Current Stack Frame),这个栈帧所关联的方法称为当前方法(Current Method),执行引擎的所有字节码指令都只针对当前栈帧进行操作。

局部变量表

一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量,编译时,在方法code属性的max_locals确定了该方法所需要分配的最大局部变量表容量。以Slot为最小单位,只有long和double(64位数据类型)需要两个slot,其余类型(32位数据类型)均可以存放到一个slot。

JVM通过索引定位的方式使用局部变量表,索引值从0开始到最大max_slot,如果是32位数据类型变量,索引n代表使用第n个slot,如果是64位数据类型变量,则使用第n和n+1两个slot。

方法执行时,JVM使用局部变量表完成参数值到参数变量列表的传递过程,如果是实例方法(非static方法),那么局部变量表第0位索引的slot默认是用于传递方法所属对象实例的引用,在方法中通过“this”访问这个隐含参数。其余参数按照参数表顺序来排列,占用从1开始的局部变量slot,参数表分配完毕后,在根据内部定义的变量顺序和作用域分配其余的slot。

max_locals可能会等于或小于参数和局部变量数量之和,因为局部变量表中的slot可重用,方法体中定义的变量,如果当前字节码PC计数器已经超出某个变量的作用域,那么这个变量对应的slot可以交给其他变量使用。这样不仅节省栈空间,而且可以在超出作用域时,加快GC对该变量的回收。因此,对象占用内存大、某方法长时间运行导致栈帧不能被回收、方法调用次数达不到JIT编译条件时,应该尽量缩短变量的作用域,或者对不会再使用的变量手动赋值null,以加速GC对改变量的回收。

由于局部变量不像类变量存在“准备阶段”,因此未初始化的局部变量不存在初始零值,因此对未初始化的局部变量进行访问会导致编译错误。

操作数栈

通过一个后进先出LIFO栈辅助实现各种算术运算指令,又或者调用其他方法的时候通过操作数栈进行参数传递(从而产生被调用函数的栈帧)。编译时,在方法code属性的max_stacks确定了该方法所需要分配的操作数栈最大深度。

概念上,两个栈帧相互之间是完全独立的,但是大多数JVM的实现会做一些优化,令两个栈帧出现部分重叠,让下面栈帧部分的操作数栈与上面栈帧的部分局部变量表重叠,这样进行方法调用时就可以共用部分数据,而无需进行额外的参数复制传递。

动态链接

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

方法返回地址

两种形式退出方法:一是遇到任意一个返回(return)的字节码指令,这种称为正常完成出口(Normal Method Invocation Completion),可能会有返回值传递给调用者;另一种是执行过程中遇到异常,并且没有在方法体的异常表中搜索到匹配的异常处理器,会导致方法退出,这种称为异常完成出口(Abrupt Method Invocation Completion),不会给调用者产生返回值。

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

方法退出的过程是把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有)压入调用者栈帧的操作数栈(借助栈帧传递返回值),调整PC计数器的值以指向调用指令后面一条指令等。

方法调用

方法调用唯一的任务是确定被调用方法的版本。由于class文件的编译过程中不包含传统编译中的链接步骤,一切方法调用在class文件里面存储的都只是常量池中的符号引用

1
6: invokespecial #28                 // Method java/lang/Error."<init>":(Ljava/lang/String;)V

而不是方法在实际运行时内存布局中的入口地址(直接引用)。这个特性带来了强大的动态扩展能力,也使得调用过程变得相对复杂,需要在类加载甚至到运行期间才能确定目标方法的直接引用。JVM提供四中方法调用字节码指令:

  • invokestatic:调用静态方法
  • invokespecial:调用实例构造器<init>方法、私有方法和父类方法
  • invokevirtual:调用所有虚方法(包含final)
  • invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象

解析(Resolution)

在类加载的解析阶段,会将其中一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。也就是说,调用目标在程序代码写好、编译器进行编译时就必须确定下来,这类方法调用称为解析。

符合“编译器可知,运行期不变”这个要求的方法主要有静态(static)方法和私有(private)方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法都不可能通过继承或者别的方式重写其他版本,因此适合在类加载阶段进行解析。只要能被invokestaic和invokespecial指令调用的方法,都可以在解析阶段确定唯一调用版本,符合这个条件的有静态方法、私有方法、实例构造器和父类方法四类,他们在类加载时就会把符号引用解析为该方法的直接引用,不会延迟到运行期再解析。这些方法和final方法一起被称为非虚方法,其他成为虚方法。

分派(Dispatch)

静态分派/重载(Overload)
1
2
3
4
5
6
7
8
9
10
11
12
abstract class Human {}
class Man extends Human {}
class Woman extends Human {}
public void function (Human human) { System.out.println("hello, human"); }
public void function (Man man) { System.out.println("hello, man"); }
public void function (Woman woman) { System.out.println("hello, woman"); }
{
    Human man = new Man();
    Human woman = new Woman();
    function(man); //输出hello, human
    function(woman); //输出hello, human
}

重载:方法名字相同,而参数不同,返回类型可以相同也可以不同。 对于变量man的定义,“Human“称为变量的静态类型(Static Type),后面的“Man”称为实际类型(Actual Type)。编译器在重载时是通过参数的静态类型而不是实际类型作为判断依据的,所以在编译阶段,编译器就根据参数的静态类型决定使用那个重载版本,选择了function (Human human)作为调用目标,并把这个方法的符号引用写到这两条invokevirutal指令的参数中。

所有依赖静态类型来定位方法执行版本的分派动作,都称为静态分派,最典型的应用就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是JVM来执行,而是编译器。另外,虽然编译器能确定方法的重载版本,但是很多情况下,版本不唯一,只能确定一个“更加适合的”版本。由于字面量不需要定义,没有显式的静态类型,它的静态类型只能通过语言的规则去理解和推断。如

1
function('a');

‘a’可以代表字符,还可以自动转型:char->int->long->float->double,但不会匹配到byte和short,因为char到byte和short是不安全的,然后还可能匹配到封装类型:java.lang.Character,和装箱类实现的接口类型:java.lang.Serializable,最后会转型为父类:java.lang.Object。如果还找不到,还可以被变长参数function(char ...)匹配,变长参数的重载优先级最低,这时候,字符’a’被当作一个数组字符。

静态方法也可以拥有重载版本,选择重载版本的过程是通过静态分派完成的。

动态分派/重写(Override)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
abstract class Human {
    public void function () { System.out.println("hello, human"); }
}
class Man extends Human {
    public void function () { System.out.println("hello, man"); }
}
class Woman extends Human {
    public void function () { System.out.println("hello, woman"); }
}
{
    Human man = new Man();
    man.function(); //输出hello, man
    Human woman = new Woman();
    woman.function(); //输出hello, woman
    man = new Woman(); //将man重新赋值
    man.function(); //输出hello, woman
}

查看对应的字节码:

1
2
3
4
5
6
7
8
9
7: astore_1
8: aload_1
9: invokevirtual #284                // Method Pilot$Human.function:()V
19: astore_2
20: aload_2
21: invokevirtual #284                // Method Pilot$Human.function:()V
31: astore_1
32: aload_1
33: invokevirtual #284                // Method Pilot$Human.function:()V

显然这里不是根据静态类型决定,因为静态类型都是Human的三个变量,JVM根据实际类型决定运行的方法版本。对invokevirtural的解析过程如下:

  1. 找到操作数栈定第一个元素所指向对象的实际类型(aload_1),记作C
  2. 如果在类型C中找到与常量描述符和简单名称都相符的方法,则进行访问权限校验,如果通过,则返回这个方法的直接引用,不通过则抛出java.lang.IllegalAccessError异常
  3. 否则,按继承关系从下往上依次对C的各个父类进行第2步的搜索和验证
  4. 最终没有找到合适方法,则抛出java.lang.AbstractMethodError异常

重写:子类对父类允许访问的方法的实现过程进行重新实现,返回值和形参都不能改变,重写方法不能抛出新的检查异常或者比被重写方法申明更加宽泛的异常。对于重写方法,JVM是根据运行时this指针(默认压栈的0号slot)判断实际类型,从而选择方法版本。

结合静态分派和动态分派,JVM首先根据运行时压栈的0号slot的this指针确定指向的对象,然后根据参数的静态类型选择方法的版本。编译阶段,根据方法接收者和参数进行选择方法的符号引用,运行阶段,只根据方法接收者进行选择方法的直接引用,因此,现阶段的Java是静态多分派、动态单分派语言

JVM动态分派实现

基于性能考虑,JVM实现动态分派并不会每次进行搜索,最常用的“稳定优化”手段是为类在方法区中建立虚方法表(Virtual Method Table,简称vtable,与此对应,在invokeinterface执行时也会用到接口方法表:Interface Method Table,简称itable),使用虚方法表索引来代替元数据查找以提高性能。

  • 如果某个方法在子类没有进行重写,那么子类的虚方法表里面的地址入口和父类该方法的地址入口相同,都指向父类实现的入口地址
  • 如果子类重写了某个方法,子类vtable中的地址会被替换为指向子类实现版本的入口地址
  • 具有相同签名的方法,在父类、子类的vtable中应当具有相同的索引号,当类型变换时,仅需要变更查找的vtable,就可以从不同的vtable中按相同的索引查找到所需的入口地址
  • 方法表一般在类加载的链接阶段进行初始化,准备了类的变量初始化后,虚拟机会把该类的方法表也初始化完毕

方法表是最“稳定优化”手段,在条件允许下,还会使用内联缓存(Inline Cache)和基于“类型继承关系分析”(Class Hierarchy Analysis,CHA)技术的守护内联(Guarded Inlining)两种非稳定的“激进优化”手段来获得更高性能。

基于栈的字节码解释执行引擎

Java发展出可以直接生成本地机器代码的编译器(GCJ,GNU Compiler for Java;AOT, Ahead Of Time Compiler),而C/C++也出现了通过解释器执行的版本(如CINT)。从程序源码到目标代码或者字节码需要经历如下步骤

执行前先进行词法分析和语法分析,把源码转化为抽象语法树(Abstract Syntax Tree,AST)。对于一门具体语言的实现来说,词法分析、语法分析以及后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整意义的编译器去实现,这类代表是C/C++语言。当然也可以选择其中的一部分步骤实现一个半独立的编译器,这类代表是Java语言,由于Javac编译器完成词法分析、语法分析,再遍历语法树生成线性字节码指令流的过程,是在JVM之外进行,而解释器在JVM内部,所以是半独立的。又或者把这些步骤和执行引擎全部集中封装到一个封闭黑匣子中,如大多数的JavaScript执行器。

Java编译器输出的指令流,基本上(基于栈的指令集应该全部是零地址指令,但Java部分字节码指令会带参数,主要是考虑了代码的可校验性)是一种基于栈的指令集架构(Instruction Set Architecture,ISA),依赖操作数栈进行工作。与之相对的是基于寄存器的指令集,最典型的是x86的二地址指令集。区别在于,操作的参数和运算过程的中间变量是通过栈元素还是通过寄存器作为信息交换途径

基于栈的指令集优点:

  • 可移植,用户不需要直接控制寄存器,可以由JVM自行决定将一些访问最频繁的数据放到寄存器中以获取尽可能好的性能。
  • 代码相对紧凑(字节码每个字节对应一条指令,多地址指令集还需要存放参数)
  • 编译器实现更加简单(不需要考虑空间分配问题,所需空间都在栈上)

主要缺点:执行速度相对较慢。 完成相同功能所需要的指令数量一般会比寄存器指令多,因为入栈、出栈本身就产生了相当多的指令。由于栈实现始终在内存之中,频繁的栈访问意味着频繁的内存访问,内存访问是执行速度的瓶颈。尽管JVM可以采取栈顶缓存手段,把最常用的操作映射到寄存器以避免直接内存访问,但也只能是优化措施。因此由于指令数量和内存访问的原因,导致了栈架构指令集的执行速度相对较慢。

字节码生成技术之动态代理

字节码生成技术的例子包括:javac和字节码类库;Web服务器中的JSP编译器;编译时织入的AOP框架;动态代理技术;反射时JVM可能会在运行时生成字节码来提高执行速度。此处以动态代理技术为例。

代理模式,真实对象和代理对象都实现某个共同的接口,外部通过代理对象访问真实对象,代理对象负责为真实对象预处理消息,过滤消息并转发消息,以及进行消息被委托类执行后的后续处理。代理解决的问题:当两个类需要通信时,引入第三方代理类,将两个类的关系解耦,让我们只了解代理类即可,而且代理的出现还可以让我们完成与另一个类之间的关系的统一管理,但是切记,代理类和真实类要实现相同的接口,因为代理真正调用的还是真实类的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
interface ISubject{
    void doSomething();
}
class RealSubject implements ISubject{
    @Override
    public void doSomething() { System.out.println("do Something"); }
}
class StaticProxySubject implements ISubject{ //静态代理
    @Override
    public void doSomething() {
        doOtherthing(); //预处理
        new RealSubject().doSomething();
        doOtherthing(); //后续操作
    }
    public void doOtherthing() { System.out.println("do Otherthing"); }
}
class DynamicProxySubject implements InvocationHandler{ //动态代理
    Object originalObj;
    object bind(Object originalObj) {
        this.originalObj = originalObj;
        return Proxy.newProxyInstance(originalObj.getClass().getClassLoader(), orgianlObj.getClass().getInterfaces(), this); //为指定类装载器、一组接口及调用处理器生成动态代理类实例
    }
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        doOtherthing(); //预处理
        Object obj = method.invoke(originalObj, args);
        doOtherthing(); //后续操作
        return obj;
    }
    public void doOtherthing() { System.out.println("do Otherthing"); }
}
public static void main(String[] args) {
    new StaticProxySubject().doSomething(); //调用静态代理
    ISubject sub = (ISubject)new DynamicProxySubject().bind(new RealObject());
    sub.doSomething(); //调用动态代理
}

动态代理与静态代理相比较,最大的好处是接口中声明的所有方法都被转移到调用处理器一个集中的方法中处理(InvocationHandler.invoke)。这样,在接口方法数量比较多的时候,我们可以进行灵活处理,而不需要像静态代理那样每一个方法进行中转。而且动态代理的应用使我们的类职责更加单一,复用性更强。

参考:java动态代理原理及解析。动态代理真正的关键是在 getProxyClass0 方法, 首先对接口进行安全检查,从loaderToCache映射表以类装载器对象为key获取value为ProxyClassFactory的值,调用ProxyClassFactory.apply动态创建代理类的字节码byte[]数组。通过main()方法中加入:

1
System.getProperties().put("sun.misc.ProxyGenerator.saveGeneratedFiles", "true");

会将字节码写出到磁盘名为”$Proxy0.class”的代理类class文件。为传入接口的每个方法doSomething(),以及从java.lang.Object中继承的equals()、hashCode()、toString()生成对应的实现,并且统一调用了InvocationHandler对象的invoke()方法来实现这些方法。区别只是传入的参数和Method对象不同而已,所以无论调用动态代理的哪一个方法,实际上都是在执行InvacationHandler.invoke()中的代理逻辑。

实际中,以byte直接拼接字节码的场合很少见,这种生成方式只能产生一些高度模板化的代码。如果有要大量操作字节码的需求,还是使用封装好的字节码类库比较好。