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方法的時候,發現該類已經被加載過了就不再重新加載了,這樣就實現了運行中修改字節碼對象實現偷樑換柱的目的。