字节码增强:原理与实战

本文由一个拦截器逻辑的使用场景及演变历程,引入字节码增强技术。介绍字节码的本质,字节码增强的原理及JVM 启动过程中的 Agent 加载、生效流程,并对常见字节码操作工具进行了简单应用。

注:本文仅讨论 javaagent “启动时加载”。

一、技术为业务需求服务 

技术是工具,是解决问题的途径。针对不同的业务需求场景,可以使用不同的技术实现。

通过一部拦截器的流浪史来引入主题:

一个简单的demo

1、基础版:新建一个Dog对象,然后调用成员方法输出到控制台

被调用方

4b8efbf405d2bf4bb91e1edc025ccdda.webp

调用方

ead48735e5c2df993735562ad336f40c.webp

2、加强版:需要统计方法执行的时间

常规开发:

被调用方

10dda128ef94ddc6c8de34a92251b187.webp

调用方

682b54bb40030504cf64254d819ee99a.webp

3、从被调用方剥离非业务逻辑

面向对象设计原则,对象应该尽可能专注自己职责范围内的事情,狗只负责叫,不负责统计自己叫了多长时间,因此统计代码应该移出Dog类。

3.1 方法提取

a354eb49ba866d0cb70058f872f4c84a.webp

3.2 类提取–(参考SpringMVC-Interceptor)

5ebfb11fd42a6655771c860f700daa34.webp

eaaf632af5b71430c51a5d4e61673be3.webp

3.3 类解耦合(使用动态代理方式-CGLib/JDK Proxy,这里Dog类没有实现接口,使用CGLib)

6f90587250d73be8eaae7cf56459544c.webp

64b272865a58ad435b942c795ebbf911.webp

7710118f713ea457e80937ff13be4349.webp

至此,非业务逻辑由从被调用方剥离出来了,同时我们也发现调用方代码却遭到改变,Main class里面需要添加动态代理类的处理逻辑。假如不允许改变调用方代码,进一步处理。

4、调用方代码剥离(切面–AspectJ)

切面

687c276f3b993ee8bd6a5765997dcd14.webp

被调方

4b8efbf405d2bf4bb91e1edc025ccdda.webp

调用方

e2852b8faad0450a313e486aae75bcbc.webp

注意:此时直接运行Main class切面不会生效,运行前先进行编译期织入 java -jar $ASPECTJ_TOOLS -cp $ASPECTJ_RT  -sourceroots src/main/java/ -d target/classes ...

至此,调用方不用显式地调用动态代理逻辑,编译期织入到class中去了(这里已经闻到了代码增强的气味了)。

切面逻辑虽然与具体的业务逻辑解耦合了,独立出切面类。但是是否生效仍然由业务代码(切面类)去控制。无论如何,都需要业务方改造,添加切面逻辑代码。

能不能更进一步,连切面都不写,也让切面逻辑生效呢?

5、javaagent 版本–隐式地,无侵入地添加切面逻辑

  • 新建独立的agent工程

  • 添加MANIFEST.MF文件以及Premain-Class,premain属性

  • 编译包含目标逻辑的源文件生成class文件

  • 注册ClassFileTransfer,在transform方法中替换byte[]

  • MANIFEST.MF指定premain函数和打开类增强开关

  • 编译输出jar包

9e01ef76c40f011c30428740f7f159ea.webp

MANIFEST.MF文件。

71e878d95118046d308d8f23a1fd046c.webp

待替换的新class文件(忽略中文乱码)。

48e460a770000ff5d92ee31a21fae7d5.webp

class转换器,将新的Dog.class替换旧的Dog.class。

b3e4035c99b5129a72d988c349d88e34.webp

maven打包输出Agent.jar

6f24c30b1c28c24e0f664313182da93e.webp

上面的javaagent实现细节可以先存疑,后面会深入描述,只需要知道按照这样的步骤可以实现我们的需求。

对于业务方而言:代码完全没有变化:

被调用方

4b8efbf405d2bf4bb91e1edc025ccdda.webp

调用方

e2852b8faad0450a313e486aae75bcbc.webp

想要使切面逻辑生效,只需要在启动命令参数中加入-javaagent 选项,指向 Agent 的 jar 包。

33d2e6251e463f9efc4f9883d37c5fee.webp

这样,拦截器逻辑以一种插件的形式抽取出来了,使用的时候加载插件就可以了。

小结一下

  1. 不同需求场景下,可以不同的方式实现切面拦截逻辑;

  2. AspectJ或者SpringAop只是一种对开发者友好的快捷方式,本质上还是修改的业务代码,只不过隐藏了调用逻辑,并不能真正“无侵入“;

  3. javaagent可以无侵入的修改一个已发布的java组件的运行逻辑。

二、什么是字节码?

byte[]

1、回归原始:JDK 里面提供了很多有用的工具

984048568dd75e35c8019fba250f5c31.webp

在我们刚开始学习 Java 语言时候的 demo 运行:

编写原始 Java 文件:

2419b4ea094a0fa09c7d09b7cafff158.webp

bbdf279439ff53173bff65e0fdd46172.webp

b4404b000b81f57e29b86b0750911a0e.webp

使用 Javac 编译字节码文件:

9fb45721c64fa05bba7aa07415a79c7a.webp

Javac生产的 class 文件有什么作用呢?

Java 语言一次编译,到处运行的核心基础-JVM。

3bb07980a28ab0a763815dc321135836.webp

2、class文件到底是个什么东西?

先用文本编辑器暴力打开看看:

0dd5a8b0490f3a3bcf89c469d2b1f321.webp

看不懂?换个方式:

16a76af0d0a882bedaff2da1413714ab.webp

想看个明白?继续整:使用010editor打开。

e22d5377618732f3807a9aa01d950c1b.webp

各个数据项按顺序紧密的从前向后排列, 相邻的项之间没有间隙, 这样可以使得class文件非常紧凑, 体积轻巧, 可以被JVM快速的加载至内存, 并且占据较少的内存空间。

主要包含的信息:

(1)魔数

(2)版本号(参考文末例子:JRE版本错误)

(3)常量池容量

(4)常量池:

  • 文字字符串, 常量值

  • 当前类的类名, 字段名, 方法名, 各个字段和方法的描述符

  • 对当前类的字段和方法的引用信息, 当前类中对其他类的引用信息等等

(5)其他属性

常量池如何索引:

相互索引:

例如方法索引,获取classIndex和nameAndTypeIndex,通过数组下标,可以找到该方法所属的class和方法名称。

MethodRef

|-----|classIndex
|-----|-----|nameIndex     --→ classNmae
|-----|nameAndTypeIndex
|-----|-----|nameIndex     --→ methodName

常量池索引和字节码指令的执行。

使用jre自带工具javap反编译class文件如下:

Main.class字节码:

ce1dc84a6d9f0e832fc52104be57de96.webp

Dog.class字节码:

9899efcc76ccdf4e4d6248077365679b.webp

可以看到字节码具备一定的可读性,对照着源码,可以按照执行逻辑走一遍字节码执行流程,相关指令的含义很容易从网上查询到。

babc7f39461dab5dc15937ba58ebf612.webp

至此,我们通过一个简单的demo执行流程,大致了解了常量的引用以及一个简单java方法对应的字节码指令执行过程。

注:

stack:最大操作数栈,JVM运行时会根据这个值来分配栈帧(Frame)中的操作栈深度。

locals:局部变量所需的存储空间,单位为Slot。

args_size: 方法参数的个数。

压栈:字节码指令执行过程中涉及到了很多压栈操作:JVM是一个基于栈的架构。方法执行的时候(包括main方法),在栈上会分配一个新的帧,这个栈帧包含一组局部变量。

这组局部变量包含了方法运行过程中用到的所有变量,包括this引用,所有的方法参数,以及其它局部定义的变量。

小结一下

  1. class文件即字节码是所有属性,方法逻辑的合集。

  2. 通过字节码二进制文件将开发者与虚拟机进行了“解耦”。

  3. 推理:修改某些字节或者替换整个二进制流可以修改运行时逻辑 。

三、如何增强字节码? 

byte[] → byte[]

思路:

  1. 如前述方式直接替换为目标逻辑编译后的字节码。

  2. 手术刀式精准操作,修改/添加某些位置的byte。

  3. 高级API。

工具集:/ASM/javaassist/ByteBuddy 等等。

0bb4273016a841485b3285020dec25ec.webp

示例:

ASM

指令级别的字节码操作(性能强悍)。

f88414d28988ac0fe365f67a53b97298.webp

54f7e16ac9e663991a46d9956b47955f.webp

 

指令→ASM api 对应关系(这里将原始类做了简化,将字符串拼接逻辑去掉,仅仅输出时间。因为一个简单的字符串拼接过程,转换成字节码指令可能需要很多行)。

先看看目标源码与字节码指令的一一映射关系。

1f5d0b01bae69d8ce0b52d504d079a27.webp

再看看增强字节码逻辑与目标源码的字节码的一一映射关系。

39a1d2b8346217e1499a4b1051533d8d.webp

通过对比我们可以发现,ASM的API精确到字节码指令级别,所有的临时变量存储,压栈操作,静态/实例方法的调用都有对应的API操作。

javassist:(dubbo)

提供字节码级别的API,类似ASM,不再赘述。

提供源码级别的API,针对本文的案例,实现如下:

8d030c20d824f0736513bfc77f79d4e4.webp

ByteBuddy

基于ASM的高级API,使我们对字节码的操作提升到更抽象层次。开发者只需要知道要实现什么目标,如何使用对应的API,不用关心底层的字节码指令排列,甚至可以不用了解字节码指令。

4e0d368787f15e035cd8766b3f72ba66.webp

d82fc7e87a2a6198c157a2e51dde7e1a.webp

关于相关框架的API不详细说,有兴趣的同学可以自行查询相关资料。

小结一下

  1. 各种级别的API可以帮助开发者轻松实现字节码增强,实现特定逻辑。

  2. 不论什么奇技淫巧,都离不开Instrumentation机制。

四、增强的 byte[] 是如何影响 JVM 的?

Event --> CallBack

由前文总结,引入Instrumentation机制。

1、铺垫知识点:

(1)JVMTI 

JVMTI 是基于事件驱动的,JVM 每执行到一定的逻辑就会调用一些事件的回调接口(如果有的话),这些接口可以供开发者扩展自己的逻辑。

JVMTIAgent 使用JVMTI来查询或控制JVM,JVMTIAgent与目标JVM运行在同一个进程中,通过JVMTI进行通信,最大化控制能力,最小化通信成本。

典型场景下,JVMTI代理会被实现的非常紧凑,其他的进程会与JVMTI代理进行通信。比如jdwp(IDEA远程调试)。

(2)JVMTIAgent

表现形式:

(1)linux:        .so文件

  • windows:  .dll文件

  • c/c++    动态链接库 

(2)JPLISAgent:      .jar文件

命令行参数

(1)-agentlib:agent-lib-name=options

(2)-agentpath:path-to-agent=options

(3)-javaagent:/data/../../Agent.jar

  • 可加载多个,通过options区分     

实现接口

(1)JNIEXPORT jint JNICALL

  • Agent_OnLoad(JavaVM *vm, char *options, void *reserved);

(2)JNIEXPORT jint JNICALL

  • Agent_OnAttach(JavaVM vm, char options, void* reserved);

(3)JNIEXPORT void JNICALL

  • Agent_OnUnload(JavaVM *vm);

JPLISAgent(Java Programming Language Instrumentation Services Agent)--   Instrumentation机制

(1)JavaSE1.5 启动时加载(本文重点)。

(2)JavaSE1.6 运行时加载。

2、简化了的核心流程逻辑

命令参数:-javaagent:/data/../../Agent.jar=optoions。

虚拟机创建-构建并初始化Agent-注册VMInit事件。

虚拟机初始化-触发VMInit事件-Agent start方法-注册回调函数并监听ClassFileLoadHook。

类加载-触发jvmtiEventClassFileLoadHook事件-替换byt[]-ClassLoader解析。

3、Java 虚拟机启动过程中 Agent 相关的流程:

c62fa51b20883cab9208211d87df4a1d.webp

(1)创建JVM的时候初始化agent

  1. 启动时读取jvm命令,-agentlib -agentpath -javaagent,并构建了Agent Library链表构建了Agent Library链表。

  2. 对agent链表中的每个agent,加载所指定的动态库(如instrument.so), 并调用里面的Agent_OnLoad方法。

  3. 创建并初始化 JPLISAgent,初始化了Premain class和包里的配置文件。

  4. 注册VMInit事件。

Agent_onLoad
|-----|createNewJPLISAgent
|-----|-----|initializeJPLISAgent
|-----|-----|-----|eventHandlerVMInit  ---- >   VMInit

5dd1426ebb5aaca3bc593728d5fedd0c.webp

a2307451a235dc61dc4c8405edc9099d.webp

6caaddda293186396f4083eb11de36dc.webp

(2)虚拟机初始化

b1d8dfea2cbc1c8d59d60d059d2e1aec.webp

 

104b489044dc8bba985e06b3099f6f61.webp

725032dc4a52643155d3464832f63b1f.webp

b684ad5a2965855654bc3c28180ce32d.webp

实际上是调用 java 类 sun.instrument.InstrumentationImpl 类里的方法loadClassAndCallPremain。

82d19ea2e4c43cb0e2569700de75b1f2.webp

(3)触发ClassFileLoadHook事件

762d9d0f19edf83a9988835e5600076b.webp

|parseClassFile
|-----|post_class_file_load_hook
|-----|-----|post_to_env
|-----|-----|-----|eventHandlerClassFileLoadHook(jvmtiEventClassFileLoadHook回调函数)
|-----|-----|-----|-----|transformClassFile
|-----|-----|-----|-----|-----|CallObjectMethod
|-----|-----|-----|-----|-----|-----|sun.instrument.InstrumentationImpl.transform()

实际调用的java方法 Instrumentationimpl.transform。

debug过程中通过ClassFileTransformer的transform函数的执行堆栈印证。

到这里,增强的byte[]如何生效并影响运行时class的过程基本可以串起来。

小结一下

  1. 虚拟机创建阶段,初始化agent,解析,加载javaagent jar,注册回调函数监听VMInt事件。

  2. 虚拟机初始化阶段,触发VMInt回调函数,注册回调函数监听ClassFileHook事件,同时执行loadClassAndCallPremain函数,注册transformer。

  3. ClassLoader加载类的时候触发tranform回调,判断是否目标类,进行对应字节码替换。

五、应用

  • 监控

  • 调试

  • 混淆

  • AOP增强

  • 日志记录

非常规应用:IDEA破解。

部分破解教程里面下载插件jar后,会要求你在IDEA的启动参数文件idea.vmoptions中添加一行,就是javaagent参数。

我们可以反编译这个插件jar包看看,发现很多class因为加了混淆,反编译后无法正常识别,但是核心入口Agent.class的主要工作就是注册Transformer,可以推测这些Transformer的功能就是在IDEA启动时之前修改某些鉴定Lisence的逻辑。

六、总结回顾

通过介绍字节码,字节码操作工具以及openJDK关于Instrumention机制的部分源码,探索了字节码增强的实现原理。

简单介绍了相关技术的应用场景。

七、附录 

  • SpringMVC-Interceptor

 

  • IDEA 远程调试

 

  • JRE版本错误


作者: Neo

更多内容敬请关注vivo 互联网技术微信公众号

注:转载文章请先与微信号:Labs2020联系

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值