使用javassist运行时动态修改字节码对象
java程序什么时候需要在运行的时候动态修改字节码对象?
如何在运行的时候动态修改字节码对象?
修改字节码对象的时候会发生哪些错误,又该如何解决这些问题?
围绕以上三个问题,本篇文章会依次讲解。
一、java程序什么时候需要在运行的时候动态修改字节码对象
我认为有两种场景,一种是无法修改源代码的时候;另外一种是功能增强的时候。
1、无法修改源代码
举个例子,java程序依赖的第三方的jar包中发现了bug,但是官方还没有修复,本地通过debug已经发现了解决方法,该如何修复该问题呢?
在spring程序中,如果目标对象在spring容器中,可以通过Spring AOP创建切面解决。但是如果目标对象并没有在spring容器中,或者干脆程序根本不是spring技术栈中的,问题就比较麻烦了,因为无法创建切面拦截目标方法执行。
这时候很容易想到,如果能在不修改第三方源代码的基础上做到修复第三方的bug就好了,这时候使用字节码修改工具动态的修改字节码对象是比较常见的方法。
2、功能增强
在fastjson框架中就是用了asm工具直接操作字节码替代反射技术以加快执行速度。详情可以参考文章:ASM在FastJson中的应用
二、如何在运行的时候修改字节码对象
常见的字节码修改工具有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使用文档:
http://repository.transtep.com/repository/thirdparty/javassist-3.1/tutorial/tutorial2.html
三、使用Javassist的弊端
一个显而易见的弊端就是替换的方法内容不能过于复杂,否则代码的可读性会变的非常差,调试和修改会变的非常困难,比如下面一段代码
这段代码不算很复杂,但是调试和修改已经非常困难(因为没法断点,编写代码逻辑的时候没有代码提示),而且由于代码作为字符串显示在源代码中,没有代码高亮,再加上换行符,如果没有代码格式化,整个就像一坨*一样,所以,不到万不得已,最好不要使用这种方式。
四、最佳实践
使用javassist工具修改字节码对象,由于替换内容的复杂性,使得维护和debug非常困难,我在实践的过程中发现,将要修改的点封装成单独的类,将核心修改点委托给该类执行是个挺不错的方法。
五、报错和问题分析
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
在截图中可以清楚的看到,实际上捕获到的异常类型是LinkeageError,但是捕获到之后被转换成了ClassFormatError抛出,ClassformatError类的定义如下:
可以看出,ClassFormatError类是LinkageError类的子类,所以这里可能只是想要做到更加符合ClassFormatError的语义要求。
3、使用反射技术实现类加载
截图中的代码
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);
}
}
@Override
Class<?> 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)
该方法从一个字节数组中获取字节码数据并最终调用defineClass1方法解析成为类对象,该方法会抛出ClassFormatError、NoClassDefFoundError等异常,但是实际上不仅仅这些异常,还有本例中的LinkageError,这里并没有包含所有的异常种类。
这个方法有个特点,如果加载了重复的类对象,会抛出LinkageError异常,这是在defineClass1方法中发生的逻辑
可以看到,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方法的时候,发现该类已经被加载过了就不再重新加载了,这样就实现了运行中修改字节码对象实现偷梁换柱的目的。