教你用 Java 字节码做点有趣的事
0.写在前面
为什么会写这篇文章呢?主要是之前调研过日志脱敏相关的一些,具体可以参考 LOG4j 脱敏插件如何编写 里面描述了日志脱敏插件编写方法:
直接在 toString 中修改代码,这种方法很麻烦,效率低,需要修改每一个要脱敏的类,或者写个 idea 插件自动修改 toString(),这样不好的地方在于所有编译器都需要开个插件,不够通用。
在编译时期修改抽象语法树修改 toString()方法,就像类似 Lombok 一样,这个之前调研过,开发难度较大,可能后会更新如何去写。
在加载的时候通过实现 Instrumentation 接口 asm 库,修改 class 文件的字节码,但是有个比较麻烦的地方在于需要给 jvm 加上启动参数 -javaagent:agentjarpath,这个已经实现了,但是实现后发现的确不够通用。
其中二三两个已经实现了,开发这个的确比较有趣,自己的知识面也得到了扩展,后续会通过写 4-5 篇的文章,一步一步的带大家如何去实现这些有趣的工具,学会了之后,通过大家丰富的想象力相信能实现更多有意思的东西。
0.1 字节码能干什么
例如我这篇文章要介绍的通过修改字节码去实现日志脱敏,其实就是修改 toString 的字节码: 可以看看怎么用:
这个类很普通对吧,和其他的实体类,唯一的区别是多了一个注解: @DesFiled(MobileDesFilter.class),有了这个注解我们执行这个 main 方法:他会输出:
可以看见我们明明输入的是不带号的手机号,为什么输出却带号了呢,这就是操纵字节码的神奇。当然大家也可以自己扩展思维,你可以用他来做 aop 切面,当然 cglib 做切面的确也是操纵的字节码,你也可以用它来做你想让它做的事
0.2 语法树
另一方面我也调研了 lombok 的实现,对此我发现修改抽象语法树,似乎更加有趣,你可以想象,你平时是否重复的给每个方法打印入参出参,耗时耗力?你平时是否在为缺少关键的日志而感到想骂人?你平时是否害怕用写 AOP 用反射打日志会影响性能?为了解决这个问题做了一个意思的工具 slothLog,github 地址:slothlog github https://github.com/lzggsimida123/slothlog.git (当然也求各位大佬们给点 star,O(∩_∩)O 哈哈~)。
通过上面会输出以下信息,将方法的出参,入参都进行输出,脱离了调试时缺少日志的苦恼
后续我会一步一步的教大家如何去完成一个类似 Lombok 的修改语法树的框架,做更多有趣的事。
0.3 关于本篇
如果你不喜欢上面这些东西,也别着急,字节码是 java 的基础,我觉得是所有 Java 程序员需要必备的,当然你也有必要了解一下。 本篇是系列的第一篇,这篇主要讲的主要是字节码是什么,通过对这篇的了解,也是后续章节的基础。
1.什么是字节码?
1.1 机器码
机器码(machine code)顾名思义也就是,机器能识别的代码,也叫原生码。机器码是 CPU 可直接解读的指令。机器码与硬件等有关,不同的 CPU 架构支持的硬件码也不相同。机器码是和我们的底层硬件直接打交道,现在学的人也是逐渐地变少了,如果对这个感兴趣的同学可以去学习一下汇编,汇编的指令会被翻译成机器码。
1.2 字节码
字节码(Byte-code)是一种包含执行程序、由一序列 op 代码/数据对组成的二进制文件。字节码是程序的中间表示形式:介于人类可读的源码和机器码之间。它经常被看作是包含一个执行程序的二进制文件,更像一个对象模型。字节码被这样叫是因为通常每个操作码 是一字节长,所以字节码的程度是根据一字节来的。字节码也是由,一组操作码组成,而操作码实际上是对栈的操作,可以移走参数和地址空间,也可以放入结果。JAVA 通过 JIT(即时编译)可以将字节码转换为机器码。
字节码的实现方式是通过编译器和虚拟机器。编译器将源码编译成字节码,特定平台上的虚拟机器将字节码转译为可以直接执行的指令。在 java 中一般是用 Javac 编译源文件变成字节码,也就是我们的 class 文件。
从网络上找到了两张图片,下面是 java 源码编译器生成字节码过程:
java 虚拟机执行引擎过程,这里会分为两个阶段:
普通的代码(非热)都是走的字节码解释器
热代码:多次调用的方法,多次执行的循环体,会被 JIT 优化成机器码。
2.字节码执行
2.1JVM 桢栈结构:
方法调用在 JVM 中转换成的是字节码执行,字节码指令执行的数据结构就是栈帧(stack frame)。也就是在虚拟机栈中的栈元素。虚拟机会为每个方法分配一个栈帧,因为虚拟机栈是 LIFO(后进先出)的,所以当前线程正在活动的栈帧,也就是栈顶的栈帧,JVM 规范中称之为“CurrentFrame”,这个当前栈帧对应的方法就是“CurrentMethod”。字节码的执行操作,指的就是对当前栈帧数据结构进行的操作。
JVM 的运行时数据区的结构如下图:
我们这里主要讨论栈帧的数据结构:有四个部分,局部变量区,操作数栈,动态链接,方法的返回地址。
2.1.1 局部变量表:
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在 Java 程序被编译成 Class 文件时,就在 Code 属性中 locals 变量:
如下面代码反编译后就能看见 locals=5。
局部变量的容量以变量槽(Slot)为最小单位,32 位虚拟机中一个 Slot 可以存放一个 32 位以内的数据类型(boolean、byte、char、short、int、float、reference(引用)和 returnAddress 八种)。
同时 Slot 对对象的引用会影响 GC,(要是被引用,不会被回收)。
系统不会为局部变量赋予初始值,也就是说不存在类变量那样的准备阶段。
虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果是实例方法(非 static),那么局部变量表的第 0 位索引的 Slot 默认是用于传递方法所属对象实例的引用,在方法中通过 this 访问。
我们上面的代码中是 4 个 Int 的 solt 加一个 this 的 solt 所以就等于 5。
2.1.2 操作数栈
Java 虚拟机的解释执行引擎被称为"基于栈的执行引擎",其中所指的栈就是指-操作数栈。
操作数栈同局部变量表一样,也是编译期间就能决定了其存储空间(最大的单位长度),通过 Code 属性存储在类或接口的字节流中。操作数栈也是个 LIFO 栈。 它不是通过索引来访问,而是通过标准的栈操作—压栈和出栈—来访问的。比如,如果某个指令把一个值压入到操作数栈中,稍后另一个指令就可以弹出这个值来使用。
虚拟机在操作数栈中存储数据的方式和在局部变量区中是一样的:如 int、long、float、double、reference 和 returnType 的存储。对于 byte、short 以及 char 类型的值在压入到操作数栈之前,也会被转换为 int。
2.1.3 动态链接
动态链接就是将符号引用所表示的方法,转换成方法的直接引用。加载阶段或第一次使用时转化为直接引用的(将变量的访问转化为访问这些变量的存储结构所在的运行时内存位置)就叫做静态解析。JVM 的动态链接还支持运行期转化为直接引用。也可以叫做 Late Binding,晚期绑定。动态链接是 java 灵活 OO 的基础结构。
注:
符号引用就是字符串,这个字符串包含足够的信息,以供实际使用时可以找到相应的位置。你比如说某个方法的符号引用,如:“java/io/PrintStream.println:(Ljava/lang/String;)V”。里面有类的信息,方法名,方法参数等信息。
当第一次运行时,要根据字符串的内容,到该类的方法表中搜索这个方法。运行一次之后,符号引用会被替换为直接引用,下次就不用搜索了。直接引用就是偏移量,通过偏移量虚拟机可以直接在该类的内存区域中找到方法字节码的起始位置。重写就是动态链接,重载就是静态解析。
2.1.4 方法返回地址
**方法正常退出,JVM 执行引擎会恢复上层方法局部变量表操作数栈并把返回值压入调用者的栈帧的操作数栈,PC 计数器的值就会调整到方法调用指令后面的一条指令。**这样使得当前的栈帧能够和调用者连接起来,并且让调用者的栈帧的操作数栈继续往下执行。 方法的异常调用完成,如果异常没有被捕获住,或者遇到 athrow 字节码指令显示抛出,那么就没有返回值给调用者。
2.2 字节码指令集
2.2.1 加载和存储指令
加载和存储指令用于将数据从栈帧的局部变量表和操作数栈之间来回传输。
1)将一个局部变量加载到操作数栈的指令包括:iload,iload_,lload、lload、float、 fload_、dload、dload_,aload、aload。
2)将一个数值从操作数栈存储到局部变量表的指令:istore,istore_,lstore,lstore_,fstore,fstore_,dstore,dstore_,astore,astore_
3)将常量加载到操作数栈的指令:bipush,sipush,ldc,ldc_w,ldc2_w,aconst_null,iconst_ml,iconst_,lconst_,fconst_,dconst_
4)局部变量表的访问索引指令:wide
2.2.2 运算指令
算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。
1)加法指令:iadd,ladd,fadd,dadd
2)减法指令:isub,lsub,fsub,dsub
3)乘法指令:imul,lmul,fmul,dmul
4)除法指令:idiv,ldiv,fdiv,ddiv
5)求余指令:irem,lrem,frem,drem
6)取反指令:ineg,leng,fneg,dneg
7)位移指令:ishl,ishr,iushr,lshl,lshr,lushr
8)按位或指令:ior,lor
9)按位与指令:iand,land
10)按位异或指令:ixor,lxor
11)局部变量自增指令:iinc
12)比较指令:dcmpg,dcmpl,fcmpg,fcmpl,lcmp
Java 虚拟机没有明确规定整型数据溢出的情况,但规定了处理整型数据时,只有除法和求余指令出现除数为 0 时会导致虚拟机抛出异常。
Java 虚拟机要求在浮点数运算的时候,所有结果否必须舍入到适当的精度,如果有两种可表示的形式与该值一样,会优先选择最低有效位为零的。称之为最接近数舍入模式。
浮点数向整数转换的时候,Java 虚拟机使用 IEEE 754 标准中的向零舍入模式,这种模式舍入的结果会导致数字被截断,所有小数部分的有效字节会被丢掉。
2.2.3 类型转换指令
类型转换指令将两种 Java 虚拟机数值类型相互转换,这些操作一般用于实现用户代码的显式类型转换操作。JVM 直接就支持宽化类型转换(小范围类型向大范围类型转换):
1.int 类型到 long,float,double 类型
2.long 类型到 float,double 类型
3.float 到 double 类型
但在处理窄化类型转换时,必须显式使用转换指令来完成,这些指令包括:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l 和 d2f。将 int 或 long 窄化为整型 T 的时候,仅仅简单的把除了低位的 N 个字节以外的内容丢弃,N 是 T 的长度。这有可能导致转换结果与输入值有不同的正负号。
在将一个浮点值窄化为整数类型 T(仅限于 int 和 long 类型),将遵循以下转换规则:
1)如果浮点值是 NaN , 那转换结果就是 int 或 long 类型的 0
2)如果浮点值不是无穷大,浮点值使用 IEEE 754 的向零舍入模式取整,获得整数 v, 如果 v 在 T 表示范围之内,那就是 v
3)否则,根据 v 的符号, 转换为 T 所能表示的最大或者最小整数
2.2.4 对象创建和访问指令
虽然类实例和数组都是对象,Java 虚拟机对类实例和数组的创建与操作使用了不同的字节码指令。
1)创建实例的指令:new
2)创建数组的指令:newarray,anewarray,multianewarray
3)访问字段指令:getfield,putfield,getstatic,putstatic
4)把数组元素加载到操作数栈指令:baload,caload,saload,iaload,laload,faload,daload,aaload
5)将操作数栈的数值存储到数组元素中执行:bastore,castore,castore,sastore,iastore,fastore,dastore,aastore
6)取数组长度指令:arraylength JVM 支持方法级同步和方法内部一段指令序列同步,这两种都是通过 moniter 实现的。
7)检查实例类型指令:instanceof,checkcast
2.2.5 操作数栈管理指令
如同操作一个普通数据结构中的堆栈那样,Java 虚拟机提供了一些用于直接操作操作数据的指令,包括:
1)将操作数栈的栈顶一个或两个元素出栈:pop、pop2
2)复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2。
3)将栈最顶端的两个数值互换:swap
2.2.6 控制转移指令
让 JVM 有条件或无条件从指定指令而不是控制转移指令的下一条指令继续执行程序。控制转移指令包括:
1)条件分支:ifeq,iflt,ifle,ifne,ifgt,ifge,ifnull,ifnotnull,if_cmpeq,if_icmpne,if_icmlt,if_icmpgt 等
2)复合条件分支:tableswitch,lookupswitch
3)无条件分支:goto,goto_w,jsr,jsr_w,ret
JVM 中有专门的指令集处理 int 和 reference 类型的条件分支比较操作,为了可以无明显标示一个实体值是否是 null,有专门的指令检测 null 值。boolean 类型和 byte 类型,char 类型和 short 类型的条件分支比较操作,都使用 int 类型的比较指令完成,而 long,float,double 条件分支比较操作,由相应类型的比较运算指令,运算指令会返回一个整型值到操作数栈中,随后再执行 int 类型的条件比较操作完成整个分支跳转。各种类型的比较都最终会转化为 int 类型的比较操作。
2.2.7 方法调用和返回指令
invokevirtual 指令:调用对象的实例方法,根据对象的实际类型进行分派(虚拟机分派)。
invokeinterface 指令:调用接口方法,在运行时搜索一个实现这个接口方法的对象,找出合适的方法进行调用。
invokespecial:调用需要特殊处理的实例方法,包括实例初始化方法,私有方法和父类方法
invokestatic:调用类方法(static)
方法返回指令是根据返回值的类型区分的,包括 ireturn(返回值是 boolean,byte,char,short 和 int),lreturn(long),freturn,drturn(double)和 areturn(引用地址),另外一个 return 供 void 方法,实例初始化方法,类和接口的类初始化 i 方法使用。
2.2.8 异常处理指令
在 Java 程序中显式抛出异常的操作(throw 语句)都有 athrow 指令来实现,除了用 throw 语句显示抛出异常情况外,Java 虚拟机规范还规定了许多运行时异常会在其他 Java 虚拟机指令检测到异常状况时自动抛出。在 Java 虚拟机中,处理异常不是由字节码指令来实现的,而是采用异常表来完成的。
2.2.9 同步指令
方法级的同步是隐式的,无需通过字节码指令来控制,它实现在方法调用和返回操作中。虚拟机从方法常量池中的方法标结构中的 ACC_SYNCHRONIZED 标志区分是否是同步方法。方法调用时,调用指令会检查该标志是否被设置,若设置,执行线程持有 moniter,然后执行方法,最后完成方法时释放 moniter。同步一段指令集序列,通常由 synchronized 块表示,JVM 指令集中有 monitorenter 和 monitorexit 来支持 synchronized 语义。
大多数的指令有前缀和(或)后缀来表明其操作数的类型。
3.字节码实例分析
这一节将给大家分析如何一步一步的分析字节码。
3.1 源代码
有如下简单代码,下面代码是一个简单的 demo,有一个常量,有一个类成员变量,同时方法有三个,一个构造方法,一个 get(),一个静态 main 方法,用来输出信息。
3.2.反编译
用命令行找到我们这段代码所在的路径,输入如下命令:
有关 Javap 命令可以用 help 或者参考 javap 命令,我们这里用的-p,-v 输出所有类和成员信息,以及附加信息(文件路径,文件大小,常量池等等)
3.3.得到如下信息
如果你是第一次用 javap,那你一定会觉得这个是啥又臭又长,别着急下面我会一句一句给你翻译,这里你需要对照上面的字节码指令,一步一步的带你翻译。
3.4.附加信息
部分信息在后面已经注释解释, 我们主要来说一下我们的 Constant pool,常量池:
在 Java 字节码中,有一个常量池,用来存放不同类型的常量。由于 Java 设计的目的之一就是字节码需要经网络传输的,因而字节码需要比较紧凑,以减少网络传输的流量和时间。常量池的存在则可以让一些相同类型的值通过索引(引用)的方式从常量池中找到,而不是在不同地方有不同拷贝,缩减了字节码的大小。
tag 中表示的数据类型,有如下 11 种,:
CONSTANT_Class_info
CONSTANT_Integer_info
CONSTANT_Long\info
CONSTANT_Float_info
CONSTANT_Double_info
CONSTANT_String_info
CONSTANT_Fieldref_info
CONSTANT_Methodref_info
CONSTANT_InterfaceMethodref_info
CONSTANT_NameAndType_info
CONSTANT_Utf8_info
注:在 Java 字节码中,所有 boolean、byte、char、short 类型都是用 int 类型存放,因而在常量池中没有和它们对应的项。 有关常量池的介绍可以参照这里:
http://www.blogjava.net/DLevin/archive/2011/09/05/358033.html
3.5.main 方法分析
这里把 main 方法单独复制了出来,每一句话都进行了解释。
在看下面之前,可以自己尝试一下是否能将 main 方法字节码看懂
思考:这里看懂了之后,大家可以自己尝试下自己写个稍微复杂的字节码,然后进行理解,加深一下映像。
原文链接:https://juejin.cn/post/6844903641489375240
最后,小编还给大家整理了-份面试题库,有需要的添加小编的 vx: mxzFAFAFA 即可免费领取! ! !
版权声明: 本文为 InfoQ 作者【比伯】的原创文章。
原文链接:【http://xie.infoq.cn/article/9339f1960796e8bb1c8684b21】。文章转载请联系作者。
评论