You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

519 lines
21 KiB
Markdown

2 years ago
# 【工具篇】 常用工具介绍
在前面的文章中,我曾使用了不少工具来辅助讲解,也收到了不少同学留言,说不了解这些工具,不知道都有什么用,应该怎么用。那么今天我便统一做一次具体的介绍。本篇代码较多,你可以点击文稿查看。
## javap查阅Java字节码
javap是一个能够将class文件反汇编成人类可读格式的工具。在本专栏中我们经常借助这个工具来查阅Java字节码。
举个例子,在讲解异常处理那一篇中,我曾经展示过这么一段代码。
```
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方法的字节码。
```
$ 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: 0major 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程序的调试一般无须深入了解。
```
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对应的版本号为52Java 10对应的版本号为54。
```
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
```
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类构造器的符号引用。它是由另外两个常量池项所构成。如果将它看成一个树结构的话那么它的叶节点会是字符串常量如下图所示。
![](https://static001.geekbang.org/resource/image/f8/8c/f87469e321c52b21b0d2abb88e7b288c.png)
3.字段区域,用来列举该类中的各个字段。
这里最主要的信息便是该字段的类型descriptor: I以及访问权限flags: (0x0002) ACC\_PRIVATE。对于声明为final的静态字段而言如果它是基本类型或者字符串类型那么字段区域还将包括它的常量值。
```
private int tryBlock;
descriptor: I
flags: (0x0002) ACC_PRIVATE
```
另外Java虚拟机同样使用了“描述符”descriptor来描述字段的类型。具体的对照如下表所示。其中比较特殊的我已经高亮显示。
4.方法区域,用来列举该类中的各个方法。
除了方法描述符以及访问权限之外每个方法还包括最为重要的代码区域Code:)。
```
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 indexBCI这是用来定位字节码的。比如说偏移量为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文件必备这些信息。
```
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的反汇编以及汇编操作所对应的命令分别为
```
$ java -cp /path/to/asmtools.jar org.openjdk.asmtools.jdis.Main Foo.class > Foo.jasm
```
```
$ java -cp /path/to/asmtools.jar org.openjdk.asmtools.jasm.Main Foo.jasm
```
该反汇编器的输出格式和javap的不尽相同。一般我只使用它来进行一些简单的字节码修改以此生成无法直接由Java编译器生成的类它在HotSpot虚拟机自身的测试中比较常见。
在第一篇的实践环节中我们需要将整数2赋值到一个声明为boolean类型的局部变量中。我采取的做法是将编译生成的class文件反汇编至一个文本文件中然后找到boolean flag = true对应的字节码序列也就是下面的两个。
```
iconst_1;
istore_1;
```
将这里的iconst\_1改为iconst\_2\[5\]保存后再汇编至class文件即可完成第一篇实践环节的需求。
除此之外你还可以利用这一套工具来验证我之前文章中的一些结论。比如我说过class文件允许出现参数类型相同、而返回类型不同的方法并且在作为库文件时Java编译器将使用先定义的那一个来决定具体的返回类型。
具体的验证方法便是在反汇编之后利用文本编辑工具复制某一方法并且更改该方法的描述符保存后再汇编至class文件。
Code Tools项目还包含另一个实用的小工具JOL\[6\]当前0.9版本的下载地址位于\[7\]。JOL可用于查阅Java虚拟机中对象的内存分布具体可通过如下两条指令来实现。
```
$ 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.ASMJava字节码框架
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版。
```
$ 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;语句对应的代码是:
```
mv.visitInsn(ICONST_1);
mv.visitVarInsn(ISTORE, 1);
```
也就是说我们只需将这里的ICONST\_1更改为ICONST\_2便可以满足要求。下面我用另一个类Wrapper来调用修改过后的FooDump.dump方法。
```
$ 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文件则相对复杂一些。不过我们可以从下面这段简单的代码来开始学起
```
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。
这样一来,新增操作可以通过在某一需要转发的请求后面附带新的请求来实现;删除操作可以通过不转发请求来实现;修改操作可以通过忽略原请求,新建并发出另外的请求来实现。
![](https://static001.geekbang.org/resource/image/2a/ce/2a5d6813e32b8f88abae2b9f7b151fce.png)
```
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](https://docs.oracle.com/javase/specs/jvms/se10/html/jvms-4.html#jvms-4.1)
\[2\]
[http://openjdk.java.net/projects/code-tools/](http://openjdk.java.net/projects/code-tools/)
\[3\]
[https://wiki.openjdk.java.net/display/CodeTools/asmtools](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](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](https://cs.au.dk/~mis/dOvs/jvmspec/ref--21.html)
\[6\]
[http://openjdk.java.net/projects/code-tools/jol/](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](http://central.maven.org/maven2/org/openjdk/jol/jol-cli/0.9/jol-cli-0.9-full.jar)
\[8\]
[https://asm.ow2.io/](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](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/](http://web.cs.ucla.edu/~msb/cs239-tutorial/)