开篇
字节码是什么、做什么这类问题不在这里赘述,《实战字节码》系列旨在帮助没接触过字节码的人能够快速上手做应用开发,并构建字节码技术的知识骨架,所以不会系统地介绍字节码技术的方方面面,也尽量避免叙述理论和概念相关的东西。重点在于动手,手把手做一个完整的小工程:以一个Maven插件的形式触发编译时字节码扫描和修改。
当然,在上手coding之前,有些基础知识还是要了解的,至少对字节码运行方式、核心指令有个印象。不必担心一篇能否涵盖所有基础知识,可以非常肯定的说——不可能。但是,以我个人开发经验而言,了解过本篇涉及的基础知识后,上手做点东西是没问题的。
没有实践只能获得概念,一边实践一边学习得到的才是知识和技能。所以后面用到哪学到哪,也不迟。
玩转字节码
简单:字节码开发并不需要太多储备知识,虽然总共有200多种指令,但其实主要就分为存取(load/store)、运算(add)、跳转(jump)、调用(invoke)、常量(ldc)、异常(try-catch-finally)这几大类,每种类型理解一个就可以以此类推其他的,比如加载int类型用iload指令,加载对象类型用aload指令,大部分指令都是不同类型的重载而已。
模仿:最快速的上手方法就是模仿。先把想要的字节码用Java语言写出来,再用javac编译成class,通过ASMPlugin、jclasslib等工具查看编译好的class文件就可以看到你想要的字节码。
基础知识
JVM运行字节码
JVM是基于栈技术实现的虚拟机,源代码被编译成字节码指令后,所有指令都按照入栈、出栈的方式执行,栈顶永远是当前正在执行的指令。而运行时变量在栈中存储的位置和大小在编译时就已经确定了,这就是本地变量表的作用(LocalVariableTable)。
举个栗子,来看看int sum = 6 + 9语句在JVM栈中是怎么执行的。如下图所示:
从本地变量表的第1和第2个位置依次将int类型的值压入栈顶,分别对应6和9两个值,此时栈状态如[上-左图]。load有一系列变种,这个例子中iload表示load操作的是int类型。字节码中还有很多指令跟类型绑定,在此不一一列举,可以此类推。
用iadd指令对栈顶两个int数值(6、9)做加法运算,如[上-中图]所示。
iadd指令执行后把栈顶6、9这两个值出栈,并将运算结果(15)压入栈顶,如[上-右图]所示。
接下来要将运算结果存到本地变量表中以供其他指令使用,这里是int类型的store指令istore,这个指令将下放int值(15)存入本地变量表第3个位置,并将该值出栈,如[下-右图]所示。
此时栈中为空,如[下-中图]所示。
使用iload指令将本地变量表中第3个int值加载到栈顶,即完成sum = 15这步赋值操作。如[下-左图]所示。
字节码长什么样
上图是Java源码,我们可以通过javap命令或其他工具可以看到编译后的class文件中fun1方法对应的字节码数据结构,如下图
很多书上或教程上都长篇累牍地介绍字节码二进制数据格式,乏味而且用处不大,通常,如果你不想自己写字节码解析器的话,大可不必了解那些规则繁琐的数据结构。我们面向实战应用,跳过这些,直接通过javap先看看解析后的字节码数据结构是什么样的。实际开发中,使用ASM、Javassist等工具即可直接拿到解析后的字节码数据。
其中,比较重要的是画红框的三部分:
方法(Method)声明部分,descriptor是对fun1方法参数和返回值的定义,对应fun1中的参数和返回值。I表示int类型。L开头的表示对象,List是实际的类型,对象类型用分号;结束。圆括号后表示返回值类型,V表示void类型。descriptor是字节码中无处不在的类型描述符,无论是识别Class、Method、Field都需要它,重要性可想而知,下文会具体介绍。
字节码的核心,方法中调用指令都在Code中。而第一行的三个参数也很重要,stack表示方法中调用栈最大深度、locals表示局部变量最多占用的槽数(存储局部变量用,除long、double两类型占用双槽外,其他类型变量均占一个槽)、args_size表示方法入参个数,fun1方法中有两个参数,分别是int和List类型,而成员方法在编译时会在第0个位置自动加入this引用,所以这里是3个参数。
局部变量表对应上面的locals和args_size,这部分详细开列了方法内局部变量的信息,可以看到第一个就是编译器自动注入的this引用。局部变量表之所以重要,是因为修改或创建字节码时,这部分可能需要自己计算,如果计算有误,则运行时会崩溃。当然,有的工具如ASM提供了自动计算stack和locals的功能。
字节码类型描述符
官方文档(https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.3.2)
字节码类型 | 对应Java类型 |
B | byte |
C | char |
D | double |
F | float |
I | int |
J | long |
LClassName; | Object和各级子类 |
S | short |
Z | boolean |
[ | Array |
V | void |
上表中非加粗类型都是实际类型的首字母,而加粗类型则需要特别注意,它们并非使用首字母。
示例
Java类型 | 字节码类型 |
byte[] | [B |
long[][] | [[J |
Boolean[] | [Ljava/lang/Boolean; |
重要指令
字节码中有200多种指令,大部分指令可以通过名字猜到用途。初学者重点关注几大类指令即可。
加载和存储
字节码中一般通过加载(load系列)指令将变量加载到栈上,如果需要保存到局部变量表中,则通过存储(store系列)指令进行操作。而常量加载使用const、push、ldc系列指令。
类型 | 字节码指令 | 说明 |
加载变量 | iload_<n>, lload_<n>, fload_<n>, dload_<n>, aload_<n> | 加载变量到栈上,下划线后是变量在局部变量表中的位置,如:iload_3就是从局部变量表下标为3的位置处加载int类型变量 |
存储变量 | istore_<n>, lstore_<n>, fstore_<n>, dstore_<n>, astore_<n> | 与load指令对应,将变量存储到局部变量表中,下划线后是局部变量表的位置。 |
相对于变量加载,常量加载要更复杂一些,取值范围不同,对应的指令也不同,具体见下表
字节码指令 | 说明 |
aconst_null | 加载null |
iconst_m1 | 加载int类型的-1 |
iconst_<i> | 加载int类型的值i,i的范围是1-5。如加载int类型的3位iconst_3 |
lconst_<l> | 加载long类型的值0、1。 |
fconst_<f> | 加载float类型的值0、1、2。 |
dconst_<d> | 加载double类型的值0、1。 |
bipush | 加载int类型-128 ~ 127的值 |
sipush | 加载int类型-32768 ~ 32767的值 |
ldc | 加载int、float、String常量,ldc只能在常量池中寻找1个字节范围内的索引值,上限为255。 |
ldc_w | 与ldc相同,但是可以在常量池中寻找2个字节范围内的索引值,可以覆盖完整的常量池。 |
ldc2_w | 加载long、double常量。 |
条件指令
对应Java关键词 | 字节码指令 | 说明 |
if | ifeq, ifne, iflt, ifle, ifgt, ifge, ifnull, ifnonnull, if_icmpeq, if_icmpne, if_icmplt, if_icmple, if_icmpgt if_icmpge, if_acmpeq, if_acmpne | Java中的if指令在不同场景下对应多种字节码指令,名称相近的指令作用相同,只不过操作不同类型数据而已。例如i系列的指令比较int类型数据,a系列指令比较引用类型(对象)数据。 |
switch | tableswitch, lookupswitch | Java中switch关键词比较数据时,如果case值比较紧凑,则使用tableswitch指令,将case合并成数组,通过下标直接访问,时间复杂度是1;如果case值比较稀疏,则使用lookupswitch遍历查找,时间复杂度是n。 |
if, switch, break, continue, goto, try-catch-finally | goto, goto_w, jsr, jsr_w, ret | Java中的goto关键词虽然极少使用,但在字节码中,各种需要跳转的指令都依赖它来完成。比如,if语句块结束时大部分情况是通过goto来完成跳转的。 |
方法调用
字节码指令 | 说明 |
invokevirtual | Java中最常规的实例方法调用 |
invokeinterface | 调用接口方法 |
invokespecial | 构造方法(<init>)调用、private的实例方法调用、super的方法调用 |
invokestatic | 调用静态方法 |
invokedynamic | Java7中新增的动态方法调用,Java中动态语言特性主要依赖这个指令实现,Java8中的Lambda就是通过该指令实现 |
上面这几种指令熟悉后,应该说程序的框架就能看懂了,还有一些像算术运算、try-catch-finally等指令,需要用到的时候通过文档或工具再查不晚,这里就不一一列举了。
必会总结
JVM栈运行方式,所有代码以入栈出栈方式运行
字节码类型描述,要能识别和手写字节码中的各种类型
各种加载、存储、条件、跳转、方法调用指令,要能理解它的作用,这点必不可少,但不必一定会手写
知道这些,下一篇就足以借助工具做字节码开发啦,嗯,就这么简单!