首页 Java字节码技术
文章
取消

Java字节码技术

为什么要学习字节码技术

  1. 从技术人员的角度看,Java 字节码是 JVM 的指令集,JVM 加载字节码格式的 class 文件,校验之后通过 JIT 编译器转换为本地机器代码执行,也就是说Java字节码就是我们给JVM的指令,为了让JVM能够按照我们的计划执行,我们就应该熟悉Java字节码技术
  2. 了解字节码及其工作原理,对于编写高性能代码至关重要,对于深入分析和排查问题也有一定作用,所以我们要想深入了解 JVM 来说,了解字节码也是夯实基础的一项基本功
  3. 对于工具领域和程序分析来说, 字节码就是必不可少的基础知识了,通过修改字节码来调整程序的行为是司空见惯的事情,而且想了解分析器(Profiler),Mock 框架,AOP 等工具和技术这一类工具,则必须完全了解 Java 字节码

Java字节码简介

Java字节码(Java bytecode),就如名称所示, Java bytecode 由单字节(byte)的指令组成,理论上最多支持256(2^8)个操作码(操作指令),实际只使用了200左右,还有一些操作码保留给了调试操作

操作码(指令)结构

操作码, 下面称为 指令, 主要由类型前缀和操作名称两部分组成,如:iadd,i代表类型前缀,即integer,add代表操作名称,即加法操作,所以合起来就是对integer类型的数据执行加法操作

指令的分类

根据指令的性质,分为4大类:

  1. 栈操作指令,包括与局部变量交互的指令
  2. 程序流程控制指令
  3. 对象操作指令,包括方法调用指令
  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();
    }
}
  1. 我们首先通过javac命令编译获得class文件:javac demo/jvm0104/HelloByteCode.java,另外可以通过-d参数指定.class文件放置的目录,默认与.java文件同目录,除此之外,javac命令默认开始了优化功能,会将字节码中的局部变量表擦除,如果想在反编译的指令集中看到局部变量表,请在编译时追加-g指令
  2. 执行命令: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
}

解读字节码清单

  1. public demo.jvm0104.HelloByteCode();,这是反编译后的第一个注意点,这是默认的无参构造函数,也就是编译器会默认生成构造函数,而不是运行时JVM自动生成的
  2. 明明未编译的默认构造函数体内什么都没有,但是这里却有一些指令,根据常识,每个构造函数会默认调用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和ACC_SUPER

  1. ACC_PUBLIC:代表该类是public类
  2. ACC_SUPER:由于历史原因,JDK 1.0 的 BUG 修正中引入 ACC_SUPER 标志来修正 invokespecial 指令调用 super 类方法的问题,从 Java 1.1 开始, 编译器一般都会自动生成ACC_SUPER 标志。

指令后面的编号#1, #2, #3是什么意思 Alt text

这就是对常量池的引用

常量池

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. #1 常量编号, 该文件中其他地方可以引用。
  2. = 等号就是分隔符.
  3. 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这一栏:

  1. 小括号内是形参
  2. 左方括号表示数组
  3. L代表对象
  4. java/lang/String,是参数对应的类型
  5. V,方法返回值是void

flags这一栏,表示main方式是static,public修饰的

另外stack=2, locals=2, args_size=1这一栏:

  1. stack表示执行方法需要的栈(stack)的深度
  2. locals表示需要在局部变量表中保留多少个槽位
  3. 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。

线程栈和字节码执行模型

  1. JVM 是一台基于栈的计算机器。每个线程都有一个独属于自己的线程栈(JVM stack),用于存储栈帧(Frame)
  2. 每一次方法调用,JVM都会自动创建一个栈帧。

栈帧包括:

  1. 局部变量表:存放方法参数和局部变量
  2. 操作数栈:存放计算值和方法返回值
  3. class引用,class 引用 指向当前方法在运行时常量池中对应的 class

每次调用方法创建栈帧

局部变量表

  1. 局部变量数组 也称为 局部变量表(LocalVariableTable), 其中包含了方法的参数,以及局部变量。
  2. 部变量数组的大小在编译时就已经确定: 和局部变量+形参的个数有关,还要看每个变量/参数占用多少个字节。

操作数栈

  1. 操作数栈是一个 LIFO 结构的栈, 用于压入和弹出值。 它的大小也在编译时确定。
  2. 操作数栈还用于接收调用其他方法时返回的结果值。

方法体中的字节码解读

字节码指令前面的数字

其实这些数字就代表了字节码指令的存储地址的起始索引,众所周知每个操作码的大小是一个字节,由于操作数也需要占用空间,所以指令前面的数字间隔不同

对象初始化指令: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
  1. 如果同时看到 new, dup 和 invokespecial 指令在一起时,那么一定是在创建类的实例对象!
  2. new 指令只是创建对象,但没有调用构造函数,也就是说对象还未初始化(实例变量,实例代码块等等都还未执行初始化)
  3. dup 指令用于复制栈顶的值,在这里就是复制new指令创建的对象引用
  4. invokespecial 指令用来调用某些特殊方法的, 当然这里调用的是构造函数。
  5. astore_1,将对象引用赋值给1号槽位的局部变量,也就是obj

注意,由于构造函数调用不会有返回值,所以在执行初始化时,栈顶的引用被弹出初始化,但初始完成后没有压入操作数栈,这样新创建的对象就会丢失,因此使用dup命令在初始化之前复制对应引用

init和clinit的区别

1.init和clinit执行时机不同

init在类构造函数被调用时执行,clinit在类加载流程的初始化过程中执行

  1. 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详解

  1. 实际上在类加载流程中的准备阶段,会为类变量(静态变量)在方法区分配内存,并设置零值。
  2. 而在初始化阶段会根据程序员通过程序制定的主观计划去初始化类变量和其他资源(调用clinit)
  3. <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,收集的顺序是由语句在源文件中出现的顺序所决定的
  4. 静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,可以对其执行赋值操作,但是不能访问,如下代码演示:
1
2
3
4
5
6
7
public class Test{
    static{
        i=0//给变量赋值可以正常编译通过
        System.out.printi);//这句编译器会提示"非法向前引用"
    }
    static int i=1
}
  1. 虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕,这也意味着父类的静态语句块要优先于子类的变量赋值操作
  2. 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。 但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法。 只有当父接口中定义的变量使用时,父接口才会初始化。 另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。
1
2
final static int a=2
final static int b=a;

在这种情况下clinit会被调用,另外接口中的属性都是static final类型的常量,因此在准备阶段就已经初始化

clinit触发的条件,或者说类初始化触发的条件

  1. 静态字段被访问
  2. 静态方法被调用

局部变量表与操作数栈的交互

有下面两个类:

获取动态平均数

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 类的实例提交了两个数值,并要求其计算当前的平均值。

经过下列命令:

  1. javac -g demo/jvm0104/*.java
  2. 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
  1. iconst_1 和 iconst_2 用来将常量值1和2加载到栈里面(常量是存储在堆内存中的方法区的常量池中,我们现在讨论的时线程栈,也就是栈内存)
  2. istore_2 和 istore_3 将它们存储到在 LocalVariableTable 的槽位 2 和槽位 3 中
1
    20: i2d

i2d这个指令将int值转换未double值

给局部变量赋值时,需要使用相应的指令来进行 store,如 astore_1。store 类的指令都会删除栈顶值。 相应的 load 指令则会将值从局部变量表压入操作数栈,但并不会删除局部变量中的值。

另外有个有意思的地方,main方法的局部变量表的0号槽位被方法参数占用,而非this,毕竟静态方法不需要this

局部变量表与操作数栈的交互

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

Java基础知识

代理模式