字节码改写/增强------Java帝国的DNA + 流量回放的魔法棒
What(是什么)
在jvm中大约有200条左右的指令。这些指令包括各种操作,用于信息加载、存储、算术计算、类型转换、对象创建、调用方法、控制流管理和异常处理 等,是整个java世界的基石。所谓的字节码增强/改写就是在不修改Java源代码 的情况下,通过直接操作编译后的字节码来**动态修改程序行为。**从实现角度字节码其实是没有增强这么一说的,只有改写这一种行为,而改写的作用一般是为了实现AOP、链路追踪、性能监控等非业务的通用功能所以也被大家叫做"增强"。
aload/astore
ldc/bipush] C --> C1[iadd/isub/imul
fadd/fsub/fmul
iand/ior/ixor] D --> D1[i2l/i2f/i2d
l2i/f2i/d2i
i2b/i2c/i2s] E --> E1[new/newarray
getfield/putfield
getstatic/putstatic] F --> F1[pop/pop2
dup/dup2
swap] G --> G1[ifeq/ifne/iflt
if_icmpeq/if_acmpeq
goto/jsr] H --> H1[invokevirtual
invokespecial
invokestatic
invokeinterface] I --> I1[athrow
jsr/ret] J --> J1[monitorenter
monitorexit] classDef root fill:#E6F3FF,stroke:#4A90E2,stroke-width:2px classDef category1 fill:#F0F8E6,stroke:#7CB342,stroke-width:2px classDef category2 fill:#FFF8E1,stroke:#FFB74D,stroke-width:2px classDef category3 fill:#F3E5F5,stroke:#BA68C8,stroke-width:2px classDef detail1 fill:#E8F5E8,stroke:#66BB6A,stroke-width:1px classDef detail2 fill:#FFF9C4,stroke:#FFCA28,stroke-width:1px classDef detail3 fill:#F8E1F4,stroke:#AB47BC,stroke-width:1px class A root class B,E,H category1 class C,F,I category2 class D,G,J category3 class B1,E1,H1 detail1 class C1,F1,I1 detail2 class D1,G1,J1 detail3
Why(为什么需要)
1)解决横切关注点问题
java
// 传统方式:代码重复,侵入性强
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节选代码
java
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表达式来修改/添加指令
java
.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方法,当你想做一些事情那么你可能需要将原有的调用修改为调用桥接方法,然后在桥接方法中实现你的处理逻辑
java
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配置
java
@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
java
if (switch) {
// 业务逻辑
}
改为
java
if (bridge(switch所属实例,switch所属类,"switch")) {
// 业务逻辑
}
这样就可以在桥接方法bridge中就可以根据录制和回放执行不同的操作
明确了代码改写后的样子剩下的就是使用asm完成对应的字节码改写,伪代码如下
java
@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可能会显著的提升性能,那么除此之外还有没有更好的方式呢?答案就是这个场景下可以完全优化掉反射,让我们重新组织字节码让改写后的字节码变成以下形式
java
if (bridge(switch,switch所属类,"switch")) {
// 业务逻辑
}
原先的getfield/getstatic指令照常执行,将结果传递给桥接方法,这样桥接方法中就无需再进行任何反射操作!
就这样我们巧妙地把反射优化掉了,让我们来对比一下优化前后的开销
优化前 | 优化后 | 节省开销 | |
---|---|---|---|
录制时开销 | 反射 + 记录配置字段值 | getfield/getstatic + 记录配置字段值 | 反射 - getfield/getstatic |
回放时开销(命中) | 查询配置字段值 | getfield/getstatic + 查询配置字段值 | -getfield/getstatic |
回放时开销(未命中) | 查询配置字段值 + 反射 | 查询配置字段值 + getfield/getstatic | 反射 - getfield/getstatic |
可以看到在录制和回放时查询不到配置字段值时性能有大幅提升,回放时如果能查询到配置字段值也只是多个一个getfield/getstatic指令的开销,对照下表可以认为其开销大幅降低,至此此功能达到性能最优状态。
访问方式 | 相对性能倍数 | 性能等级 | 适用场景 | 优缺点 |
---|---|---|---|---|
直接访问 | 1x (基准) | ⭐⭐⭐⭐⭐ | 编译时已知字段 | ✅ 最快 |
✅ JIT优化好 | ||||
❌ 缺乏灵活性 | ||||
VarHandle | 1.2-2x | ⭐⭐⭐⭐ | 高性能动态访问(Java 9+) | ✅ 接近原生性能 |
✅ 类型安全 | ||||
❌ API复杂 | ||||
缓存的MethodHandle | 2-5x | ⭐⭐⭐ | 动态访问(Java 7+) | ✅ 比反射快 |
✅ 可优化 | ||||
❌ 学习成本高 | ||||
缓存的反射 | 10-50x | ⭐⭐ | 通用动态访问 | ✅ 使用简单 |
✅ 兼容性好 | ||||
❌ 性能开销大 | ||||
未缓存的反射 | 100-1000x | ⭐ | 偶尔使用的场景 | ✅ 灵活性最高 |
❌ 性能最差 | ||||
❌ 重复查找开销 |
本以为这件事情到此就告一段落了,但意想不到的情况又发生了:线上录制流量的代码版本是release_xxx ,线下回放容器的代码版本是feature_xxx ,在变更中业务同学修改了配置字段所在类的包名,这样一来根据全类名 + 字段名 构建的唯一key就失效了,回放时依旧没法完成mock,按理说配置字段其实是发生了变更的,所以无法mock配置字段也是符合预期的,然而业务同学认为虽然他们修改了配置类的包名但本身类的内容其实是完全没有变化的,他们并不希望感知这种变更,认为平台应当兼容掉这种差异。秉持着"用户是上帝"的理念我们对这种场景进行了深度分析,分析后可以发现配置字段虽然全类名 + 字段名的唯一key发生了变化,但spring表达式中的key却还是保持不变的,还是以上面的配置项为例
java
@Value("${feature.switch:false}")
private boolean switch;
我们可以观察到"feature.switch"在这种场景下它是依旧保持不变的,因此除了全类名 + 字段名作为唯一key之外我们可以给配置字段绑定另外一个辅助key
使用一个ConcurrentHashMap存储唯一key和表达式的key的关联关系
java
/**
* 配置字段表达式映射
*/
private static final Map<String, String> CONFIG_EXPRESSION_MAPPING = new ConcurrentHashMap<>();
然后使用asm的AnnotationVisitor解析出表达式中的key记录到上面的map中
java
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);
}
}
基于此将流程改造为
-
录制时会获取辅助key如果存在则按照(辅助key-值)的格式存入流量;
-
回放时如果按照全类名 + 字段名找不到对应的配置则获取辅助key使用辅助key查找配置;
上线之后此问题果然迎刃而解,正当觉得这下总算可以高枕无忧时,又一个不同版本回放的问题涌现了:之前是配置类的全类名变了,这次是配置字段对应的类的全类名变了,比如下面这样一个配置项
java
@ApolloJsonValue("${gray.unit.model.config:{}}")
private GrayModel grayUnitModelConfig;
GrayModel的包名可能被修改为了另外一个包名,也可能类名变味了GrayModel2,这都会造成全类名的变更,从而在反序列化时(hessian2)被降级序列化为HashMap从而导致了ClassCastException ,为了解决这个问题我们引入了一个配置字段类型变更自适应机制,当检测到找到配置的类型和当前Field的类型不匹配时则将此对象转为所需要的对象,这个机制主要由两部分构成------类型检测 + 对象转换
1)类型检测
类型检测的难度在于泛型,对于自定义泛型类仍无法区分其泛型参数,只能退化为原始类型判断,这里根据是否启用严格模式来判定
java
Type genericType = field.getGenericType();
if(!isInstance(genericType, result, true)){
// 做对象转换
}
java
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做了基准测试后给出了推荐的读写参数
java
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等框架的底层灵魂,而基于字节码的流量回放技术是保障系统稳定性的终极武器。