JVM-基础-类字节码详解
Java源代码通过编译器编译为字节码,再通过类加载子系统进行加载到JVM中运行。
多语言编译为字节码在JVM运行
计算机是不能直接运行java代码的,必须要先运行java虚拟机,再由java虚拟机运行编译后的java代码。
这个编译后的java代码,就是本文要介绍的java字节码。
为什么jvm不能直接运行java代码呢,这是因为在cpu层面看来计算机中所有的操作都是一个个指令的运行汇集而成的,java是高级语言,只有人类才能理解其逻辑,计算机是无法识别的,所以java代码必须要先编译成字节码文件,jvm才能正确识别代码转换后的指令并将其运行。
- Java代码间接翻译成字节码,储存字节码的文件再交由运行于不同平台上的JVM虚拟机去读取执行,从而实现一次编写,到处运行的目的。
- JVM不只支持Java,由此衍生出了许多基于JVM的编程语言,如Groovy, Scala, Koltin等等。
Java字节码文件
class文件本质上是一个以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑的排列在class文件中。jvm根据其特定的规则解析该二进制数据,从而得到相关信息。
Class文件采用一种伪结构来存储数据,它有两种类型:无符号数和表。这里暂不详细的讲。
本文将通过简单的java例子编译后的文件来理解。
Class文件的结构属性
在理解之前先从整体看下java字节码文件包含了哪些类型的数据:
从一个例子开始
下面以一个简单的例子来逐步讲解字节码。
1 | //Main.java |
通过以下命令, 可以在当前所在路径下生成一个Main.class文件。
1 | javac Main.java |
以文本的形式打开生成的class文件,内容如下:
1 | cafe babe 0000 0034 0013 0a00 0400 0f09 |
- 文件开头的4个字节(“cafe babe”)称之为
魔数
,唯有以”cafe babe”开头的class文件方可被虚拟机所接受,这4个字节就是字节码文件的身份识别。 - 0000是编译器jdk版本的次版本号0,0034转化为十进制是52,是主版本号,java的版本号从45开始,除1.0和1.1都是使用45.x外了,以后每升一个大版本,版本号加一。也就是说,编译生成该class文件的jdk版本为1.8.0。
- 继续往下是常量池…
反编译字节码文件
使用到java内置的一个反编译工具javap可以反编译字节码文件, 用法: javap <options> <classes>
其中<options>
选项包括:
1 | -help --help -? 输出此用法消息 |
输入命令javap -verbose -p Main.class
查看输出内容:
1 | Classfile /E:/JavaCode/TestProj/out/production/TestProj/com/rhythm7/Main.class |
字节码文件信息
开头的7行信息包括:Class文件当前所在位置,最后修改时间,文件大小,MD5值,编译自哪个文件,类的全限定名,jdk次版本号,主版本号。
然后紧接着的是该类的访问标志。
标志名称 | 标志值 | 含义 |
---|---|---|
ACC_PUBLIC | 0x0001 | 是否为Public类型 |
ACC_FINAL | 0x0010 | 是否被声明为final,只有类可以设置 |
ACC_SUPER | 0x0020 | 是否允许使用invokespecial字节码指令的新语义 |
ACC_INTERFACE | 0x0200 | 标志这是一个接口 |
ACC_ABSTRACT | 0x0400 | 是否为abstract类型,对于接口或者抽象类来说,次标志值为真,其他类型为假 |
ACC_SYNTHETIC | 0x1000 | 标志这个类并非由用户代码产生 |
ACC_ANNOTATION | 0x2000 | 标志这是一个注解 |
ACC_ENUM | 0x4000 | 标志这是一个枚举 |
常量池
Constant pool
意为常量池。
常量池可以理解成Class文件中的资源仓库。主要存放的是两大类常量:字面量(Literal)和符号引用(Symbolic References)。
字面量类似于java中的常量概念,如文本字符串,final常量等。
符号引用则属于编译原理方面的概念,包括以下三种:
- 类和接口的全限定名(Fully Qualified Name)
- 字段的名称和描述符号(Descriptor)
- 方法的名称和描述符
不同于C/C++, JVM是在加载Class文件的时候才进行的动态链接,也就是说这些字段和方法符号引用只有在运行期转换后才能获得真正的内存入口地址。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建或运行时解析并翻译到具体的内存地址中。
直接通过反编译文件来查看字节码内容:
1 | #1 = Methodref #4.#18 // java/lang/Object."<init>":()V |
第一个常量是一个方法定义,指向了第4和第18个常量。以此类推查看第4和第18个常量。最后可以拼接成第一个常量右侧的注释内容:
1 | java/lang/Object."<init>":()V |
这段可以理解为该类的实例构造器的声明,由于Main类没有重写构造方法,所以调用的是父类的构造方法。此处也说明了Main类的直接父类是Object。 该方法默认返回值是V, 也就是void,无返回值。
第二个常量同理可得:
1 | #2 = Fieldref #3.#19 // com/rhythm7/Main.m:I |
关于字节码的类型对应如下:
标识字符 | 含义 |
---|---|
B | 基本类型byte |
C | 基本类型char |
D | 基本类型double |
F | 基本类型float |
I | 基本类型int |
J | 基本类型long |
S | 基本类型short |
Z | 基本类型boolean |
V | 特殊类型void |
L | 对象类型,以分号结尾,如Ljava/lang/Object; |
对于数组类型,每一位使用一个前置的[
字符来描述,如定义一个**java.lang.String[][]**类型的维数组,将被记录为[[Ljava/lang/String;
方法表集合
在常量池之后的是对类内部的方法描述,在字节码中以表的集合形式表现,暂且不管字节码文件的16进制文件内容如何,我们直接看反编译后的内容。
1 | private int m; |
此处声明了一个私有变量m,类型为int,返回值为int
1 | public com.rhythm7.Main(); |
这里是构造方法:Main(),返回值为void, 公开方法。
code内的主要属性为:
- stack: 最大操作数栈,JVM运行时会根据这个值来分配栈帧(Frame)中的操作栈深度,此处为1
- locals: 局部变量所需的存储空间,单位为Slot, Slot是虚拟机为局部变量分配内存时所使用的最小单位,为4个字节大小。方法参数(包括实例方法中的隐藏参数this),显示异常处理器的参数(try catch中的catch块所定义的异常),方法体中定义的局部变量都需要使用局部变量表来存放。值得一提的是,locals的大小并不一定等于所有局部变量所占的Slot之和,因为局部变量中的Slot是可以重用的。
- args_size: 方法参数的个数,这里是1,因为每个实例方法都会有一个隐藏参数this
- attribute_info: 方法体内容,0,1,4为字节码”行号”,该段代码的意思是将第一个引用类型本地变量推送至栈顶,然后执行该类型的实例方法,也就是常量池存放的第一个变量,也就是注释里的java/lang/Object.””:()V, 然后执行返回语句,结束方法。
- LineNumberTable: 该属性的作用是描述源码行号与字节码行号(字节码偏移量)之间的对应关系。可以使用 -g:none 或-g:lines选项来取消或要求生成这项信息,如果选择不生成LineNumberTable,当程序运行异常时将无法获取到发生异常的源码行号,也无法按照源码的行数来调试程序。
- LocalVariableTable: 该属性的作用是描述帧栈中局部变量与源码中定义的变量之间的关系。可以使用 -g:none 或 -g:vars来取消或生成这项信息,如果没有生成这项信息,那么当别人引用这个方法时,将无法获取到参数名称,取而代之的是arg0, arg1这样的占位符。 start 表示该局部变量在哪一行开始可见,length表示可见行数,Slot代表所在帧栈位置,Name是变量名称,然后是类型签名。
类名
最后很显然是源码文件:
1 | SourceFile: "Main.java" |
再看两个示例
分析try-catch-finally
1 | public class TestCode { |
试问当不发生异常和发生异常的情况下,foo()的返回值分别是多少。
查看字节码的foo方法内容:
1 | javac TestCode.java |
1 | public int foo(); |
在字节码的4,5,以及13,14中执行的是同一个操作,就是将int型的3入操作数栈顶,并存入第二个局部变量。这正是我们源码在finally语句块中内容。也就是说,JVM在处理异常时,会在每个可能的分支都将finally语句重复执行一遍。
通过一步步分析字节码,可以得出最后的运行结果是:
- 不发生异常时: return 1
- 发生异常时: return 2
- 发生非Exception及其子类的异常,抛出异常,不返回值
Kotlin函数扩展的实现
字节码的增强技术
字节码增强技术就是一类对现有字节码进行修改或者动态生成全新字节码文件的技术。
实现方式 - ASM
对于需要手动操纵字节码的需求,可以使用ASM,它可以直接生产.class
字节码文件,也可以在类被加载入JVM之前动态修改类行为(如下图所示)。
ASM的应用场景有AOP(Cglib就是基于ASM)、热部署、修改其他jar包中的类等。当然,涉及到如此底层的步骤,实现起来也比较麻烦。
接下来,本文将介绍ASM的两种API,并用ASM来实现一个比较粗糙的AOP。
但在此之前,为了让大家更快地理解ASM的处理流程,强烈建议读者先对访问者模式进行了解。
简单来说,访问者模式主要用于修改或操作一些数据结构比较稳定的数据。我们知道字节码文件的结构是由JVM固定的,所以很适合利用访问者模式对字节码文件进行修改。
ASM API
- 核心API
ASM Core API可以类比解析XML文件中的SAX(Simple API for XML)方式,不需要把这个类的整个结构读取进来,就可以用流式的方法来处理字节码文件。
好处是非常节约内存,但是编程难度较大。然而出于性能考虑,一般情况下编程都使用Core API。
在Core API中有以下几个关键类:- ClassReader:用于读取已经编译好的.class文件。
- ClassWriter:用于重新构建编译后的类,如修改类名、属性以及方法,也可以生成新的类的字节码文件。
- 各种Visitor类:如上所述,CoreAPI根据字节码从上到下依次处理,对于字节码文件中不同的区域有不同的Visitor,比如用于访问方法的MethodVisitor、用于访问类变量的FieldVisitor、用于访问注解的AnnotationVisitor等。为了实现AOP,重点要使用的是MethodVisitor。
- 树形API
ASM Tree API可以类比解析XML文件中的DOM方式,把整个类的结构读取到内存中,缺点是消耗内存多,但是编程比较简单。
TreeApi不同于CoreAPI,TreeAPI通过各种Node类来映射字节码的各个区域,类比DOM节点,就可以很好地理解这种编程方式。
直接利用ASM实现AOP
利用ASM的CoreAPI来增强类。
这里不纠结于AOP的专业名词如切片、通知,只实现在方法调用前、后增加逻辑,通俗易懂且方便理解。
首先定义需要被增强的Base类:其中只包含一个process()方法,方法内输出一行“process”。
增强后,我们期望的是,方法执行前输出“start”,之后输出”end”。
1 | public class Base { |
为了利用ASM实现AOP,需要定义两个类:
- 一个是MyClassVisitor类,用于对字节码的visit以及修改;
- 另一个是Generator类,在这个类中定义ClassReader和ClassWriter,其中的逻辑是,classReader读取字节码,然后交给MyClassVisitor类处理,处理完成后由ClassWriter写字节码并将旧的字节码替换掉。
Generator类较简单,先看一下它的实现,如下所示,然后重点解释MyClassVisitor类。
1 | import org.objectweb.asm.ClassReader; |
MyClassVisitor继承自ClassVisitor,用于对字节码的观察。它还包含一个内部类MyMethodVisitor,继承自MethodVisitor用于对类内方法的观察,它的整体代码如下:
1 | import org.objectweb.asm.ClassVisitor; |
利用这个类就可以实现对字节码的修改。详细解读其中的代码,对字节码做修改的步骤是:
- 首先通过MyClassVisitor类中的visitMethod方法,判断当前字节码读到哪一个方法了。跳过构造方法
<init>
后,将需要被增强的方法交给内部类MyMethodVisitor来进行处理。 - 接下来,进入内部类MyMethodVisitor中的visitCode方法,它会在ASM开始访问某一个方法的Code区时被调用,重写visitCode方法,将AOP中的前置逻辑就放在这里。 MyMethodVisitor继续读取字节码指令,每当ASM访问到无参数指令时,都会调用MyMethodVisitor中的visitInsn方法。我们判断了当前指令是否为无参数的“return”指令,如果是就在它的前面添加一些指令,也就是将AOP的后置逻辑放在该方法中。
- 综上,重写MyMethodVisitor中的两个方法,就可以实现AOP了,而重写方法时就需要用ASM的写法,手动写入或者修改字节码。通过调用methodVisitor的visitXXXXInsn()方法就可以实现字节码的插入,XXXX对应相应的操作码助记符类型,比如mv.visitLdcInsn(“end”)对应的操作码就是ldc “end”,即将字符串“end”压入栈。 完成这两个visitor类后,运行Generator中的main方法完成对Base类的字节码增强,增强后的结果可以在编译后的target文件夹中找到Base.class文件进行查看,可以看到反编译后的代码已经改变了。
实现方式 - Javassist
ASM是在指令层次上操作字节码的,直观感受是在指令层次上操作字节码的框架实现起来比较晦涩。故除此之外,简单介绍另外一类框架:强调源代码层次操作字节码的框架Javassist。
利用Javassist实现字节码增强时,可以无须关注字节码刻板的结构,其优点就在于编程简单。直接使用java编码的形式,而不需要了解虚拟机指令,就能动态改变类的结构或者动态生成类。其中最重要的是ClassPool、CtClass、CtMethod、CtField这四个类:
- CtClass(compile-time class):编译时类信息,它是一个class文件在代码中的抽象表现形式,可以通过一个类的全限定名来获取一个CtClass对象,用来表示这个类文件。
- ClassPool:从开发视角来看,ClassPool是一张保存CtClass信息的HashTable,key为类名,value为类名对应的CtClass对象。当需要对某个类进行修改时,就是通过pool.getCtClass(“className”)方法从pool中获取到相应的CtClass。
- CtMethod、CtField:这两个比较好理解,对应的是类中的方法和属性。
1 | import com.meituan.mtrace.agent.javassist.*; |
运行时类的重载
如果我们在一个JVM中,先加载了一个类,然后又对其进行字节码增强并重新加载会发生什么呢?
模拟这种情况,只需要我们在上文中Javassist的Demo中main()方法的第一行添加Base b=new Base()
,即在增强前就先让JVM加载Base类,然后在执行到cc.toClass()方法时会抛出错误。
跟进cc.toClass()方法中,我们会发现它是在最后调用了ClassLoader的native方法defineClass()时报错。
也就是说,JVM是不允许在运行时动态重载一个类的。
显然,如果只能在类加载前对类进行强化,那字节码增强技术的使用场景就变得很窄了。
我们期望的效果是:在一个持续运行并已经加载了所有类的JVM中,还能利用字节码增强技术对其中的类行为做替换并重新加载。
那如何解决JVM不允许运行时重加载类信息的问题呢?
Instrument
JVMTI & Agent & Attach API
使用场景
通过上述几个类库,我们可以在运行时对JVM中的类进行修改并重载了。通过这种手段,可以做的事情就变得很多了:
- 热部署:不部署服务而对线上服务做修改,可以做打点、增加日志等操作。
- Mock:测试时候对某些服务做Mock。
- 性能诊断工具:比如bTrace就是利用Instrument,实现无侵入地跟踪一个正在运行的JVM,监控到类和方法级别的状态信息。