字節碼改寫/增強——Java帝國的DNA + 流量回放的魔法棒
What(是什麼)
在jvm中大約有200條左右的指令。這些指令包括各種操作,用於信息加載、存儲、算術計算、類型轉換、對象創建、調用方法、控制流管理和異常處理等,是整個java世界的基石。所謂的字節碼增強/改寫就是在不修改Java源代碼的情況下,通過直接操作編譯後的字節碼來動態修改程序行為。從實現角度字節碼其實是沒有增強這麼一説的,只有改寫這一種行為,而改寫的作用一般是為了實現AOP、鏈路追蹤、性能監控等非業務的通用功能所以也被大家叫做"增強"。
Why(為什麼需要)
1)解決橫切關注點問題
// 傳統方式:代碼重複,侵入性強
public class UserService {
public User getUserById(Long id) {
Logger.info("開始查詢用户: " + id);
long start = System.currentTimeMillis();
User user = userDao.findById(id);
long end = System.currentTimeMillis();
Logger.info("查詢完成,耗時: " + (end - start) + "ms");
return user;
}
public void updateUser(User user) {
Logger.info("開始更新用户: " + user.getId());
long start = System.currentTimeMillis();
userDao.update(user);
long end = System.currentTimeMillis();
Logger.info("更新完成,耗時: " + (end - start) + "ms");
}
}
// 字節碼增強方式:代碼乾淨,關注點分離
public class UserService {
@Loggable @Performance
public User getUserById(Long id) {
return userDao.findById(id); // 只關注業務邏輯
}
@Loggable @Performance
public void updateUser(User user) {
userDao.update(user); // 只關注業務邏輯
}
}
2)性能優化需求
- 避免反射的性能開銷
- 減少代碼冗餘
- 運行時優化
3)無法修改源碼的情況
- 為第三方庫添加功能
- 修復第三方庫的bug
Where(在哪裏使用)
How(如何實現)
1)asm/javasist
asm自2002年正式誕生,伴隨了java在世界上的蓬勃發展,是字節碼改寫的經典之作
asm庫提供了一套API,使得開發者可以以更高效和結構化的方式構建、修改和分析java字節碼,避免開發者直接處理字節碼的複雜性。從某種角度來看可以説asm是對jvm指令的一種抽象,在字節碼的世界裏asm幾乎無往不利,但強大的代價就是它還是太難了——它的學習和使用成本還是太過高昂,使用者還是要去了解jvm的指令集學習自己需要的指令。
這對於入門人員甚至普通開發者來説簡直就是一場噩夢,而javasist無疑是在降低使用門檻這條路上走的最遠的,它使開發者能夠以接近java源碼的方式來操作字節碼。它本是為jboss的AOP功能而開源的,但逐漸成為了很多對字節碼指令沒那麼熟悉的人做字節碼改寫時的首選,除了mybatis,還有阿里著名的transmittable-thread-local、dubbo都是使用此種方式,如果你想做簡單的字節碼改寫它會是一個不錯的選擇。
以下為transmittable-thread-local節選代碼
private boolean updateBeforeAndAfterExecuteMethodOfExecutorSubclass(@NonNull final CtClass clazz) throws NotFoundException, CannotCompileException {
final CtClass runnableClass = clazz.getClassPool().get(RUNNABLE_CLASS_NAME);
final CtClass threadClass = clazz.getClassPool().get("java.lang.Thread");
final CtClass throwableClass = clazz.getClassPool().get("java.lang.Throwable");
boolean modified = false;
try {
final CtMethod beforeExecute = clazz.getDeclaredMethod("beforeExecute", new CtClass[]{threadClass, runnableClass});
// unwrap runnable if IsAutoWrapper
String code = "$2 = com.alibaba.ttl.threadpool.agent.internal.transformlet.impl.Utils.doUnwrapIfIsAutoWrapper($2);";
logger.info("insert code before method " + signatureOfMethod(beforeExecute) + " of class " +
beforeExecute.getDeclaringClass().getName() + ": " + code);
beforeExecute.insertBefore(code);
modified = true;
} catch (NotFoundException e) {
// clazz does not override beforeExecute method, do nothing.
}
try {
final CtMethod afterExecute = clazz.getDeclaredMethod("afterExecute", new CtClass[]{runnableClass, throwableClass});
// unwrap runnable if IsAutoWrapper
String code = "$1 = com.alibaba.ttl.threadpool.agent.internal.transformlet.impl.Utils.doUnwrapIfIsAutoWrapper($1);";
logger.info("insert code before method " + signatureOfMethod(afterExecute) + " of class " +
afterExecute.getDeclaringClass().getName() + ": " + code);
afterExecute.insertBefore(code);
modified = true;
} catch (NotFoundException e) {
// clazz does not override afterExecute method, do nothing.
}
return modified;
}
但軟件行業有個定律:抽象在軟件開發中的確涉及信息選擇和隱藏的過程,而這種信息的隱藏可能會被認為是對底層細節描述能力的某種“喪失”。
同樣的javasist作為一個更高級的抽象層,這意味着會有一些性能開銷,這在需要進行大量字節碼操作的場合可能成為瓶頸,同時對於一些非常細緻的字節碼操作或者需要很細粒度控制的場景來説,它不夠靈活,因此它更適合於簡單或常見的字節碼操作。
2)Class-File API
Java Class-File API 是在JEP-484中作為 Java 24 的一部分引入的,它旨在創建一個接口,允許類文件處理,而無需依賴於舊版 jdk 的asm的內部複製實現。
它允許以lambda表達式來修改/添加指令
.labelBinding(notSales)
.aload(3)
.ldc("engineer")
.invokevirtual(stringClass, "equals", MethodTypeDesc.of(ClassDesc.of("Z"), stringClass))
.ifeq(notEngineer)
.dload(1)
.ldc(0.25)
.dmul()
.dreturn()
看上去對用户非常友好,不過從https://www.reddit.com/r/java/comments/1f2lkff/jep_484_classfile_api_final_for_java_24/的討論來看相比於asm而言它不夠靈活和完善,而官方引入此功能並不是為了淘汰現有的處理類文件的庫,也不是成為世界上最快的類文件庫,而是為了解除jdk和asm的綁定,讓jdk的發佈不再受限於asm,但當它足夠成熟時也是為字節碼改寫提供了一種更便捷的方式。
3)bytebuddy
鏈路追蹤系統中非常出名的skywalking就是基於它去實現的,此外**Jackson、Hibernate、Mockito等知名框架也都使用了bytebuddy**
4)jvm-sandbox
arthas的前身greys的作者基於greys沉澱出來的作品,底層基於asm(大神對於asm的理解和使用功力非常深),它出現是為了做jvm上的spring aop,強烈建議如果你要做一個aop類型(不需要對行間代碼進行修改僅在方法進入退出時執行某些操作)的agent可以使用此框架
5)bytekit
是arthas的開發人員基於arthas抽象而來,底層基於asm
6)字節碼改寫經驗
6.1)橋接(代理)方法
在字節碼改寫中非常重要的一個部分就是橋接(代理)方法,原來調用A方法,當你想做一些事情那麼你可能需要將原有的調用修改為調用橋接方法,然後在橋接方法中實現你的處理邏輯
public Object method() {
// 業務處理邏輯
invokeMethod(params);
// 業務處理邏輯
}
public Object method() {
// 業務處理邏輯
invokeBrigeMethod(params...);
// 業務處理邏輯
}
橋接方法是否是必須的呢?一般來説如果你的處理邏輯比較簡單也是可以不需要橋接方法的,直接將相應的調用指令替換成處理指令,但一般來説如果處理沒那麼簡單使用橋接方法可以使編程更簡單,而且很多時候還有一些隱含的好處,比如可以維持原有業務代碼的行號避免干擾業務排查問題,對業務類的字節碼變更較小,可複用性更高等等。
需要注意的是橋接方法所在的類需要讓其加載到BootStrap類加載器中這樣才能繞過jvm的類加載器隔離
6.2)棧的平衡以及如何分析棧內操作數的狀態
jvm指令分為操作碼和操作數,當改寫後一般會將某指令替換為invokestatic指令(調用橋接方法)要注意不同指令之間操作數的差異時刻保持棧的平衡
6.3)基本類型的拆裝箱
java中Obejct數組和容器中是無法存儲基本類型,所以要進行相應的裝箱操作,同樣的橋接方法返回的可能是包裝類如果需要的是基本類型則要做相應的拆箱
流量回放
流量回放的本質是將流量入口和關鍵子節點的信息記錄下來,再根據入口信息重新發起一次調用在調用過程中當執行到關鍵子節點進行mock(直接使用錄製時的響應返回而不去真實執行)。
理論上流量回放並不和字節碼改寫強綁定,但實際中如果通過硬編碼方式去實現流量回放對業務的侵入性是非常高的,業務方無論是接受度還是配合度都會大打折扣,發佈升級也都會和業務強耦合,所以可以説字節碼改寫當前是流量回放的最優方案。
破解流量回放技術瓶頸——跨環境配置一致性挑戰與突破
當前流量回放比較流行的開源產品jvm-sandbox-repeater和arex-agent-java中基本思路還是在around切面中記錄請求和響應為主,但它們都沒有解決不同環境配置項不一致的問題,類似下面的apollo配置
@Value("${feature.switch:false}")
private boolean switch;
如果錄製和回放環境(一般是線上錄製線下回放)的配置項不一樣可能會導致以下幾種情況
- 錄製和回放時執行的邏輯分支不一致
- 子調用的入參不一致
- 主調用的響應不一致
無論是哪一種都是不符合預期的會使得回放無法滿足迴歸的訴求,接下來讓我們看看如何解決這個問題的。
首先讓我們看下和字段相關的jvm指令
| 指令名稱 | 讀寫屬性 | 操作碼 | 功能描述 | 棧變化 | 字段類型 |
|---|---|---|---|---|---|
| getfield | 讀 | 0xB4 | 獲取實例字段值 | objectref → value | 實例字段 |
| putfield | 寫 | 0xB5 | 設置實例字段值 | objectref, value → | 實例字段 |
| getstatic | 讀 | 0xB2 | 獲取靜態字段值 | → value | 靜態字段 |
| putstatic | 寫 | 0xB3 | 設置靜態字段值 | value → | 靜態字段 |
一般而言在運行時只會執行到getfield/getstatic(讀)指令,putfield/putstatic(寫)一般只會在啓動線程和監聽線程中執行無法確定執行時機而且如果我們決定回放putfield/putstatic(寫)操作到回放環境那麼勢必會污染回放環境,而mock讀操作則無此副作用。
基於以上考量所以我們要做的就是記錄下getfield/getstatic指令的值在回放時做mock,為了完成這個功能我們需要將getfield/getstatic指令改寫為調用橋接方法bridge
if (switch) {
// 業務邏輯
}
改為
if (bridge(switch所屬實例,switch所屬類,"switch")) {
// 業務邏輯
}
這樣就可以在橋接方法bridge中就可以根據錄製和回放執行不同的操作
明確了代碼改寫後的樣子剩下的就是使用asm完成對應的字節碼改寫,偽代碼如下
@Override
public void visitFieldInsn(int opcode, String owner, String name, String desc) {
final String className = owner.replace("/", ".");
if (shouldTransform(className, name)) {
// 檢測是否是getfield/getstatic指令
if (opcode == Opcodes.GETFIELD || opcode == Opcodes.GETSTATIC) {
// 靜態字段實例為空需要新增一個為null的操作數
if (opcode == Opcodes.GETSTATIC) {
visitInsn(Opcodes.ACONST_NULL);
}
visitLdcInsn(Type.getType(String.format("L%s;", owner)));
visitLdcInsn(name);
invokeStatic(Type.getType(橋接類, 橋接方法);
// 如果返回類型是基本類型則需要拆箱
return;
}
}
super.visitFieldInsn(opcode, owner, name, desc);
}
理論上上述字節碼改寫就可以實現掉配置字段的mock,但一次調用中可能有大量的對配置字段的getfield/getstatic調用,改寫後的字節碼的性能也是非常主要的一個考量點,可以看到錄製時的開銷就是一次Field反射調用 + 記錄配置字段的開銷,那麼很自然地就想到緩存Field可能會顯著的提升性能,那麼除此之外還有沒有更好的方式呢?答案就是這個場景下可以完全優化掉反射,讓我們重新組織字節碼讓改寫後的字節碼變成以下形式
if (bridge(switch,switch所屬類,"switch")) {
// 業務邏輯
}
原先的getfield/getstatic指令照常執行,將結果傳遞給橋接方法,這樣橋接方法中就無需再進行任何反射操作!
就這樣我們巧妙地把反射優化掉了,讓我們來對比一下優化前後的開銷
| 優化前 | 優化後 | 節省開銷 | |
|---|---|---|---|
| 錄製時開銷 | 反射 + 記錄配置字段值 | getfield/getstatic + 記錄配置字段值 | 反射 - getfield/getstatic |
| 回放時開銷(命中) | 查詢配置字段值 | getfield/getstatic + 查詢配置字段值 | -getfield/getstatic |
| 回放時開銷(未命中) | 查詢配置字段值 + 反射 | 查詢配置字段值 + getfield/getstatic | 反射 - getfield/getstatic |
可以看到在錄製和回放時查詢不到配置字段值時性能有大幅提升,回放時如果能查詢到配置字段值也只是多個一個getfield/getstatic指令的開銷,對照下表可以認為其開銷大幅降低,至此此功能達到性能最優狀態。
本以為這件事情到此就告一段落了,但意想不到的情況又發生了:線上錄製流量的代碼版本是release_xxx,線下回放容器的代碼版本是feature_xxx,在變更中業務同學修改了配置字段所在類的包名,這樣一來根據全類名 + 字段名構建的唯一key就失效了,回放時依舊沒法完成mock,按理説配置字段其實是發生了變更的,所以無法mock配置字段也是符合預期的,然而業務同學認為雖然他們修改了配置類的包名但本身類的內容其實是完全沒有變化的,他們並不希望感知這種變更,認為平台應當兼容掉這種差異。秉持着"用户是上帝"的理念我們對這種場景進行了深度分析,分析後可以發現配置字段雖然全類名 + 字段名的唯一key發生了變化,但spring表達式中的key卻還是保持不變的,還是以上面的配置項為例
@Value("${feature.switch:false}")
private boolean switch;
我們可以觀察到"feature.switch"在這種場景下它是依舊保持不變的,因此除了全類名 + 字段名作為唯一key之外我們可以給配置字段綁定另外一個輔助key
使用一個ConcurrentHashMap存儲唯一key和表達式的key的關聯關係
/**
* 配置字段表達式映射
*/
private static final Map<String, String> CONFIG_EXPRESSION_MAPPING = new ConcurrentHashMap<>();
然後使用asm的AnnotationVisitor解析出表達式中的key記錄到上面的map中
class ExtendIdentityAnnotationVisitor extends AnnotationVisitor {
@Override
public void visit(String attributeName, Object attributeValue) {
// org.springframework.beans.factory.annotation.Value/com.ctrip.framework.apollo.spring.annotation.ApolloJsonValue/com.alibaba.nacos.api.config.annotation.NacosValue
if ("value".equals(attributeName)) {
Optional.ofNullable(attributeValue).map(String::valueOf).filter(StringUtil::isNotEmpty)
.ifPresent(expression -> {
// 解析表達式獲取key
String configKey = resolveConfigKey(expression);
// 記錄到配置字段表達式映射
});
}
super.visit(attributeName, attributeValue);
}
}
基於此將流程改造為
1) 錄製時會獲取輔助key如果存在則按照(輔助key-值)的格式存入流量;
2) 回放時如果按照全類名 + 字段名找不到對應的配置則獲取輔助key使用輔助key查找配置;
上線之後此問題果然迎刃而解,正當覺得這下總算可以高枕無憂時,又一個不同版本回放的問題涌現了:之前是配置類的全類名變了,這次是配置字段對應的類的全類名變了,比如下面這樣一個配置項
@ApolloJsonValue("${gray.unit.model.config:{}}")
private GrayModel grayUnitModelConfig;
GrayModel的包名可能被修改為了另外一個包名,也可能類名變味了GrayModel2,這都會造成全類名的變更,從而在反序列化時(hessian2)被降級序列化為HashMap從而導致了ClassCastException,為了解決這個問題我們引入了一個配置字段類型變更自適應機制,當檢測到找到配置的類型和當前Field的類型不匹配時則將此對象轉為所需要的對象,這個機制主要由兩部分構成——類型檢測 + 對象轉換
1)類型檢測
類型檢測的難度在於泛型,對於自定義泛型類仍無法區分其泛型參數,只能退化為原始類型判斷,這裏根據是否啓用嚴格模式來判定
Type genericType = field.getGenericType();
if(!isInstance(genericType, result, true)){
// 做對象轉換
}
public static boolean isInstance(Type type, Object obj, boolean strict) {
if (null == type) {
throw new NullPointerException("Type must not be null");
}
// 通常對isInstance的語義,null應屬於任何引用類型
if (null == obj) {
if (type instanceof Class) {
Class<?> clazz = (Class<?>) type;
return !clazz.isPrimitive();
}
return true;
}
if (type instanceof Class<?>) {
Class<?> clazz = (Class<?>) type;
return clazz.isInstance(obj);
} else if (type instanceof ParameterizedType) {
ParameterizedType parameterizedType = (ParameterizedType) type;
Type rawType = parameterizedType.getRawType();
if (rawType instanceof Class) {
Class<?> rawClass = (Class<?>) rawType;
if (!rawClass.isInstance(obj)) {
return false;
}
if (Collection.class.isAssignableFrom(rawClass) && obj instanceof Collection) {
Type elementType = parameterizedType.getActualTypeArguments()[0];
Collection<?> collection = (Collection<?>) obj;
for (Object element : collection) {
if (element != null && !isInstance(elementType, element, strict)) {
return false;
}
}
return true;
} else if (Map.class.isAssignableFrom(rawClass) && obj instanceof Map) {
Type keyType = parameterizedType.getActualTypeArguments()[0];
Type valueType = parameterizedType.getActualTypeArguments()[1];
Map<?, ?> map = (Map<?, ?>) obj;
for (Map.Entry<?, ?> entry : map.entrySet()) {
Object key = entry.getKey();
Object value = entry.getValue();
if ((key != null && !isInstance(keyType, key, strict)) || (value != null && !isInstance(valueType, value, strict))) {
return false;
}
}
return true;
}
// 對於自定義泛型類,Java運行時無法區分其泛型參數,只能退化為原始類型判斷,這裏根據是否啓用嚴格模式來判定
return !strict;
}
} else if (type instanceof GenericArrayType) {
if (!obj.getClass().isArray()) {
return false;
}
GenericArrayType genericArrayType = (GenericArrayType) type;
Type componentType = genericArrayType.getGenericComponentType();
for (int i = 0; i < Array.getLength(obj); ++i) {
Object element = Array.get(obj, i);
if (element != null && !isInstance(componentType, element, strict)) {
return false;
}
}
return true;
} else if (type instanceof TypeVariable<?>) {
// 只要滿足其上界中的任意一個即可
TypeVariable<?> typeVariable = (TypeVariable<?>) type;
for (Type bound : typeVariable.getBounds()) {
if (isInstance(bound, obj, strict)) {
return true;
}
}
return false;
} else if (type instanceof WildcardType) {
WildcardType wildcardType = (WildcardType) type;
// ? extends 上界: 必須assignable to上界
for (Type upperType : wildcardType.getUpperBounds()) {
if (!isInstance(upperType, obj, strict)) {
return false;
}
}
// ? super 下界: 必須是下界assignable的子類
for (Type lowerType : wildcardType.getLowerBounds()) {
if (!isInstance(lowerType, obj, strict)) {
return false;
}
}
return true;
}
// 其它未知類型一律false
return false;
}
2)對象轉換
當前經過嘗試比較好的選擇是fastjson2的序列化與反序列化,針對對象轉換這個場景使用jmh做了基準測試後給出了推薦的讀寫參數
private static final JSONWriter.Feature[] WRITE_FEATURES = {
JSONWriter.Feature.IgnoreNoneSerializable,
JSONWriter.Feature.FieldBased,
JSONWriter.Feature.ReferenceDetection,
JSONWriter.Feature.NotWriteDefaultValue,
JSONWriter.Feature.NotWriteHashMapArrayListClassName,
JSONWriter.Feature.WriteNameAsSymbol
};
private static final JSONReader.Feature[] READ_FEATURES = {
JSONReader.Feature.FieldBased,
JSONReader.Feature.UseDefaultConstructorAsPossible,
JSONReader.Feature.UseNativeObject,
JSONReader.Feature.IgnoreAutoTypeNotMatch,
};
// 對象轉換
result = JSON.parseObject(JSON.toJSONString(obj, WRITE_FEATURES), type, READ_FEATURES);
注意事項:本案例為了聚焦字節碼改寫省略了橋接方法實現、如何找到配置字段等細節如果感興趣可諮詢
總結
字節碼是Java"一次編譯,到處運行"的核心密碼,JVM生態的技術皇冠。它讓跨平台成為可能,讓動態優化成為現實,是Spring等框架的底層靈魂,而基於字節碼的流量回放技術是保障系統穩定性的終極武器。
參考資料
https://gitlab.ow2.org/asm/asm
https://github.com/jboss-javassist/javassist
https://openjdk.org/jeps/484
https://www.baeldung.com/java-class-file-api
https://www.reddit.com/r/java/comments/1f2lkff/jep_484_classfile_api_final_for_java_24/
https://bytebuddy.net/#/
https://github.com/apache/skywalking
https://github.com/FasterXML/jackson
https://github.com/alibaba/jvm-sandbox
https://github.com/oldmanpushcart/greys-anatomy
https://github.com/alibaba/bytekit
https://github.com/alibaba/jvm-sandbox-repeater
https://github.com/arextest/arex-agent-java
https://github.com/alibaba/arthas