JVM和JVM语言介绍

111 阅读8分钟

Java虚拟机(简称JVM)是一种依赖于平台的软件,它允许你执行用Java等语言编写的程序。Scala和Kotlin等语言利用JVM来执行,因此也经常被称为JVM语言。用这些语言编写的代码通常通过其文件扩展名识别,如.java.scala 。编译这些语言的源文件会产生.class ,这些文件是你的源代码的特殊表示,包含成功执行的必要信息。每个类文件以神奇的数字0xCAFEBABE 开始,这有助于识别这种格式。

这是一个类文件按照Java虚拟机规范的表示方法:

ClassFile {
    u4             magic;
    u2             minor_version;
    u2             major_version;
    u2             constant_pool_count;
    cp_info        constant_pool[constant_pool_count-1];
    u2             access_flags;
    u2             this_class;
    u2             super_class;
    u2             interfaces_count;
    u2             interfaces[interfaces_count];
    u2             fields_count;
    field_info     fields[fields_count];
    u2             methods_count;
    method_info    methods[methods_count];
    u2             attributes_count;
    attribute_info attributes[attributes_count];
}

注意:大小表示为ux 类型的值,其中x 是一个2的指数。例如,u2 是一个占用2字节或16位的值,u4 是4字节或32位。你可以使用javap 来生成一个可读的类文件的表示。

javac Main.java
javap -c -v Main

常量池

一个类的常量池是一种键值存储,包含像String 常量的条目,以及对所有类和类所引用的方法的引用。每个常量池条目的类型由一个在积分范围内的字节表示[1, 18] ,通常被称为 "常量池标签"。

请看下面的片段。

/ Main.java
class Foo {
    public void bar() {
    }
}

public class Main {
    public static void main(String[] args) {
        Foo f = new Foo();
        f.bar();

        String lang = "java";
    }
}

常量"java" ,在常量池中被存储为:

#11 = Utf8    java

你可以把这个格式概括为:

#index = type   value

你还可以在该类的常量池中找到该类中使用的类和方法的信息:

// Main.class
#6  = Utf8              ()V
#7  = Class             #8             // Foo
#8  = Utf8              Foo
#9  = Methodref         #7.#3          // Foo.'<init>':()V
#10 = Methodref         #7.#11         // Foo.bar:()V
#11 = NameAndType       #12:#6         // bar:()V
#12 = Utf8              bar

类引用(由Class 类型表示)只由一个简单的Utf8 条目组成,表示被引用类的名称。方法引用(MethodRef 条目)则更为复杂,其形式为:<Class>.<NameAndType>NameAndType 条目又是由两个Utf8 条目组成,即方法的名称和它的描述符。

任何引用另一个条目的条目将包含一个指向该另一个条目的索引。例如,在索引7处有这样一个条目:#7 = Class #8 // Foo 。这个条目指的是一个类,其名称包含在索引8中。索引8中的条目是一个Utf8 ,其名称为:Foo

常量池中的某个条目所引用的任何索引必须是只属于该常量池的有效索引。

介绍字节码表示法

在上面的例子中,通过javap 获得的main 方法的字节码的可读表示是:

0: new           #7                  // class Foo
3: dup
4: invokespecial #9                  // Method Foo.'<init>':()V
7: astore_1
8: aload_1
9: invokevirtual #10                 // Method Foo.bar:()V
12: ldc          #13                 // String java
14: astore_2
15: return

你在这里看到的注释是由javap 插入的澄清,并没有出现在常量池中。

一个方法的每一行表示都描述了一条字节码指令,格式如下。

offset: instruction arg1, arg2

你可能已经注意到,这里显示的指令偏移量是不连续的。第一条指令是在0 ,而第二条指令是从3 。这是因为指令可能有任何数量的操作数嵌入到字节码中。例如,该invokespecial指令需要一个2字节的操作数。同样,new 指令在开始时需要一个2字节的操作数,它占用的空间由偏移量1和2代表,这就是为什么3是指令的下一个可用偏移量。

注意:字节码被表示为byte 数组,其偏移量与常量池索引不一样。

方法调用

JVM使用某些指令,如invokevirtual,invokespecial, 和invokestatic 来调用方法,这取决于它们的性质。例如,构造函数通过invokespecial ,静态方法通过invokestatic ,而其他方法通过invokevirtual 。像invokeinterfaceinvokedynamic 这样的指令不属于本博客的范围。

让我们仔细看看main 的列表中的invokevirtual 指令。

9: invokevirtual #10 // Method Foo.bar:()V

在上面的例子中,invokevirtual 是在偏移量9 。它需要一个2字节的操作数,其内容位于偏移量1011invokevirtual的操作数被解释为类的常量池中MethodRef 条目的索引。指定的索引值是10 ,意思是常量池中的第10个条目。javap 为我们提供了有用的注释--Method Foo.bar:()V ,包括该条目的值。我们现在有了JVM调用指定方法所需的所有信息,Foo.bar() 。参数是通过使用*const*load 系列的指令将值推入操作数堆栈来事先传递给被调用的方法。

注意:在这里,我们说*load ,因为这条指令可以被认为是整个指令家族。根据它的前缀,我们可以把它解释为加载一个整数、一个浮点常数,甚至是一个对象引用。同样的原则也适用于*const 系列,除了只有整数和浮点类型(还有,作为常量值的一个特例,null )。这个系列的指令的例子有:aload,iload,fload, 等等。

控制流

if 条件、循环和无条件的跳转是控制流的重要组成部分。让我们来看看JVM是如何执行这些指令的。

先决条件。本地数组和堆栈

每个方法在Java调用栈中都有一个分配给它的小空间,称为框架。帧中存储着本地变量、方法的操作数栈以及方法所含类的常量池的地址。

一个方法的堆栈框架的简化视图

操作数栈,正如其名称所示,是一个堆栈结构。它用于存储指令的输入和输出数据。例如,iadd 指令希望两个整数值事先存在于操作数栈中。它从堆栈中取出其操作数,将其相加,然后将结果推回操作数堆栈,供以后的指令使用。

一个方法的参数,以及在其内部声明的任何局部变量将在相应的堆栈框架的局部变量阵列中拥有一个预定的槽。对于实例方法(非静态方法),局部变量数组的第一个条目总是对this 指针所指的对象的引用。被引用的对象和方法的声明参数必须首先被推到调用方法的操作数栈中。

调用方法的操作数栈的图片,参数被推送到栈中

invokevirtual被调用时,要从操作数堆栈中弹出的数值是根据被调用方法的描述符计算的。同样数量的值(再加上一个this 指针)被从操作栈中弹出。然后,这些值被放入新框架的局部变量数组中,第一个条目总是this 指针,然后是按声明顺序排列的各个参数。

将参数从操作栈复制到方法的本地数组中

一旦参数被复制过来,JVM就会将程序计数器设置为方法的第一条指令的偏移量,并再次开始执行字节码。当到达方法的终点时,当前帧被丢弃,JVM将控制流返回到invokevirtual 之后的下一条指令。任何返回的值都会从被调用的方法的操作数堆栈中弹出,并推到前一个方法的操作数堆栈中,供后续指令使用。

如果条件

考虑下面的片段和它的字节码

int i = 0;
if (i == 0) {
    i++;
}

// Explanatory comments added for better understanding
0: iconst_0               // Push const `0` to stack
1: istore_1               // Pop value off the stack and store it in local array at pos `1`
2: iload_1                // Push value from local array at pos `1` to stack
3: ifne          9        // Compare it against `0` and if not equals to 0, continue execution from offset `9`
6: iinc          1, 1     // Increment the value in local array at pos `1` by `1`
9: return                 // End of method

当一个变量(例如本例中的x )与0 进行比较时,会使用诸如ifeq,ifne,iflt,ifge,ifgt, 和ifle 等指令。这些指令从堆栈中弹出数值,与0 进行比较,如果条件成立,控制就会跳转到指定的偏移量。像if_icmpxx (其中xx为[eq,neq,lt,gt,ge,le])这样的指令是通过从堆栈中弹出参数,然后对其进行比较。

循环

考虑一下下面的片段和它的字节码

for (int i = 0; i <= 10; i++) {
    //
}

// Explanatory comments added for better understanding
0: iconst_0                // Push `0` to stack
1: istore_1                // Pop an int value, i.e. `0` and store it in local array at pos `1`
2: iload_1                 // Load value from local array at pos `1` onto the stack
3: bipush        10        // Push const `10` to stack
5: if_icmpgt     14        // Pop both the values, i.e. `0` and `10` and compare. If true, continue exec from offset `14`
8: iinc          1, 1      // Increment value at local array pos `1` by `1`.
11: goto         2         // Go to offset `2` and repeat instructions until the loop condition evaluates to false
14: return

循环只是一组语句的执行,直到指定的条件被评估为假。生成的字节码或多或少与我们之前看到的类似。唯一不同的是,goto 指令被用来跳到之前的偏移量并恢复执行,即执行之前执行的语句,从而基本上保持循环的运行。

JVM是目前最令人兴奋的平台之一。到目前为止,我们在这篇博客中所看到的只是其工作和内部结构的一小部分。