36 SubstrateVM:AOT编译框架

今天我们来聊聊 GraalVM 中的 Ahead-Of-Time(AOT)编译框架 SubstrateVM。

先来介绍一下 AOT 编译,所谓 AOT 编译,是与即时编译相对立的一个概念。我们知道,即时编译指的是在程序的运行过程中,将字节码转换为可在硬件上直接运行的机器码,并部署至托管环境中的过程。

而 AOT 编译指的则是,在程序运行之前,便将字节码转换为机器码的过程。它的成果可以是需要链接至托管环境中的动态共享库,也可以是独立运行的可执行文件。

狭义的 AOT 编译针对的目标代码需要与即时编译的一致,也就是针对那些原本可以被即时编译的代码。不过,我们也可以简单地将 AOT 编译理解为类似于 GCC 的静态编译器。

AOT 编译的优点显而易见:我们无须在运行过程中耗费 CPU 资源来进行即时编译,而程序也能够在启动伊始就达到理想的性能。

然而,与即时编译相比,AOT 编译无法得知程序运行时的信息,因此也无法进行基于类层次分析的完全虚方法内联,或者基于程序 profile 的投机性优化(并非硬性限制,我们可以通过限制运行范围,或者利用上一次运行的程序 profile 来绕开这两个限制)。这两者都会影响程序的峰值性能。

Java 9 引入了实验性 AOT 编译工具jaotc。它借助了 Graal 编译器,将所输入的 Java 类文件转换为机器码,并存放至生成的动态共享库之中。

在启动过程中,Java 虚拟机将加载参数-XX:AOTLibrary所指定的动态共享库,并部署其中的机器码。这些机器码的作用机理和即时编译生成的机器码作用机理一样,都是在方法调用时切入,并能够去优化至解释执行。

由于 Java 虚拟机可能通过 Java agent 或者 C agent 改动所加载的字节码,或者这份 AOT 编译生成的机器码针对的是旧版本的 Java 类,因此它需要额外的验证机制,来保证即将链接的机器码的语义与对应的 Java 类的语义是一致的。

jaotc 使用的机制便是类指纹(class fingerprinting)。它会在动态共享库中保存被 AOT 编译的 Java 类的摘要信息。在运行过程中,Java 虚拟机负责将该摘要信息与已加载的 Java 类相比较,一旦不匹配,则直接舍弃这份 AOT 编译的机器码。

jaotc 的一大应用便是编译 java.base module,也就是 Java 核心类库中最为基础的类。这些类很有可能会被应用程序所调用,但调用频率未必高到能够触发即时编译。

因此,如果 Java 虚拟机能够使用 AOT 编译技术,将它们提前编译为机器码,那么将避免在执行即时编译生成的机器码时,因为“不小心”调用到这些基础类,而需要切换至解释执行的性能惩罚。

不过,今天要介绍的主角并非 jaotc,而是同样使用了 Graal 编译器的 AOT 编译框架 SubstrateVM。

SubstrateVM 的设计与实现

SubstrateVM 的设计初衷是提供一个高启动性能、低内存开销,并且能够无缝衔接 C 代码的 Java 运行时。它与 jaotc 的区别主要有两处。

第一,SubstrateVM 脱离了 HotSpot 虚拟机,并拥有独立的运行时,包含异常处理,同步,线程管理,内存管理(垃圾回收)和 JNI 等组件。

第二,SubstrateVM 要求目标程序是封闭的,即不能动态加载其他类库等。基于这个假设,SubstrateVM 将探索整个编译空间,并通过静态分析推算出所有虚方法调用的目标方法。最终,SubstrateVM 会将所有可能执行到的方法都纳入编译范围之中,从而免于实现额外的解释执行器。

有关 SubstrateVM 的其他限制,你可以参考这篇文档

从执行时间上来划分,SubstrateVM 可分为两部分:native image generator 以及 SubstrateVM 运行时。后者 SubstrateVM 运行时便是前面提到的精简运行时,经过 AOT 编译的目标程序将跑在该运行时之上。

native image generator 则包含了真正的 AOT 编译逻辑。它本身是一个 Java 程序,将使用 Graal 编译器将 Java 类文件编译为可执行文件或者动态链接库。

在进行编译之前,native image generator 将采用指针分析(points-to analysis),从用户提供的程序入口出发,探索所有可达的代码。在探索的同时,它还将执行初始化代码,并在最终生成可执行文件时,将已初始化的堆保存至一个堆快照之中。这样一来,SubstrateVM 将直接从目标程序开始运行,而无须重复进行 Java 虚拟机的初始化。

SubstrateVM 主要用于 Java 虚拟机语言的 AOT 编译,例如 Java、Scala 以及 Kotlin。Truffle 语言实现本质上就是 Java 程序,而且它所有用到的类都是编译时已知的,因此也适合在 SubstrateVM 上运行。不过,它并不会 AOT 编译用 Truffle 语言写就的程序。

SubstrateVM 的启动时间与内存开销

SubstrateVM 的启动时间和内存开销非常少。我们曾比较过用 C 和用 Java 两种语言写就的 Hello World 程序。C 程序的执行时间在 10ms 以下,内存开销在 500KB 以下。在 HotSpot 虚拟机上运行的 Java 程序则需要 40ms,内存开销为 24MB。

使用 SubstrateVM 的 Java 程序的执行时间则与 C 程序持平,内存开销在 850KB 左右。这得益于 SubstrateVM 所保存的堆快照,以及无须额外初始化,直接执行目标代码的特性。

同样,我们还比较了用 JavaScript 编写的 Hello World 程序。这里的测试对象是 Google 的 V8 以及基于 Truffle 的 Graal.js。这两个执行引擎都涉及了大量的解析代码以及执行代码,因此可以当作大型应用程序来看待。

V8 的执行效率非常高,能够与 C 程序的 Hello World 相媲美,但是它使用了约 18MB 的内存。运行在 HotSpot 虚拟机上的 Graal.js 则需要 650ms 方能执行完这段 JavaScript 的 Hello World 程序,而且内存开销在 120MB 左右。

运行在 SubstrateVM 上的 Graal.js 无论是执行时间还是内存开销都十分优越,分别为 10ms 以下以及 4.2MB。我们可以看到,它在运行时间与 V8 持平的情况下,内存开销远小于 V8。

由于 SubstrateVM 的轻量特性,它十分适合于嵌入至其他系统之中。Oracle Labs 的另一个团队便是将 Truffle 语言实现嵌入至 Oracle 数据库之中,这样就可以在数据库中运行任意语言的预储程序(stored procedure)。如果你感兴趣的话,可以搜索 Oracle Database Multilingual Engine(MLE),或者参阅这个网址。我们团队也在与 MySQL 合作,开发 MySQL MLE,详情可留意我们在今年 Oracle Code One 的[讲座](https://oracle.rainfocus.com/widget/oracle/oow18/catalogcodeone18\?search=MySQL JavaScript)。

Metropolis 项目

去年 OpenJDK 推出了Metropolis 项目,他们希望可以实现“Java-on-Java”的远大目标。

我们知道,目前 HotSpot 虚拟机的绝大部分代码都是用 C++ 写的。这也造就了一个非常有趣的现象,那便是对 Java 语言本身的贡献需要精通 C++。此外,随着 HotSpot 项目日渐庞大,维护难度也逐渐上升。

由于上述种种原因,使用 Java 来开发 Java 虚拟机的呼声越来越高。Oracle 的架构师 John Rose 便提出了使用 Java 开发 Java 虚拟机的四大好处:

  1. 能够完全控制编译 Java 虚拟机时所使用的优化技术;
  2. 能够与 C++ 语言的更新解耦合;
  3. 能够减轻开发人员以及维护人员的负担;
  4. 能够以更为敏捷的方式实现 Java 的新功能。

当然,Metropolis 项目并非第一个提出 Java-on-Java 概念的项目。实际上,JikesRVM 项目Maxine VM 项目都已用 Java 完整地实现了一套 Java 虚拟机(后者的即时编译器 C1X 便是 Graal 编译器的前身)。

然而,Java-on-Java 技术通常会干扰应用程序的垃圾回收、即时编译优化,从而严重影响 Java 虚拟机的启动性能。

举例来说,目前使用了 Graal 编译器的 HotSpot 虚拟机会在即时编译过程中生成大量的 Java 对象,这些 Java 对象同样会占据应用程序的堆空间,从而使得垃圾回收更加频繁。

另外,Graal 编译器本身也会触发即时编译,并与应用程序的即时编译竞争编译线程的 CPU 资源。这将造成应用程序从解释执行切换至即时编译生成的机器码的时间大大地增长,从而降低应用程序的启动性能。

Metropolis 项目的第一个子项目便是探索部署已 AOT 编译的 Graal 编译器的可能性。这个子项目将借助 SubstrateVM 技术,把整个 Graal 编译器 AOT 编译为机器码。

这样一来,在运行过程中,Graal 编译器不再需要被即时编译,因此也不会再占据可用于即时编译应用程序的 CPU 资源,使用 Graal 编译器的 HotSpot 虚拟机的启动性能将得到大幅度地提升。

此外,由于 SubstrateVM 编译得到的 Graal 编译器将使用独立的堆空间,因此 Graal 编译器在即时编译过程中生成的 Java 对象将不再干扰应用程序所使用的堆空间。

目前 Metropolis 项目仍处于前期验证阶段,如果你感兴趣的话,可以关注之后的发展情况。

总结与实践

今天我介绍了 GraalVM 中的 AOT 编译框架 SubstrateVM。

SubstrateVM 的设计初衷是提供一个高启动性能、低内存开销,和能够无缝衔接 C 代码的 Java 运行时。它是一个独立的运行时,拥有自己的内存管理等组件。

SubstrateVM 要求所要 AOT 编译的目标程序是封闭的,即不能动态加载其他类库等。在进行 AOT 编译时,它会探索所有可能运行到的方法,并全部纳入编译范围之内。

SubstrateVM 的启动时间和内存开销都非常少,这主要得益于在 AOT 编译时便已保存了已初始化好的堆快照,并支持从程序入口直接开始运行。作为对比,HotSpot 虚拟机在执行 main 方法前需要执行一系列的初始化操作,因此启动时间和内存开销都要远大于运行在 SubstrateVM 上的程序。

Metropolis 项目将运用 SubstrateVM 项目,逐步地将 HotSpot 虚拟机中的 C++ 代码替换成 Java 代码,从而提升 HotSpot 虚拟机的可维护性,也加快新 Java 功能的开发效率。


今天的实践环节,请你参考我们官网的SubstrateVM 教程,AOT 编译一段 Java-Kotlin 代码。

尾声丨道阻且长,努力加餐.html

说句实话,我也不知道是怎么写完这 36 篇技术文章的。

一周三篇的文章接近近万字,说多不多,对我而言还是挺困难的一件事。基本上,我连续好几个月的业余时间都贡献给写作,甚至一度重温了博士阶段被论文支配的恐怖。我想,这大概也算是在工作相对清闲的国外环境下,体验了一把 997 的生活。

这一路下来,我感觉写专栏的最大问题,其实并不在于写作本身,而在于它对你精力的消耗,这种消耗甚至会让你无法专注于本职工作。因此,我也愈发地佩服能够持续分享技术的同行们。还好我的工作挺有趣的,每天开开心心地上班写代码,只是一到下班时间就蔫了,不得不应付编辑的催稿回家码字。

我在写作的中途,多次感受到存稿不足的压力,以致于需要请年假来填补写作的空缺。不过,最后做到了风雨无阻、节假无休地一周三更,也算是幸不辱命吧。

说回专栏吧。在思考专栏大纲时,我想着,最好能够和杨晓峰老师的 Java 核心技术专栏形成互补,呈现给大家的内容相对更偏向于技术实现。

因此,有读者曾反馈讲解的知识点是否太偏,不实用。当时我的回答是,我并不希望将专栏单纯写成一本工具书,这样的知识你可以从市面上任意买到一本书获得。

我更希望的是,能够通过介绍 Java 虚拟机各个组件的设计和实现,让你之后遇到虚拟机相关的问题时,能够联想到具体的模块,甚至是对于其他语言的运行时,也可以举一反三相互对照。

不过,当我看到 Aleksey Shipilev介绍 JMH 的讲座时,发现大部分的内容专栏里都有涉及。于是心想,我还能够在上述答复中加一句:看老外的技术讲座再也不费劲了。

还有一个想说的是关于专栏知识点的正确性。我认为虚拟机的设计可以写一些自己的理解,但是具体到目前 HotSpot 的工程实现则是确定的。

为此,几乎每篇专栏我都会大量阅读 HotSpot 的源代码,和同事讨论实现背后的设计理念,在这个过程中,我也发现了一些 HotSpot 中的 Bug,或者年久失修的代码,又或者是设计不合理的地方。这大概也能够算作写专栏和我本职工作重叠的地方吧。

我会仔细斟酌文章中每一句是否可以做到达意。即便是这样,文章肯定还有很多不足,比如叙述不够清楚,内容存在误导等问题。许多读者都热心地指了出来,在此感谢各位的宝贵意见。接下来一段时间,我会根据大家的建议,对前面的文章进行修订。

专栏虽然到此已经结束了,但是并不代表你对 Java 虚拟机学习的停止, 我想,专栏的内容仅仅是为你打开了 JVM 学习的大门,里面的风景,还是需要你自己来探索。在文章的后面,我列出了一系列的 Java 虚拟机技术的相关博客和阅读资料,你仍然可以继续加餐。

你可以关注国内几位 Java 虚拟机大咖的微信公众号:R 大,个人认为是中文圈子里最了解 Java 虚拟机设计实现的人,你可以关注他的知乎账号你假笨,原阿里 Java 虚拟机团队成员,现PerfMa CEO;江南白衣,唯品会资深架构师;占小狼,美团基础架构部技术专家;杨晓峰,前甲骨文首席工程师。

如果英文阅读没问题的话,你可以关注Cliff ClickAleksey Shipilëv(他的JVM Anatomy Park十分有趣)和Nitsan Wakart的博客。你也可以关注Java Virtual Machine Language SubmitOracle Code One(前身是 JavaOne 大会)中关于 Java 虚拟机的演讲,以便掌握 Java 的最新发展动向。

当然,如果对 GraalVM 感兴趣的话,你可以订阅我们团队的博客。我会在之后考虑将文章逐一进行翻译。

其他的阅读材料,你可以参考 R 大的这份书单,或者这个汇总贴

如果这个专栏激发了你对 Java 虚拟机的学习热情,那么我建议你着手去阅读 HotSpot 源代码,并且回馈给 OpenJDK 开源社区。这种回馈并不一定是提交 patch,也可以是 Bug report 或者改进建议等等。

工具篇 常用工具介绍

在前面的文章中,我曾使用了不少工具来辅助讲解,也收到了不少同学留言,说不了解这些工具,不知道都有什么用,应该怎么用。那么今天我便统一做一次具体的介绍。本篇代码较多,你可以点击文稿查看。

javap:查阅 Java 字节码

javap 是一个能够将 class 文件反汇编成人类可读格式的工具。在本专栏中,我们经常借助这个工具来查阅 Java 字节码。

举个例子,在讲解异常处理那一篇中,我曾经展示过这么一段代码。

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
public class Foo {

private int tryBlock;

private int catchBlock;

private int finallyBlock;

private int methodExit;



public void test() {

try {

tryBlock = 0;

} catch (Exception e) {

catchBlock = 1;

} finally {

finallyBlock = 2;

}

methodExit = 3;

}

}

编译过后,我们便可以使用 javap 来查阅 Foo.test 方法的字节码。

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
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
$ javac Foo.java

$ javap -p -v Foo

Classfile ../Foo.class

Last modified ..; size 541 bytes

MD5 checksum 3828cdfbba56fea1da6c8d94fd13b20d

Compiled from "Foo.java"

public class Foo

minor version: 0

major version: 54

flags: (0x0021) ACC_PUBLIC, ACC_SUPER

this_class: #7 // Foo

super_class: #8 // java/lang/Object

interfaces: 0, fields: 4, methods: 2, attributes: 1

Constant pool:

#1 = Methodref #8.#23 // java/lang/Object."<init>":()V

#2 = Fieldref #7.#24 // Foo.tryBlock:I

#3 = Fieldref #7.#25 // Foo.finallyBlock:I

#4 = Class #26 // java/lang/Exception

#5 = Fieldref #7.#27 // Foo.catchBlock:I

#6 = Fieldref #7.#28 // Foo.methodExit:I

#7 = Class #29 // Foo

#8 = Class #30 // java/lang/Object

#9 = Utf8 tryBlock

#10 = Utf8 I

#11 = Utf8 catchBlock

#12 = Utf8 finallyBlock

#13 = Utf8 methodExit

#14 = Utf8 <init>

#15 = Utf8 ()V

#16 = Utf8 Code

#17 = Utf8 LineNumberTable

#18 = Utf8 test

#19 = Utf8 StackMapTable

#20 = Class #31 // java/lang/Throwable

#21 = Utf8 SourceFile

#22 = Utf8 Foo.java

#23 = NameAndType #14:#15 // "<init>":()V

#24 = NameAndType #9:#10 // tryBlock:I

#25 = NameAndType #12:#10 // finallyBlock:I

#26 = Utf8 java/lang/Exception

#27 = NameAndType #11:#10 // catchBlock:I

#28 = NameAndType #13:#10 // methodExit:I

#29 = Utf8 Foo

#30 = Utf8 java/lang/Object

#31 = Utf8 java/lang/Throwable

{

private int tryBlock;

descriptor: I

flags: (0x0002) ACC_PRIVATE



private int catchBlock;

descriptor: I

flags: (0x0002) ACC_PRIVATE



private int finallyBlock;

descriptor: I

flags: (0x0002) ACC_PRIVATE



private int methodExit;

descriptor: I

flags: (0x0002) ACC_PRIVATE



public Foo();

descriptor: ()V

flags: (0x0001) ACC_PUBLIC

Code:

stack=1, locals=1, args_size=1

0: aload_0

1: invokespecial #1 // Method java/lang/Object."<init>":()V

4: return

LineNumberTable:

line 1: 0



public void test();

descriptor: ()V

flags: (0x0001) ACC_PUBLIC

Code:

stack=2, locals=3, args_size=1

0: aload_0

1: iconst_0

2: putfield #2 // Field tryBlock:I

5: aload_0

6: iconst_2

7: putfield #3 // Field finallyBlock:I

10: goto 35

13: astore_1

14: aload_0

15: iconst_1

16: putfield #5 // Field catchBlock:I

19: aload_0

20: iconst_2

21: putfield #3 // Field finallyBlock:I

24: goto 35

27: astore_2

28: aload_0

29: iconst_2

30: putfield #3 // Field finallyBlock:I

33: aload_2

34: athrow

35: aload_0

36: iconst_3

37: putfield #6 // Field methodExit:I

40: return

Exception table:

from to target type

0 5 13 Class java/lang/Exception

0 5 27 any

13 19 27 any

LineNumberTable:

line 9: 0

line 13: 5

line 14: 10

line 10: 13

line 11: 14

line 13: 19

line 14: 24

line 13: 27

line 14: 33

line 15: 35

line 16: 40

StackMapTable: number_of_entries = 3

frame_type = 77 /* same_locals_1_stack_item */

stack = [ class java/lang/Exception ]

frame_type = 77 /* same_locals_1_stack_item */

stack = [ class java/lang/Throwable ]

frame_type = 7 /* same */

}

SourceFile: "Foo.java"

这里面我用到了两个选项。第一个选项是 -p。默认情况下 javap 会打印所有非私有的字段和方法,当加了 -p 选项后,它还将打印私有的字段和方法。第二个选项是 -v。它尽可能地打印所有信息。如果你只需要查阅方法对应的字节码,那么可以用 -c 选项来替换 -v。

javap 的 -v 选项的输出分为几大块。

\1. 基本信息,涵盖了原 class 文件的相关信息。

class 文件的版本号(minor version: 0,major version: 54),该类的访问权限(flags: (0x0021) ACC_PUBLIC, ACC_SUPER),该类(this_class: #7)以及父类(super_class: #8)的名字,所实现接口(interfaces: 0)、字段(fields: 4)、方法(methods: 2)以及属性(attributes: 1)的数目。

这里属性指的是 class 文件所携带的辅助信息,比如该 class 文件的源文件的名称。这类信息通常被用于 Java 虚拟机的验证和运行,以及 Java 程序的调试,一般无须深入了解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Classfile ../Foo.class

Last modified ..; size 541 bytes

MD5 checksum 3828cdfbba56fea1da6c8d94fd13b20d

Compiled from "Foo.java"

public class Foo

minor version: 0

major version: 54

flags: (0x0021) ACC_PUBLIC, ACC_SUPER

this_class: #7 // Foo

super_class: #8 // java/lang/Object

interfaces: 0, fields: 4, methods: 2, attributes: 1

class 文件的版本号指的是编译生成该 class 文件时所用的 JRE 版本。由较新的 JRE 版本中的 javac 编译而成的 class 文件,不能在旧版本的 JRE 上跑,否则,会出现如下异常信息。(Java 8 对应的版本号为 52,Java 10 对应的版本号为 54。)

1
Exception in thread "main" java.lang.UnsupportedClassVersionError: Foo has been compiled by a more recent version of the Java Runtime (class file version 54.0), this version of the Java Runtime only recognizes class file versions up to 52.0

类的访问权限通常为 ACC_ 开头的常量。具体每个常量的意义可以查阅 Java 虚拟机规范 4.1 小节 [1]。

\2. 常量池,用来存放各种常量以及符号引用。

常量池中的每一项都有一个对应的索引(如 #1),并且可能引用其他的常量池项(#1 = Methodref #8.#23)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Constant pool:

#1 = Methodref #8.#23 // java/lang/Object."<init>":()V

...

#8 = Class #30 // java/lang/Object

...

#14 = Utf8 <init>

#15 = Utf8 ()V

...

#23 = NameAndType #14:#15 // "<init>":()V

...

#30 = Utf8 java/lang/Object

举例来说,上图中的 1 号常量池项是一个指向 Object 类构造器的符号引用。它是由另外两个常量池项所构成。如果将它看成一个树结构的话,那么它的叶节点会是字符串常量,如下图所示。

img

\3. 字段区域,用来列举该类中的各个字段。

这里最主要的信息便是该字段的类型(descriptor: I)以及访问权限(flags: (0x0002) ACC_PRIVATE)。对于声明为 final 的静态字段而言,如果它是基本类型或者字符串类型,那么字段区域还将包括它的常量值。

1
2
3
4
5
6
7
private int tryBlock;

descriptor: I

flags: (0x0002) ACC_PRIVATE


另外,Java 虚拟机同样使用了“描述符”(descriptor)来描述字段的类型。具体的对照如下表所示。其中比较特殊的,我已经高亮显示。

\4. 方法区域,用来列举该类中的各个方法。

除了方法描述符以及访问权限之外,每个方法还包括最为重要的代码区域(Code:)。

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
  public void test();

descriptor: ()V

flags: (0x0001) ACC_PUBLIC

Code:

stack=2, locals=3, args_size=1

0: aload_0

...

10: goto 35

...

34: athrow

35: aload_0

...

40: return

Exception table:

from to target type

0 5 13 Class java/lang/Exception

0 5 27 any

13 19 27 any

LineNumberTable:

line 9: 0

...

line 16: 40

StackMapTable: number_of_entries = 3

frame_type = 77 /* same_locals_1_stack_item */

stack = [ class java/lang/Exception ]

...

代码区域一开始会声明该方法中的操作数栈(stack=2)和局部变量数目(locals=3)的最大值,以及该方法接收参数的个数(args_size=1)。注意这里局部变量指的是字节码中的局部变量,而非 Java 程序中的局部变量。

接下来则是该方法的字节码。每条字节码均标注了对应的偏移量(bytecode index,BCI),这是用来定位字节码的。比如说偏移量为 10 的跳转字节码 10: goto 35,将跳转至偏移量为 35 的字节码 35: aload_0。

紧跟着的异常表(Exception table:)也会使用偏移量来定位每个异常处理器所监控的范围(由 from 到 to 的代码区域),以及异常处理器的起始位置(target)。除此之外,它还会声明所捕获的异常类型(type)。其中,any 指代任意异常类型。

再接下来的行数表(LineNumberTable:)则是 Java 源程序到字节码偏移量的映射。如果你在编译时使用了 -g 参数(javac -g Foo.java),那么这里还将出现局部变量表(LocalVariableTable:),展示 Java 程序中每个局部变量的名字、类型以及作用域。

行数表和局部变量表均属于调试信息。Java 虚拟机并不要求 class 文件必备这些信息。

1
2
3
4
5
6
7
LocalVariableTable:

Start Length Slot Name Signature

14 5 1 e Ljava/lang/Exception;

0 41 0 this LFoo;

最后则是字节码操作数栈的映射表(StackMapTable: number_of_entries = 3)。该表描述的是字节码跳转后操作数栈的分布情况,一般被 Java 虚拟机用于验证所加载的类,以及即时编译相关的一些操作,正常情况下,你无须深入了解。

2.OpenJDK 项目 Code Tools:实用小工具集

OpenJDK 的 Code Tools 项目 [2] 包含了好几个实用的小工具。

在第一篇的实践环节中,我们使用了其中的字节码汇编器反汇编器 ASMTools[3],当前 6.0 版本的下载地址位于 [4]。ASMTools 的反汇编以及汇编操作所对应的命令分别为:

1
$ java -cp /path/to/asmtools.jar org.openjdk.asmtools.jdis.Main Foo.class > Foo.jasm

1
$ java -cp /path/to/asmtools.jar org.openjdk.asmtools.jasm.Main Foo.jasm

该反汇编器的输出格式和 javap 的不尽相同。一般我只使用它来进行一些简单的字节码修改,以此生成无法直接由 Java 编译器生成的类,它在 HotSpot 虚拟机自身的测试中比较常见。

在第一篇的实践环节中,我们需要将整数 2 赋值到一个声明为 boolean 类型的局部变量中。我采取的做法是将编译生成的 class 文件反汇编至一个文本文件中,然后找到 boolean flag = true 对应的字节码序列,也就是下面的两个。

1
2
3
iconst_1;

istore_1;

将这里的 iconst_1 改为 iconst_2[5],保存后再汇编至 class 文件即可完成第一篇实践环节的需求。

除此之外,你还可以利用这一套工具来验证我之前文章中的一些结论。比如我说过 class 文件允许出现参数类型相同、而返回类型不同的方法,并且,在作为库文件时 Java 编译器将使用先定义的那一个,来决定具体的返回类型。

具体的验证方法便是在反汇编之后,利用文本编辑工具复制某一方法,并且更改该方法的描述符,保存后再汇编至 class 文件。

Code Tools 项目还包含另一个实用的小工具 JOL[6],当前 0.9 版本的下载地址位于 [7]。JOL 可用于查阅 Java 虚拟机中对象的内存分布,具体可通过如下两条指令来实现。

1
2
3
$ java -jar /path/to/jol-cli-0.9-full.jar internals java.util.HashMap

$ java -jar /path/to/jol-cli-0.9-full.jar estimates java.util.HashMap

3.ASM:Java 字节码框架

ASM[8] 是一个字节码分析及修改框架。它被广泛应用于许多项目之中,例如 Groovy、Kotlin 的编译器,代码覆盖测试工具 Cobertura、JaCoCo,以及各式各样通过字节码注入实现的程序行为监控工具。甚至是 Java 8 中 Lambda 表达式的适配器类,也是借助 ASM 来动态生成的。

ASM 既可以生成新的 class 文件,也可以修改已有的 class 文件。前者相对比较简单一些。ASM 甚至还提供了一个辅助类 ASMifier,它将接收一个 class 文件并且输出一段生成该 class 文件原始字节数组的代码。如果你想快速上手 ASM 的话,那么你可以借助 ASMifier 生成的代码来探索各个 API 的用法。

下面我将借助 ASMifier,来生成第一篇实践环节所用到的类。(你可以通过该地址 [9] 下载 6.0-beta 版。)

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
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
$ echo '

public class Foo {

public static void main(String[] args) {

boolean flag = true;

if (flag) System.out.println("Hello, Java!");

if (flag == true) System.out.println("Hello, JVM!");

}

}' > Foo.java

# 这里的 javac 我使用的是 Java 8 版本的。ASM 6.0 可能暂不支持新版本的 javac 编译出来的 class 文件

$ javac Foo.java

$ java -cp /PATH/TO/asm-all-6.0_BETA.jar org.objectweb.asm.util.ASMifier Foo.class | tee FooDump.java

...

public class FooDump implements Opcodes {



public static byte[] dump () throws Exception {



ClassWriter cw = new ClassWriter(0);

FieldVisitor fv;

MethodVisitor mv;

AnnotationVisitor av0;



cw.visit(V1_8, ACC_PUBLIC + ACC_SUPER, "Foo", null, "java/lang/Object", null);



...



{

mv = cw.visitMethod(ACC_PUBLIC + ACC_STATIC, "main", "([Ljava/lang/String;)V", null, null);

mv.visitCode();

mv.visitInsn(ICONST_1);

mv.visitVarInsn(ISTORE, 1);

mv.visitVarInsn(ILOAD, 1);

...

mv.visitInsn(RETURN);

mv.visitMaxs(2, 2);

mv.visitEnd();

}

...

可以看到,ASMifier 生成的代码中包含一个名为 FooDump 的类,其中定义了一个名为 dump 的方法。该方法将返回一个 byte 数组,其值为生成类的原始字节。

在 dump 方法中,我们新建了功能类 ClassWriter 的一个实例,并通过它来访问不同的成员,例如方法、字段等等。

每当访问一种成员,我们便会得到另一个访问者。在上面这段代码中,当我们访问方法时(即 visitMethod),便会得到一个 MethodVisitor。在接下来的代码中,我们会用这个 MethodVisitor 来访问(这里等同于生成)具体的指令。

这便是 ASM 所使用的访问者模式。当然,这段代码仅包含 ClassWriter 这一个访问者,因此看不出具体有什么好处。

我们暂且不管这个访问者模式,先来看看如何实现第一篇课后实践的要求。首先,main 方法中的 boolean flag = true; 语句对应的代码是:

1
2
3
mv.visitInsn(ICONST_1);

mv.visitVarInsn(ISTORE, 1);

也就是说,我们只需将这里的 ICONST_1 更改为 ICONST_2,便可以满足要求。下面我用另一个类 Wrapper,来调用修改过后的 FooDump.dump 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ echo 'import java.nio.file.*;



public class Wrapper {

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

Files.write(Paths.get("Foo.class"), FooDump.dump());

}

}' > Wrapper.java

$ javac -cp /PATH/TO/asm-all-6.0_BETA.jar FooDump.java Wrapper.java

$ java -cp /PATH/TO/asm-all-6.0_BETA.jar:. Wrapper

$ java Foo

这里的输出结果应和通过 ASMTools 修改的结果一致。

通过 ASM 来修改已有 class 文件则相对复杂一些。不过我们可以从下面这段简单的代码来开始学起:

1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) throws Exception {

ClassReader cr = new ClassReader("Foo");

ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);

cr.accept(cw, ClassReader.SKIP_FRAMES);

Files.write(Paths.get("Foo.class"), cw.toByteArray());

}

这段代码的功能便是读取一个 class 文件,将之转换为 ASM 的数据结构,然后再转换为原始字节数组。其中,我使用了两个功能类。除了已经介绍过的 ClassWriter 外,还有一个 ClassReader。

ClassReader 将读取“Foo”类的原始字节,并且翻译成对应的访问请求。也就是说,在上面 ASMifier 生成的代码中的各个访问操作,现在都交给 ClassReader.accept 这一方法来发出了。

那么,如何修改这个 class 文件的字节码呢?原理很简单,就是将 ClassReader 的访问请求发给另外一个访问者,再由这个访问者委派给 ClassWriter。

这样一来,新增操作可以通过在某一需要转发的请求后面附带新的请求来实现;删除操作可以通过不转发请求来实现;修改操作可以通过忽略原请求,新建并发出另外的请求来实现。

img

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
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
import java.nio.file.*;

import org.objectweb.asm.*;



public class ASMHelper implements Opcodes {



static class MyMethodVisitor extends MethodVisitor {

private MethodVisitor mv;

public MyMethodVisitor(int api, MethodVisitor mv) {

super(api, null);

this.mv = mv;

}



@Override

public void visitCode() {

mv.visitCode();

mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");

mv.visitLdcInsn("Hello, World!");

mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);

mv.visitInsn(RETURN);

mv.visitMaxs(2, 1);

mv.visitEnd();

}

}



static class MyClassVisitor extends ClassVisitor {



public MyClassVisitor(int api, ClassVisitor cv) {

super(api, cv);

}



@Override

public MethodVisitor visitMethod(int access, String name, String descriptor, String signature,

String[] exceptions) {

MethodVisitor visitor = super.visitMethod(access, name, descriptor, signature, exceptions);

if ("main".equals(name)) {

return new MyMethodVisitor(ASM6, visitor);

}

return visitor;

}

}



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

ClassReader cr = new ClassReader("Foo");

ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);

ClassVisitor cv = new MyClassVisitor(ASM6, cw);

cr.accept(cv, ClassReader.SKIP_FRAMES);

Files.write(Paths.get("Foo.class"), cw.toByteArray());

}

}

这里我贴了一段代码,在 ClassReader 和 ClassWriter 中间插入了一个自定义的访问者 MyClassVisitor。它将截获由 ClassReader 发出的对名字为“main”的方法的访问请求,并且替换为另一个自定义的 MethodVisitor。

这个 MethodVisitor 会忽略由 ClassReader 发出的任何请求,仅在遇到 visitCode 请求时,生成一句“System.out.println(“Hello World!”);”。

由于篇幅的限制,我就不继续深入介绍下去了。如果你对 ASM 有浓厚的兴趣,可以参考这篇教程 [10]。

你对这些常用工具还有哪些问题呢?可以给我留言,我们一起讨论。感谢你的收听,我们下期再见。

[1] https://docs.oracle.com/javase/specs/jvms/se10/html/jvms-4.html#jvms-4.1 [2] http://openjdk.java.net/projects/code-tools/ [3] https://wiki.openjdk.java.net/display/CodeTools/asmtools [4] https://adopt-openjdk.ci.cloudbees.com/view/OpenJDK/job/asmtools/lastSuccessfulBuild/artifact/asmtools-6.0.tar.gz [5] https://cs.au.dk/\~mis/dOvs/jvmspec/ref--21.html [6] http://openjdk.java.net/projects/code-tools/jol/ [7] http://central.maven.org/maven2/org/openjdk/jol/jol-cli/0.9/jol-cli-0.9-full.jar [8] https://asm.ow2.io/ [9] https://repository.ow2.org/nexus/content/repositories/releases/org/ow2/asm/asm-all/6.0\_BETA/asm-all-6.0\_BETA.jar [10] http://web.cs.ucla.edu/\~msb/cs239-tutorial/