理解Java字节码原理

背景

为什么要学习字节码技术?无论是Java程序员还是架构师,在我们平常使用的中间件,dubbo、fastjson和arthas等框架或者组件时,我们已经在接触字节码技术了,更重要的是,Java字节码恰恰是JVM的基础。

什么是字节码?Java字节码是Java代码(即类文件)的中间表示,它在JVM中执行, 没有Java字节码就无法运行整个Java开发生态系统,特别是如何处理和定义Java开发人员正在编写的代码。

通过本章的分析,主要涵盖以下几个内容:

  • 获取字节码
  • 读取字节码
  • 字节码处理:局部变量,指令跳转,方法调用
  • 字节码在arthas中的使用
  • ASM学习

让我们正式开始学习字节码技术吧!

字节码简要介绍

Java字节码是JVM执行的指令形式。通常,Java程序员不需要知道Java字节码的工作原理。但是,了解平台的低级细节是什么让你成为一个更好的程序员, 我也是其中一员 :-)

理解字节码对于工具和程序分析领域至关重要,应用程序可以修改字节码以根据应用程序的需求调整行为。分析器,模拟框架,AOP - 要创建这些工具,开发人员必须彻底了解Java字节码。

先看一个简单的例子

在开始字节码之前,我们先看一个1 + 1的问题,因为栈很容易评估反向表达式1 1 +,所以可以先把2个1压入堆栈,然后执行加法操作:

Java字节码的计算模型是面向堆栈的模型。上面的例子用Java字节码指令表示是相同的,唯一的区别是操作码附加了一些特定的语义:

操作码iconst_1将常量1放入堆栈。指令iadd对两个整数执行加法运算,并将结果保留在堆栈的顶部。

字节码是单字节指令组成,因此有256种可能的操作码。实际指令使用大约200个操作码,其中一些操作码保留用于调试器操作。

根据指令的性质,我们可以将这些分组分为几个类别:

  • 堆栈操作指令,包括与局部变量的交互。
  • 控制流程
  • 对象操作,包括 方法调用
  • 算术和类型转换

javap反编译代码

要获取反编译后的字节码,我们只需要对.class文件执行javap -c命令即可,作为演示开始编写源代码并编译,我们将从这个类开始,该类将作为我们的示例应用程序的入口点。

1
2
3
4
5
6
7
8
9
10
/**
* @author yiji@apache.org
*/
public class HelloWorld {

public static void main(String[] args){
System.out.println("Hello world!");
}

}

编译类文件后,要获取上述示例的字节码列表,需要执行以下命令:javap -c HelloWorld

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Compiled from "HelloWorld.java"
public class HelloWorld {
public HelloWorld();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return

public static void main(java.lang.String[]);
Code:
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String Hello world!
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
}

构造函数的主体应该是空的,为什么仍然会生成一些指令?每个构造函数都会调用super(),编译器在背后帮我做的这件事情。

您可能已经注意到指令指后面#1,#2,#3的某些编号参数,这是对常量池的引用。如何才能找出常量是什么以及如何在列表中看到常量池?我们可以在反汇编类时将-verbose参数应用于javap

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class HelloWorld
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #6.#15 // java/lang/Object."<init>":()V
#2 = Fieldref #16.#17 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #18 // Hello world!
#4 = Methodref #19.#20 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Class #21 // HelloWorld
#6 = Class #22 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#15 = NameAndType #7:#8 // "<init>":()V

在反编译结果中包含有关类文件的大量技术信息:版本,MD5校验和,访问修饰符和常量池等。

我们还可以看到那里的访问控制标志:ACC_PUBLIC和ACC_SUPER。该ACC_PUBLIC标志通俗易懂。但是是ACC_SUPER代表什么意思?ACC_SUPER纠正invokespecial指令调用超级方法的问题。可以将其视为Java 1.0的错误修复,以便它可以正确地发现超类方法。从Java 1.1开始,编译器总是ACC_SUPER为字节码生成访问者标志。

可以在常量池中找到表示的常量定义:

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

常量定义是可组合的,这意味着常量可以由从同一个常量池引用的其他常量组成。当使用javap -verbose参数时,还有一些其他的东西可以揭示出来。例如,有关方法的更多信息:

1
2
3
4
5
6
7
8
9
10
11
12
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String Hello world!
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 7: 0
line 8: 8

访问器标志也是为方法生成的,我们也可以看到执行该方法需要多深的堆栈,它需要多少参数,以及需要在局部变量表中保留多少局部变量槽。

理解JVM字节码执行模型

要理解字节码的细节,我们需要了解字节码的执行模型。JVM是基于堆栈的模型执行,每个线程都有一个存储帧的JVM堆栈。每次调用方法时都会创建一个栈帧。一个栈帧由一个操作数堆栈、一个局部变量数组和一个对当前方法类的运行时常量池的引用组成。我们在最初的例子中看到了所有这些信息。

局部变量表

包含方法的参数,还用于保存局部变量的值。局部变量数组的大小在编译时确定,并且取决于局部变量和方法参数的数量和大小。

操作数堆栈

用于推送和弹出值的后进先出(LIFO)堆栈, 它的大小也在编译时确定。某些操作码指令将值推送到操作数栈; 其他一些操作指令,从操作数栈中获取操作数,并保存在局部变量表中。操作数堆栈还用于从方法接收返回值。

在调试过程中我们可以丢弃当前方法栈帧回到上次方法调用:

每条指令都有自己的十六进制表示:

下面是指令替换成字节码十六进制的标识:

更多的字节码指令展示

前面已经提到了与堆栈一起使用的基本指令:将值推送到堆栈或从堆栈中获取值。但还有更多, 交换指令可以交换堆栈顶部的两个值。

下面是一些处理堆栈周围值的指令,首先是一些基本指令:dup和pop。dup指令复制堆栈顶部的值,pop指令从堆栈顶部弹出数据。

还有一些更复杂的指令:例如swap,dup_x1和dup2_x1。交换指令在堆栈顶部交换两个值,例如A和B交换位置; dup_x1将顶部值的副本从顶部插入堆栈中的第3个值; dup2_x1复制两个顶部值并插入第3个值之后。下面的例子详细描述每条指令执行的状态。

dup_x1和dup2_x1指令似乎有点绕,为什么需要这样的行为,举个实际的例子,如何交换双重类型的2个值?

double在堆栈中占用两个插槽,这意味着如果我们在堆栈上有两个double值,它们将占用四个插槽。要交换两个double值,我们想使用swap指令,但问题是它只适用于单字指令。

解决方法是使用dup2_x2复制顶部双精度值的指令,然后我们可以使用pop2指令弹出顶部值。

局部变量表

当堆栈执行时,局部变量表用于保存中间结果并与堆栈直接交互。现在我们添加一下代码进行分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* @author yiji@apache.org
*/
public class HelloWorld {

public static void main(String[] args) {
Average average = new Average();

int n1 = 1;
int n2 = 2;

average.execute(n1);
average.execute(n2);

double avg = average.getAverage();
}
}

我们通过类Average计算数字的平均值,每次执行average.execute(...)会进行计算。通过反编译代码得到字节码指令如下:

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
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=6, args_size=1
0: new #2 // class Average
3: dup
4: invokespecial #3 // Method Average."<init>":()V
7: astore_1
8: iconst_1
9: istore_2
10: iconst_2
11: istore_3
12: aload_1
13: iload_2
14: invokevirtual #4 // Method Average.execute:(I)V
17: aload_1
18: iload_3
19: invokevirtual #4 // Method Average.execute:(I)V
22: aload_1
23: invokevirtual #5 // Method Average.getAverage:()D
26: dstore 4
28: return
LineNumberTable:
line 7: 0
line 9: 8
line 10: 10
line 12: 12
line 13: 17
line 15: 22
line 16: 28
LocalVariableTable:
Start Length Slot Name Signature
0 29 0 args [Ljava/lang/String;
8 21 1 average LAverage;
10 19 2 n1 I
12 17 3 n2 I
28 1 4 avg D

字节码偏移量0,3,4主要用于创建Average对象并初始化,new会创建类型对象标识符引用并压入操作数栈顶,dup会复制栈顶元素副本,invokespecial会调用对象初始化方法<init>,对象会从栈顶弹出。

7: astore_1 会将操作数栈顶元素弹出存放在局部变量表第一个元素,也就是average

iconst_1iconst_2将常量1和2加载到操作数栈,LocalVariableTable并通过指令istore_2和istore_3分别将它们存储在插槽2和3中,这里分别对应到n1n2

需要注意调用类似存储的指令实际上会从操作数栈顶部删除值。为了再次使用变量值,我们必须将其加载回堆栈。例如,在上面的字节码列表中,在调用execute方法之前,我们必须再次将参数的值加载到堆栈:

1
2
3
12: aload_1
13: iload_2
14: invokevirtual #4 // Method Average.execute:(I)V

在调用getAverage()方法之后,执行结果会存储到操作数栈顶并再次将其存储到局部变量,avg变量的类型为double,所以使用dstore指令将结果写会到第4个局部变量中。

值得注意的事情局部变量表第一个插槽被方法的参数占用(静态方法)。在我们当前的示例中,它是静态方法,并且没有将此引用(this)分配给表中的插槽0。但是,对于非静态方法,这将分配给插槽0。

1
如果看不到反编译局部变量,可以用javac -g 指令生成调试信息,在用javap -c -l 就会生效。

指令跳转

跳转指令用于根据条件判定执行流程。比如If-else,三元运算符,各种循环甚至异常处理操作码都属于Java字节码的跳转控制。现在开始跳转指令分析,先添加如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* @author yiji@apache.org
*/
public class HelloWorld {

static int[] numbers = {1, 2, 3};

public static void main(String[] args) {
Average average = new Average();

for (int number : numbers) {
average.execute(number);
}

}
}

同样,先执行javac -g HelloWorld, 然后反编译执行javap -c -l HelloWorld得到:

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
public static void main(java.lang.String[]);
Code:
0: new #2 // class Average
3: dup
4: invokespecial #3 // Method Average."<init>":()V
7: astore_1
8: getstatic #4 // Field numbers:[I
11: astore_2
12: aload_2
13: arraylength
14: istore_3
15: iconst_0
16: istore 4
18: iload 4
20: iload_3
21: if_icmpge 42
24: aload_2
25: iload 4
27: iaload
28: istore 5
30: aload_1
31: iload 5
33: invokevirtual #5 // Method Average.execute:(I)V
36: iinc 4, 1
39: goto 18
42: return
LineNumberTable:
line 12: 0
line 14: 8
line 15: 30
line 14: 36
line 18: 42
LocalVariableTable:
Start Length Slot Name Signature
30 6 5 number I
- - 4 $index
- - 3 $length
- - 2 $array
0 43 0 args [Ljava/lang/String;
8 35 1 average LAverage;

字节码位置816的指令用于组织循环控制。你可以看到有三个变量,源代码中不会显示:$array$length$index-这些都是循环变量。该变量$array存储数组引用,$length使用该arraylength指令从该字段导出循环的长度。循环计数器$index在每次迭代后使用iinc指令递增。

这几个局部变量是分别通过字节码指令推演出来的,11: astore_2 会将操作数栈顶存到局部变量表2的位置,此时的栈顶是数组。13: arraylength 对数组长度计算放到栈顶,14: istore_3会将栈顶存放到局部变量3的位置。15: iconst_016: istore 4将数组索引存储到局部变量表4的位置。

循环体的第一条指令用于执行循环计数器与数组长度的比较:

1
2
3
18: iload         4
20: iload_3
21: if_icmpge 42

if_icmpge指令会比较操作数栈顶部2个元素。该if_ icmpge指令的意思是,如果一个值大于等于其他值,跳转到紧跟的字节码偏移,如果$index大于或等于$length,则执行应标有43位置的字节码指令(直接返回),如果条件不成立,然后循环继续下一次迭代(执行if_icmpge下一行指令)。

在执行完获取数组值,然后调用方法execute之后,会执行如下跳转指令:

1
2
36: iinc          4, 1
39: goto 18

iinc会对第4个局部变量执行自增1,然后goto指令跳转到字节偏移18的位置开始下一次循环。

对象初始化

Java中有一个关键字new,也有一个名为new的字节码指令。当我们创建Average类的实例时:

1
Average average = new Average();

编译器会帮我生成一系列字节码指令:

1
2
3
4
0: new           #2                  // class Average
3: dup
4: invokespecial #3 // Method Average."<init>":()V
7: astore_1

为什么要三个指令而不是一个?

新指令创建对象,但它不会调用构造函数。由于构造函数调用不会返回值,因此在调用对象上的方法之后,对象将被初始化但操作数栈也会弹出对象,因此在初始化对象后我们将无法对该对象执行任何操作。dup指令用于复制堆栈顶部的值,这就是为什么我们需要提前复制引用,以便在构造函数返回后,我们可以将对象实例分配给局部变量或字段。下一条指令可以将对象引用存储:

  • astore {N}或astore_{N} - 分配给局部变量,其中{N}是局部变量表中变量的位置。
  • putfield - 将值分配给实例字段
  • putstatic - 将值赋给静态字段

虽然是一个构造函数调用,可能更早就会发起调用。比如类的静态初始化,而是由以下指令之一触发:newgetstaticputstaticinvokestatic。也就是说,如果您创建类的新实例,访问静态字段或调用静态方法,则会触发静态初始化。

方法调用

我们在类实例化时调用了初始化方法,该方法是通过invokespecial指令调用的。但是,还有一些用于方法调用的指令:

  • invokestatic,这是对类的静态方法的调用。这是最快的方法调用指令。
  • invokespecial,指令用于调用构造函数。但它也用于调用同类的私有方法和超类的可访问的方法(比如父类构造器)。
  • invokevirtual, 用于调用public,protected和package私有方法(具体类型的实例对象)。
  • invokeinterface,当要调用的方法属于接口方法。

invokevirtualinvokeinterface之间有什么区别?为什么不使用invokevirtual搞定一切调用?毕竟接口方法是公开方法。

这主要更高效的方法调用。例如,invokestatic我们确切地知道要调用哪个方法:它是静态的,它只属于一个类。由于invokespecial有一个有限的选项列表 ,这意味着运行时将更快地找到所需的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
class A
method method()

class B extends A implements X
method method()
method methodB()
method methodX()

interface X
method public()

class C implements X
method methodX()

看起来在这种情况BmethodX方法和类C没有任何不同。如果还有另一个类C,它也实现了接口但不属于与B相同的层次结构, 这意味着它在方法解析过程中可以做的事情少于invokevirtual

字节码在arthas中的使用

前面已经讲解足够多的字节码知识,下面我们学习下arthas诊断工具是如何使用字节码技术的。

装载参数到操作数栈

在实际方法调用前,会将方法参数装载操作数栈中,AdviceWeaver中提供了方法进入前的钩子:

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
@Override
protected void onMethodEnter() {

codeLockForTracing.lock(new CodeLock.Block() {
@Override
public void code() {

final StringBuilder append = new StringBuilder();
_debug(append, "debug:onMethodEnter()");

// 加载before方法
loadAdviceMethod(KEY_ARTHAS_ADVICE_BEFORE_METHOD);

_debug(append, "debug:onMethodEnter() > loadAdviceMethod()");

// 推入Method.invoke()的第一个参数
pushNull();

// 方法参数
loadArrayForBefore();

_debug(append, "debug:onMethodEnter() > loadAdviceMethod() > loadArrayForBefore()");

// 调用方法
invokeVirtual(ASM_TYPE_METHOD, ASM_METHOD_METHOD_INVOKE);
pop();

_debug(append, "debug:onMethodEnter() > loadAdviceMethod() > loadArrayForBefore() > invokeVirtual()");
}
});

mark(beginLabel);
}

我们主要分析loadArrayForBefore()方法:

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
private void loadArrayForBefore() {
// bipush指令加载7到操作数栈顶
push(7);
// anewarray指令从栈顶弹出7,创建长度为7的数组
newArray(ASM_TYPE_OBJECT);

// dup指令复制栈顶引用, 数组引用
dup();
// 加载0到栈顶,作为数组索引
push(0);
// 加载adviceId到栈顶
push(adviceId);
// 对栈顶整数值装箱(因为是创建Object[])
box(ASM_TYPE_INT);
// 将栈顶adviceId存储到数组0位置,Object[0]=adviceId
// 当前指令会弹出array, index, value
arrayStore(ASM_TYPE_INTEGER);

// dup指令复制栈顶引用, 数组引用
dup();
// 加载1到栈顶,作为数组索引
push(1);
// 加载classloader到栈顶
loadClassLoader();
// 将栈顶classloader存储到数组1位置,Object[1]=classloader
// 当前指令会弹出array, index, value
arrayStore(ASM_TYPE_CLASS_LOADER);

// dup指令复制栈顶引用, 数组引用
dup();
// 加载2到栈顶,作为数组索引
push(2);
// 加载className到栈顶
push(className);
// 将栈顶className存储到数组1位置,Object[2]=className
// 当前指令会弹出array, index, value
arrayStore(ASM_TYPE_STRING);

// dup指令复制栈顶引用, 数组引用
dup();
// 加载3到栈顶,作为数组索引
push(3);
// 加载name到栈顶
push(name);
// 将栈顶name存储到数组1位置,Object[3]=name
// 当前指令会弹出array, index, value
arrayStore(ASM_TYPE_STRING);

// dup指令复制栈顶引用, 数组引用
dup();
// 加载4到栈顶,作为数组索引
push(4);
// 加载方法类型描述符到栈顶
push(desc);
// 将栈顶name存储到数组4位置,Object[4]=desc
// 当前指令会弹出array, index, value
arrayStore(ASM_TYPE_STRING);

// dup指令复制栈顶引用, 数组引用
dup();
// 加载5到栈顶,作为数组索引
push(5);
// 加载this(aload_0)或者null到栈顶
loadThisOrPushNullIfIsStatic();
// 将栈顶this或者null存储到数组5位置,Object[5]=this|null
// 当前指令会弹出array, index, value
arrayStore(ASM_TYPE_OBJECT);

// dup指令复制栈顶引用, 数组引用
dup();
// 加载6到栈顶,作为数组索引
push(6);
// 加载参数值作为数组放到索引6的位置
loadArgArray();
arrayStore(ASM_TYPE_OBJECT_ARRAY);
}

从这里可以看到方法调用拦截前arthas会把当前commandadviceId、类、方法、类型描述符和参数值全部保存在数组中。GeneratorAdapter.loadArgArray直接调用的是asm字节码库方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void loadArgArray() {
// 获取参数个数,压入操作数栈顶
push(argumentTypes.length);
// anewarray创建和参数个数等长的对象数组
newArray(OBJECT_TYPE);
for (int i = 0; i < argumentTypes.length; i++) {
// 复制栈顶数组对象引用
dup();
// 压入数组当前索引i
push(i);
// 使用`{prefix}load`指令加载参数到栈顶
// 比如整数就会用`iload`指令
loadArg(i);
// 对参数装箱
box(argumentTypes[i]);
// 存储参数值到当前数组索引i中
arrayStore(OBJECT_TYPE);
}
}

装载方法调用结果到操作数栈

和方法调用前一样,我们可以在方法退出时进行字节码拦截,这段代码我们可以在AdviceWeaver中找到:

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
@Override
protected void onMethodExit(final int opcode) {

if (!isThrow(opcode)) {
codeLockForTracing.lock(new CodeLock.Block() {
@Override
public void code() {

final StringBuilder append = new StringBuilder();
_debug(append, "debug:onMethodExit()");

// 加载返回对象
loadReturn(opcode);
_debug(append, "debug:onMethodExit() > loadReturn()");


// 加载returning方法
loadAdviceMethod(KEY_ARTHAS_ADVICE_RETURN_METHOD);
_debug(append, "debug:onMethodExit() > loadReturn() > loadAdviceMethod()");

// 推入Method.invoke()的第一个参数
pushNull();

// 加载return通知参数数组
loadReturnArgs();
_debug(append, "debug:onMethodExit() > loadReturn() > loadAdviceMethod() > loadReturnArgs()");

invokeVirtual(ASM_TYPE_METHOD, ASM_METHOD_METHOD_INVOKE);
pop();

_debug(append, "debug:onMethodExit() > loadReturn() > loadAdviceMethod() > loadReturnArgs() > invokeVirtual()");
}
});
}

}

重点我们看下loadReturnArgs中如何使用通过字节码装载参数的:

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
private void loadReturnArgs() {
/**
* 假设字母分别代码以下值:
*
* r -> return value
* f -> advice static method field
* n -> null
* a -> array
*
* 当前栈顶的状态: rrfn , rrfn的右方是栈顶
*
* 我们要构造的最终结果是:rfna, 代表的含义会回调通知拦截方法f,
* 传递给方法的2个参数分别为null和array, array中已经保存了
* 结果r的引用,即array[0]=r
*
*/

// rrfn -> rfnrfn
// 复制栈顶fn并且插入到栈顶第3个元素之后
dup2X1();
// rfnrfn -> rfnr
// 弹出栈顶2个元素
pop2();
// rfnr -> rfnr1
// 压入1到操作数栈顶
push(1);
// rfnr1 -> rfnra
// 弹出栈顶1并创建数组压入栈顶
newArray(ASM_TYPE_OBJECT);
// rfnra -> rfnraa
// 复制栈顶数组引用
dup();
// rfnraa -> rfnaaraa
// 复制栈顶aa并且插入到栈顶第3个元素之后
dup2X1();
// rfnaaraa -> rfnaar
// 弹出栈顶的2个元素值
pop2();
// rfnaar -> rfnaar0
// 压入数组索引0到栈顶
push(0);
// rfnaar0 -> rfnaa0r
// 交换栈顶2个元素值
swap();
// rfnaa0r -> rfna
// 将r值保存在数组索引0位置,array[0]=r,并弹出数组
arrayStore(ASM_TYPE_OBJECT);
}

通过上面给出了详细的字节码推演,可以看到字节码在arthas中核心的应用场景点。当然解析arthas超过这个小节的重点,后面有时间我会另外章节讲解arthas原理。

ASM介绍和学习

这里主要简单介绍asm库的简单使用并会给出有用的插件帮助深入研究字节码的技巧,授人以鱼不如授人以渔,哈哈。

时间倒转,再回到经典的Hello World程序:

1
2
3
4
5
6
7
8
9
/**
* @author yiji@apache.org
*/
public class HelloWorld {

public static void main(String[] args) {
System.out.println("Hello world!");
}
}

生成与HelloWorld相对应的字节码的最常见方案是创建ClassWriter,访问类似字段,方法等,并在生成类描述后写出最终字节。

首先,先构造ClassWriter实例:

1
2
3
ClassWriter cw = new ClassWriter(
ClassWriter.COMPUTE_MAXS |
ClassWriter.COMPUTE_FRAMES);

COMPUTE_MAXS告诉ASM自动计算最大堆栈大小和方法的最大局部变量数。COMPUTE_FRAMES告诉ASM从头开始自动计算方法的堆栈映射帧。

定义一个类我们必须调用ClassWriter.visit() 的方法:

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

接下来,我们生成默认构造函数和main方法。如果跳过生成默认构造函数,则不会发生任何错误,但生成手动生成一个更完备一些。

1
2
3
4
5
6
7
mv = cw.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null);
mv.visitCode();
mv.visitVarInsn(ALOAD, 0);
mv.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);
mv.visitInsn(RETURN);
mv.visitMaxs(1, 1);
mv.visitEnd();

我们首先使用该visitMethod() 方法创建了构造函数。接下来,要通过调用visitCode()方法开始生成构造函数的代码体。最后我们调用visitMaxs()- 这是要求ASM重新计算最大堆栈大小。ASM可以自动完成计算,我们可以将随机参数传递给visitMaxs()方法。最后,调用visitEnd()结束构造函数方法体。

以下是main方法的ASM代码:

1
2
3
4
5
6
7
8
mv = cw.visitMethod(ACC_PUBLIC + ACC_STATIC, "main", "([Ljava/lang/String;)V", null, null);
mv.visitCode();
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("Hello world, I'm asm !");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
mv.visitInsn(RETURN);
mv.visitMaxs(2, 1);
mv.visitEnd();

通过visitMethod()再次调用,给出方法修饰符、方法名称、方法类型描述符、签名和异常生成新方法定义。然后调用visitCode()visitMaxs()visitEnd()方法完成代码的编写。visitFieldInsn会最终转换成getstatic指令,visitLdcInsn会最终转换ldc加载常量池引用。

最终给出可出可运行的字节码应用程序:

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
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;

import java.lang.reflect.Method;

/**
* @author yiji@apache.org
*/
public class HelloWorldDump extends ClassLoader implements Opcodes {

byte[] bytes;

public HelloWorldDump(byte[] bytes) {
this.bytes = bytes;
}

public Class findClass(String name) {
byte[] ba = this.bytes;
return defineClass(name, ba, 0, ba.length);
}

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

HelloWorldDump loader = new HelloWorldDump(dump());
Class clazz = loader.findClass("HelloWorld");

Method main = clazz.getMethod("main", new Class[]{String[].class});
main.invoke(null, new Object[]{new String[0]});
}

public static byte[] dump() throws Exception {

ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS |
ClassWriter.COMPUTE_FRAMES);
MethodVisitor mv;

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

{
mv = cw.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null);
mv.visitCode();
mv.visitVarInsn(ALOAD, 0);
mv.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);
mv.visitInsn(RETURN);
mv.visitMaxs(1, 1);
mv.visitEnd();
}
{
mv = cw.visitMethod(ACC_PUBLIC + ACC_STATIC, "main", "([Ljava/lang/String;)V", null, null);
mv.visitCode();
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("Hello world, I'm asm !");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
mv.visitInsn(RETURN);
mv.visitMaxs(2, 1);
mv.visitEnd();
}
cw.visitEnd();

return cw.toByteArray();
}
}

分析字节码工具

想写好完备的字节码程序对程序员能力要求非常高,幸好asm提供了自动生成字节码代码工具,我们熟悉的intellij idea也有对应的插件。学习使用ASM的最佳方法是编写一个等同于想要生成的Java源文件,然后使用ASM Bytecode Viewer插件(或ASMifier工具)的ASMifier模式来查看等效的ASM代码。如果要实现类转换器,请编写两个Java源文件(转换前),并在ASMifier模式下使用插件的比较视图来比较等效的ASM代码。

小结

研究字节码的动机主要来源于greys,当时想研究它的原理写一套更好用的诊断工具,但是后来发现arthas用起来满足需要,就放弃了重新开发的想法,刚好把精力再投回Dubbo

这里主要记录字节码学习过程,希望能帮助更多对这块感兴趣的同学,同时也能加深对jvm字节码的理解,为后续深入研究JVM也有帮助。

感谢您的阅读,本文由 诣极的博客 版权所有。如若转载,请注明出处:诣极的博客(https://zonghaishang.github.io/2018/10/22/理解字节码原理/
Dubbo无法处理Spring代理对象
理解arthas原理