为什么要学习字节码技术
- 从技术人员的角度看,Java 字节码是 JVM 的指令集,JVM 加载字节码格式的 class 文件,校验之后通过 JIT 编译器转换为本地机器代码执行,也就是说Java字节码就是我们给JVM的指令,为了让JVM能够按照我们的计划执行,我们就应该熟悉Java字节码技术
- 了解字节码及其工作原理,对于编写高性能代码至关重要,对于深入分析和排查问题也有一定作用,所以我们要想深入了解 JVM 来说,了解字节码也是夯实基础的一项基本功
- 对于工具领域和程序分析来说, 字节码就是必不可少的基础知识了,通过修改字节码来调整程序的行为是司空见惯的事情,而且想了解分析器(Profiler),Mock 框架,AOP 等工具和技术这一类工具,则必须完全了解 Java 字节码
Java字节码简介
Java字节码(Java bytecode),就如名称所示, Java bytecode 由单字节(byte)的指令组成,理论上最多支持256(2^8)个操作码(操作指令),实际只使用了200左右,还有一些操作码保留给了调试操作
操作码(指令)结构
操作码, 下面称为 指令, 主要由类型前缀和操作名称两部分组成,如:iadd,i代表类型前缀,即integer,add代表操作名称,即加法操作,所以合起来就是对integer类型的数据执行加法操作
指令的分类
根据指令的性质,分为4大类:
- 栈操作指令,包括与局部变量交互的指令
- 程序流程控制指令
- 对象操作指令,包括方法调用指令
- 算术运算以及类型转换指令
此外还有一些执行专门任务的指令,比如同步(synchronization)指令,以及抛出异常相关的指令等等
获取字节码清单
即通过javap工具反编译获取class文件的指令清单
对于下面的类:
1
2
3
4
5
6
7
package demo.jvm0104;
public class HelloByteCode {
public static void main(String[] args) {
HelloByteCode obj = new HelloByteCode();
}
}
- 我们首先通过javac命令编译获得class文件:
javac demo/jvm0104/HelloByteCode.java
,另外可以通过-d参数指定.class文件放置的目录,默认与.java文件同目录,除此之外,javac命令默认开始了优化功能,会将字节码中的局部变量表擦除,如果想在反编译的指令集中看到局部变量表,请在编译时追加-g
指令 - 执行命令:
javap -c demo/jvm0104/HelloByteCode.class
,反编译成功后结果如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Compiled from "HelloByteCode.java"
public class demo.jvm0104.HelloByteCode {
public demo.jvm0104.HelloByteCode();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: new #2 // class demo/jvm0104/HelloByteCode
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: return
}
解读字节码清单
public demo.jvm0104.HelloByteCode();
,这是反编译后的第一个注意点,这是默认的无参构造函数,也就是编译器会默认生成构造函数,而不是运行时JVM自动生成的- 明明未编译的默认构造函数体内什么都没有,但是这里却有一些指令,根据常识,每个构造函数会默认调用super(),而下面的这些指令就是做的这个工作
1
2
3
4
5
public demo.jvm0104.HelloByteCode();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
查看class文件的常量池信息
常量池(Constant pool),大多数时候指的是 运行时常量池。但运行时常量池里面的常量是从哪里来的呢? 主要就是由 class 文件中的 常量池结构体 组成的。
如何查看常量池信息
javap -c -verbose HelloByteCode
,要加上-verbose
参数
处理结果如下:
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
Classfile /C:/Users/wangwei/Documents/JVM/HelloByteCode.class
Last modified Sep 1, 2022; size 441 bytes
MD5 checksum 844ef2f6966e464d707692d8dc22f594
Compiled from "HelloByteCode.java"
public class demo.jvm0104.HelloByteCode
minor version: 0
major version: 55
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #2 // demo/jvm0104/HelloByteCode
super_class: #4 // java/lang/Object
interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #4.#19 // java/lang/Object."<init>":()V
#2 = Class #20 // demo/jvm0104/HelloByteCode
#3 = Methodref #2.#19 // demo/jvm0104/HelloByteCode."<init>":()V
#4 = Class #21 // java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Utf8 Code
#8 = Utf8 LineNumberTable
#9 = Utf8 LocalVariableTable
#10 = Utf8 this
#11 = Utf8 Ldemo/jvm0104/HelloByteCode;
#12 = Utf8 main
#13 = Utf8 ([Ljava/lang/String;)V
#14 = Utf8 args
#15 = Utf8 [Ljava/lang/String;
#16 = Utf8 obj
#17 = Utf8 SourceFile
#18 = Utf8 HelloByteCode.java
#19 = NameAndType #5:#6 // "<init>":()V
#20 = Utf8 demo/jvm0104/HelloByteCode
#21 = Utf8 java/lang/Object
{
public demo.jvm0104.HelloByteCode();
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 2: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Ldemo/jvm0104/HelloByteCode;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #2 // class demo/jvm0104/HelloByteCode
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: return
LineNumberTable:
line 4: 0
line 5: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 args [Ljava/lang/String;
8 1 1 obj Ldemo/jvm0104/HelloByteCode;
}
SourceFile: "HelloByteCode.java"
什么是ACC_PUBLIC和ACC_SUPER
- ACC_PUBLIC:代表该类是public类
- ACC_SUPER:由于历史原因,JDK 1.0 的 BUG 修正中引入 ACC_SUPER 标志来修正 invokespecial 指令调用 super 类方法的问题,从 Java 1.1 开始, 编译器一般都会自动生成ACC_SUPER 标志。
指令后面的编号#1, #2, #3是什么意思
这就是对常量池的引用
常量池
1
2
3
4
5
6
7
8
9
10
11
12
13
Constant pool:
#1 = Methodref #4.#19 // java/lang/Object."<init>":()V
#2 = Class #20 // demo/jvm0104/HelloByteCode
#3 = Methodref #2.#19 // demo/jvm0104/HelloByteCode."<init>":()V
#4 = Class #21 // java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Utf8 Code
#8 = Utf8 LineNumberTable
#9 = Utf8 LocalVariableTable
#10 = Utf8 this
......
显然每个编号代表一个常量,其中常量之间可以相互引用、组合称为一个新的常量
如第一行:#1 = Methodref #4.#19 // java/lang/Object."<init>":()V
,解读如下:
- #1 常量编号, 该文件中其他地方可以引用。
- = 等号就是分隔符.
- Methodref 表明这个常量指向的是一个方法;具体是哪个类的哪个方法呢? 类指向的 #4, 方法签名指向的 #19; 当然双斜线注释后面已经解析出来可读性比较好的说明了。
常量池小总结
常量池就是一个常量的大字典,使用编号的方式把程序里用到的各类常量统一管理起来,这样在字节码操作里,只需要引用编号即可。
查看方法信息
经过javap -verbose
,方法信息里面新增了更多的内容:
1
2
3
4
5
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
在descriptor这一栏:
- 小括号内是形参
- 左方括号表示数组
L
代表对象java/lang/String
,是参数对应的类型V
,方法返回值是void
flags这一栏,表示main方式是static,public修饰的
另外stack=2, locals=2, args_size=1
这一栏:
- stack表示执行方法需要的栈(stack)的深度
- locals表示需要在局部变量表中保留多少个槽位
- args_size,方法参数的个数
实际上我们一般把方法修饰符,返回值,名称,参数类型清单合在一起叫做方法签名,即这些信息可以表示一个唯一的方法
注意无参构造函数的变化
1
2
3
4
5
6
7
8
9
10
11
12
13
public demo.jvm0104.HelloByteCode();
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 2: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Ldemo/jvm0104/HelloByteCode;
我们发现args_size=1,也就是无参构造函数实际却有一个参数,其实在 Java 中, 如果是静态方法则没有 this 引用。 对于非静态方法,this 将被分配到局部变量表的第 0 号槽位中,因此这里locals=1。
线程栈和字节码执行模型
- JVM 是一台基于栈的计算机器。每个线程都有一个独属于自己的线程栈(JVM stack),用于存储栈帧(Frame)
- 每一次方法调用,JVM都会自动创建一个栈帧。
栈帧包括:
- 局部变量表:存放方法参数和局部变量
- 操作数栈:存放计算值和方法返回值
- class引用,class 引用 指向当前方法在运行时常量池中对应的 class
局部变量表
- 局部变量数组 也称为 局部变量表(LocalVariableTable), 其中包含了方法的参数,以及局部变量。
- 部变量数组的大小在编译时就已经确定: 和局部变量+形参的个数有关,还要看每个变量/参数占用多少个字节。
操作数栈
- 操作数栈是一个 LIFO 结构的栈, 用于压入和弹出值。 它的大小也在编译时确定。
- 操作数栈还用于接收调用其他方法时返回的结果值。
方法体中的字节码解读
其实这些数字就代表了字节码指令的存储地址的起始索引,众所周知每个操作码的大小是一个字节,由于操作数也需要占用空间,所以指令前面的数字间隔不同
对象初始化指令:new 指令, init 以及 clinit 简介
对象初始化指令解析
1
2
3
4
5
6
7
8
9
10
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #2 // class demo/jvm0104/HelloByteCode
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: return
- 如果同时看到 new, dup 和 invokespecial 指令在一起时,那么一定是在创建类的实例对象!
- new 指令只是创建对象,但没有调用构造函数,也就是说对象还未初始化(实例变量,实例代码块等等都还未执行初始化)
- dup 指令用于复制栈顶的值,在这里就是复制new指令创建的对象引用
- invokespecial 指令用来调用某些特殊方法的, 当然这里调用的是构造函数。
- astore_1,将对象引用赋值给1号槽位的局部变量,也就是obj
注意,由于构造函数调用不会有返回值,所以在执行初始化时,栈顶的引用被弹出初始化,但初始完成后没有压入操作数栈,这样新创建的对象就会丢失,因此使用dup命令在初始化之前复制对应引用
init和clinit的区别
1.init和clinit执行时机不同
init在类构造函数被调用时执行,clinit在类加载流程的初始化过程中执行
- init和clinit执行目的不同
init对非静态变量初始化,clinit对静态变量和静态代码块进行初始化,如下方程序:
1
2
3
4
5
6
7
8
9
10
class X {
static Log log = LogFactory.getLog(); // <clinit>
private int x = 1; // <init>
X(){
// <init>
}
static {
// <clinit>
}
}
clinit详解
- 实际上在类加载流程中的准备阶段,会为类变量(静态变量)在方法区分配内存,并设置零值。
- 而在初始化阶段会根据程序员通过程序制定的主观计划去初始化类变量和其他资源(调用clinit)
- <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,收集的顺序是由语句在源文件中出现的顺序所决定的
- 静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,可以对其执行赋值操作,但是不能访问,如下代码演示:
1
2
3
4
5
6
7
public class Test{
static{
i=0;//给变量赋值可以正常编译通过
System.out.print(i);//这句编译器会提示"非法向前引用"
}
static int i=1;
}
- 虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕,这也意味着父类的静态语句块要优先于子类的变量赋值操作
- 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。 但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法。 只有当父接口中定义的变量使用时,父接口才会初始化。 另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。
1
2
final static int a=2;
final static int b=a;
在这种情况下clinit会被调用,另外接口中的属性都是static final类型的常量,因此在准备阶段就已经初始化
clinit触发的条件,或者说类初始化触发的条件
- 静态字段被访问
- 静态方法被调用
局部变量表与操作数栈的交互
有下面两个类:
获取动态平均数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
package demo.jvm0104;
//移动平均数
public class MovingAverage {
private int count = 0;
private double sum = 0.0D;
public void submit(double value){
this.count ++;
this.sum += value;
}
public double getAvg(){
if(0 == this.count){ return sum;}
return this.sum/this.count;
}
}
调用上诉类的方法
1
2
3
4
5
6
7
8
9
10
11
package demo.jvm0104;
public class LocalVariableTest {
public static void main(String[] args) {
MovingAverage ma = new MovingAverage();
int num1 = 1;
int num2 = 2;
ma.submit(num1);
ma.submit(num2);
double avg = ma.getAvg();
}
}
其中 main 方法中向 MovingAverage 类的实例提交了两个数值,并要求其计算当前的平均值。
经过下列命令:
javac -g demo/jvm0104/*.java
javap -c -verbose demo/jvm0104/LocalVariableTest
对应的字节码清单如下:
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[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=6, args_size=1
0: new #2 // class demo/jvm0104/MovingAverage
3: dup
4: invokespecial #3 // Method demo/jvm0104/MovingAverage."<init>":()V
7: astore_1
8: iconst_1
9: istore_2
10: iconst_2
11: istore_3
12: aload_1
13: iload_2
14: i2d
15: invokevirtual #4 // Method demo/jvm0104/MovingAverage.submit:(D)V
18: aload_1
19: iload_3
20: i2d
21: invokevirtual #4 // Method demo/jvm0104/MovingAverage.submit:(D)V
24: aload_1
25: invokevirtual #5 // Method demo/jvm0104/MovingAverage.getAvg:()D
28: dstore 4
30: return
LineNumberTable:
line 5: 0
line 6: 8
line 7: 10
line 8: 12
line 9: 18
line 10: 24
line 11: 30
LocalVariableTable:
Start Length Slot Name Signature
0 31 0 args [Ljava/lang/String;
8 23 1 ma Ldemo/jvm0104/MovingAverage;
10 21 2 num1 I
12 19 3 num2 I
30 1 4 avg D
下面对陌生的命令进行说明:
1
2
3
4
8: iconst_1
9: istore_2
10: iconst_2
11: istore_3
- iconst_1 和 iconst_2 用来将常量值1和2加载到栈里面(常量是存储在堆内存中的方法区的常量池中,我们现在讨论的时线程栈,也就是栈内存)
- istore_2 和 istore_3 将它们存储到在 LocalVariableTable 的槽位 2 和槽位 3 中
1
20: i2d
i2d这个指令将int值转换未double值
给局部变量赋值时,需要使用相应的指令来进行 store,如 astore_1。store 类的指令都会删除栈顶值。 相应的 load 指令则会将值从局部变量表压入操作数栈,但并不会删除局部变量中的值。
另外有个有意思的地方,main方法的局部变量表的0号槽位被方法参数占用,而非this,毕竟静态方法不需要this