当前位置: 首页 > news >正文

java程序什么时候需要在运行的时候动态修改字节码对象

一、java程序什么时候需要在运行的时候动态修改字节码对象

我认为有两种场景,一种是无法修改源代码的时候;另外一种是功能增强的时候。

1、无法修改源代码

举个例子,java程序依赖的第三方的jar包中发现了bug,但是官方还没有修复,本地通过debug已经发现了解决方法,该如何修复该问题呢?

在spring程序中,如果目标对象在spring容器中,可以通过Spring AOP创建切面解决。但是如果目标对象并没有在spring容器中,或者干脆程序根本不是spring技术栈中的,问题就比较麻烦了,因为无法创建切面拦截目标方法执行。

这时候很容易想到,如果能在不修改第三方源代码的基础上做到修复第三方的bug就好了,这时候使用字节码修改工具动态的修改字节码对象是比较常见的方法。

2、功能增强

在fastjson框架中就是用了asm工具直接操作字节码替代反射技术以加快执行速度。

二、如何在运行的时候修改字节码对象

常见的字节码修改工具有asm和javassist两种,asm工具是直接操作字节码对象底层的,使用它需要对字节码数据结构有很深入的理解;javassist相对于asm工具来说就很亲民了,它提供了两种级别的API:源级别和字节码级别,如果用户使用源代码级API,他们可以不需要了解Java字节码的规范的前提下编辑类文件,这得使操作Java字节码变得简单。

由于技术水平有限,这里使用javassist工具进行字节码修改的操作。

以下程序使用javassist工具演示如何在运行中动态的整体替换掉一个方法中的所有内容。

首先创建一个类Test1

package com.kdyzm;import lombok.extern.slf4j.Slf4j;/*** @author kdyzm* @date 2022/1/29*/
@Slf4j
public class Test1 {public void sayHi() {log.info("Hello,world");}
}

然后创建主类Main

package com.kdyzm;import javassist.*;
import lombok.extern.slf4j.Slf4j;/*** @author kdyzm* @date 2022/1/29*/
@Slf4j
public class Main {public static void main(String[] args) throws NotFoundException, CannotCompileException {ClassPool classPool = ClassPool.getDefault();classPool.appendClassPath(new LoaderClassPath(Thread.currentThread().getContextClassLoader()));String clsName = "com.kdyzm.Test1";CtClass ctClass = classPool.get(clsName);CtMethod ctMethod = ctClass.getDeclaredMethod("sayHi");ctMethod.setBody("log.info(\"Hello,kdyzm\");");ctClass.toClass();// 释放对象ctClass.detach();new Test1().sayHi();}
}

在以上代码中,Test1对象本应当打印输出

Hello,world

但是在运行中被我将sayHi方法体替换成了

log.info("Hello,kdyzm");

所以,最终方法的执行结果是

Hello,kdyzm

当然,这是一个最简单的代码示例。更多的高级用法可以参考CtMethod使用文档:

Javassist Tutorial

三、使用Javassist的弊端

一个显而易见的弊端就是替换的方法内容不能过于复杂,否则代码的可读性会变的非常差,调试和修改会变的非常困难,比如下面一段代码

image-20220301155749929

这段代码不算很复杂,但是调试和修改已经非常困难(因为没法断点,编写代码逻辑的时候没有代码提示),而且由于代码作为字符串显示在源代码中,没有代码高亮,再加上换行符,如果没有代码格式化,整个就像一坨*一样,所以,不到万不得已,最好不要使用这种方式。

四、最佳实践

使用javassist工具修改字节码对象,由于替换内容的复杂性,使得维护和debug非常困难,我在实践的过程中发现,将要修改的点封装成单独的类,将核心修改点委托给该类执行是个挺不错的方法。

image-20220301160857666

五、报错和问题分析

1、出现的问题

将在二、如何在运行的时候修改字节码对象中的Main类的main方法中新增加一行代码: new Test1().sayHi();

package com.kdyzm;import javassist.*;
import lombok.extern.slf4j.Slf4j;/*** @author kdyzm* @date 2022/1/29*/
@Slf4j
public class Main {public static void main(String[] args) throws NotFoundException, CannotCompileException {new Test1().sayHi();//此处新增加一行代码ClassPool classPool = ClassPool.getDefault();classPool.appendClassPath(new LoaderClassPath(Thread.currentThread().getContextClassLoader()));String clsName = "com.kdyzm.Test1";CtClass ctClass = classPool.get(clsName);CtMethod ctMethod = ctClass.getDeclaredMethod("sayHi");ctMethod.setBody("log.info(\"Hello,kdyzm\");");ctClass.toClass();// 释放对象ctClass.detach();new Test1().sayHi();}
}

看似人畜无害的一行代码加完之后执行就会报错:

16:10:45.519 [main] INFO com.kdyzm.Test1 - Hello,world
Exception in thread "main" javassist.CannotCompileException: by java.lang.ClassFormatError: loader (instance of  sun/misc/Launcher$AppClassLoader): attempted  duplicate class definition for name: "com/kdyzm/Test1"at javassist.util.proxy.DefineClassHelper.toClass(DefineClassHelper.java:271)at javassist.ClassPool.toClass(ClassPool.java:1240)at javassist.ClassPool.toClass(ClassPool.java:1098)at javassist.ClassPool.toClass(ClassPool.java:1056)at javassist.CtClass.toClass(CtClass.java:1298)at com.kdyzm.Main.main(Main.java:21)
Caused by: java.lang.ClassFormatError: loader (instance of  sun/misc/Launcher$AppClassLoader): attempted  duplicate class definition for name: "com/kdyzm/Test1"at javassist.util.proxy.DefineClassHelper$Java7.defineClass(DefineClassHelper.java:182)at javassist.util.proxy.DefineClassHelper.toClass(DefineClassHelper.java:260)... 5 more

问题代码就出在:ctClass.toClass();这行代码上,从问题描述上来看,是重复加载了同一个类导致的。

2、异常分析

通过一步一步debug,最终看到了报错执行的方法是:javassist.util.proxy.DefineClassHelper.Java7#defineClass

image-20220304140313960

在截图中可以清楚的看到,实际上捕获到的异常类型是LinkeageError,但是捕获到之后被转换成了ClassFormatError抛出,ClassformatError类的定义如下:

image-20220304140540554

可以看出,ClassFormatError类是LinkageError类的子类,所以这里可能只是想要做到更加符合ClassFormatError的语义要求。

3、使用反射技术实现类加载

image-20220304141258211

截图中的代码

defineClass.invokeWithArguments(loader, name, b, off, len, protectionDomain)

实际上是使用反射调用了ClassLoader类的defineClass方法,看下defineClass的定义就知道了

private static class Java7 extends Helper {private final SecurityActions stack = SecurityActions.stack;private final MethodHandle defineClass = getDefineClassMethodHandle();private final MethodHandle getDefineClassMethodHandle() {if (privileged != null && stack.getCallerClass() != this.getClass())throw new IllegalAccessError("Access denied for caller.");try {return SecurityActions.getMethodHandle(ClassLoader.class, "defineClass",new Class[] {String.class, byte[].class, int.class, int.class,ProtectionDomain.class});} catch (NoSuchMethodException e) {throw new RuntimeException("cannot initialize", e);}}@OverrideClass<?> defineClass(String name, byte[] b, int off, int len, Class<?> neighbor,ClassLoader loader, ProtectionDomain protectionDomain)throws ClassFormatError{if (stack.getCallerClass() != DefineClassHelper.class)throw new IllegalAccessError("Access denied for caller.");try {return (Class<?>) defineClass.invokeWithArguments(loader, name, b, off, len, protectionDomain);} catch (Throwable e) {if (e instanceof RuntimeException) throw (RuntimeException) e;if (e instanceof ClassFormatError) throw (ClassFormatError) e;throw new ClassFormatError(e.getMessage());}}}

和常见的反射技术不同的是,这里使用的MethodHandle类实现反射,最终调用的方法是:java.lang.ClassLoader#defineClass(java.lang.String, byte[], int, int, java.security.ProtectionDomain)

image-20220304141754544

该方法从一个字节数组中获取字节码数据并最终调用defineClass1方法解析成为类对象,该方法会抛出ClassFormatError、NoClassDefFoundError等异常,但是实际上不仅仅这些异常,还有本例中的LinkageError,这里并没有包含所有的异常种类。

这个方法有个特点,如果加载了重复的类对象,会抛出LinkageError异常,这是在defineClass1方法中发生的逻辑

image-20220304142408725

可以看到,defineClass1方法是一个本地方法,底层是C++实现的,没法直接看到

4、defineClass1源码解析

以jdk1.8为例,defineClass1的源码地址:https://github.com/openjdk/jdk/blob/jdk8-b81/jdk/src/share/native/java/lang/ClassLoader.c#L90

由于这玩意是C实现的,我看的也是云里来雾里去,大体上的调用链是:

Java_java_lang_ClassLoader_defineClass1->JVM_DefineClassWithSource->resolve_from_stream->SystemDictionary::find_or_define_instance_class或者SystemDictionary::define_instance_class

在find_or_define_instance_class方法上,有一段注释如下:

// Support parallel classloading
// All parallel class loaders, including bootstrap classloader
// lock a placeholder entry for this class/class_loader pair
// to allow parallel defines of different classes for this class loader
// With AllowParallelDefine flag==true, in case they do not synchronize around
// FindLoadedClass/DefineClass, calls, we check for parallel
// loading for them, wait if a defineClass is in progress
// and return the initial requestor's results
// This flag does not apply to the bootstrap classloader.
// With AllowParallelDefine flag==false, call through to define_instance_class
// which will throw LinkageError: duplicate class definition.
// False is the requested default.
// For better performance, the class loaders should synchronize
// findClass(), i.e. FindLoadedClass/DefineClassIfAbsent or they
// potentially waste time reading and parsing the bytestream.
// Note: VM callers should ensure consistency of k/class_name,class_loader

代码可能看不大懂,但是这段注释还是能看个几分明白,特别是这段

With AllowParallelDefine flag==false, call through to define_instance_class which will throw LinkageError: duplicate class definition.

define_instance_class方法会抛出LinkageError:duplicate class definition.这和java代码中看到的错误异常一模一样,而且,注释的最后,还贴心的给了一个提示:VM callers should ensure consistency of k/class_name,class_loader,这告诉我们,要确保目标类和加载的ClassLoader的一致性,否则会抛出异常:LinkageError。

下面的代码就看不懂了,但是基本上我也找到了答案:调用java.lang.ClassLoader#defineClass(java.lang.String, byte[], int, int, java.security.ProtectionDomain)方法要确保一个类只会被同一个ClassLoader加载一次,否则就会报错:loader (instance of sun/misc/Launcher$AppClassLoader): attempted duplicate class definition for name xxx

5、问题复现

上面使用了javassist修改完字节码问题件之后出现了attempted duplicate class definition for name xxx的错误,现在不使用javassist,使用最简单的代码来重现这个问题

import lombok.extern.slf4j.Slf4j;import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.reflect.Method;
import java.security.AccessController;
import java.security.PrivilegedActionException;
import java.security.PrivilegedExceptionAction;
import java.security.ProtectionDomain;/*** @author kdyzm* @date 2022/3/2*/
@Slf4j
public class Main2 {public static void main(String[] args) throws Throwable {defineClass();defineClass();}private static void defineClass() throws Throwable {ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();MethodHandle methodHandle = null;try {methodHandle = getMethodHandle(ClassLoader.class, "defineClass", new Class[]{String.class,byte[].class,int.class,int.class,ProtectionDomain.class});} catch (Throwable e) {log.error("", e);return;}byte[] bytes = getClassBytes();try {Class<Test1> clazz = (Class<Test1>) methodHandle.invokeWithArguments(contextClassLoader,"com.kdyzm.Test1",bytes,0,bytes.length,null);log.info(clazz.toString());} catch (Throwable throwable) {log.error("",throwable);}}static MethodHandle getMethodHandle(final Class<?> clazz,final String name,final Class<?>[] params) throws NoSuchMethodException {try {return AccessController.doPrivileged((PrivilegedExceptionAction<MethodHandle>) () -> {Method rmet = clazz.getDeclaredMethod(name, params);rmet.setAccessible(true);MethodHandle meth = MethodHandles.lookup().unreflect(rmet);rmet.setAccessible(false);return meth;});} catch (PrivilegedActionException e) {if (e.getCause() instanceof NoSuchMethodException) {throw (NoSuchMethodException) e.getCause();}throw new RuntimeException(e.getCause());}}private static byte[] getClassBytes() throws IOException {FileInputStream fis = new FileInputStream("D:\\projects-my\\Main\\target\\classes\\com\\kdyzm\\Test1.class");ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();byte[] buff = new byte[1024];int length = -1;while ((length = fis.read(buff)) != -1) {byteArrayOutputStream.write(buff, 0, length);}return byteArrayOutputStream.toByteArray();}
}

结果报错如下:

15:12:21.799 [main] INFO com.kdyzm.Main2 - class com.kdyzm.Test1
15:12:21.803 [main] ERROR com.kdyzm.Main2 - 
java.lang.LinkageError: loader (instance of  sun/misc/Launcher$AppClassLoader): attempted  duplicate class definition for name: "com/kdyzm/Test1"at java.lang.ClassLoader.defineClass1(Native Method)at java.lang.ClassLoader.defineClass(ClassLoader.java:756)at java.lang.invoke.MethodHandle.invokeWithArguments(MethodHandle.java:627)at com.kdyzm.Main2.defineClass(Main2.java:44)at com.kdyzm.Main2.main(Main2.java:25)

可以看到第一次加载成功后再次调用defineClass方法加载Test1类就会直接报错LinkageError,符合预期结果。

六、其它疑问的思考

上面只是说了javassist调用了ClassLoader的defineClass方法实现的类加载,但是类加载的方法有好几种,为什么要调用defineClass方法而不调用Class.forName方法或者ClassLoader.loadClass方法加载类?毕竟,调用defineClass方法必须通过反射调用,而且重复加载类还会报错异常。。。

我的理解是:使用javassist并没有修改字节码文件,而只是修改了字节码对象,举个例子,我们通过jar包运行的程序,根本不可能在运行中修改jar包中打包的class文件。提前调用defineClass方法加载好被修改该过的类,这样运行中正常调用Class.forName或者ClassLoader.loadClass方法的时候,发现该类已经被加载过了就不再重新加载了,这样就实现了运行中修改字节码对象实现偷梁换柱的目的。

相关文章:

  • minSdkVersion、targetSdkVersion、compileSdkVersion三者的作用解析
  • [Qt] Qt Creator中配置 Vs-Code 编码风格
  • 算法第八天:leetcode 35.搜索插入位置
  • TVBOX 最新版下载+视频源教程
  • 深入理解Java中的并发编程
  • 【LeetCode215】数组中的第K个最大元素
  • 爆赞!GitHub首本Python开发实战背记手册,标星果然百万名不虚传
  • Ant-Design-Vue动态表头并填充数据
  • vue3写一个定时器
  • Kantana和The Sandbox联手打造元宇宙娱乐的未来
  • Android开启HTTP服务
  • 微服务必备容器化技术
  • LUA移植到STM32F4,移植REPL,通过RTT Viewer交互
  • GraogGNSSLib学习
  • 医学人工智能在“免疫组化”领域的最新研究进展|顶刊速递·24-06-19
  • [译]前端离线指南(上)
  • CentOS6 编译安装 redis-3.2.3
  • C语言笔记(第一章:C语言编程)
  • hadoop集群管理系统搭建规划说明
  • Java精华积累:初学者都应该搞懂的问题
  • js操作时间(持续更新)
  • magento 货币换算
  • opencv python Meanshift 和 Camshift
  • quasar-framework cnodejs社区
  • SQLServer之创建数据库快照
  • 创建一个Struts2项目maven 方式
  • 开源地图数据可视化库——mapnik
  • 看域名解析域名安全对SEO的影响
  • 思否第一天
  • 为物联网而生:高性能时间序列数据库HiTSDB商业化首发!
  • 想使用 MongoDB ,你应该了解这8个方面!
  • [Shell 脚本] 备份网站文件至OSS服务(纯shell脚本无sdk) ...
  • FaaS 的简单实践
  • LIGO、Virgo第三轮探测告捷,同时探测到一对黑洞合并产生的引力波事件 ...
  • ​​​​​​​sokit v1.3抓手机应用socket数据包: Socket是传输控制层协议,WebSocket是应用层协议。
  • ‌U盘闪一下就没了?‌如何有效恢复数据
  • $.ajax()
  • (3) cmake编译多个cpp文件
  • (5)STL算法之复制
  • (C#)一个最简单的链表类
  • (k8s)kubernetes集群基于Containerd部署
  • (博弈 sg入门)kiki's game -- hdu -- 2147
  • (不用互三)AI绘画:科技赋能艺术的崭新时代
  • (二十三)Flask之高频面试点
  • (十一)手动添加用户和文件的特殊权限
  • (一)使用Mybatis实现在student数据库中插入一个学生信息
  • (转)VC++中ondraw在什么时候调用的
  • .Net 访问电子邮箱-LumiSoft.Net,好用
  • .Net+SQL Server企业应用性能优化笔记4——精确查找瓶颈
  • .NET下的多线程编程—1-线程机制概述
  • @拔赤:Web前端开发十日谈
  • [@Controller]4 详解@ModelAttribute
  • [BJDCTF2020]EzPHP1
  • [BPU部署教程] 教你搞定YOLOV5部署 (版本: 6.2)
  • [BT]小迪安全2023学习笔记(第29天:Web攻防-SQL注入)