文章

Java Virtual Machine

直接看jvm的各个内存分区,是一种虚幻的认知。看完jvm的具体实现,才能有具体的认识。

  1. class文件
    1. class文件结构
      1. 简单的字段结构
      2. 复杂的字段结构
      3. 类的其他信息
      4. attribute
        1. Code属性和字节码
        2. Signature属性
    2. 字节码
  2. 类加载
    1. 解析
    2. 类加载器
  3. 字节码执行引擎
    1. 栈帧
      1. 局部变量表
      2. 操作数栈(operand stack)
      3. 动态连接
    2. 方法调用
      1. 方法在哪儿?
      2. 怎么记录调用的是哪个方法?
      3. 怎么确定该调用哪个方法?
    3. invokedynamic
      1. 动态语言 vs. 静态语言
      2. 动态语言 on jvm
  4. 编译优化
    1. 前端编译器优化
      1. 泛型
        1. 真实泛型
        2. 伪泛型
      2. 自动装箱/拆箱
        1. 引入Integer的意义
      3. vararg
      4. foreach
      5. var
      6. 内部类
      7. enum
    2. 后端编译器优化
    3. inline
    4. 逃逸分析
    5. 及时编译器 vs. 静态编译器
      1. GraalVM
      2. 彩蛋:C模拟多态
  5. Java内存区域
  6. GC
  7. 感想

class文件

class文件本质上是一种协议,一种二进制协议。和我们使用protobuf定义的协议没什么本质的区别。

class文件不是针对Java的,而是针对jvm的。Java有自己的语法,jvm有自己的另一套语法(或者说字节码的指令集),Java不支持的功能,字节码可能是支持的,所以Java没有完全达到字节码的功能上限。这也意味着其他基于jvm的语言就能创造出一些Java没有的语法,在编译生成的class文件里使用这些Java不曾使用的字节码。本质上来说,字节码的语法决定了基于jvm的语言的语法上限。

class文件结构

其实就是协议结构!

class的协议看起来还是比较简单的,至少第一层级是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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];
}

简单的字段结构

有一些协议字段是数值,每个字段都有固定的长度。比如:

  • magic:4byte,0xCAFEBABE
  • minor version
  • major version

magic number的唯一作用就是标识这是一个class文件。比使用文件拓展名相对安全,linux系统的文件就是这么做的。

version标识编译该class文件的jdk版本。低版本jvm必须拒绝高版本jdk编译的class文件,即使协议未发生任何变化。

对于占用空间超过一个byte的结构,class文件规定使用big endian(高位在低地址),所以以big endian的方式读取,看到的数据才是正确的:

1
2
3
4
$ od -t x2 -w8 -N 16 -Ad --endian=big TreeNode.class
0000000 cafe babe 0000 003d
0000008 001c 0a00 0200 0307
0000016

x86默认是little endian,如果两个字节一起读长这样:

1
2
3
4
$ od -t x2 -w8 -N 16 TreeNode.class
0000000 feca beba 0000 3d00
0000010 1c00 000a 0002 0703
0000020

如果四个字节一起读长这样:

1
2
3
4
$ od -t x4 -w8 -N 16 TreeNode.class
0000000 bebafeca 3d000000
0000010 000a1c00 07030002
0000020

总之little endian读出来的高位一定会放在最高地址。

big endian还有一种比较鸡贼的读法:既然高位放在低地址,而一个字节一个字节的读本来就是从低地址往高地址读,那么big endian一个字节一个字节读,读出来的就是实际存放的字节序。比如:

1
2
3
4
$ od -t x1 -w8 -N 16 TreeNode.class
0000000 ca fe ba be 00 00 00 3d
0000010 00 4b 0a 00 02 00 03 07
0000020

big endian的存储方式相当于我们在作文本上写数字:我们相当于计算机,从左往右读,也就是左边是低地址。每个格子相当于1byte。当我们写12345的时候,万位是1,写在低地址,所以我们最先读到的是1。

big endian更适合被输出出来,因为和我们的阅读习惯一致。但是little endian更适合做加减(先算低字节,再算高字节)和类型转换(直接抛弃高字节,只读第一个字节即可),所以x86 cpu采用little endian存储。

class文件采用big endian,有可能因为网络协议规定使用big endian,而Java一开始是为网络传输设计的。

复杂的字段结构

有一些字段是变长的,就先放一个count,标识变长字段的长度。比如常量池:

  • constant pool count:代表后面跟着多少个constant pool
  • constant pool:自定义结构cp_info

constant pool是一个自定义结构,复杂的地方在于这个自定义结构本身是不确定的

  • 如果第一个byte是1,它代表一个utf8 info,其实就是utf8字符串,此时:
    • 第二个字节是字符串长度len
    • 后面跟着len长度的字节,代表字符串内容
  • 如果第一个byte是7,它代表一个class info
    • 第二个和第三个字节加起来是一个数值,这个字段代表的是一个类名,它实际存储的是一个偏移地址,指向一个utf8 info。utf8 info就是类名的字符串表示
  • 也有简单的,比如long info:
    • 第一个byte是5
    • 后面四个byte代表它的值

constant info一共定义了大概二十来种。其中只有utf8/integer/float/long/double info是比较单纯的类型,是我们一般情况下所理解的“常量”,被称之为字面量(literal)。

其他更多的类型都是复合类型,比如上面说的class info。field info,也类似,它要记录三部分数据:谁定义的、字段名称、字段类型——

  • 第一个byte为9;
  • 后面的两个byte代表一个偏移量,指向一个class info,代表定义它的类(谁定义的);
  • 后面的两个byte代表一个偏移量,指向一个name and type info
    • 第一个byte是12
    • 后面的两个byte代表一个偏移量,指向一个utf8 info,代表字段名称;
    • 后面的两个byte代表一个偏移量,指向一个utf8 info,代表字段类型(的字符串);:

复合类型不仅记录各个字符串(或值),还记录了这些值所代表的种类(比如:类名、函数名、字段名等等)。他们还有一个称呼:符号引用(symbolic reference)。

因此,所谓的常量池,并非只记录一个常量了事,他们既包括字面量,又包括符号引用

这里的“常量”和Java语法里所定义的那个“常量”(final)完全不是一码事。

Java语法里的一些限制就来自于class协议的结构,比如:

  • 方法名/变量名不能超过64K:因为utf8 info结构里,length只有2byte,所以代表名称的utf8字符串最多长2^16 byte,也就是2^6 k-byte;
  • constant pool count占2 byte,所以Java里定义的变量和常量再加上其他metadata,也不能超过64K个;

总之如果上限足够大,Java语法里可能没明确规定出来,但Java世界的限制是的确存在的。

类的其他信息

  • access flags:类的访问标识,比如public/abstract/final等。一个bit代表一种标识;
  • this class:类名。占2 byte,是一个偏移量,指向constant pool里的一个class info
  • super class:父类名称。结构同上。除了Object,任何Java类的该字段都不会是0;
  • interfaces count
  • interfaces

显然,super class只有一个,interfaces却是变长的,说明所有基于jvm的语言都只能单继承、多实现

  • fields count
  • fields:自定义类型,field info
    • access flags:字段的访问标识
    • name index
    • descriptor index
    • attributes count
    • attributes

name index和descriptor index分别代表字段名和类型。不过不是很懂为什么他们是两个指向utf8 info的指针,为什么不直接用一个指向field info的指针?

当jvm访问一个field时,会用到constant pool里的field info。

descriptor index指向的utf8 info并不是记录类型的全程。为了精简,jvm使用几个特殊符号代表了类型的字面量:

  • B:byte
  • V:void
  • I:int
  • Z:boolean
  • L对象类型,使用;结束。比如Ljava/lang/Object;代表Object类
  • [:数组的一个维度

这样一来节省了不少字符串长度。比如:[[java/lang/String;代表java.lang.String[][]

  • methods count
  • methods:自定义类型,method info
    • access flags
    • name index
    • descriptor index
    • attributes count
    • attributes

和字段类似。描述方法时,先参数列表,后返回值:比如:

  • int counter(int i)(I)I
  • 默认的构造函数:()V
  • <T> T get(String key)(Ljava/lang/String;)Ljava/lang/Object;

编译器可能会自动往class文件里添加方法,比如类构造器<clinit>(class init,用于初始化静态变量、静态代码块),实例构造器<init>

注意一下后面的attribute。每个方法都有至少一个attribute,名为Code,代表这个方法的代码,以字节码的形式存储

attribute

class本身带有attribute,是个变长列表:

  • attributes count
  • attributes:attribute info
    • attribute name index,属性名称。存储的是一个偏移量,指向constant pool里的一个utf8 info。比如“Code”;
    • attribute length:属性长度
    • info[attribute_length]:一个自定义的属性结构

字段和方法也可以有attribute。

attribute也有很多类型。比如:

  • Code:存储代码编译后的字节码指令
  • Signature:用于记录泛型的签名
  • 等等

Code属性和字节码

字节码,一条指令的存储空间为一字节。因为是单字节指令集,所以不超过256种。字节码是一种指令集,是class文件的一部分,放在方法的Code属性里。

Code属性里代表字节码长度的是4byte空间,代表2^32,但jvm规定它只使用了2byte,即字节码条数不能超过65536(字节码条数,不是方法里的代码行数,一行代码可能对应好几条字节码)。因此方法体不能过大

一般手写代码都不会超过,但某些自动生成的代码,比如JSP编译之后的class文件,可能会把网页内容放到class的方法体内,导致字节码条数超限。

Code属性还有两个属性:

  • max stack:所使用的操作数栈(operand stack)最大深度
  • max locals:存储本方法的局部变量所需的slot数。一个slot 32bit,所以long/double需要两个slot;

对于所有的实例函数,max locals为参数个数加一,因为this作为第一个参数被编译器传到了函数里

类似python的self,但是由编译器隐式传入的。

当然,static函数就不传入this,如果无参,max locals就为0。

Code属性还包括异常表,结构为:

  • start pc:起始行
  • end pc:结束行(不含)
  • handler pc:处理行
  • catch type:偏移量,指向constant pool的一个class info

含义:如果[start, end)出现了类型为catch type的异常(包括子类),转到handler行

从我们的视角:无论是否出异常,都会用到跳转

  1. 如果try出异常,跳转到catch块;
  2. 如果try不出异常,跳转到finally块;
  3. catch块出异常,跳转到finally块;

但是在编译器的实现里,finally块不需要跳转,只要把finally块多抄几遍就行了。只有try或catch出现异常时才需要跳转,而跳转的依据就是异常表

使用如下代码把上面关于Code属性的知识总结一遍:

1
2
3
4
5
6
7
8
9
10
11
12
public int inc() {
    int x;
    try {
        x = 1;
        return x;
    } catch (Exception e) {
        x = 2;
        return x;
    } finally {
        x = 3;
    }
}
  1. 如果try正常,再进入finally,x=3,返回1;
  2. 如果try出异常,跳转到catch,再进入finally,x=3,返回2;
  3. 如果catch出异常,进入finally,x=3;

使用jdk编译生成的字节码:

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
// 第一块:x = 1
 0 iconst_1
 1 istore_1     // 存储x
 2 iload_1
 3 istore_2     // 存储返回值
// finally
 4 iconst_3
 5 istore_1
// return
 6 iload_2
 7 ireturn
 
 
// 第二块:x = 2
 8 astore_2     // 存储e(Exception)
 9 iconst_2
10 istore_1
11 iload_1
12 istore_3     // 存储返回值
// finally
13 iconst_3
14 istore_1
// return
15 iload_3
16 ireturn


// 第三块:x = 3
17 astore 4     // 存储非Exception类型的exception
// finally
19 iconst_3
20 istore_1
21 aload 4
23 athrow

这段字节码的:

  • max stack = 1,最多用一个栈空间就够了
  • max locals = 5,分别用来存储:
    • this:第0个变量
    • x
    • 返回值:因为返回值和x最终的值并不相同(finally里把x改成了,而返回的是1或2),所以额外需要一个返回值,否则不需要
    • e:Exception类型的异常
    • 非Exception类型的异常

try块里,最多用到第2个变量,作为返回值;catch块里,第二个变量用来存储e,最多用到第三个变量,作为返回值。finally块,用到了第4个变量,存储非Exception类型的异常。

异常表:

start pcend pchandler pccatch type
048cp_info #7 java/lang/Exception
0417cp_info #0 any
81317cp_info #0 any
171917cp_info #0 any

从字节码可以看出,try块和catch块里都抄了一遍finally,如果二者正常结束,根本不需要再进行finally的跳转

  1. 只有try异常才需要跳转到catch(caught异常)或finally(非caught异常);
  2. 只有catch块异常才需要跳转到finally。

事实上,被抄了两遍的“finally块”和真正的finally块并不相同,只抄了是finally块里面的代码(x = 3对应的字节码)。真正的finally块,还有一个astore 4用来存储意料之外的异常,使用aload 4athrow把异常抛出

所以说,异常表是字节码的一部分。

Code属性还能在最后添加别的attribute,比如:

  • LocalVariableTable:记录局部变量的名称。如果编译时使用-g:none,则不会生成该属性,使用IDE查看方法源码时发现变量名称会丢失,变成了arg0、arg1;
  • LineNumberTable:记录字节码和源码的行号对应关系,在debug的时候按照源码设置断点就用到了这个表;

Signature属性

参考下述泛型部分。

字节码

字节码确实是只占用一个字节的操作码(Opcode),但是后面可能会跟随任意个(包括0个)操作数(Operand)。

jvm采用基于操作数栈的架构,而非寄存器,所以大部分指令不需要操作数,只需要操作码(默认操作栈顶,或者栈顶的前两个元素)

相对的,x86指令集后面几乎都会带上寄存器参数。

但是这样也会使生成的字节码指令比较多,从上面的示例字节码就可以看出来,数据在局部变量和栈顶之间不断相互load、store。

  • 优点:可移植性好,因为不依赖寄存器,所以不依赖硬件;
  • 缺点:执行较慢,因为:
    • 使用栈,就是在使用内存,内存速度远低于寄存器;
    • 出栈入栈会产生比较多的指令数量;

jvm一般有栈顶缓存,把最常用的操作映射到寄存器里,以避免使用内存。但只是优化,终究没有基于寄存器的指令集快。

还有基于寄存器的虚拟机,实现时也会把虚拟机寄存器尽量映射到物理寄存器以提高性能。

类加载

class文件是一套协议,对应一串字节流,但未必要对应一个磁盘上的文件,也可以是从网络获取的,只要是符合协议格式的字节流就行。

  1. 加载:使用类加载器加载,但并不一定非得从本地加载。关于类加载器,参考Java - classloader
  2. 验证:校验字节码格式、语义正确性等;
  3. 准备:为类变量(static)分配内存(在方法区中),设置类型初始值(不是变量初始值)。实例变量不在这里创建,在对象实例化的时候创建(在堆中)
  4. 解析:将class文件里用到常量池内符号引用的地方转换为直接引用
  5. 初始化:因为是类加载的过程,所以这里的初始化是类变量(static)的初始化。其实是在执行类的构造器<clinit>——编译器自动收集类中的所有static变量的赋值动作、静态代码块(static {})中的语句,合并生成为<clinit>。和实例构造器的区别在于,如果没有赋值动作和静态代码块,<clinit>也可以没有;

jvm保证<clinit>一定是线程安全的,只有一个线程可以执行,其他线程都要阻塞,所以才有使用静态内部类构造单例的做法

然后类就可以用了。

解析

解析是将class文件里用到常量池内符号引用的地方转换为直接引用

对于类、字段、方法的符号引用解析,实际就是拿着符号引用(或者说)字面量,找到对应的元素。解析过程是递归的,比如找到字段的类型是另一个类,就会去加载另一个类。对于字段和方法,会先在本类找,找不到会去父类找,还找不到则会报错:NoSuchFieldError/NoSuchMethodError

对于方法名,解析的时候还会做静态解析替换:字节码里用的是invokeX #N,N代表符号引用的序号,比如invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V。因此字节码里写的是符号引用,如果可以根据这个符号引用确定具体的方法,就可以在类加载时解析出该方法在方法区中的地址(直接引用),将符号引用替换为直接引用。

这里只涉及到静态解析,如果方法涉及到多态,就不知道该调用哪个了,只能在在运行时做动态分派

类加载器

关于类加载器,参考Java - classloader

字节码执行引擎

class二进制是静态协议,jvm最终要将其动态执行。

栈帧

栈帧是所有语言在方法调用和执行时候需要用到的数据结构,因为方法的调用逻辑本身就和栈的逻辑一致。不同的地方体现在——不同的执行引擎怎么设计栈帧的结构。

java的栈帧包括四部分:

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

在编译成字节码的时候,一个方法对应的栈帧需要保存多少局部变量(max locals)、使用多深的操作数栈(max stack),都已经确定了,所以一个栈帧需要多大的内存,也是在运行之前就确定了的,其大小仅和虚拟机的实现方式相关。

局部变量表

在说方法的Code属性的时候已经提到了局部变量表,注意以下事情:

  1. 第0个参数是this;
  2. 参数相当于紧接着this后最先定义的局部变量
  3. 局部变量表里存放的是对象的地址,是指向堆的偏移量,所以对象的大小不影响在栈里所占用的空间
  4. jvm以32bit作为一个slot,64bit数据(long、double)需要两个slot,但是并不存在原子性问题,因为栈帧是线程私有的,不存在并发安全问题;
  5. slot是可以复用的,如果PC的值超出了一个变量的作用域,该变量就不再有用了,空间可以被别的局部变量复用
  6. 局部变量没有默认值!

有默认值的是类变量(static),即使不在初始化阶段赋上我们设定的默认值,也能在准备阶段赋上系统的类型默认值(比如char的默认值0)。但是局部变量没有默认值,不初始化不能使用:

1
2
3
4
5
public void foo() {
    int x;
    // IDE拒绝编译:variable 'x' might not have been initialized!
    System.out.println(x);
}

操作数栈(operand stack)

操作数栈是栈帧里的一个栈,不要和栈帧混淆!在字节码执行过程中,会依托于操作数栈进行数据的计算、赋值,所以会不断出栈入栈。

栈中栈 :D

jvm只管创建个操作数栈,然后按照字节码一条条执行就好,实现起来还是比较简单的。编译器才是最难的,把Java代码转成一行行操作操作数栈的字节码。

虽然理论上,两个栈帧是不重合的,但是在实际实现时,jvm会做一些优化:上一个方法里传给下一个方法的参数,没必要在两个栈帧里存两遍!所以上一个栈帧的操作数栈可以有一部分和下一个栈帧里的局部变量表共用一块区域!这样就可以省下参数复制的时间和空间开销。

动态连接

字节码里的方法调用指令,后面跟的是一个符号引用(方法名)。其中一部分是确定的,在类加载过程中就转化为该方法在方法区的直接引用,称为静态解析。

另一部分(涉及到多态的)方法需要在运行时才能转为直接引用,称为动态连接。

方法调用

方法调用的本质:确定调用的是哪一个方法。

方法在哪儿?

  • 放在jvm方法区,是一堆字节码,代表一条条指令

当类被加载时,类的字节码文件中的方法代码被转化为JVM可执行的指令,并在方法区中存储。(当方法被调用时,JVM会在方法区中查找该方法的字节码指令,并执行该方法。)

怎么记录调用的是哪个方法?

class文件里记录的是符号引用,比如:

1
invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V

代表调用PrintStream类里的println方法,方法入参是String,返回值是void。

  • 符号引用:是一种用符号来描述目标的引用方式,比如类名、字段名、方法名等。比如是个方法名,代表你要调用哪个方法,它是一种间接的引用方式,并不是直接的内存地址。
  • 直接引用:直接引用指的是直接指向目标的指针、偏移量或者是句柄。在Java虚拟机中,直接引用可以是指向方法区中方法的指针或者是方法的偏移量。

怎么确定该调用哪个方法?

知道了方法在哪儿里放着,知道要调用的方法的符号,怎么根据符号确定一个唯一的方法

其实很像我们使用IDE查看代码。当使用IDE查看一个方法调用时:

  1. 有时候IDE能直接跳转到那个方法,说明方法确定了。(静态解析)
  2. 有时候单看一行并不知道调用的是哪个类里的方法,只有看完上下文(运行时),才知道调用的是哪个类里的重载方法。(动态分派)
  • 静态解析:如果调用的是非虚方法(比如static方法),则方法是确定的,直接在类加载时把符号引用替换为方法的直接引用;
  • 动态分派:动态分派说白了就是在运行时可以知道当前的这个对象属于哪一个类,直接去哪个类里找相应名称的方法就行了。JVM在运行时使用方法表(Method Table)实现动态分派。每个类都有一个方法表,方法表包含了该类中所有方法的引用。当一个对象被创建时,它会被分配一个指向该类方法表的指针。当调用该对象的方法时,JVM会根据该指针找到该对象所属类的方法表,并在方法表中查找方法的引用(其实还是知道了对象的类型,就能找到它所对应的类,就能在类里找到相应的方法。方法表不过是加速查询罢了)。在动态分派过程中,JVM会根据方法的接收者类型确定方法的实际实现。接收者类型是指方法调用时所使用的对象的类型。例如,如果调用对象是一个子类对象,那么JVM会在子类的方法表中查找方法的引用。如果该子类实现了该方法,JVM就会调用子类的方法实现;否则,JVM会查找该子类的父类方法表,直到找到该方法的引用或者到达Object类为止

实际上,如果每次都这么找,效率太低。所以会在类的方法区创建一个虚方法表(vtable,Virtual Method Table),如果子类没有override父类的方法,子类里该方法对应的地址为父类里该方法的入口地址,否则就是自己方法的入口地址。这样查一次就能找到最终的地址,不用发起多级查找。

仔细想想IDE里哪些方法能直接跳转?

  1. 静态方法(invokestatic);
  2. 私有方法(invokespecial):If a method is invoked by invokespecial, it does not undergo virtual lookup. Instead, the JVM will look only in the exact place in the vtable for the requested method. This means that an invokespecial is used for three cases: private methods, calls to a superclass method, and calls to the constructor body (which is turned into a method called in bytecode). In all three cases, virtual lookup and the possibility of overriding must be explicitly excluded;
  3. 实例构造方法<init>(invokespecial);
  4. 父类方法(invokespecial);
  5. 接口方法(invokeinterface):some additional lookup is needed when a method is invoked on an object for which only the interface type is known at compile time;

详细介绍可以参考这篇非常不错的Mastering the mechanics of Java method invocation

这四类方法也被称为非虚方法。还有一个特殊的方法:final,也是确定的,因为不能被重载。所以final方法虽然被invokevirtual调用,但也是非虚方法。

Java里其他的方法都是虚方法(invokevirtual,只有在运行时才能确定调用的方法究竟指的是方法区里的哪一个。而override的本质,就是在运行时动态分派时,把方法的符号引用换成不同类(子类或父类)里方法的直接引用

One of the most important areas of the klass is the vtable. This area is essentially a table of function pointers that point to the implementations of methods defined by the type. When an instance method is called via invokevirtual, the JVM consults the vtable to see exactly which code needs to be executed. If a klass does not have a definition for the method, the JVM follows a pointer to the klass corresponding to the superclass and tries again

后面说的inline也涉及到了虚方法的确定。

invokedynamic

通过 Python 理解 JVM 指令 invokedynamic

我看了很多关于 invokedynamic(后面用 indy 简称)的文章,感觉讲的都比较模糊,对于初学者不那么容易理解。尤其是说 Java 中的 Lambda 是通过 indy 实现的,就会更糊涂了。加上一些 CallSite/MethodHandle 等周边知识,就把 indy 的本质掩盖了。希望通过此文能澄清一下。首先澄清一点,Lambda 的创建是通过 indy 来实现的,但是 Lambda 的调用就是普通的接口调用(invokeinterface)!这种术语的混用很容易搞糊涂,所以不适合一开始就用来了解 indy。indy 是一种运行时链接机制,可以理解为一种创建函数的函数。它根据方法名和方法类型选择执行的真实函数,或者说动态创建一个符合要求的函数代理。我们在动态语言中经常用到的动态绑定就是一种这样的机制。比如拿 Python 中方法调用为例:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
# 我们在动态语言中经常用到这样的方法
# 比如 Python 代码

class A:
    def add(self, a, b) -> int:
        return a + b
a = A()

# 对于 Python 来说,A 是个 type 对象,代表一个class,它有属性 add (是一个函数)
# a 是个 A 对象, 代表一个class的实例,它没有属性 add
# a.add 其实就是动态调用,因为这时 a.add 在对象实例上还没有存在
a.add(1, 2)
# 这里 Python 解释器看到,需要调用a对象的一个方法 add,这个 add 接受两个参数
# 但是 a 对象没有 add 这个属性,所以它要去动态创建这个属性
# 它的真实执行过程是
# 1. 找到 a 的 class A
# 2. 看它是否有 add 函数,且这个函数能接受3个入参(self, a, b)
# 3. 创建一个新函数(这里做了很大的简化)
def _create_method(clazz, inst, method_name):
    def _bind_method(a, b):
        return clazz.getattr(method_name)(inst, a, b)
    return _bind_method
# 4. 把 a.add 赋值为上述方法,并调用
a.add = _create_method(A, a, "add")
a.add(1, 2)
# 5. 后续调用就会直接调用这个新方法而不是再绑定一遍
# 这里 _create_method 就是 Bootstrap Method,它根据上下文返回一个方法

# 正是由于这种动态特性,我们才能做这样的操作,比如想给 A 新增一个方法
def sub(self, a, b):
    return a - b
# 如下语句报错
a.sub(2, 1)
# 这时a没有sub方法,所以就要执行上述的动态绑定过程,但是类A也没有sub函数的定义

# 如果我们把这个函数给A定义好,那么 a.sub 的动态绑定流程就可以成功了
A.sub = sub
a.sub(2, 1)  # 成功返回1

# 这里 A 类动态添加了一个方法 sub, a 要调用 sub 这时候它需要去查找 class A 的方法并把它的 self 参数绑定为 a
# 这个过程就是 bootstrap method 做的事情,后续再调用 a.sub 就直接调用那个已经绑定好的函数了

# 我们当然也可以这样做:
a.mul = lambda x, y: x*y
# 注意
# 1)lambda没有self,因为是直接绑定到 a 实例上的,不需要经过 bootstrap method 的 self 绑定机制
# 2)这里只有 a 这个实例添加了一个方法 mul,只有它可以调用,其他A类的实例没有

# 所以 Python 是个很动态的语言(一切都是函数调用),它的方法调用机制跟一些静态链接的语言差异很大

如果想在JVM上实现类似的机制,那么是很麻烦的,因为Java的方法调用机制是固定的,所以实现类似的机制成本非常高。为了类似的动态绑定机制能在JVM中高效的实现,Java 7引入了invokedynamic,它的作用就是根据指定的方法名和方法类型,调用一个bootstrap method,这个bootstrap method不管怎么样,返回一个签名和方法类型符合的“可调用的东西”(Java里叫做CallSite,是一个方法引用的容器,可以理解成动态语言里的函数)就行了。随后就直接调用即可。以Java 9字符串连接的实现为例:

1
2
int i = 1;
String a = "a " + i + " b";

这里计算a的过程,之前是通过 StringBuilder 来动态建立的。但是我们在编译时就已经知道要连接几个参数了,也知道这些参数的类型,那么我们可以把这个过程当作函数调用:

1
2
3
4
5
int i = 1;
// 创建函数
String some_function(int i) { return "a \1 b".format(i); }
// 调用
String a = some_function(i);

要是javac编译器可以生成这样的代码就好了:

1
2
3
4
5
// 根据静态字符串创建一个方法,只调用一次
var callSite = Bootstrap("a \1 b");
// 该方法可以缓存起来,每次类似的调用都可以直接使用
// 传入i,则返回对应的字符串
String a = callSite.target.invoke(i);

这里的 callSite.target 指向一个高效的针对这种情况的字符串拼接的实现,它的签名是 (int)->String。但是如果我们还有其他字符串连接呢?

1
2
3
4
5
6
String k = "hello";
Long n = 2983323;
String a = k + " a " + n + " b";
// 我们希望编译器生成这样的代码
var callSite = Bootstrap("\1 a \2 b");  //  (String, Long)->String, 只调用一次
String a = callSite.target.invoke(k, a);

这些函数的实现应该大同小异,只是签名不同,如果我们使用统一的函数来实现它,且为了JVM能高效地调用这个函数(包含JIT优化),我们就得让这个函数和其他直接编译的函数没什么差别,但是还得动态生成这些方法。所以需要一个动态生成函数(方法)的机制,这就是 invokedynamic 能解决的问题。它是一个通用的动态函数生成机制。Bootstrap Method就是一个函数生成器。通常Bootstrap Method会使用字节码动态生成机制,动态创建一个类,实现某个接口

1
2
3
4
5
6
7
8
9
10
String k = "hello";
Long n = 2983323;
String a = k + " a " + n + " b";
// 编译器实际生成的代码
String a = invokedynamic<(String) -> CallSite[(String, Long)->String]>("\1 a \2 b")(k, a)
String k = "world!";
Long n = 38323;
String a = k + " a " + n + " b";
// 编译器实际生成的代码
String a = invokedynamic<(String) -> CallSite[(String, Long)->String]>("\1 a \2 b")(k, a)

invokedynamic<(String) -> CallSite[(String, Long)->String]>(args...)(args...)这种语法是我编的,它代表了两步调用,一个是根据参数生成CallSite,另一部是实际调用 CallSite,这里合到一起了。我们看到第二次调用的时候,虽然参数都变了,但是CallSite参数(常量字符串)没变,所以 invokedynamic 还会直接使用已经缓存好的 CallSite,而不是去再次调用一遍 Bootstrap Method。这样后续的调用都可以被JIT优化了,跟正常的函数调用一样。这样实现的另一个好处是,假如新版 JDK 出来,有一个更快速的字符串拼接实现,你的字节码是不需要动的,也就是不需要重新编译,直接就可以享受最新的实现。那么Java 里的 Lambda 是怎样实现的呢?跟上述字符串实现有什么区别呢?

1
2
3
4
5
6
7
8
9
String msg = "hello";
Runnable r = () -> System.out.println(msg);
// 这里编译器生成的代码是
// 静态方法编译期生成 private static void lambda$main$0(String msg) {return System.out.println(msg);}
// 不用生成额外的inner class
Runnable r = invokedynamic<(MethodHandle)->CallSite[(msg)->Runnable]>(lambda$main$0)(msg);
// 注意 invokedynamic 返回的值是 CallSite, 调用 CallSite 的结果是 Runnable
// 这里lambda只是被创建,并没有被调用,后续调用lambda只是通过接口来调用, 使用invokeinterface
r.run();

所以Lambda并不是通过indy调用,而是通过indy创建,后续调用lambda只是通过接口来调用, 使用invokeinterface。我们可以梳理下逻辑,lambda函数本身要实现一个特定签名的接口,因为javac考虑到内部类的方法太占用class file 空间,lambda多了比较浪费,所以选择把lambda逻辑在静态方法中实现,需要时再动态生成内部类。那么怎样按需动态生成并能缓存生成的结果呢?invokedynamic!这里和字符串连接的实现没有本质的区别,只不过这个CallSite的返回不是一个简单的字符串,而是另一个函数而已。

动态语言 vs. 静态语言

  • 静态类型语言(statically-typed language):在编译期进行类型检查;变量和表达式必须具有特定的类型,并且在编译或解释过程中会进行类型检查以确保类型的一致性;
  • 动态类型语言(dynamically-typed language):在运行期进行类型检查;变量和表达式的类型是在运行时确定的,变量可以在运行时改变其类型,而且通常不需要显式地声明变量的类型;

Java是静态类型语言,对于下面的代码:

1
obj.println("Hello world");

如果变量obj的类型是java.io.PrintStream类型,那么obj指向的对象也必须是PrintStream类型,否则类型检查不通过。即使obj指向的对象有一个println(String)方法也不行。

但是动态类型语言比如JavaScript、Python、Ruby则可以。他们就像duck typing一样(只要你表现得像鸭子,你就是鸭子),变量obj本身不需要类型,而且只要obj指向的对象有一个println(String)方法,那么它就是PrintStream,根本不需要一个像Java一样的PrintStream接口强制约束obj指向的对象的类型。

动态语言和静态语言各有优劣:

  • 灵活性:静态语言显得太繁文缛节,必须声明、检查变量的类型,写起来不如python灵活;
  • 健壮性:但正是因为静态语言要求了类型检查,才能保证写出来的代码没有类型错误,而不像动态语言,跑的时候才知道写错了,对象没有这个方法;更惨的是,如果两个完全不相干的对象有同样的方法,跑起来也不会报错,但其实语义完全错了,可能还发现不了。比如Wine#press指的是“制作(葡萄酒)”,Trousers#press指的是“熨烫(裤子)”,但是如果不小心把Trousers类型的对象赋值给Wine,跑的时候也能正常调用press方法,但是输出已经完全错误了(这是陷入了debug的汪洋大海啊……)

因此动态语言要求开发者脑子里必须时刻知道自己在写什么,显然:

  • 难理解:别人很难看懂你在写什么;
  • 难维护:项目比较大的时候,很容易出现错误;时间长了再看,你也不知道自己在写什么;

自由的代价:规模一大,就会变成无组织,无纪律。

静态语言有更多的规章制度,所以体量大了才更不容易出错;动态语言有更强的灵活性,所以项目小的时候写起来才特别方便。

动态语言 on jvm

Java能在编译时(由Java编译器)做类型检查,但是注意:是java编译器,不是jvm

jvm不做类型检查,所以在jvm之上是能实现动态语言的,比如Jpython、JRuby!根据一个JRuby开发者的说法

Now the astute reader may already have noticed that other than being specified as reference or primitive types, the opcodes themselves have no type information. Even beyond that, there are no actual variable declarations at the bytecode level whatsoever. The only types we see come in the form of opcode prefixes (as in aload, iinc, etc) and the method signatures against which we execute invoke* operations. The stack itself is also untyped; we push a reference type (aload) one minute and push a primitive type (iload) the next (though values on the stack do not “lose” their types). And when I tell you that the type signatures shown above for each method invocation or object construction are simply strings stuffed into the class’s pool of constants…well…now you may start to realize that Java’s sometimes touted, oft-maligned static-typing…is just a façade.

Let’s dispense with the formality once and for all. The biggest lie that’s been spread about the JVM (ok, maybe the biggest after “it’s slow”) is that it’s never going to be a good host for dynamic languages. “But look at Java,” people cry, “it’s so staticky and rigid; it’s far too difficult to implement a dynamic language on top of that!” And in a very naive way, they’re partially correct. Writing a language implementation in Java and following Java’s rules can certainly make life difficult for a dynamic language implementer. We end up stripping types (making everything Object, since we don’t know types until runtime), boxing types (stuffing primitives in carrier objects, to simplify passing them through our Object-only code), and boxing array arguments (since many dynamic languages also have flexible “arities” or numbers of arguments, and others allow optional, “rest”, and other special argument types). With each sacrifice we make, we lose many of the benefits static typing provides us, not to mention confounding the JVM’s efforts to optimize.

But it’s not nearly as bad as it seems. Because much of the rigid, static nature of Java is in the language itself (and not the JVM) we can in many cases ignore the rules. We don’t have to declare local variable types. We can juggle items on the stack at will. We can cheat in clever ways, allowing much of normal code execution to proceed with very little type information. In many cases we can get that code to run nearly as well as statically-typed code of twice the size, because the JVM is so dynamic already at its core. JVM bytecode is our assembly, and it’s a powerful tool in the right hands.

Unfortunately, on current JVMs, there’s one place we absolutely, positively must follow the rules: method invocation.

Question: In the bytecode above, all invocations came with a formal “signature” representing the type to call against and the types of the method’s arguments and return value. If we do not know those types until runtime, and they may be variant even then…how do we support invocation in a dynamic language?

Answer: Very carefully.

虽然Java类型很严格,但是这关我JRuby什么事呢?严格的是Java,又不是jvm!jvm的字节码:

  • 并不需要声明变量;
  • 操作数栈没有类型:push/pop任何类型的数据都可以;
  • opcode没有类型:字节码的前缀虽然有别,但是只体现出了操作数的primitive类型(比如iload只能操作int)和reference类型(aload),并不需要声明具体是哪个类的类型(Person、String等等)。这好办啊,实现动态语言的时候,所有的类型都用object,primitive通过装箱也用object,所有的load只需要用aload就行了:

    We end up stripping types (making everything Object, since we don’t know types until runtime), boxing types (stuffing primitives in carrier objects, to simplify passing them through our Object-only code), and boxing array arguments (since many dynamic languages also have flexible “arities” or numbers of arguments, and others allow optional, “rest”, and other special argument types).

比如下面的一行代码(在public static void main(String... args)中):

1
int i = args.length;

编译后的字节码:

1
2
3
 0 aload_0
 1 arraylength
 2 istore_1

这段Java字节码的意思是:

  • aload_0: 将索引为 0 的本地变量(通常是 this 对象,但这里是static方法,所以没有this,索引为0的本地变量指的是args)加载到操作数栈中。
  • arraylength: 获取操作数栈顶的数组的长度,并将其推送到操作数栈顶。
  • istore_1: 将操作数栈顶的整数值存储到索引为 1 的本地变量数组中(其实就是变量i)。

操作数栈一会儿放args数组,一会儿放数组的长度(int),这不也啥都能放嘛!没有说操作数栈只能放某个类型的数据啊!

因此,实际上在jvm上实现动态类型语言并没有想象的那么难。

这么改造的话,新的语言将失去静态语言提供的好处,也干扰了jvm的优化工作。但也动态语言的好处:不用声明局部变量了,可以直接修改操作数栈上的值,可以提供很少的类型就能让代码运行。jvm的字节码就像是新语言的底层汇编语言,它本身就很动态

With each sacrifice we make, we lose many of the benefits static typing provides us, not to mention confounding the JVM’s efforts to optimize.

But it’s not nearly as bad as it seems. Because much of the rigid, static nature of Java is in the language itself (and not the JVM) we can in many cases ignore the rules. We don’t have to declare local variable types. We can juggle items on the stack at will. We can cheat in clever ways, allowing much of normal code execution to proceed with very little type information. In many cases we can get that code to run nearly as well as statically-typed code of twice the size, because the JVM is so dynamic already at its core. JVM bytecode is our assembly, and it’s a powerful tool in the right hands.

但jvm也并不是完全没类型。有一点确实很难绕过:方法调用,因为jvm里所有的方法调用都必须指定方法所在的类。这对于动态语言来说,极不友好。在方法调用中,按照jvm规定,编译后的字节码里,符号引用里已经写好了方法属于哪个类(当然也包括方法名、参数、返回值)。通过这个符号引用,jvm可以找到方法的直接引用。下面这个字节码所代表的方法调用,println方法只能是属于PrintStream的,不能是别的类的:

1
invokevirtual #4; // Method java/io/PrintStream.println : (Ljva/lang/String;)V

动态语言里,编译时最多确定方法名、参数、返回值,至于方法属于哪个类并不知道。

基于jvm的动态语言实现者并没有坐以待毙,用java反射里的Method就能解决这个问题。它是对方法调用的一个抽象,虽然要从类里获取(不能直接new),但那是运行时获取的,编译时不需要。而且它接收object array作为参数,返回object作为返回值,object就意味着没有类型,非常好!但是:

  1. ruby是有解释器的,解释器里的代码根本没有类,怎么从类里通过反射获取方法?
  2. 性能:一个类库用用反射就算了,一门语言的每一个操作都在用反射,那性能不敢想

The traditional way to get around all this rigidity (a technique used heavily even by normal Java libraries, since everyone wants to bend the rules sometimes) is to abstract out the act of “invoking” itself, usually by creating “Method” objects that do the call for you. And oddly enough, the reflection capabilities of the JVM come into heavy play here. “Method” happens to be one of the types in the java.lang.reflect package, and it even has an “invoke” method on it. Even better, “invoke” returns Object, and accepts as parameters an Object receiver and an array of Object arguments. Can it truly be this easy? Well, yes and no.

Using reflection to invoke methods works great…except for a few problems. Method objects must be retrieved from a specific type, and can’t be created in a general way. You can’t ask the JVM to give you a Method that just represents a signature, or even a name and a signature; it must be retrieved from a specific type available at runtime. Oh, but that’s at runtime, right? We’re ok, because we do actually have types at runtime, right? Well, yes and no.

First off, you’re ignoring the second inconvenience above. Language implementations like JRuby or Rhino, which have interpreters, often simply don’t have normal Java types they can present for reflection. And if you don’t have normal types, you don’t have normal methods either; JRuby, for example, has a method object type that represents a parsed bit of Ruby code and logic for interpreting it.

Second, reflected invocation is a lot slower than direct invocation. Over the years, the JVM has gotten really good at making reflected invocation fast. Modern JVMs actually generate a bunch of code behind the scenes to avoid a much of the overhead old JVMs dealt with. But the simple truth is that reflected access through any number of layers will always be slower than a direct call, partially because the completely generified “invoke” method must check and re-check receiver type, argument types, visibility, and other details, but also because arguments must all be objects (so primitives get object-boxed) and must be provided as an array to cover all possible arities (so arguments get array-boxed).

The performance difference may not matter for a library doing a few reflected calls, especially if those calls are mostly to dynamically set up a static structure in memory against which it can make normal calls. But in a dynamic language, where every call must use these mechanisms, it’s a severe performance hit.

为了不用反射,jruby为每个类的方法动态生成一个invoker class,参数和返回值很像反射里的Method,都是通用的类。这样方法的调用就是在调用这些invoker而不是反射。快是快了,但是每个方法都要生成一个class!比jsp还猛。class爆炸了……jvm也炸了……(永久代:你不要过来啊~)

There are dozens of these cases in JRuby’s core classes, and if we attempted to extend this mechanism to all Java types we encountered (we don’t, for memory-saving purposes), there would be hundreds of cases of nearly-complete duplication.

所以反射的方法弃疗了:

So it is with great reluctance that we are forced to abandon the idea of generating a lot of fat, wasteful, but speedy invokers. And it’s with even greater reluctance we must abandon the idea of recompiling, since we can barely afford to generate all that code once. If only there were a way to share all that code and decrease the amount of PermGen we consume, or at least make it possible for generated code to be easily garbage collected. Hmmm.

因此,有必要从jvm的层面支持动态方法调用。JSR292引入的invokedynamic指令就是做这个的。

当然,这玩意儿对Java开发者来说不需要深究,对于基于jvm的动态语言实现者比如JRuby、JPython是必须要知道的。有兴趣可以看看stackoverflow上的讨论这个JRuby语言开发者写的文章

编译优化

java的编译可以发生在两个阶段

  1. javac:也叫前端编译器,javac编译器把java文件转成class文件
  2. JIT:也叫后端编译器,运行时编译器(JIT,Just in Time compiler)把字节码转为本地机器码

前端编译器优化

优化javac只能影响java语言。

很多Java语法特性其实都是语法糖,靠javac实现,而非底层jvm支持。支持这些语法并不需要jvm在字节码层面进行任何变动,但会显著影响到程序猿的编码风格,提高编程效率。

比如:

  • 泛型:擦除为raw type,再使用强制转型;
  • 变长参数:实际还是array;
  • 自动装箱/拆箱:类似强制转型,Integer#valueOf/Integer#intValue
  • foreach:iterator;
  • try with resource
  • var:编译器必须能确定类型,并编译为具体类型;
  • 内部类:一个自动关联外部类实例的独立类;
  • 枚举类:enum关键字实际创建了一个继承Enum的类;
  • switch支持枚举和字符串:TODO

虚拟机运行时不支持这些语法,他们在javac编译阶段就被还原回了最基础的语法(即“解语法糖”)。

泛型

泛型可以用在三个地方:类/接口、方法。

java 1.5之前没有泛型,只能使用Object(比如List,类似List<Object>)和强制转型两种手段组合来实现泛型的效果。这样做的缺点非常明显:只有运行时才知道强制转型对不对,编译时无法下判断,给代码的健壮性带来极大损害。

python:正是在下!

泛型的实现有两种路线:

  • 真实泛型:在C#中,无论源代码、编译后的中间语言、运行时,泛型都是一个真实存在的类型。List<int>List<String>就是两个不同的类型,是两个在运行期生成的类型(在中间语言时,泛型还是一个占位符),有自己的方法表和类型数据。
  • 伪泛型:只在源代码里存在,在编译后的class文件里(中间语言),泛型已经不存在了,被替换成了raw type(比如List,而非List<String>),并在相应的地方插入了强制转型代码

所以对jvm来说,一切都没有变化,要执行的还是原来的那些字节码。只不过原来程序猿写强制转型,现在javac自动生成强制转型。但无疑给程序猿带来了极佳的体验,用起来仿佛真的有泛型一样。

比如:

1
2
3
4
5
    List<String> l = new ArrayList<>();
    l.add("hello");
    l.add("world");
    String s = l.get(0);
    System.out.println(s);

编译后的字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
 0 new #7 <java/util/ArrayList>
 3 dup
 4 invokespecial #9 <java/util/ArrayList.<init> : ()V>
 7 astore_1
 8 aload_1
 9 ldc #10 <hello>
11 invokeinterface #12 <java/util/List.add : (Ljava/lang/Object;)Z> count 2
16 pop
17 aload_1
18 ldc #18 <world>
20 invokeinterface #12 <java/util/List.add : (Ljava/lang/Object;)Z> count 2
25 pop
26 aload_1
27 iconst_0
28 invokeinterface #20 <java/util/List.get : (I)Ljava/lang/Object;> count 2
33 checkcast #24 <java/lang/String>
36 astore_2
37 getstatic #26 <java/lang/System.out : Ljava/io/PrintStream;>
40 aload_2
41 invokevirtual #32 <java/io/PrintStream.println : (Ljava/lang/String;)V>
44 return
  1. 添加字符串调用的add方法实际是(Ljava/lang/Object;)Z类型:参数是Object,类是java/util/List,而非java/util/List<String>
  2. 获取字符串调用的get方法实际是(I)Ljava/lang/Object;类型:参数是int,返回的是个Object,而非String;
  3. 获取到Object之后编译器要再帮忙做个强制转型checkcast #24 <java/lang/String>;然后变量s才能直接当String用。

但伪泛型要做的工作并非只是这么多。编译后类型信息被擦掉了(raw type),那么需要的时候又该怎么获取参数化类型?

泛型类、泛型方法、泛型field都会带上一个Signature属性,记录着这个泛型原有的类型(记录下它在源代码里的样子,不然被擦了就找不着了)。

比如类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class TreeMain<T> {

    T param;

    List<String> a;

    void func(List<String> list) {
        List<Integer> l = new ArrayList<>();
        l.add(1);
        l.add(2);
        int s = l.get(0);
        System.out.println(s);
    }

    T foo(List<T> list) {
        T s = list.get(0);
        return s;
    }

    <A> A bar(List<A> list) {
        A s = list.get(0);
        return s;
    }
}
  • param:<TT;>
  • a:<Ljava/util/List<Ljava/lang/String;>;>
  • func:<(Ljava/util/List<Ljava/lang/String;>;)V>
  • foo:<(Ljava/util/List<TT;>;)TT;>
  • bar:<<A:Ljava/lang/Object;>(Ljava/util/List<TA;>;)TA;>

反射调用时就从Signature属性里获取原本的参数类型信息

字段a的完整表述如下:

  • field_info
    • name: a
    • type: <Ljava/util/List;>
    • attribute_info
      • name: <Signature>
      • signature: <Ljava/util/List<Ljava/lang/String;>;>

那么这两种泛型实现的思路有什么优缺点?

真实泛型

类型膨胀:毕竟每个List和其他类型都能组成一个新类型,如果情况多了,生成的类型也多了。

伪泛型

实现简单:javac把反省信息擦掉后,jvm不用大改,继续按照原有方式运行(把未知问题转化为已知问题)。

但是因为运行时无法获取到泛型类型信息,会让某些代码写起来比较麻烦。比如不能直接写List<Studeng>.class,在需要这些信息的场合(比如反序列化)就会很麻烦。参考序列化 - 泛型TypeReference

再比如把list转array,不得不额外传入list的实际类型,看起来就很抽象:

1
2
3
4
    public static <T> T[] convert(List<T> list, Class<T> type) {
        T[] array = (T[]) Array.newInstance(type, list.size());
        return array;
    }

程序猿:我不是已经把类型告诉你了吗?编译器:我擦了:D

伪泛型还会造成一些功能的缺失,比如不能有以下重载方法(返回值在java里本来就不计入方法签名):

  • int func(List<Integer> list)
  • void fun(List<String> list)

看起来参数不一样的两个方法,实际参数类型是一样的。因此无法编译:'fun(List<String>)' clashes with 'fun(List<Integer>)'; both methods have same erasure

最后,因为primitive和Object不能互转,Java的泛型干脆不支持primitive泛型了……都得用包装类转一下……

自动装箱/拆箱

看似在把int放到list,实际上放进去的是Integer:

1
2
3
4
5
6
7
    void func(List<String> list) {
        List<Integer> l = new ArrayList<>();
        l.add(1);
        l.add(2);
        int s = l.get(0);
        System.out.println(s);
    }

不信看字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
 0 new #7 <java/util/ArrayList>
 3 dup
 4 invokespecial #9 <java/util/ArrayList.<init> : ()V>
 7 astore_2
 8 aload_2
 9 iconst_1
10 invokestatic #10 <java/lang/Integer.valueOf : (I)Ljava/lang/Integer;>
13 invokeinterface #16 <java/util/List.add : (Ljava/lang/Object;)Z> count 2
18 pop
19 aload_2
20 iconst_2
21 invokestatic #10 <java/lang/Integer.valueOf : (I)Ljava/lang/Integer;>
24 invokeinterface #16 <java/util/List.add : (Ljava/lang/Object;)Z> count 2
29 pop
30 aload_2
31 iconst_0
32 invokeinterface #22 <java/util/List.get : (I)Ljava/lang/Object;> count 2
37 checkcast #11 <java/lang/Integer>
40 invokevirtual #26 <java/lang/Integer.intValue : ()I>
43 istore_3
44 getstatic #30 <java/lang/System.out : Ljava/io/PrintStream;>
47 iload_3
48 invokevirtual #36 <java/io/PrintStream.println : (I)V>
51 return
  1. 在add之前,javac帮忙调用了Integer#valueOf,把int转成了Integer;
  2. 在get之后,javac帮忙调用了Integer#intValue,把Integer转成了int;

所以和泛型强制转型一样,int和Integer的转换也是javac帮我们做了。实际上他们并不是同一类型。

引入Integer的意义

那么问题来了,Java里既然有int,为什么还要有包装类Integer?因为int是一个原始类型,不能作为对象使用。而Integer是int的包装类,它可以将int转换为对象,从而:

  1. 可以在需要使用对象的场景中使用,比如集合类、泛型等
  2. 此外,Integer还提供了一些方便的方法,如将字符串转换为整数、整数转换为字符串等。

看来引入Integer,主要是为了用在泛型上。既然如此,Java List为什么不能用List<int>?为什么非得用List<Integer>因为List里面放的是Object[] elementData,只能放Object,而int不是Object的子类。所以jdk构造了int的包装对象Integer。之所以设计成Object,主要是为了通用性:不止可以放数值,可以放任意类型的对象。有一些库比如Guava的ImmutableIntArray,Trove的TIntArrayList,里面放的是int[] array,相当于是List<int>,相比于List<Integer>更省空间,效率也更高。但是这样的实现就不通用了。jdk的List则可以放置任意对象,通用性非常好。

vararg

1
2
3
    public static void main(String... args) {
        int i = args.length;
    }

字节码:

1
2
3
4
0 aload_0
1 arraylength
2 istore_1
3 return

因为是static方法,第0个参数不是this,而是args。把它放到栈顶,然后使用arraylength获取它的长度,暴露了它是个array。

foreach

1
2
3
4
5
6
7
8
9
10
11
12
    public static void main(String... args) {
        int i = args.length;
        for (String arg : args) {
            System.out.println(arg);
        }
    }

    public static void main2(List<String> args) {
        for (String arg : args) {
            System.out.println(arg);
        }
    }

编译后的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
    public static void main(String... args) {
        int i = args.length;
        String[] var2 = args;
        int var3 = args.length;

        for(int var4 = 0; var4 < var3; ++var4) {
            String arg = var2[var4];
            System.out.println(arg);
        }

    }

    public static void main2(List<String> args) {
        Iterator var1 = args.iterator();

        while(var1.hasNext()) {
            String arg = (String)var1.next();
            System.out.println(arg);
        }

    }
  • 数组的foreach用的是下标遍历;
  • List的foreach用的是iterator遍历;

前者的字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
 0 aload_0
 1 arraylength
 2 istore_1
 3 aload_0
 4 astore_2
 5 aload_2
 6 arraylength
 7 istore_3
 8 iconst_0
 9 istore 4
11 iload 4
13 iload_3
14 if_icmpge 37 (+23)
17 aload_2
18 iload 4
20 aaload
21 astore 5
23 getstatic #7 <java/lang/System.out : Ljava/io/PrintStream;>
26 aload 5
28 invokevirtual #13 <java/io/PrintStream.println : (Ljava/lang/String;)V>
31 iinc 4 by 1
34 goto 11 (-23)
37 return

后者的字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
 0 aload_0
 1 invokeinterface #19 <java/util/List.iterator : ()Ljava/util/Iterator;> count 1
 6 astore_1
 7 aload_1
 8 invokeinterface #25 <java/util/Iterator.hasNext : ()Z> count 1
13 ifeq 36 (+23)
16 aload_1
17 invokeinterface #31 <java/util/Iterator.next : ()Ljava/lang/Object;> count 1
22 checkcast #35 <java/lang/String>
25 astore_2
26 getstatic #7 <java/lang/System.out : Ljava/io/PrintStream;>
29 aload_2
30 invokevirtual #13 <java/io/PrintStream.println : (Ljava/lang/String;)V>
33 goto 7 (-26)
36 return

var

jdk 10引入了var,可以在声明局部变量的时候使用。乍一看以为Java变成动态语言了,实际上并不是。

scala和Java一样是静态语言,但依然有var。JavaScript有var,而且var可以多次赋值不同类型的值,所以它是动态语言。因此有没有var并非区分typed/dynamic language的依据。

var实际上也是语法糖,这一点看javac编译后的字节码就知道了:

1
2
3
4
5
6
7
8
9
10
11
    public static void main(String... args) {
        var a = 1;
        var s = "hello";
        var list = new ArrayList<>();
        list.add(a);
        list.add(s);
        var len = list.size();
        for (var x : list) {
            System.out.println(x);
        }
    }

方法的LocalVariableTable显示了每一个变量的类型:

  • args: [Ljava/lang/String;
  • a: I
  • s: Ljava/lang/String;
  • list: Ljava/util/ArrayList;
  • len: I
  • x: Ljava/lang/Object;

比较有意思的是list变量,类型为ArrayList。因为没有了泛型信息,所以var推测出来的为raw type,因此这样的list可以添加任意类型的对象,不再受泛型的约束,和jdk 1.5之前直接用raw type声明对象一样。

此时,从list里取出来的对象x也只能是Object类型。知道泛型只是javac实现的语法糖的话,对于这一点完全不会感到奇怪。

上面的代码和下面地代码编译出来的字节码是一模一样的:

1
2
3
4
5
6
7
8
9
10
11
    public static void main(String... args) {
        int a = 1;
        String s = "hello";
        ArrayList list = new ArrayList();
        list.add(a);
        list.add(s);
        int len = list.size();
        for (Object x : list) {
            System.out.println(x);
        }
    }

当然,var推断变量a为int后,添加到list里依然需要Integer的装箱操作:

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
 0 iconst_1
 1 istore_1
 2 ldc #7 <hello>
 4 astore_2
 5 new #9 <java/util/ArrayList>
 8 dup
 9 invokespecial #11 <java/util/ArrayList.<init> : ()V>
12 astore_3
13 aload_3
14 iload_1
15 invokestatic #12 <java/lang/Integer.valueOf : (I)Ljava/lang/Integer;>
18 invokevirtual #18 <java/util/ArrayList.add : (Ljava/lang/Object;)Z>
21 pop
22 aload_3
23 aload_2
24 invokevirtual #18 <java/util/ArrayList.add : (Ljava/lang/Object;)Z>
27 pop
28 aload_3
29 invokevirtual #22 <java/util/ArrayList.size : ()I>
32 istore 4
34 aload_3
35 invokevirtual #26 <java/util/ArrayList.iterator : ()Ljava/util/Iterator;>
38 astore 5
40 aload 5
42 invokeinterface #30 <java/util/Iterator.hasNext : ()Z> count 1
47 ifeq 70 (+23)
50 aload 5
52 invokeinterface #36 <java/util/Iterator.next : ()Ljava/lang/Object;> count 1
57 astore 6
59 getstatic #40 <java/lang/System.out : Ljava/io/PrintStream;>
62 aload 6
64 invokevirtual #46 <java/io/PrintStream.println : (Ljava/lang/Object;)V>
67 goto 40 (-27)
70 return

内部类

Java的内部类是在类的内部定义的类。它们的作用是为外部类提供更好的封装和组织代码的能力。因为在内部类里可以访问外部类的成员,包括私有成员。

比如jdk里的迭代器模式,一般都是通过内部类实现的(比如ArrayList里的迭代器)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class MyCollection {
    private Object[] elements;

    // ...

    public Iterator iterator() {
        return new MyIterator();
    }

    private class MyIterator implements Iterator {
        private int currentIndex = 0;

        public boolean hasNext() {
            return currentIndex < elements.length;
        }

        public Object next() {
            return elements[currentIndex++];
        }
    }
}

在这个例子中,MyCollection类包含一个内部类MyIterator,它可以访问外部类的私有成员elements,并提供对集合元素的迭代访问。

内部类好用的地方在于能访问外部类的属性,包括私有属性。它像一个独立的类一样(实际上它就是一个独立的类),可以实现很好的封装,同时又能完美访问外部类里所有需要的数据,不影响外部类数据的可见性。

内部类是怎么做到自由访问外部类的属性的?实际上:

  1. 内部类也会被编译器转换成一个独立的类(所以独立类的效果它也有);
  2. 并且在其生成的字节码中包含一个对外部类实例的引用。这个引用通过构造函数进行传递,以便内部类可以访问外部类的成员

这一切是编译器带来的语法糖,这个转换过程是由编译器自动处理的,对开发者来说是透明的。因此,开发者可以像使用普通类一样使用内部类,而无需关注底层的实现细节。

比如以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Outer {

    private int out;

    private int access1() {
        return out;
    }

    private int access2() {
        return new Inner().in;
    }

    private class Inner {
        private int in;

        private int access1() {
            return out;
        }

        private int access2() {
            return Outer.this.out;
        }
    }
}

编译后会产生两个class文件:

  1. Outer.class
  2. Outer$Inner.class

可见内部类成了独立的类,类名就是外部类和内部类类名的拼接。同时内部类有两个field:

  1. in: <I>
  2. this$0: <Lexample/innerclass/Outer;>

第二个变量明显是javac塞进去的,是Outer类型

cglib、动态代理,不外乎也是这么做的:在字节码层面帮你无中生有。

这个变量是什么时候初始化的?看内部类的<init>

1
2
3
4
5
6
7
8
9
10
11
// 将局部变量表中索引为0的引用类型变量(即当前内部类对象的引用)加载到操作数栈中
0 aload_0
// 将局部变量表中索引为1的引用类型变量(即外部类对象的引用)加载到操作数栈中
1 aload_1
// 将操作数栈顶的引用类型数值(即外部类对象的引用)存储到当前内部类对象的字段this$0中
2 putfield #1 <example/innerclass/Outer$Inner.this$0 : Lexample/innerclass/Outer;>
// 将局部变量表中索引为0的引用类型变量(即当前内部类对象的引用)加载到操作数栈中
5 aload_0
// 调用超类java.lang.Object的构造函数,完成内部类对象的初始化
6 invokespecial #7 <java/lang/Object.<init> : ()V>
9 return

所以在new内部类的时候,外部类就自动作为参数被传了进来,set到了javac多加的this$0属性上。不信可以看Outer#access2的字节码:

1
2
3
4
5
6
 0 new #13 <example/innerclass/Outer$Inner>
 3 dup
 4 aload_0
 5 invokespecial #15 <example/innerclass/Outer$Inner.<init> : (Lexample/innerclass/Outer;)V>
 8 getfield #18 <example/innerclass/Outer$Inner.in : I>
11 ireturn

new Inner的时候,Inner的构造函数需要一个Outer类型的参数,Outer把自己作为参数传了进去

所以javac通过这些操作把内部类和外部类关联了起来。然后内部类就可以访问外部类的属性了。比如Inner#access1Inner#access2的字节码时一模一样的:

1
2
3
4
0 aload_0
1 getfield #1 <example/innerclass/Outer$Inner.this$0 : Lexample/innerclass/Outer;>
4 getfield #13 <example/innerclass/Outer.out : I>
7 ireturn

都是在调用javac生成的Outer类型的属性this$0来访问Outer里的数据

由此也可以看出内部类相比于独立类的好处:对开发者来说它天然和外部类相关联(对javac来说,没什么“天然”,靠的全都是自己的努力),所以当需要一个类处理外部类的逻辑是,使用内部类很方便。所以jdk里那么多工具类都用到了内部类来封装一些外部类的逻辑。

因此,想访问内部类,一定要先通过外部类new一个内部类出来(javac帮忙把外部类set为内部类里的一个属性)。

enum

enum关键字也是javac的语法糖。实际上,javac会把enum修饰的类编译为一个extends Enum的final类

比如:

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
public enum Operation {

    PLUS("I am plus"),
    MINUS("I am minus"),
    TIMES("I am times"),
    DIVIDE("I am divide"),
    UNKNOWN("I am trouble-maker");

    @Getter
    private String name;

    Operation(String name) {
        this.name = name;
    }

    public double calculate(double x, double y) {
        // 'this' in Enum is an instantiated object,
        // in this case such as: PLUS/MINUS/TIMES/DIVEDE/UNKNOWN
        return switch (this) {
            case PLUS -> x + y;
            case MINUS -> x - y;
            case TIMES -> x * y;
            case DIVIDE -> x / y;
            default -> throw new AssertionError("Unknown operation: " + this + " , name is: " + this.name);
        };
    }
}

使用CFR反编译后:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/*
 * Decompiled with CFR 0.150.
 * 
 * Could not load the following classes:
 *  example.enums.Operation$1
 */
package example.enums;

import example.enums.Operation;

public final class Operation
extends Enum<Operation> {
    public static final /* enum */ Operation PLUS = new Operation("I am plus");
    public static final /* enum */ Operation MINUS = new Operation("I am minus");
    public static final /* enum */ Operation TIMES = new Operation("I am times");
    public static final /* enum */ Operation DIVIDE = new Operation("I am divide");
    public static final /* enum */ Operation UNKNOWN = new Operation("I am trouble-maker");
    private String name;
    private static final /* synthetic */ Operation[] $VALUES;

    public static Operation[] values() {
        return (Operation[])$VALUES.clone();
    }

    public static Operation valueOf(String name) {
        return Enum.valueOf(Operation.class, name);
    }

    private Operation(String name) {
        this.name = name;
    }

    public double calculate(double x, double y) {
        return switch (1.$SwitchMap$example$enums$Operation[this.ordinal()]) {
            case 1 -> x + y;
            case 2 -> x - y;
            case 3 -> x * y;
            case 4 -> x / y;
            default -> throw new AssertionError((Object)("Unknown operation: " + this + " , name is: " + this.name));
        };
    }

    public String getName() {
        return this.name;
    }

    private static /* synthetic */ Operation[] $values() {
        return new Operation[]{PLUS, MINUS, TIMES, DIVIDE, UNKNOWN};
    }

    static {
        $VALUES = Operation.$values();
    }
}
  1. enum类成了Enum的子类,且构造函数私有;
  2. 每一个enum值都是类里的一个不可变对象,因为构造函数私有,不会再有新的该类型对象;
  3. 新增了values方法,返回上述所有对象组成的数组
  4. 新增了valueOf方法,利用了EnumvalueOf方法

之前根据string返回一个enum对象是这么写的:

1
2
3
4
5
    private static Map<String, Platform> reverseLookup = Arrays.stream(Platform.values()).collect(Collectors.toMap(e -> e.getValue().toLowerCase(), Function.identity()));

    public static Platform fromValue(@NonNull String value) {
        return reverseLookup.getOrDefault(value.toLowerCase(), OTHERS);
    }

既然知道新生成的valueOf方法利用了Enum#valueOf,那么看看它的实现:

1
2
3
4
5
6
7
8
9
10
    public static <T extends Enum<T>> T valueOf(Class<T> enumClass,
                                                String name) {
        T result = enumClass.enumConstantDirectory().get(name);
        if (result != null)
            return result;
        if (name == null)
            throw new NullPointerException("Name is null");
        throw new IllegalArgumentException(
            "No enum constant " + enumClass.getCanonicalName() + "." + name);
    }

我们就可以直接借助新生成的valueOf来实现根据string转enum对象的逻辑:

1
2
3
4
5
6
7
    public static Platform fromValue2(@NonNull String value) {
        try {
            return Platform.valueOf(value);
        } catch (Exception e) {
            return OTHERS;
        }
    }

比原来简单了许多。

知道sugar背后的东西,感觉对这些语法糖前所未有得更熟悉了。

后端编译器优化

优化jvm会影响到所有基于jvm的语言。

最初的Java可以认为是解释执行的:jvm解释字节码。但是当某些代码块运行的过于频繁时,把他们编译为机器的本地指令会执行的更快,所以就出现了JIT编译器。此时,Java不能再说是解释执行了

  • 解释器
    • 启动快,直接执行,省去编译时间
  • 编译器
    • 热点代码块更高的执行效率

如果一段代码被认为是热点代码,被JIT编译为了本地指令,之后它是怎么被调用的?jvm会把原来函数的调用入口地址换成新的地址,下次执行就使用JIT编译好的指令了。

inline

inline用到了前面介绍的虚方法。

inline可以把没必要的函数调用转成一行普通的函数,省去栈帧的创建开销。

inline除了能消除不必要的方法调用成本,还为其他优化奠定了基础:

1
2
3
4
5
6
7
8
9
10
public static void testInline(String... args) {
    Object obj = null;
    foo(obj);
}

public static void foo(Object obj) {
    if (obj != null) {
        // do something
    }
}

单看两个方法都是有用的,一旦inline,发现foo里其实是无用代码,可以做无用代码消除。

但实际上,大多数Java方法(实例方法)都无法inline,因为他们都是虚方法,只有运行时才知道究竟调的是哪个方法。所以编译器为了inline,做了许多额外工作:

  1. 非虚方法,可以直接inline;
  2. 虚方法:使用Class Hierarchy Analysis,CHA,来确定某个接口是否多于一种实现、某个类是否存在子类等
    1. 只有一个版本:可以liline;
    2. 有多个版本:在方法入口前建立一个inline cache
      1. 第一次调用后:inline;
      2. 如果后续每次调用的都是同一个方法:继续使用inline;
      3. 如果某次调用换方法了:用到了多态,取消inline;

逃逸分析

Escape Analysis主要是分析对象的作用域。

  • 方法逃逸:一个对象会作为参数传入其它函数,跑出了本方法;
  • 线程逃逸:比如类变量,会被其他线程访问到;

计算机硬件经过 25 年的发展,内存与处理器虽然都在进步,但是内存延迟与处理器执行性能之间的冯诺依曼瓶颈(Von Neumann Bottleneck)不仅没有缩减,反而还在持续加大,“RAM Is the New Disk”已经从嘲讽梗逐渐成为了现实。一次内存访问(将主内存数据调入处理器 Cache)大约需要耗费数百个时钟周期,而大部分简单指令的执行只需要一个时钟周期而已。因此,在程序执行性能这个问题上,如果编译器能减少一次内存访问,可能比优化掉几十、几百条其他指令都来得更有效果。

编译器做逃逸分析主要就是为了减少内存访问。如果一个对象不会逃逸,可以做一些高效的优化:

  • 栈上分配:Stack Allocation,现在该变量就像局部变量一样,为线程私有,无需分配到堆上,在栈回收的时候自动销毁,无需gc;
  • 同步消除:Synchronization Elimination,既然只有一个线程用,那就可以消除掉没必要的同步访问措施了;
  • 标量替换:Scalar Replacement,对象是标量的聚合体,如果对象不会逃逸,可以考虑拆散对象,不创建对象,只创建一些用到的属性;

及时编译器 vs. 静态编译器

C/C++编译器是静态编译器,Java的JIT编译器则是及时编译器。二者各有什么优劣?

动态性:

  • 编译耗时
    • 静态编译器占用的是编译时间,大家一般不在乎;
    • 及时编译器占用的是运行时间,所以不敢乱做编译优化,怕影响运行,所以会比较受限;
  • 动态优化:
    • 静态编译器无法以运行期的实际指标为基础做优化,比如分支频率预测、分支裁剪
    • 动态编译器可以不断收集运行时指标,实际上JIT就是这么干的。动态优化会成为Java独有的性能优势

语言带来的难度不同:

  • 虚方法
    • C没有类和继承的概念,因此没有方法的重写(override)概念;
    • C++如果希望派生类能够重写(override)父类的方法,则需要在父类方法声明中加上virtual关键字,否则派生类无法重写该方法,只能继承父类的行为;
    • Java没有virtual关键字,所以每一个父类方法都可能是virtual,需要运行时才能决定,因此编译优化的难度大于C/C++(比如上面说的inline)
  • 越界检查
    • C/C++不会做越界检查,如果不幸越界,要么误修改到其他变量,要么程序直接崩了;
    • Java会在运行时频繁检查数组越界,会额外消耗时间;
      • JVM会在访问数组元素时检查索引值是否小于0或大于等于数组长度。如果索引越界,JVM会抛出一个ArrayIndexOutOfBoundsException异常,指示数组访问越界错误;
      • 对于集合类,例如ArrayList,它们在内部维护了数据结构来存储元素,并且在添加、删除或访问元素时执行越界检查。这些集合类会在内部对索引值进行检查,以确保它们在有效范围内,如果索引越界,同样会抛出IndexOutOfBoundsException或其子类的异常。
  • 对象回收
    • C++不存在对象回收,因此干的活比较少,效率较高(程序猿的开发效率就会变低);
    • JVM则把垃圾回收的活儿揽到了自己身上(提升开发效率);

GraalVM

但是,新的GraalVM也是静态编译Java代码为本地机器码,和C++很像了!

GraalVM主要是为了让Java更适应在云原生时代。参考QCon2020 主题演讲:云原生时代,Java 的危与机

GraalVM native image: ideal for cloud native application

危机:

  1. 优势削弱:今天 Java 技术“一次编译,到处运行”的优势,已经被容器大幅度地削弱,已不再是大多数服务端开发者技术选型的主要考虑因素了;
  2. 弱项凸显:更加迫在眉睫的风险来自于那些与技术潮流直接冲突的假设。譬如,Java 总体上是面向大规模、长时间的服务端应用而设计的,严(luō)谨(suō)的语法利于约束所有人写出较一致的代码;静态类型动态链接的语言结构,利于多人协作开发,让软件触及更大规模;即时编译器、性能制导优化、垃圾收集子系统等 Java 最具代表性的技术特征,都是为了便于长时间运行的程序能享受到硬件规模发展的红利。但是,在微服务的背景下,提倡服务围绕业务能力而非技术来构建应用,不再追求实现上的一致,一个系统由不同语言,不同技术框架所实现的服务来组成是完全合理的;服务化拆分后,很可能单个微服务不再需要再面对数十、数百 GB 乃至 TB 的内存;有了高可用的服务集群,也无须追求单个服务要 7×24 小时不可间断地运行,它们随时可以中断和更新。同时,微服务又对应用的容器化亲和性,譬如镜像体积、内存消耗、启动速度,以及达到最高性能的时间等方面提出了新的要求,在这两年的网红概念 Serverless 也进一步增加这些因素的考虑权重,而这些却正好都是 Java 的弱项:哪怕再小的 Java 程序也要带着完整的虚拟机和标准类库,使得镜像拉取和容器创建效率降低,进而使整个容器生命周期拉长。基于 Java 虚拟机的执行机制,使得任何 Java 的程序都会有固定的基础内存开销,以及固定的启动时间,而且 Java 生态中广泛采用的依赖注入进一步将启动时间拉长,使得容器的冷启动时间很难缩短。

GraalVM就是朝着把代码提前编译(Ahead-of-Time Compilation,AOT)为本地机器指令去的。

Java 支持提前编译最大的困难在于它是一门动态链接的语言,它假设程序的代码空间是开放的(Open World),允许在程序的任何时候通过类加载器去加载新的类,作为程序的一部分运行。要进行提前编译,就必须放弃这部分动态性,假设程序的代码空间是封闭的(Closed World),所有要运行的代码都必须在编译期全部可知。这一点不仅仅影响到了类加载器的正常运作,除了无法再动态加载外,反射(通过反射可以调用在编译期不可知的方法)、动态代理、字节码生成库(如 CGLib)等一切会运行时产生新代码的功能都不再可用,如果将这些基础能力直接抽离掉,Helloworld 还是能跑起来,但 Spring 肯定跑不起来,Hibernate 也跑不起来,大部分的生产力工具都跑不起来,整个 Java 生态中绝大多数上层建筑都会轰然崩塌。

要获得有实用价值的提前编译能力,只有依靠提前编译器、组件类库和开发者三方一起协同才有可能办到。由于 Leyden 刚刚开始,几乎没有公开的资料,所以下面我是以 SubstrateVM 为目标对象进行的介绍:

  • 有一些功能,像反射这样的基础特性是不可能妥协的,折衷的解决办法是由用户在编译期,以配置文件或者编译器参数的形式,明确告知编译器程序代码中有哪些方法是只通过反射来访问的,编译器将方法添加到静态编译的范畴之中。同理,所有使用到动态代理也的地方,也必须在事先列明,在编译期就将动态代理的字节码全部生成出来。其他所有无法通过程序指针分析(Points-To Analysis)得到的信息,譬如程序中用到的资源、配置文件等等,也必须照此处理。
  • 另一些功能,如动态生成字节码也十分常用,但用户自己也往往无法得知那些动态字节码的具体信息,就只能由用到 CGLib、javassist 等库的程序去妥协放弃。在 Java 世界中也许最典型的场景就是 Spring 用 CGLib 来进行类增强,默认情况下,每一个 Spring 管理的 Bean 都要用到 CGLib。从 Spring Framework 5.2 开始增加了@proxyBeanMethods注解来排除对 CGLib 的依赖,仅使用标准的动态代理去增强类。

2019 年起,Pivotal 的 Spring 团队与 Oracle Labs 的 GraalVM 团队共同孵化了 Spring GraalVM Native 项目,这个目前仍处于 Experimental / Alpha 状态的项目,能够让程序先以传统方式运行(启动)一次,自动化地找出程序中的反射、动态代理的代码,代替用户向编译器提供绝大部分所需的信息,并能将启动时初始化的 Bean 在编译期就完成初始化,直接绕过 Spring 程序启动最慢的阶段,这样从启动到程序可以提供服务,耗时竟能够低于 0.1 秒

Although it would be possible to tell GraalVM about these dynamic aspects of the application, doing so would undo most of the benefit of static analysis. So instead, when using Spring Boot to create native images, a closed-world is assumed and the dynamic aspects of the application are restricted.

只有在满足这些限制的基础上,才能使用GraalVM的AOT(Ahead Of Time,在build time提前处理的特性),不然它是提前分析不了的。甚至连代理类都要在build的时候提前生成字节码了。参考spring AOT processing

When an application is running on the JVM, proxy classes are generated dynamically as the application runs. When creating a native image, these proxies need to be created at build-time so that they can be included by GraalVM.

看完新的Java,只能说,任何事情都是有代价的,但是只有顺应潮流才不会被淘汰。为此,甚至当年的立足之本,write once,run anywhere都可以不完全适用。

彩蛋:C模拟多态

在C语言中模拟多态的一种常见方式是使用函数指针和结构体:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#include <stdio.h>

// 定义动物结构体
typedef struct {
    void (*makeSound)();
} Animal;

// 定义狗结构体
typedef struct {
    Animal base;
    int age;
} Dog;

// 狗的特定叫声
void dogBark() {
    printf("Woof woof!\n");
}

// 定义猫结构体
typedef struct {
    Animal base;
    int weight;
} Cat;

// 猫的特定叫声
void catMeow() {
    printf("Meow meow!\n");
}

int main() {
    // 定义狗对象
    Dog myDog;
    myDog.base.makeSound = dogBark;
    myDog.age = 3;

    // 定义猫对象
    Cat myCat;
    myCat.base.makeSound = catMeow;
    myCat.weight = 5;

    // 调用动物的叫声方法
    Animal* animalPtr;

    // 指向狗对象
    animalPtr = (Animal*)&myDog;
    animalPtr->makeSound();

    // 指向猫对象
    animalPtr = (Animal*)&myCat;
    animalPtr->makeSound();

    return 0;
}

通过定义Animal结构体和包含函数指针的子结构体Dog和Cat来模拟多态。每个子结构体都包含一个指向特定叫声函数的函数指针。在main函数中,创建了一个Animal指针animalPtr,通过将其指向不同的子对象,可以调用相应的叫声函数实现多态效果。

Java内存区域

了解了class协议的结构和字节码执行引擎,再回头看JVM运行时数据区,一切都变得具象了起来。

GC

分区具象了,GC也具象了:JVM GC算法与实现

感想

不知道之前为什么没有读出爽感,难道是之前太菜了?不应该啊,这本书虽然比较深入,但不存在特别跳跃或晦涩的地方,以第一次读时候的水平应该也不至于看不懂。但的确只有这次再读才读出了手不释卷的感觉,一种欣赏优秀设计的爽感。尤其是看完后面的,前面的东西再看就豁然开朗!感觉书应该倒着写的,但没办法,这样的话就要看太久了,不是很个人都愿意这么看。 看来读得懂和读得爽确实需要不一样的见解,常读常新。

本文由作者按照 CC BY 4.0 进行授权