Java 字节码
字节码简介
什么是字节码
Java 字节码是 Java 虚拟机执行的一种指令格式。之所以被称之为字节码,是因为:Java 字节码文件(.class
)是一种以 8 位字节为基础单位的二进制流文件,各个数据项严格按照顺序紧凑地排列在 .class 文件中,中间没有添加任何分隔符。整个 .class 文件本质上就是一张表。
Java 能做到 “一次编译,到处运行”,一是因为 JVM 针对各种操作系统、平台都进行了定制;二是因为无论在什么平台,都可以编译生成固定格式的 Java 字节码文件(.class
)。
反编译字节码文件
我们通过一个示例来讲解如何反编译字节码文件。
(1)首先,创建一个 Demo.java
文件,内容如下:
1 | public class Demo { |
(2)执行 javac Demo.java
,编译 Demo.java
文件,在当前路径下生成一个 Demo.class
文件。
文件内容如下,是一堆 16 进制数:
1 | ca fe ba be 20 20 20 34 20 1d 0a 20 06 20 0f 09 20 10 20 11 08 20 12 0a 20 13 20 14 07 20 15 07 20 16 01 20 06 3c 69 6e 69 74 3e 01 20 03 28 29 56 01 20 04 43 6f 64 65 01 20 0f 4c 69 6e 65 4e 75 6d 62 65 72 54 61 62 6c 65 01 20 04 6d 61 69 6e 01 20 16 28 5b 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 3b 29 56 01 20 0a 53 6f 75 72 63 65 46 69 6c 65 01 20 09 44 65 6d 6f 2e 6a 61 76 61 0c 20 07 20 08 07 20 17 0c 20 18 20 19 01 20 0b 68 65 6c 6c 6f 20 77 6f 72 6c 64 07 20 1a 0c 20 1b 20 1c 01 20 26 69 6f 2f 67 69 74 68 75 62 2f 64 75 6e 77 75 2f 6a 61 76 61 63 6f 72 65 2f 62 79 74 65 63 6f 64 65 2f 44 65 6d 6f 01 20 10 6a 61 76 61 2f 6c 61 6e 67 2f 4f 62 6a 65 63 74 01 20 10 6a 61 76 61 2f 6c 61 6e 67 2f 53 79 73 74 65 6d 01 20 03 6f 75 74 01 20 15 4c 6a 61 76 61 2f 69 6f 2f 50 72 69 6e 74 53 74 72 65 61 6d 3b 01 20 13 6a 61 76 61 2f 69 6f 2f 50 72 69 6e 74 53 74 72 65 61 6d 01 20 07 70 72 69 6e 74 6c 6e 01 20 15 28 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 3b 29 56 20 21 20 05 20 06 20 20 20 20 20 02 20 01 20 07 20 08 20 01 20 09 20 20 20 1d 20 01 20 01 20 20 20 05 2a b7 20 01 b1 20 20 20 01 20 0a 20 20 20 06 20 01 20 20 20 03 20 09 20 0b 20 0c 20 01 20 09 20 20 20 25 20 02 20 01 20 20 20 09 b2 20 02 12 03 b6 20 04 b1 20 20 20 01 20 0a 20 20 20 0a 20 02 20 20 20 06 20 08 20 07 20 01 20 0d 20 20 20 02 20 0e |
前面已经提过:Java 字节码文件(.class
)是一种以 8 位字节为基础单位的二进制流文件,各个数据项严格按照顺序紧凑地排列在 .class 文件中,中间没有添加任何分隔符。
(3)使用到 Java 内置的反编译工具 javap
可以反编译字节码文件。
执行 javap -verbose -p Demo.class
,控制台会输出相对而言,可以理解的指令。输出内容大致如下:
1 | Classfile /D:/Workspace/Demo.class |
提示:通过
javap -help
可了解javap
的基本用法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 用法: javap <options> <classes>
其中, 可能的选项包括:
-help --help -? 输出此用法消息
-version 版本信息
-v -verbose 输出附加信息
-l 输出行号和本地变量表
-public 仅显示公共类和成员
-protected 显示受保护的/公共类和成员
-package 显示程序包/受保护的/公共类
和成员 (默认)
-p -private 显示所有类和成员
-c 对代码进行反汇编
-s 输出内部类型签名
-sysinfo 显示正在处理的类的
系统信息 (路径, 大小, 日期, MD5 散列)
-constants 显示最终常量
-classpath <path> 指定查找用户类文件的位置
-cp <path> 指定查找用户类文件的位置
-bootclasspath <path> 覆盖引导类文件的位置
字节码文件结构
字节码看似杂乱无序,实际上是由严格的格式要求组成的。
魔数
每个 .class
文件的头 4 个字节称为 **魔数(magic_number)
**,它的唯一作用是确定这个文件是否为一个能被虚拟机接收的 .class
文件。魔数的固定值为:0xCAFEBABE
。
版本号
版本号(version)有 4 个字节,前两个字节表示次版本号(Minor Version),后两个字节表示主版本号(Major Version)。
举例来说,如果版本号为:“00 00 00 34”。那么,次版本号转化为十进制为 0,主版本号转化为十进制为 52,在 Oracle 官网中查询序号 52 对应的主版本号为 1.8,所以编译该文件的 Java 版本号为 1.8.0。
常量池
紧接着主版本号之后的字节为常量池(constant_pool),常量池可以理解为 .class
文件中的资源仓库。
常量池整体上分为两部分:常量池计数器以及常量池数据区
常量池计数器(constant_pool_count) - 由于常量的数量不固定,所以需要先放置两个字节来表示常量池容量计数值。
常量池数据区 - 数据区的每一项常量都是一个表,且结构各不相同。
常量池主要存放两类常量:
- 字面量 - 如文本字符串、声明为
final
的常量值。 - 符号引用
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
访问标志
紧接着常量池的 2 个字节代表访问标志(access_flags),这个标志用于识别一些类或者接口的访问信息,描述该 Class 是类还是接口,以及是否被 public
、abstract
、final
等修饰符修饰。
访问标志有以下类型:
标志名称 | 标志值 | 含义 |
---|---|---|
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 | 标志这是一个枚举 |
类索引、父类索引、接口索引
类索引(this_class)和父类索引都是一个 u2 类型的数据,而接口索引集合是一组 u2 类型的数据的集合。**.class 文件中由这 3 项数据来确定这个类的继承关系**。
字段表
字段表用于描述类和接口中声明的变量。包含类级变量以及实例级变量,但是不包含方法内部声明的局部变量。
字段表也分为两部分,第一部分为两个字节,描述字段个数;第二部分是每个字段的详细信息 fields_info。
方法表
字段表结束后为方法表,方法表的结构如同字段表一样,依次包括了访问标志、名称索引、描述符索引、属性表集合几项。
属性表集合
属性表集合存放了在该文件中类或接口所定义属性的基本信息。
字节码指令
字节码指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零到多个代表此操作所需参数(Operands)而构成。由于 JVM 采用面向操作数栈架构而不是寄存器架构,所以大多数的指令都不包括操作数,只有一个操作码。
JVM 操作码的长度为 1 个字节,因此指令集的操作码最多只有 256 个。
字节码操作大致分为 9 类:
- 加载和存储指令
- 运算指令
- 类型转换指令
- 对象创建与访问指令
- 操作数栈管理指令
- 控制转移指令
- 方法调用和返回指令
- 异常处理指令
- 同步指令
字节码增强
字节码增强技术就是一类对现有字节码进行修改或者动态生成全新字节码文件的技术。
常见的字节码增强框架有:
- asm -
- javassist - Javassist 的是通过控制底层字节码来实现动态代理,不需要反射完成调用,所以性能肯定比 JDK 的动态代理方式性能要好。
- Byte Buddy - Byte Buddy 则属于后起之秀,在很多优秀的项目中,像 Spring、Jackson 都用到了 Byte Buddy 来完成底层代理。相比 Javassist,Byte Buddy 提供了更容易操作的 API,编写的代码可读性更高。更重要的是,生成的代理类执行速度比 Javassist 更快。
Asm
对于需要手动操纵字节码的需求,可以使用 Asm,它可以直接生产 .class
字节码文件,也可以在类被加载入 JVM 之前动态修改类行为。
Asm 的应用场景有 AOP(Cglib 就是基于 Asm)、热部署、修改其他 jar 包中的类等。当然,涉及到如此底层的步骤,实现起来也比较麻烦。
Asm 有两类 API:核心 API 和树形 API
- 核心 API - Asm Core API 可以类比解析 XML 文件中的 SAX 方式,不需要把这个类的整个结构读取进来,就可以用流式的方法来处理字节码文件。好处是非常节约内存,但是编程难度较大。然而出于性能考虑,一般情况下编程都使用 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 节点,就可以很好地理解这种编程方式。
Javassist
利用 Javassist 实现字节码增强时,可以无须关注字节码刻板的结构,其优点就在于编程简单。直接使用 java 编码的形式,而不需要了解虚拟机指令,就能动态改变类的结构或者动态生成类。
其核心类如下:
CtClass(compile-time class)
- 编译时类信息。它是一个class
文件在代码中的抽象,可以通过一个类的全限定名来获取一个CtClass
对象,用来表示这个类文件。ClassPool
-ClassPool
可以看成一张保存CtClass
信息的 HashTable,key 为类名,value 为类名对应的CtClass
对象。当我们需要对某个类进行修改时,就是通过pool.getCtClass("className")
方法从 pool 中获取到相应的CtClass
。CtMethod
、CtField
- 对应的是类中的方法和属性。
运行时类的重载
Instrument
instrument 是 JVM 提供的一个可以修改已加载类的类库,专门为 Java 语言编写的插桩服务提供支持。它需要依赖 JVMTI 的 Attach API 机制实现。在 JDK 1.6 以前,instrument 只能在 JVM 刚启动开始加载类时生效,而在 JDK 1.6 之后,instrument 支持了在运行时对类定义的修改。要使用 instrument 的类修改功能,我们需要实现它提供的 ClassFileTransformer 接口,定义一个类文件转换器。接口中的 transform()方法会在类文件被加载时调用,而在 transform 方法里,可以利用 ASM 或 Javassist 对传入的字节码进行改写或替换,生成新的字节码数组后返回。
JavaAgent
Javaagent 是什么?
Javaagent 是 java 命令的一个参数。参数 javaagent 可以用于指定一个 jar 包,它利用 JVM 提供的 Instrumentation API 来更改加载 JVM 中的现有字节码。
- 这个 jar 包的 MANIFEST.MF 文件必须指定 Premain-Class 项。
- Premain-Class 指定的那个类必须实现 premain() 方法。
premain 方法,从字面上理解,就是运行在 main 函数之前的的类。当 Java 虚拟机启动时,在执行 main 函数之前,JVM 会先运行-javaagent
所指定 jar 包内 Premain-Class 这个类的 premain 方法 。
在命令行输入 java
可以看到相应的参数,其中有 和 java agent 相关的:
1 | -agentlib:<libname>[=<选项>] |
Java Agent 技术简介
Java Agent 直译为 Java 代理,也常常被称为 Java 探针技术。
Java Agent 是在 JDK1.5 引入的,是一种可以动态修改 Java 字节码的技术。Java 中的类编译后形成字节码被 JVM 执行,在 JVM 在执行这些字节码之前获取这些字节码的信息,并且通过字节码转换器对这些字节码进行修改,以此来完成一些额外的功能。
Java Agent 是一个不能独立运行 jar 包,它通过依附于目标程序的 JVM 进程,进行工作。启动时只需要在目标程序的启动参数中添加-javaagent 参数添加 ClassFileTransformer 字节码转换器,相当于在 main 方法前加了一个拦截器。
Java Agent 功能介绍
Java Agent 主要有以下功能
- Java Agent 能够在加载 Java 字节码之前拦截并对字节码进行修改;
- Java Agent 能够在 Jvm 运行期间修改已经加载的字节码;
Java Agent 的应用场景
- IDE 的调试功能,例如 Eclipse、IntelliJ IDEA ;
- 热部署功能,例如 JRebel、XRebel、spring-loaded;
- 各种线上诊断工具,例如 Btrace、Greys,还有阿里的 Arthas;
- 各种性能分析工具,例如 Visual VM、JConsole 等;
- 全链路性能检测工具,例如 Skywalking、Pinpoint 等;
Java Agent 实现原理
在了解 Java Agent 的实现原理之前,需要对 Java 类加载机制有一个较为清晰的认知。一种是在 man 方法执行之前,通过 premain 来执行,另一种是程序运行中修改,需通过 JVM 中的 Attach 实现,Attach 的实现原理是基于 JVMTI。
主要是在类加载之前,进行拦截,对字节码修改
下面我们分别介绍一下这些关键术语:
JVMTI 就是 JVM Tool Interface,是 JVM 暴露出来给用户扩展使用的接口集合,JVMTI 是基于事件驱动的,JVM 每执行一定的逻辑就会触发一些事件的回调接口,通过这些回调接口,用户可以自行扩展
JVMTI 是实现 Debugger、Profiler、Monitor、Thread Analyser 等工具的统一基础,在主流 Java 虚拟机中都有实现
JVMTIAgent是一个动态库,利用 JVMTI 暴露出来的一些接口来干一些我们想做、但是正常情况下又做不到的事情,不过为了和普通的动态库进行区分,它一般会实现如下的一个或者多个函数:
- Agent_OnLoad 函数,如果 agent 是在启动时加载的,通过 JVM 参数设置
- Agent_OnAttach 函数,如果 agent 不是在启动时加载的,而是我们先 attach 到目标进程上,然后给对应的目标进程发送 load 命令来加载,则在加载过程中会调用 Agent_OnAttach 函数
- Agent_OnUnload 函数,在 agent 卸载时调用
javaagent 依赖于 instrument 的 JVMTIAgent(Linux 下对应的动态库是 libinstrument.so),还有个别名叫 JPLISAgent(Java Programming Language Instrumentation Services Agent),专门为 Java 语言编写的插桩服务提供支持的
instrument 实现了 Agent_OnLoad 和 Agent_OnAttach 两方法,也就是说在使用时,agent 既可以在启动时加载,也可以在运行时动态加载。其中启动时加载还可以通过类似-javaagent:jar 包路径的方式来间接加载 instrument agent,运行时动态加载依赖的是 JVM 的 attach 机制,通过发送 load 命令来加载 agent
JVM Attach 是指 JVM 提供的一种进程间通信的功能,能让一个进程传命令给另一个进程,并进行一些内部的操作,比如进行线程 dump,那么就需要执行 jstack 进行,然后把 pid 等参数传递给需要 dump 的线程来执行
Java Agent 案例
加载 Java 字节码之前拦截
App 项目
(1)创建一个名为 javacore-javaagent-app
的 maven 工程
1 |
|
(2)创建一个应用启动类
1 | public class AppMain { |
(3)创建一个模拟应用初始化的类
1 | public class AppInit { |
(4)输出
1 | APP 启动!!! |
Agent 项目
(1)创建一个名为 javacore-javaagent-agent
的 maven 工程
1 | <?xml version="1.0" encoding="UTF-8"?> |
(2)创建一个 Agent 启动类
1 | public class RunTimeAgent { |
这里每个类加载的时候都会走这个方法,我们可以通过 className 进行指定类的拦截,然后借助 javassist 这个工具,进行对 Class 的处理,这里的思想和反射类似,但是要比反射功能更加强大,可以动态修改字节码。
(3)使用 javassist 拦截指定类,并进行代码增强
1 | package io.github.dunwu.javacore.javaagent; |
(4)输出
指定 VM 参数 -javaagent:F:\code\myCode\agent-test\runtime-agent\target\runtime-agent-1.0-SNAPSHOT.jar=hello,运行 AppMain
1 | 探针启动!!! |
运行时拦截(JDK 1.6 及以上)
如何实现在程序运行时去完成动态修改字节码呢?
动态修改字节码需要依赖于 JDK 为我们提供的 JVM 工具,也就是上边我们提到的 Attach,通过它去加载我们的代理程序。
首先我们在代理程序中需要定义一个名字为 agentmain 的方法,它可以和上边我们提到的 premain 是一样的内容,也可根据 agentmain 的特性进行自己逻辑的开发。
1 | /** |
然后就是我们需要将配置中设置,让其知道我们的探针需要加载这个类,在 maven 中设置如下,如果是 META-INF/MANIFEST.MF 文件同理。
1 | <!--<Premain-Class>com.zhj.agent.agentmain.RunTimeAgent</Premain-Class>--> |
这样其实我们的探针就已经改造好了,然后我们需要在目标程序的 main 方法中植入一些代码,使其可以读取到我们的代理程序,这样我们也无需去配置 JVM 的参数,就可以加载探针程序。
1 | public class APPMain { |
其中 VirtualMachine 是 JDK 工具包下的类,如果系统环境变量没有配置,需要自己在 Maven 中引入本地文件。
1 | <dependency> |
这样我们在程序启动后再去动态修改字节码文件的简单案例就完成了。
字节码工具
- jclasslib - IDEA 插件,可以直观查看当前字节码文件的类信息、常量池、方法区等信息。
- classpy - Classpy 是一个用于研究 Java 类文件、Lua 二进制块、Wasm 二进制代码和其他二进制文件格式的 GUI 工具。
- ASM ByteCode Outline - 利用 ASM 手写字节码时,需要利用一系列 visitXXXXInsn() 方法来写对应的助记符,所以需要先将每一行源代码转化为一个个的助记符,然后通过 ASM 的语法转换为 visitXXXXInsn() 这种写法。第一步将源码转化为助记符就已经够麻烦了,不熟悉字节码操作集合的话,需要我们将代码编译后再反编译,才能得到源代码对应的助记符。第二步利用 ASM 写字节码时,如何传参也很令人头疼。ASM 社区也知道这两个问题,所以提供了此工具。