我们写的Java源代码需要通过Javac等前端编译器转换为Java字节码,虚拟机加载了这些Java字节码后,可以通过解释执行或JIT编译执行转换为本地机器码进行执行,如下图所示。
Java虚拟机在加载时认的是Java字节码,而动态类加载可以让我们在需要的时候生成字节码并加载到虚拟机中。不过我们不但能加载新生成的类,而且借助Instrumentation类能够修改已经加载的类,这让字节码增强有了无限遐想的空间。
如上的话说白了就是,你可以在不需要重启虚拟机,不需要Java源代码的情况下,任意更改Java字节码的逻辑并且让它生效。
1. 字节码增强API
在字节码增强中,我们需要知道两个类,一个是ClassFileTransformer,这个类定义了类加载前的预处理类,可以在这个类中对要加载的类的字节码做一些处理,譬如进行字节码增强;还有一个是Instrumentation,其中定义了非常重要的API,如下:
public interface Instrumentation {// 增加一个Class 文件的转换器,转换器用于改变Class二进制流的数据,// 参数 canRetransform 设置是否允许重新转换
// 在对一个类注册了该转换器后,未来该类的每一次redefine以及retransform,都会被该转换器检查到,并且执行该转换器的操作void addTransformer(ClassFileTransformer transformer, boolean canRetransform);void addTransformer(ClassFileTransformer transformer);//删除一个类转换器boolean removeTransformer(ClassFileTransformer transformer);//是否允许对class retransformboolean isRetransformClassesSupported();// 在类加载之后,重新定义Class// 该方法是1.6之后加入的,事实上,该方法是update了一个类
// 用于对已经加载的类进行插桩,并且是从最初类加载的字节码开始重新应用转换器,并且每一个被注册到JVM的转换器都将会被执行void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;//是否允许对class重新定义boolean isRedefineClassesSupported();// 此方法用于替换类的定义,而不引用现有的类文件字节,就像从源代码重新编译// 以进行修复和继续调试时所做的那样。// 在要转换现有类文件字节的地方(例如在字节码插装中),应该使用retransformClasses。// 该方法可以修改方法体、常量池和属性值,但不能新增、删除、重命名属性或方法,// 也不能修改方法的签名void redefineClasses(ClassDefinition... definitions) throws ClassNotFoundException, UnmodifiableClassException;//获取已经被JVM加载的class,有className可能重复(可能存在多个classloader)@SuppressWarnings("rawtypes")Class[] getAllLoadedClasses();// 获取某个对象的(字节)大小,注意嵌套对象或者对象中的属性引用需要另外单独计算long getObjectSize(Object objectToSize);// 将某个jar加入到Bootstrap Classpath里优先其他jar被加载void appendToBootstrapClassLoaderSearch(JarFile jarfile);// 将某个jar加入到Classpath里供AppClassloard去加载void appendToSystemClassLoaderSearch(JarFile jarfile);// 设置某些native方法的前缀,主要在找native方法的时候做规则匹配void setNativeMethodPrefix(ClassFileTransformer transformer, String prefix);// 是否支持设置native方法的前缀boolean isNativeMethodPrefixSupported(); }
如上的这些方法是字节码增强过程中经常使用的。
举个例子,现在要增强自己写的业务代码,只需要:
MyTransformer monitor = new MyTransformer(); inst.addTransformer(monitor, true);
然后编写Transformer,如下:
public class MyTransformer implements ClassFileTransformer {@Overridepublic byte[] transform(ClassLoader classLoader,String className,Class<?> classBeingRedefined,ProtectionDomain protectionDomain,byte[] bytes) {// 增强逻辑} }
注册一个Transformer,从此之后的类加载都会被Transformer拦截。ClassFileTransformer的transform()方法可以直接对类的字节码进行修改,但是只能修改方法体,不能变更方法签名、增加和删除方法/类的成员属性。
大概的流程如下图所示。
假设要增强已经被系统加载了的类(因为虚拟机在启动时就会预先加载一些基本的系统类),比如要增强java.lang.Thread类,那此时可能需要retransformClasses()类了,如下:
if (inst.isRetransformClassesSupported()) {Class<?>[] classes = inst.getAllLoadedClasses();for (Class<?> c : classes) {if (c.getName().contains("java.lang.Thread")) {if(inst.isModifiableClass(c)){try {inst.retransformClasses(c);} catch (UnmodifiableClassException e) {e.printStackTrace();}break;}}} }
不过也不是什么类都可以被增强的,像基本类型对应的封装类型,如java.lang.Integer是不能被增强的,调用对应Class对象的isModifiableClass()方法会返回false,如果强行进行retransformClasses(),会抛出UnmodifiableClassException异常。retransformClasses()方法对已经加载的类重新触发类加载,然后使用addTransformer()方法注册的ClassFileTransformer重新对类进行修饰。
retransformClasses()方法对已经加载的类重新触发类加载,然后使用addTransformer()方法注册的ClassFileTransformer重新对类进行修饰。应用场景如下:第一,在执行premain()或agentmain()方法前,JVM早已加载了不少类,而这些类的加载事件并没有被拦截,因此也没有被注入。使用retransformClasses()方法可以注入这些已加载但未实现增强的类。第二,在定义了多个Java Agent的情况下,有时候可能需要调用removeTransformer()方法需要移除其中的部分注入,然后调用retransformClasses()方法重新从原始byte数组开始进行注入。需要注意的是,新加载的类不能修改旧有类声明,譬如不能增加属性、不能修改方法声明
在对Java的native方法进行增强时,由于native方法在Java层面没有方法体,所以需要使用setNativeMethodPrefix()和isNativeMethodPrefixSupported()等完成。
在增强业务类或系统类时,如果在增强的类中引用JavaAgent中的类时,通常会引起ClassNotFoundException,如下面的MyTransformer类中引用了specpower包下的Stats类,此时需要将JavaAgent这个jar包通过appendToBootstrapClassLoaderSearch()方法追加根类加载器搜索路径。如果JavaAgent中的类很多,会严重污染目标用户的业务类,甚至引起干扰。可通过BTrace那样,将整个JavaAgent的类名改一个唯一的名称,或者像Arthas一样,使用间谍类Spy来解决。
下面是引起ClassNotFoundException异常的原因图示。
2. 字节码增强工具
不过Java字节码并不同于Java源代码,可读性肯定要差,修改起来难度不低,所以大家一直在为降低这个难度而努力着。。。,到目前为止,已经有不少第三方工具包可以使用了,我用表格列表了一下常用的三方工具包,如下:
ASM | 不受任何限制进行字节码修改,效率相对高一些,要求掌握字节码指令 | 操作难度大,编写代码多 | Arthas、BTrace等,值得一提的是,Javaassit和ByteBuddy的底层实现用了ASM |
Javaassit | Java原始语法,书写相对直观,不要求使用者掌握字节码指令 | 性能比ASM稍差 | Fastjson、MyBaties |
ByteBuddy |
支持任意维度的拦截,可以获取原始类、方法以及代理类和全部参数, 提供了丰富的API,无需掌握字节码指令 |
性能比ASM稍差 | SkyWalking、Mockito |
其实字节码工具在选择时,最看重的2个点就是的易用和性能好,一般线上的监控产品大多都会选择ASM,因为性能好。
Java的字节码是以栈为基础运行的,如果我们要直接更改这些字节码,除了要对指令熟悉外,还需要关注局部变量表、操作数栈的一致性等等问题,所以最好借助字节码工具来降低难度。尤其是能降低一些切面逻辑编写的难度,也就是在方法前,方法后等特定点上插入自己的增强逻辑。
3. JavaAgent
现在认识了字节码增强的API,有了字节码修改时辅助的工具,现在还有个问题,就是怎么将我们要修改的逻辑挂在目标应用程序上呢?这就要讲一下JavaAgent了,这个JavaAgent可以通过在启动时或者运行时动态挂载到目标应用程序上。
在启动时,使用-javaagent命令,调用到JavaAgent的premain()方法
在运行时,使用VirtualMachine类的attach()方法挂载,调用到JavaAgent的agentmain()方法
通常我们为了兼容两种启动模式,可以都实现一下premain()和agentmain()方法,如下:
public class MyAgent {public static void premain(String agentArgs, Instrumentation inst) {instrument(agentArgs,inst);}public static void agentmain(String agentArgs, Instrumentation inst){instrument(agentArgs,inst);}private static void instrument(String agentArgs, Instrumentation inst){MyTransformer monitor = new MyTransformer();inst.addTransformer(monitor, true); }}
需要提示的是,在执行premain()或agentmain()方法前,JVM早已加载了不少类,而这些类的加载事件并没有被拦截,因此也没有被注入。如果要对这些已经加载的类进行增强,需要使用retransformClasses()方法。
JavaAgent的出现,极大的解耦了增强实现和目标应用程序的耦合程度,为后来的一大批做监控、做可观测以及做各种性能剖析的商业化公司提供了技术上的落地实现。想像一下,如果客户开发的某个应用程序需要稳定性保障,你做为一个监控产品的提供者,你可以给用户这样说,“我们产品不需要你的程序修改任何一行代码,甚至不需要重启应用程序就可以具备监控功能”。现在的商业公司提供的监控产品并不局限在Java生态,无论用户用的是什么技术栈,什么操作系统,什么架构,都能够自动识别并监控。
JavaAgent的实现基于JVMTI,我们还可以编写JVMTIAgent,不过需要用C/C++等语言编写,它能调用JNI接口以及订阅JVMTI事件等,比JavaAgent更加强大。
4. 字节码增强注意点
字节码增强需要注意的点有几个:
(1) 重定义可能会更改方法体、常量池和属性。重定义不得添加、移除、重命名字段或方法。
(2) 如果重定义的方法有活动的堆栈帧,那么这些活动的帧将继续运行原方法的字节码。将在新的调用上使用此重定义的方法。
(3)一个程序可能会挂载许多JavaAgent,这可能会导致前一个JavaAgent增强过的逻辑被后面JavaAgent增强时擦写,导致前一个增强逻辑失效;或者前一个JavaAgent出现阻塞,导致后续的JavaAgent没有正确加载,所以谨慎安排JavaAgent的挂载顺序,可参考实例:https://www.cnblogs.com/huaweiyun/p/16876537.html
(4)字节码增强本身的一些致使Bug也不少,这些Bug严重到会导致JVM Crash,而且有些Bug很不好浮现,不好追踪
(5)会影响到目标程序
(6)javaagent的premain和agentmain的类是通过应用类加载器加载的,所以如果要和业务代码通信,需要考虑classloader不同的情况,一般要通过反射(可以传入指定classloader加载类)和业务代码通信,或者可以学习Arthas那样用间谍类来解决
(7)注意依赖冲突的问题,比如agent的fatjar中包含了某个第三方的类,业务代码中也包含了相同的第三方但是不同版本的类,由于classloader存在父类优先委派加载的情况,可能会导致类加载异常,所以一般会通过shaded修改第三方类库的包名或者通过classloader隔离,也可以对整个三方包进行名称更改,maven是支持做这样的操作的
一是JavaAgent代码会影响到目标程序,例如不小心用到了目标应用程序的日志框架,导致JavaAgent相关的数据打印到了目标应用程序的日志里,所以一定要做好类隔离,可以像BTrace进行改名,也可以像Arthas那样用Spy间谍类避免;
二是JavaAgent会影响目标程序的效率,这可能是因为可能增强时插入的逻辑过多,涉及到增强的方法过多等因素造成,还可能会造成逆优化,也就是已经被JIT编译好的方法会被弃掉。所以对于商业化监控系统来说,一定要考虑Trace Agent的自适应采集设计,如何自动调节采集频率以及采集比例控制能力等性能开销优化。
关于最后一点影响目标应用程序的执行效率,需要我们特别关注。JavaAgent是附着在目标应用程序上执行的,JavaAgent中对任何全局变量等的修改都会影响到目标应用程序,假设JavaAgent写了一个会导致JVM Crash的Bug,那么目标应用程序就会Crash。
5. 小实例
现在我们要编写一个JavaAgent并统计一下方法valueOf在不同参数下的调用情况,如下:
public class MyAgent {public static void premain(String agentArgs, Instrumentation inst) {instrument(agentArgs,inst);}public static void agentmain(String agentArgs, Instrumentation inst){instrument(agentArgs,inst);}private static void instrument(String agentArgs, Instrumentation inst){MyTransformer monitor = new MyTransformer();inst.addTransformer(monitor, true); JarFile f = null;try {// 生成的jar路径,为的是让BigDecimal类中valueOf方法顺利找到Stats类f = new JarFile(生成的jar路径);} catch (IOException e) {e.printStackTrace();}inst.appendToBootstrapClassLoaderSearch(f); }
在编写Agent时,有两个方法premain()和agentmain(),如果使用-javaagent参数启动Agent,则调用premain(),如果使用VirtualMachine类的attach()方法挂载则调用agentmain()。
接下来我们编写MyTransformer,如下:
public class MyTransformer implements ClassFileTransformer {@Overridepublic byte[] transform(ClassLoader classLoader,String className,Class<?> classBeingRedefined,ProtectionDomain protectionDomain,byte[] bytes) {try {if (className == null) {return null;}if (!className.equals("java/math/BigDecimal")) {return null;}String currentClassName = className.replaceAll("/", ".");CtClass ctClass = ClassPool.getDefault().get(currentClassName);CtBehavior[] methods = ctClass.getDeclaredBehaviors();for (CtBehavior method : methods) {if ("valueOf".equals(method.getName())) {String sig = method.getSignature();if ("(JI)Ljava/math/BigDecimal;".equals(sig)) {method.insertBefore("specpower.Stats.valueOf($1,$2);");}}}return ctClass.toBytecode();} catch (Exception e) {e.printStackTrace();}return null;} }
使用javassist来改写字节码,所以要把相关的jar包引入项目中。对BigDecimal的valueOf(long unscaledVal,int scale)方法进行增强,也就是在方法体的开始处插入一段调用代码:
specpower.Stats.valueOf(unscaledVal,scale);
在Stats类中统计相关的信息,如下:
package specpower;...public class Stats {public static AtomicInteger a = new AtomicInteger(0);public static AtomicInteger b = new AtomicInteger(0);public static void valueOf(long val, int scale) {if (val == 0 && scale == 2) {a.incrementAndGet();} else if (val == 1 && scale == 0) {b.incrementAndGet();}} }
统计当参数为0,2或1,0时,valueOf的调用次数,在SPECPower退出时打印。
在MyAgent中注册钩子函数:
Runtime.getRuntime().addShutdownHook(new Thread(){public void run(){Stats.print();} });
在Stats中打印即可,如下:
public static void print() {System.out.println(a.get() + " " + b.get()); }
打JavaAgent的jar包时,一定要在MANIFEST.MF文件中指定如下内容:
Agent-Class: specpower.MyAgent Can-Redefine-Classes: true Can-Retransform-Classes: true Premain-Class: specpower.MyAgent