Java 中的字节码是 Java 代码编译后的中间代码格式。JVM 需要读取并解析字节码才能执行相应的任务。Java 字节码是 JVM 的指令集。JVM 加载字节码格式的 class 文件,校验之后通过 JIT 编译器转换为本地机器代码执行。简单说字节码就是我们编写的 Java 应用程序大厦的每一块砖,如果没有字节码的支撑,大家编写的代码也就没有了用武之地,无法运行。也可以说,Java 字节码就是 JVM 执行的指令格式。
一、Java 字节码简介有一件有趣的事情,就如名称所示, Java bytecode
由单字节(byte
)的指令组成,理论上最多支持 256
个操作码(opcode)。实际上 Java 只使用了 200 左右的操作码, 还有一些操作码则保留给调试操作。
操作码, 下面称为 指令
, 主要由类型前缀
和操作名称
两部分组成。例如,'i
' 前缀代表 ‘integer
’,所以,'iadd
' 很容易理解, 表示对整数执行加法运算。
根据指令的性质,主要分为四个大类:
- 栈操作指令,包括与局部变量交互的指令
- 程序流程控制指令
- 对象操作指令,包括方法调用指令
- 算术运算以及类型转换指令
还有一些执行专门任务的指令,比如同步(synchronization)指令,以及抛出异常相关的指令等等。
可以用 javap
工具来获取 class 文件中的指令清单。 javap
是标准 JDK 内置的一款工具, 专门用于反编译 class 文件。
package demo.jvm0104;
public class HelloByteCode {
public static void main(String[] args) {
HelloByteCode obj = new HelloByteCode();
}
}
使用 javac 编译 ,或者在 IDEA 或者 Eclipse 等集成开发工具自动编译,基本上是等效的。只要能找到对应的 class 即可。
Compiled from "HelloByteCode.java"
public class demo.jvm0104.HelloByteCode {
public demo.jvm0104.HelloByteCode();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
public static void main(java.lang.String[]);
Code:
0: new #2 // class demo/jvm0104/HelloByteCode
3: dup
4: invokespecial #3 // Method "":()V
7: astore_1
8: return
}
常量池
大家应该都听说过, 英文是 Constant pool
。这里做一个强调: 大多数时候指的是运行时常量池
。但运行时常量池里面的常量是从哪里来的呢? 主要就是由 class 文件中的 常量池结构体
组成的。
javap -c -verbose demo.jvm0104.HelloByteCode
Classfile /XXXXXXX/demo/jvm0104/HelloByteCode.class
Last modified 2019-11-28; size 301 bytes
MD5 checksum 542cb70faf8b2b512a023e1a8e6c1308
Compiled from "HelloByteCode.java"
public class demo.jvm0104.HelloByteCode
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#13 // java/lang/Object."":()V
#2 = Class #14 // demo/jvm0104/HelloByteCode
#3 = Methodref #2.#13 // demo/jvm0104/HelloByteCode."":()V
#4 = Class #15 // java/lang/Object
#5 = Utf8
#6 = Utf8 ()V
#7 = Utf8 Code
#8 = Utf8 LineNumberTable
#9 = Utf8 main
#10 = Utf8 ([Ljava/lang/String;)V
#11 = Utf8 SourceFile
#12 = Utf8 HelloByteCode.java
#13 = NameAndType #5:#6 // "":()V
#14 = Utf8 demo/jvm0104/HelloByteCode
#15 = Utf8 java/lang/Object
{
public demo.jvm0104.HelloByteCode();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
LineNumberTable:
line 3: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: 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 "":()V
7: astore_1
8: return
LineNumberTable:
line 5: 0
line 6: 8
}
SourceFile: "HelloByteCode.java"
其中显示了很多关于 class 文件信息: 编译时间, MD5 校验和, 从哪个 .java
源文件编译得来,符合哪个版本的 Java 语言规范等等。还可以看到 ACC_PUBLIC
和 ACC_SUPER
访问标志符。 ACC_PUBLIC
标志很容易理解:这个类是 public
类,因此用这个标志来表示。但 ACC_SUPER
标志是怎么回事呢? 这就是历史原因, JDK 1.0 的 BUG 修正中引入 ACC_SUPER
标志来修正 invokespecial
指令调用 super 类方法的问题,从 Java 1.1 开始, 编译器一般都会自动生成ACC_SUPER
标志。
JVM 是一台基于栈的计算机器。每个线程都有一个独属于自己的线程栈(JVM stack),用于存储栈帧
(Frame)。每一次方法调用,JVM都会自动创建一个栈帧。栈帧
由 操作数栈
, 局部变量数组
以及一个class 引用
组成。class 引用
指向当前方法在运行时常量池中对应的 class)。
局部变量数组
也称为 局部变量表
(LocalVariableTable), 其中包含了方法的参数,以及局部变量。 局部变量数组的大小在编译时就已经确定: 和局部变量+形参的个数有关,还要看每个变量/参数占用多少个字节。操作数栈是一个 LIFO 结构的栈, 用于压入和弹出值。 它的大小也在编译时确定。有一些操作码/指令可以将值压入“操作数栈”; 还有一些操作码/指令则是从栈中获取操作数,并进行处理,再将结果压入栈。操作数栈还用于接收调用其他方法时返回的结果值。
我们都知道 new
是 Java 编程语言中的一个关键字, 但其实在字节码中,也有一个指令叫做 new
。 当我们创建类的实例时, 编译器会生成类似下面这样的操作码:
0: new #2 // class demo/jvm0104/HelloByteCode
3: dup
4: invokespecial #3 // Method "":()V
当你同时看到 new, dup
和 invokespecial
指令在一起时,那么一定是在创建类的实例对象!
为什么是三条指令而不是一条呢?这是因为:
new
指令只是创建对象,但没有调用构造函数。invokespecial
指令用来调用某些特殊方法的, 当然这里调用的是构造函数。dup
指令用于复制栈顶的值。
由于构造函数调用不会返回值,所以如果没有 dup 指令, 在对象上调用方法并初始化之后,操作数栈就会是空的,在初始化之后就会出问题, 接下来的代码就无法对其进行处理。
这就是为什么要事先复制引用的原因,为的是在构造函数返回之后,可以将对象实例赋值给局部变量或某个字段。因此,接下来的那条指令一般是以下几种:
astore {N}
orastore_{N}
– 赋值给局部变量,其中{N}
是局部变量表中的位置。putfield
– 将值赋给实例字段putstatic
– 将值赋给静态字段
在调用构造函数的时候,其实还会执行另一个类似的方法 ,甚至在执行构造函数之前就执行了。还有一个可能执行的方法是该类的静态初始化方法
, 但
并不能被直接调用,而是由这些指令触发的:
new
, getstatic
, putstatic
or invokestatic
。也就是说,如果创建某个类的新实例, 访问静态字段或者调用静态方法,就会触发该类的静态初始化方法【如果尚未初始化】。
Java 虚拟机的字节码指令集在 JDK7 之前一直就只有前面提到的 4 种指令(invokestatic,invokespecial,invokevirtual,invokeinterface)。随着 JDK 7 的发布,字节码指令集新增了invokedynamic
指令。这条新增加的指令是实现“动态类型语言”(Dynamically Typed Language)支持而进行的改进之一,同时也是 JDK 8 以后支持的 lambda 表达式的实现基础。
我们知道在不改变字节码的情况下,我们在 Java 语言层面想调用一个类 A 的方法 m,只有两个办法:
- 使用
A a=new A(); a.m()
,拿到一个 A 类型的实例,然后直接调用方法; - 通过反射,通过 A.class.getMethod 拿到一个 Method,然后再调用这个
Method.invoke
反射调用;
这两个方法都需要显式的把方法 m 和类型 A 直接关联起来,假设有一个类型 B,也有一个一模一样的方法签名的 m 方法,怎么来用这个方法在运行期指定调用 A 或者 B 的 m 方法呢?这个操作在 JavaScript 这种基于原型的语言里或者是 C# 这种有函数指针/方法委托的语言里非常常见,Java 里是没有直接办法的。Java 里我们一般建议使用一个 A 和 B 公有的接口 IC,然后 IC 里定义方法 m,A 和 B 都实现接口 IC,这样就可以在运行时把 A 和 B 都当做 IC 类型来操作,就同时有了方法 m,这样的“强约束”带来了很多额外的操作。
而新增的 invokedynamic 指令,配合新增的方法句柄(Method Handles,它可以用来描述一个跟类型 A 无关的方法 m 的签名,甚至不包括方法名称,这样就可以做到我们使用方法 m 的签名,但是直接执行的时候调用的是相同签名的另一个方法 b),可以在运行时再决定由哪个类来接收被调用的方法。在此之前,只能使用反射来实现类似的功能。该指令使得可以出现基于 JVM 的动态语言,让 jvm 更加强大。而且在 JVM 上实现动态调用机制,不会破坏原有的调用机制。这样既很好的支持了 Scala、Clojure 这些 JVM 上的动态语言,又可以支持代码里的动态 lambda 表达式。
博文参考- Why Should I Know About Java Bytecode: https://jrebel.com/rebellabs/rebel-labs-report-mastering-java-bytecode-at-the-core-of-the-jvm/
- 轻松看懂Java字节码: https://juejin.im/post/5aca2c366fb9a028c97a5609
- invokedynamic指令:https://www.cnblogs.com/wade-luffy/p/6058087.html
- Java 8的Lambda表达式为什么要基于invokedynamic?:https://www.zhihu.com/question/39462935
- Invokedynamic:https://www.jianshu.com/p/ad7d572196a8
- JVM之动态方法调用:invokedynamic: https://ifeve.com/jvm%E4%B9%8B%E5%8A%A8%E6%80%81%E6%96%B9%E6%B3%95%E8%B0%83%E7%94%A8%EF%BC%9Ainvokedynamic/