字节码执行引擎

执行引擎是Java虚拟机核心的组成部分之一。

运行时栈帧结构

Java虚拟机以方法作为最基本的执行单元,**“栈帧”(Stack Frame)**则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack) 的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。

image-20210809094000440

方法调用

调用不同类型的方法,字节码指令集里设计了不同的指令。在Java虚拟机支持以下5条方法调用字节码指令,分别是:

  • invokestatic。用于调用静态方法。

  • invokespecial。用于调用实例构造器<init>()方法、私有方法和父类中的方法。

  • invokevirtual。用于调用所有的虚方法。

  • invokeinterface。用于调用接口方法,会在运行时再确定一个实现该接口的对象。

  • invokedynamic。先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。前面4条调用指令,分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户设定的引导方法来决定的。

只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package jvm;
public class JvmTest {
    public static void function1(){
        return;
    }
    
    public void function2() {
        return;
    }
    
    public static void main(String[] args) {
        JvmTest jt = new JvmTest();
        JvmTest.function1();
        jt.function2();
    }
}

例如上面的代码,使用javap进行反编译之后,查看main()部分的反编译代码,如下。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: new           #2                  // class jvm/JvmTest
         3: dup
         4: invokespecial #3                  // Method "<init>":()V
         7: astore_1
         8: invokestatic  #4                  // Method function1:()V
        11: aload_1
        12: invokevirtual #5                  // Method function2:()V
        15: return
      LineNumberTable:
        line 14: 0
        line 15: 8
        line 16: 11
        line 17: 15
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      16     0  args   [Ljava/lang/String;
            8       8     1    jt   Ljvm/JvmTest;

可以看到,构造函数使用invokespecial调用,静态方法使用invokestatic调用,invokevirtual调用的是动态方法。

异常处理

例如下面的一段程序

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package jvm;
public class JvmTest3 {
    public static void main(String[] args) {
        String str = null;
        try {
            str.isEmpty();
        } catch (NullPointerException e) {
            str = "xxx";
        } finally {
            str = "yyy";
        }
    }
}

使用javap反编译之后,查看反编译后的字节码,截取了main方法部分,后面的Exception table就是处理异常部分。

image-20210809104848596

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

下面以一个简单的例子来看一下执行引擎的执行过程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 一个简单的calc()方法
package jvm
public class JvmTest2 {

    public int calc() {
        int a = 100;
        int b = 200;
        int c = 300;
        return (a + b) * c;
    }

    public static void main(String[] args) {
        JvmTest2 jt = new JvmTest2();
        int result = jt.calc();
        System.out.println(result);
    }
}

使用反编译命令javap查看方法calc的指令码

 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
public int calc();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=4, args_size=1
         0: bipush        100
         2: istore_1
         3: sipush        200
         6: istore_2
         7: sipush        300
        10: istore_3
        11: iload_1
        12: iload_2
        13: iadd
        14: iload_3
        15: imul
        16: ireturn
      LineNumberTable:
        line 6: 0
        line 7: 3
        line 8: 7
        line 10: 11
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      17     0  this   Ljvm/JvmTest2;
            3      14     1     a   I
            7      10     2     b   I
           11       6     3     c   I

javap提示这段代码需要深度为2的操作数栈和4个变量槽的局部变量空间。

image-20210809102425845

首先,执行偏移地址为0的指令,Bipush指令的作用是将单字节的整型常量值(-128~127)推入操作数栈顶,跟随有一个参数,指明推送的常量值,这里是100。

image-20210809102558396

执行偏移地址为2的指令,istore_1指令的作用是将操作数栈顶的整型值出栈并存放到第1个局部变量槽中。后续4条指令(直到偏移为11的指令为止)都是做一样的事情,也就是在对应代码中把变量a、b、c赋值为100、200、300。这4条指令的图示略过。

image-20210809102705215

执行偏移地址为12的指令,iload_2指令的执行过程与iload_1类似,把第2个变量槽的整型值入栈。画出这个指令的图示主要是为了显示下一条iadd指令执行前的堆栈状况。

image-20210809102806423

执行偏移地址为13的指令,iadd指令的作用是将操作数栈中头两个栈顶元素出栈,做整型加法,然后把结果重新入栈。在iadd指令执行完毕后,栈中原有的100和200被出栈,它们的和300被重新入栈。

image-20210809102934517

执行偏移地址为14的指令,iload_3指令把存放在第3个局部变量槽中的300入栈到操作数栈中。这时操作数栈为两个整数300。下一条指令imul是将操作数栈中头两个栈顶元素出栈,做整型乘法,然后把结果重新入栈,与iadd完全类似,所以笔者省略图示。

image-20210809103045346

上面的执行过程仅仅是一种概念模型,虚拟机最终会对执行过程做出一系列优化来提高性能,实际的运作过程并不会完全符合概念模型的描述。更确切地说,实际情况会和上面描述的概念模型差距非常大,差距产生的根本原因是虚拟机中解析器和即时编译器都会对输入的字节码进行优化,即使解释器中也不是按照字节码指令去逐条执行的。

指令重排

学习《Java高并发编程 1.5.3 》

JVM与多线程

volatile

Java内存模型规定了所有的变量都存储在主内存(Main Memory)中(此处的主内存与介绍物理硬件时提到的主内存名字一样,两者也可以类比,但物理上它仅是虚拟机内存的一部分)。每条线程还有自己的工作内存(Working Memory,可与前面讲的处理器高速缓存类比),线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如下图所示

image-20210809110734530

关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制。当一个变量被定义成volatile之后,它将具备两项特性:第一项是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。

关于volatile变量的可见性,经常会被开发人员误解,他们会误以为下面的描述是正确的:“volatile变量对所有线程是立即可见的,对volatile变量所有的写操作都能立刻反映到其他线程之中。换句话说,volatile变量在各个线程中是一致的,所以基于volatile变量的运算在并发下是线程安全的”。这句话的论据部分并没有错,**但是由其论据并不能得出“基于volatile变量的运算在并发下是线程安全的”这样的结论。**volatile变量在各个线程的工作内存中是不存在一致性问题的(从物理存储的角度看,各个线程的工作内存中volatile变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题),但是Java里面的运算操作符并非原子操作,这导致volatile变量的运算在并发下一样是不安全的。

 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
// 输出的counter小于期望值100000,这是因为counter++不是原子操作,不是线程安全的
public class JvmTest4 {
    private static volatile long counter = 0;
    
    public static void increase() {
        counter++;
    }
    
    public static void main(String[] args) throws InterruptedException {

        List<Thread> threads = new ArrayList<>();
        for (int i = 0; i < 100; i++) {
            Thread t = new Thread(() -> {
                for (int j = 0; j < 10000; j++) {
                    increase();
                }
            });
            t.start();
            threads.add(t);
        }

        for (Thread t : threads) {
            t.join();
        }

        System.out.println(counter);
    }
}

观察反编译的指令码,可以看出一条简单的counter++,并不是原子操作,而是由4条语句组成的。因此虽然volatile关键字保证了线程的可见性,但并不是线程安全的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public static void increase();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=4, locals=0, args_size=0
         0: getstatic     #2                  // Field counter:J
         3: lconst_1
         4: ladd
         5: putstatic     #2                  // Field counter:J
         8: return

使用volatile变量的第二个语义是禁止指令重排序优化,它的作用相当于一个内存屏障(Memory Barrier或Memory Fence,指重排序时不能把后面的指令重排序到内存屏障之前的位置。

同步与锁

下面的例子展示了jvm对于synchronized关键字的处理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public class JvmTest5 {

    public void test() {
        synchronized (this) {
            int a = 100 * 200;
            return;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        JvmTest5 jt = new JvmTest5();
        jt.test();
    }
}

test方法的字节指令码,第三行monitorenter表示进入临界区,monitorexit表示退出临界区。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
 public void test();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=4, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: sipush        20000
         7: istore_2
         8: aload_1
         9: monitorexit
        10: return
        11: astore_3
        12: aload_1
        13: monitorexit
        14: aload_3
        15: athrow

对于方法级的同步是隐式的,无须通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池中的方法表结构中的ACC_SYNCHRONIZED访问标志得知一个方法是否被声明为同步方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public synchronized void test() {
       int a = 100 * 200;
}

// 字节码指令
public synchronized void test();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=1, locals=2, args_size=1
         0: sipush        20000
         3: istore_1
         4: return

如上所示,当方法加上synchronized关键字后,flags中会出现ACC_SYNCHRONIZED访问标志。

比较交换(CAS)与原子操作类

与锁相比,使用比较交换(下文简称CAS)会使程序看起来更加复杂一些。但由于其非阻塞性,它对死锁问题天生免疫,并且,线程间的相互影响也远远比基于锁的方式要小。更为重要的是,使用无锁的方式完全没有锁竞争带来的系统开销,也没有线程间频繁调度带来的开销,因此,它要比基于锁的方式拥有更优越的性能。

CAS算法的过程是这样:它包含三个参数CAS(V,E,N)。V表示要更新的变量,E表示预期值,N表示新值。仅当V值等于E值时,才会将V的值设为N,如果V值和E值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。

Jdk提供了一些常用的原子操作类

image-20210809124818213

下面是一个使用AtomicInteger的例子,在没有使用锁和同步关键字的情况下,得到最终counter的值完全正确。

 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
// AtomicInteger例子,在没有使用锁和同步关键字的情况下,得到最终counter的值完全正确
// 关键的方法:counter.getAndIncrement();
public class JvmTest6 {

    public static AtomicInteger counter = new AtomicInteger();

    public static void main(String[] args) throws InterruptedException {

        List<Thread> threads = new ArrayList<>();

        for (int i = 0; i < 50; i++) {
            Thread t = new Thread(() -> {
                for (int j = 0; j < 10000; j++) {
                    counter.getAndIncrement();
                }
            });

            t.start();
            threads.add(t);
        }

        for (Thread t : threads) {
            t.join();
        }

        System.out.println(counter.get());
    }
}

CAS可能会出现ABA的问题,即值没有变,但是是经过了几次操作后回到了原来的值,为了解决这个问题,jdk提供了支持时间戳的方式,不仅比较值是否一致,还要比较时间戳是否一致。AtomicStampedReference就是这样的一个类。