一、背景介绍

1、需求说明

需求是在程序运行期间,向某个类的某个方法前、后加入某段业务代码,或者直接替换整个方法的业务逻辑,即业务方法客制化。注意是运行期间动态更改,做到无侵入,而不是事先在代码中写死切入点或逻辑。

拿到这个需求,首先想到的是使用 spring aop 技术,但这种方式需要事先在方法上加注解进行拦截,可我们在服务启动前并不知道要拦截哪些方法。或者直接拦截所有方法,但这样或多或少都会有一些性能问题,每次方法调用时,都会进入切面,需要判断是否需要对这个方法做客制化,而判断的规则以及客制化代码一般存储在缓存中,这时还会涉及缓存查询,性能肯定会有所降低。鉴于以上考虑,选择 Java 动态字节码技术 来实现。

2、动态字节码技术

Java 代码都是要被编译成字节码后才能放到 JVM 里执行的,而字节码一旦被加载到虚拟机中,就可以被解释执行。字节码文件(.class)就是普通的二进制文件,它是通过 Java 编译器生成的。而只要是文件就可以被改变,如果我们用特定的规则解析了原有的字节码文件,对它进行修改或者干脆重新定义,这不就可以改变代码行为了么。动态字节码技术优势在于 Java 字节码生成之后,对其进行修改,增强其功能,这种方式相当于对应用程序的二进制文件进行修改。

Java 生态里有很多可以动态处理字节码的技术,比较流行的有两个,一个是 ASM,一个是 Javassist 。

ASM:直接操作字节码指令,执行效率高,但涉及到JVM的操作和指令,要求使用者掌握Java类字节码文件格式及指令,对使用者的要求比较高。

Javassist:提供了更高级的API,执行效率相对较差,但无需掌握字节码指令的知识,简单、快速,对使用者要求较低。

考虑到简单易用性,这里选择 Javassist 工具来实现。

3、技术设计

① 首先需要一个扫描服务类及方法的功能,这样我们才能选择某个方法切入。

调用客户端服务扫描切入点接口,需要扫描出服务中的包名、类名、方法名、以及方法参数列表。

② 维护规则,配置切入的位置、业务代码。

位置可以是前置、后置、替换。客制化的代码类需要实现 ICustomizeHandler 接口的 execute 方法,目的是固定结构。

在切入方法时,只需要创建这个 handler 的实例对象,然后执行 execute 方法即可。这种方式比较简单,但也有一定的局限性。

在 execute 方法中如果要引用 spring 容器中的其它对象,需要通过 ApplicationContext 上下文获取,不能使用依赖注入,如果要使用依赖注入,还需要处理类的属性。

③ 维护切入点与规则之间的关系,因为一个切入点可以维护多个规则。

维护好规则和关系之后,就需要应用规则,即调用客户端客制化接口,动态应用规则。

4、准备工作

① 切入点、客制化代码、以及关系 已经维护好了,客制化 test-service 服务中 org.test.demo.app.service.impl.DemoServiceImpl 类的 selectOrder 方法,在方法前、后加一段代码,打印一些东西。

② OrderServiceImpl 的代码,之后通过观察控制台打印内容来确认客制化效果。

 1 package org.test.demo.app.service.impl;
 2 
 3 import java.util.List;
 4 
 5 import org.slf4j.Logger;
 6 import org.slf4j.LoggerFactory;
 7 import org.springframework.stereotype.Service;
 8 import org.test.demo.app.service.DemoService;
 9 import org.test.demo.domain.entity.Order;
10 import org.test.demo.domain.repository.OrderRepository;
11 
12 @Service
13 public class DemoServiceImpl implements DemoService {
14 
15     private static final Logger LOGGER = LoggerFactory.getLogger(DemoServiceImpl.class);
16 
17     private final OrderRepository orderRepository;
18 
19     public DemoServiceImpl(OrderRepository orderRepository) {
20         this.orderRepository = orderRepository;
21     }
22 
23     @Override
24     public List<Order> selectOrder(String orderNumber, String status) {
25 
26         Order params = new Order();
27         params.setOrderNumber(orderNumber);
28         params.setStatus(status);
29         List<Order> orders = orderRepository.select(params);
30         LOGGER.info("order size is {}", orders.size());
31 
32         return orders;
33     }
34 
35 }
View Code

 

背景及准备工作介绍完了,下面就来看看如何一步步实现动态切面的能力。接下来首先对一些必备知识做简要介绍,然后对实现过程中一些核心逻辑做介绍。

二、知识准备:Javassist

1、Javassist

Javassist 是一个开源的分析、编辑和创建Java字节码的类库。其主要的优点,在于简单,而且快速。直接使用 java 编码的形式,而不需要了解虚拟机指令,就能动态改变类的结构,或者动态生成类。

Javassist 中最为重要的是 ClassPool,CtClass ,CtMethod 以及 CtField 这几个类。

ClassPool:一个基于 Hashtable 现的 CtClass 对象容器,其中键是类名称,值是表示该类的 CtClass 对象。

CtClass:CtClass 表示类,一个 CtClass (编译时类)对象可以处理一个 class 文件,这些 CtClass 对象可以从 ClassPool 获得。

CtMethods:表示类中的方法。

CtFields :表示类中的字段。

2、ClassPool 使用

① 获取 ClassPool 对象

1 // 获取 ClassPool 对象,使用系统默认类路径
2 ClassPool pool = new ClassPool(true);
3 // 效果与 new ClassPool(true) 一致
4 ClassPool pool1 = ClassPool.getDefault();

② 获取类

1 // 通过类名获取 CtClass,未找到会抛出异常
2 CtClass ctClass = pool.get("org.test.demo.DemoService");
3 // 通过类名获取 CtClass,未找到返回 null,不会抛出异常
4 CtClass ctClass1 = pool.getOrNull("org.test.demo.DemoService");

③ 创建新类

1 // 复制一个类,创建一个新类
2 CtClass ctClass2 = pool.getAndRename("org.test.demo.DemoService", "org.test.demo.DemoCopyService");
3 // 通过类名,创建一个新类
4 CtClass ctClass3 = pool.makeClass("org.test.demo.NewDemoService");
5 // 通过文件流,创建一个新类,注意文件必须是编译后的 class 文件,不是源代码文件。
6 CtClass ctClass4 = pool.makeClass(new FileInputStream(new File("./customize/DemoBeforeHandler.class")));

④ 添加类搜索路径

通过 ClassPool.getDefault() 获取的 ClassPool 使用 JVM 的类搜索路径。如果程序运行在 JBoss 或者 Tomcat 等 Web 服务器上,ClassPool 可能无法找到用户的类,因为 Web 服务器使用多个类加载器作为系统类加载器。在这种情况下,ClassPool 必须添加额外的类搜索路径才能搜索到用户的类。

1 // 将类搜索路径插入到搜索路径之前
2 pool.insertClassPath(new ClassClassPath(this.getClass()));
3 // 将类搜索路径添加到搜索路径之后
4 pool.appendClassPath(new ClassClassPath(this.getClass()));
5 // 将一个目录作为类搜索路径
6 pool.insertClassPath("/usr/local/javalib");

⑤ 避免内存溢出

如果 CtClass 对象的数量变得非常大(这种情况很少发生,因为 Javassist 试图以各种方式减少内存消耗),ClassPool 可能会导致巨大的内存消耗。为了避免此问题,可以从 ClassPool 中显式删除不必要的 CtClass 对象。或者每次使用新的 ClassPool 对象。

1 // 从 ClassPool 中删除 CtClass 对象
2 ctClass.detach();
3 // 也可以每次创建一个新的 ClassPool,而不是 ClassPool.getDefault(),避免内存溢出
4 ClassPool pool2 = new ClassPool(true);

3、CtClass 使用

通过 CtClass 对象可以得到很多关于类的信息以及对类进行修改等操作。

① 获取类属性

 1 // 类名
 2 String simpleName = ctClass.getSimpleName();
 3 // 类全名
 4 String name = ctClass.getName();
 5 // 包名
 6 String packageName = ctClass.getPackageName();
 7 // 接口
 8 CtClass[] interfaces = ctClass.getInterfaces();
 9 // 继承类
10 CtClass superclass = ctClass.getSuperclass();
11 // 获取字节码文件,可以通过 ClassFile 对象进行字节码级操作
12 ClassFile classFile = ctClass.getClassFile();
13 // 获取带参数的方法,第二个参数为参数列表数组,类型为 CtClass
14 CtMethod ctMethod = ctClass.getDeclaredMethod("selectOrder", new CtClass[] {pool.get(String.class.getName()), pool.get(String.class.getName())});
15 // 获取字段
16 CtField ctField = ctClass.getField("orderRepository");

② 类型判断

 1 // 判断数组类型
 2 ctClass.isArray();
 3 // 判断原生类型
 4 ctClass.isPrimitive();
 5 // 判断接口类型
 6 ctClass.isInterface();
 7 // 判断枚举类型
 8 ctClass.isEnum();
 9 // 判断注解类型
10 ctClass.isAnn

③ 添加类属性

1 // 添加接口
2 ctClass.addInterface(...);
3 // 添加构造器
4 ctClass.addConstructor(...);
5 // 添加字段
6 ctClass.addField(...);
7 // 添加方法
8 ctClass.addMethod(...);

④ 编译类

1 // 编译成字节码文件,使用当前线程上下文类加载器加载类,如果类已存在或者编译失败将抛出异常
2 Class clazz = ctClass.toClass();
3 // 编辑成字节码文件,返回 byte 数组
4 byte[] bytes = ctClass.toBytecode();

4、CtMethod 使用

① 获取方法属性

 1 CtClass ctClass5 = pool.get(TestService.class.getName());
 2 CtMethod ctMethod = ctClass5.getDeclaredMethod("selectOrder");
 3 // 方法名
 4 String methodName = ctMethod.getName();
 5 // 返回类型
 6 CtClass returnType = ctMethod.getReturnType();
 7 // 方法参数,通过此种方式得到方法参数列表 格式:com.test.TestService.selectOrder(java.lang.String,java.util.List,com.test.Order)
 8 ctMethod.getLongName();
 9 // 方法签名 格式:(Ljava/lang/String;Ljava/util/List;Lcom/test/Order;)Ljava/lang/Integer;
10 ctMethod.getSignature();
11 
12 // 获取方法参数名称,可以通过这种方式得到方法真实参数名称
13 List<String> argKeys = new ArrayList<>();
14 MethodInfo methodInfo = ctMethod.getMethodInfo();
15 CodeAttribute codeAttribute = methodInfo.getCodeAttribute();
16 LocalVariableAttribute attr = (LocalVariableAttribute) codeAttribute.getAttribute(LocalVariableAttribute.tag);
17 int len = ctMethod.getParameterTypes().length;
18 // 非静态的成员函数的第一个参数是this
19 int pos = Modifier.isStatic(ctMethod.getModifiers()) ? 0 : 1;
20 for (int i = pos; i < len; i++) {
21     argKeys.add(attr.variableName(i));
22 }

② 方法操作

 1 // 在方法体前插入代码块
 2 ctMethod.insertBefore("");
 3 // 在方法体后插入代码块
 4 ctMethod.insertAfter("");
 5 // 在某行 字节码 后插入代码块
 6 ctMethod.insertAt(10, "");
 7 // 添加参数
 8 ctMethod.addParameter(CtClass);
 9 // 设置方法名
10 ctMethod.setName("newName");
11 // 设置方法体
12 ctMethod.setBody("");

③ 方法内部引用变量

5、实际应用

① 创建新类

 1 public static void main(String[] args) throws Exception {
 2     ClassPool pool = new ClassPool(true);
 3 
 4     // 创建 IHello 的实现类
 5     CtClass newClass = pool.makeClass("org.test.HelloImpl");
 6     // 添加接口
 7     newClass.addInterface(pool.get(IHello.class.getName()));
 8     // 返回类型 Void
 9     CtClass returnType = pool.get(void.class.getName());
10     // 参数
11     CtClass[] parameters = new CtClass[]{ pool.get(String.class.getName()) };
12     // 定义方法
13     CtMethod method = new CtMethod(returnType, "sayHello", parameters, newClass);
14     // 方法体代码块,必须用 {} 包裹代码
15     String source = "{" +
16                         "System.out.println(\"hello \" + $1);"
17                   + "}"
18                     ;
19     // 设置方法体
20     method.setBody(source);
21     // 添加方法
22     newClass.addMethod(method);
23     // 编译、转换成 Class 字节码对象
24     Class helloClass = newClass.toClass();
25 
26     IHello hello = (IHello) helloClass.newInstance();
27     hello.sayHello("javassist");
28 }

② 创建代理方法

 1 public static void main(String[] args) throws Exception {
 2     ClassPool pool = new ClassPool(true);
 3 
 4     CtClass targetClass = pool.get("com.lyyzoo.test.bytecode.javassist.service.HelloServiceImpl");
 5 
 6     CtMethod method = targetClass.getDeclaredMethod("sayHello");
 7 
 8     // 复制方法生成一个新的代理方法
 9     CtMethod agentMethod = CtNewMethod.copy(method, method.getName()+"$agent", targetClass, null);
10     agentMethod.setModifiers(Modifier.PRIVATE);
11     // 添加方法
12     targetClass.addMethod(agentMethod);
13     // 构建新的方法体,并使用代理方法
14     String source = "{"
15             + "System.out.println(\"before handle >  ...\" + $type);"
16             + method.getName() + "$agent($$);"
17             + "System.out.println(\"after handle ...\");"
18             + "}"
19             ;
20     // 设置方法体
21     method.setBody(source);
22     // 编译,注意:如果类已经加载了,是不能重定义的,会报错 duplicate class definition....
23     targetClass.toClass();
24 
25     // 使用 javassist.util.HotSwapAgent 重定义类。这种方式必须 attach 代理程序才能使用:-XXaltjvm=dcevm -javaagent:E:\hotswap-agent-1.3.0.jar
26     //HotSwapAgent.redefine(HelloServiceImpl.class, targetClass);
27 
28     IHello hello = new HelloServiceImpl();
29     hello.sayHello("javassist");
30 }

6、资料参考

Javassist 有着丰富的API来操作类,其它的特性及使用可以参考如下文章

Javassist 使用指南(一)

Javassist 使用指南(二)

Javassist 使用指南(三)

三、知识准备:Javaagent

对于Java 程序员来说,Java Intrumentation、Java agent 这些技术可能平时接触的很少。实际上,我们日常应用的各种工具中,有很多都是基于他们实现的,例如常见的热部署(JRebel, spring-loaded)、IDE debug、各种线上诊断工具(btrace,、Arthas)等等。

1、Instrumentation

使用 java.lang.instrument.Instrumentation,使得开发者可以构建一个独立于应用程序的代理程序(Agent),用来监测和协助运行在 JVM 上的程序,甚至能够替换和修改某些类的定义。有了这样的功能,开发者就可以实现更为灵活的运行时虚拟机监控和 Java 类操作,这样的特性实际上提供了一种虚拟机级别支持的 AOP 实现方式,使得开发者无需对 JDK 做任何升级和改动,就可以实现某些 AOP 的功能了。Instrumentation 的最大作用,就是类定义动态改变和操作。

Instrumentation的一些主要方法如下:

 1 public interface Instrumentation {
 2     /**
 3      * 注册一个Transformer,从此之后的类加载都会被 transformer 拦截。
 4      * ClassFileTransformer 的 transform 方法可以直接对类的字节码进行修改,但是只能修改方法体,不能变更方法签名、增加和删除方法/类的成员属性
 5      */
 6     void addTransformer(ClassFileTransformer transformer);
 7     
 8     /**
 9      * 对JVM已经加载的类重新触发类加载,使用上面注册的 ClassFileTransformer 重新对类进行修饰。
10      */
11     void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;
12 
13     /**
14      * 重新定义类,不是使用 transformer 修饰,而是把处理结果(bytecode)直接给JVM。
15      * 调用此方法同样只能修改方法体,不能变更方法签名、增加和删除方法/类的成员属性
16      */
17     void redefineClasses(ClassDefinition... definitions) throws  ClassNotFoundException, UnmodifiableClassException;
18 
19     /**
20      * 获取一个对象的大小
21      */
22     long getObjectSize(Object objectToSize);
23     
24     /**
25      * 将一个jar加入到bootstrap classloader 的 classpath 里
26      */
27     void appendToBootstrapClassLoaderSearch(JarFile jarfile);
28 
29     /**
30      * 将一个jar加入到 system classloader 的 classpath 里
31      */
32     void appendToSystemClassLoaderSearch(JarFile jarfile);
33 
34     /**
35      * 获取当前被JVM加载的所有类对象
36      */
37     Class[] getAllLoadedClasses();
38 }

2、Javaagent

Java agent 是一种特殊的Java程序(Jar文件),它是 Instrumentation 的客户端。与普通 Java 程序通过main方法启动不同,agent 并不是一个可以单独启动的程序,而必须依附在一个Java应用程序(JVM)上,与它运行在同一个进程中,通过 Instrumentation API 与虚拟机交互。

Java agent 与 Instrumentation 密不可分,二者也需要在一起使用。因为JVM 会把 Instrumentation 的实例会作为参数注入到 Java agent 的启动方法中。因此如果想使用 Instrumentation 功能,拿到 Instrumentation 实例,我们必须通过Java agent。

Java agent 有两个启动时机,一个是在程序启动时通过 -javaagent 参数启动代理程序,一个是在程序运行期间通过 Java Tool API 中的 attach api 动态启动代理程序。

① JVM启动时静态加载

对于VM启动时加载的 agent,Instrumentation 会通过 premain 方法传入代理程序,premain 方法会在程序 main 方法执行之前被调用。此时大部分Java类都没有被加载(“大部分”是因为,agent类本身和它依赖的类还是无法避免的会先加载的),是一个对类加载埋点做手脚(addTransformer)的好机会。但这种方式有很大的局限性,Instrumentation 仅限于 main 函数执行前,此时有很多类还没有被加载,如果想为其注入 Instrumentation 就无法办到。

 1 /**
 2  * agentArgs 是 premain 函数得到的程序参数,通过 -javaagent 传入。这个参数是个字符串,如果程序参数有多个,需要程序自行解析这个字符串。
 3  * inst 是一个 java.lang.instrument.Instrumentation 的实例,由 JVM 自动传入。
 4  */
 5 public static void premain(String agentArgs, Instrumentation inst) {
 6     
 7 }
 8 
 9 /**
10  * 带有 Instrumentation 参数的 premain 优先级高于不带此参数的 premain。
11  * 如果存在带 Instrumentation 参数的 premain,不带此参数的 premain 将被忽略。
12  */
13 public static void premain(String agentArgs) {
14     
15 }

这种方式的应用比如在 IDEA 启动 debug 模式时,就是以 -javaagent 的形式启动 debug 代理程序实现的。

② JVM 启动后动态加载

对于VM启动后动态加载的 agent,Instrumentation 会通过 agentmain 方法传入代理程序,agentmain 在 main 函数开始运行后才被调用。

 1 /**
 2  * agentArgs 是 agentmain 函数得到的程序参数,在 attach 时传入。这个参数是个字符串,如果程序参数有多个,需要程序自行解析这个字符串。
 3  * inst 是一个 java.lang.instrument.Instrumentation 的实例,由 JVM 自动传入。
 4  */
 5 public static void agentmain(String agentArgs, Instrumentation inst) {
 6 
 7 }
 8 
 9 /**
10  * 带有 Instrumentation 参数的 agentmain 优先级高于不带此参数的 agentmain。
11  * 如果存在带 Instrumentation 参数的 agentmain,不带此参数的 agentmain 将被忽略。
12  */
13 public static void agentmain(String agentArgs) {
14 
15 }

这种方式的应用比如在启用 Arthas 来诊断线上问题时,通过 attach api,来动态加载代理程序到目标VM。

3、MANIFEST.MF

写好的代理类想要运行,在打 jar 包前,还需要要在 MANIFEST.MF 中指定代理程序入口。

①、MANIFEST.MF

大多数 JAR 文件会包含一个 META-INF 目录,它用于存储包和扩展的配置数据,如安全性和版本信息。其中会有一个 MANIFEST.MF 文件,该文件包含了该 Jar 包的版本、创建人和类搜索路径等信息,如果是可执行Jar 包,会包含Main-Class属性,表明 Main 方法入口。

例如下面是通过 mvn clean package 命令打包后的 Jar 包中的 MANIFEST.MF 文件,从中可以看出 jar 的版本、创建者、SpringBoot 版本、程序入口、类搜索路径等信息。

② 与 agent 相关的参数

  • Premain-Class :JVM 启动时指定了代理,此属性指定代理类,即包含 premain 方法的类
  • Agent-Class :JVM动态加载代理,此属性指定代理类,即包含 agentmain 方法的类
  • Boot-Class-Path :设置引导类加载器搜索的路径列表,列表中的路径由一个或多个空格分开。
  • Can-Redefine-Classes :布尔值(true 或 false)。是否能重定义此代理所需的类
  • Can-Retransform-Classes :布尔值(true 或 false)。是否能重转换此代理所需的类
  • Can-Set-Native-Method-Prefix :布尔值(true 或 false)。是否能设置此代理所需的本机方法前缀

4、Attach API

Java agent 可以在JVM启动后再加载,就是通过 Attach API 实现的。当然,Attach API 不仅仅是为了实现动态加载 agent,Attach API 其实是跨JVM进程通讯的工具,能够将某种指令从一个JVM进程发送给另一个JVM进程。

加载 agent 只是 Attach API 发送的各种指令中的一种, 诸如 jstack 打印线程栈、jps 列出Java进程、jmap 做内存dump等功能,都属于Attach API 可以发送的指令。

Attach API不是Java的标准API,而是Sun公司提供的一套扩展API,用来向目标JVM"附着"(Attach)代理工具程序的。有了它,开发者可以方便的监控一个JVM,运行一个外加的代理程序。

① 引入 Attach API

在使用 Attach API时,需要引入 tools.jar 

1 <dependency>
2     <groupId>jdk.tools</groupId>
3     <artifactId>jdk.tools</artifactId>
4     <version>1.8</version>
5     <scope>system</scope>
6     <systemPath>${env.JAVA_HOME}/lib/tools.jar</systemPath>
7 </dependency>

打包运行时,需要将 tools.jar 打包进去

 1 <build>
 2     <plugins>
 3         <plugin>
 4             <groupId>org.springframework.boot</groupId>
 5             <artifactId>spring-boot-maven-plugin</artifactId>
 6             <configuration>
 7                 <includeSystemScope>true</includeSystemScope>
 8             </configuration>
 9         </plugin>
10     </plugins>
11 </build>

② attach agent

1 // VirtualMachine等相关Class位于JDK的tools.jar
2 VirtualMachine vm = VirtualMachine.attach("1234");  // 1234表示目标JVM进程pid
3 try {
4     vm.loadAgent(".../agent.jar");    // 指定agent的jar包路径,发送给目标进程
5 } finally {
6     vm.detach();
7 }

5、资料参考

其它更详细的相关知识请参考如下文章

基于Java Instrument的Agent实现

谈谈Java Intrumentation和相关应用

聊一聊 JAR 文件和 MANIFEST.MF

四、知识准备:JVM类加载器

1、类加载器简介

类加载器(class loader)用来加载 Java 类到 Java 虚拟机中。一般来说,Java 虚拟机使用 Java 类的方式如下:Java 源程序(.java 文件)在经过 Java 编译器编译之后就被转换成 Java 字节代码(.class 文件)。类加载器负责读取 Java 字节代码,并转换成 java.lang.Class 类的一个实例。每个这样的实例用来表示一个 Java 类。

基本上所有的类加载器都是 java.lang.ClassLoader 类的一个实例。java.lang.ClassLoader 类的基本职责就是根据一个指定的类的名称,找到或者生成其对应的字节代码,然后从这些字节代码中定义出一个 Java 类,即 java.lang.Class 类的一个实例。

Java 中的类加载器大致可以分成两类,一类是系统提供的,另外一类则是由 Java 应用开发人员编写的,开发人员可以通过继承 java.lang.ClassLoader 类的方式实现自定义类加载器,以满足一些特殊的需求。

系统提供的类加载器主要有下面三个:

  • 引导类加载器(Bootstrap ClassLoader):负责将 $JAVA_HOME/lib 或者 -Xbootclasspath 参数指定路径下面的文件(按照文件名识别,如 rt.jar) 加载到虚拟机内存中。它用来加载 Java 的核心库,是用原生代码实现的,并不继承自 java.lang.ClassLoader,引导类加载器无法直接被 java 代码引用。
  • 扩展类加载器(Extension ClassLoader):负责加载 $JAVA_HOME/lib/ext 目录中的文件,或者 java.ext.dirs 系统变量所指定的路径的类库,它用来加载 Java 的扩展库。
  • 应用程序类加载器(Application ClassLoader):一般是系统的默认加载器,它根据 Java 应用的类路径(CLASSPATH)来加载 Java 类。一般 Java 应用的类都是由它来完成加载的,可以通过 ClassLoader.getSystemClassLoader() 来获取它。

2、类加载过程 — 双亲委派模型

① 类加载器结构

除了引导类加载器之外,所有的类加载器都有一个父类加载器。应用程序类加载器的父类加载器是扩展类加载器,扩展类加载器的父类加载器是引导类加载器。一般来说,开发人员自定义的类加载器的父类加载器是应用程序类加载器。

② 双亲委派模型

类加载器在尝试去查找某个类的字节代码并定义它时,会先代理给其父类加载器,由父类加载器先去尝试加载这个类,如果父类加载器没有,继续寻找父类加载器,依次类推,如果到引导类加载器都没找到才从自身查找。这个类加载过程就是双亲委派模型。

首先要明白,Java 虚拟机判定两个 Java 类是否相同,不仅要看类的全名是否相同,还要看加载此类的类加载器是否一样(可以通过 class.getClassLoader() 获得)。只有两个类来源于同一个Class文件,并且被同一个类加载器加载,这两个类才相等。不同类加载器加载的类之间是不兼容的。

双亲委派模型就是为了保证 Java 核心库的类型安全的。所有 Java 应用都至少需要引用 java.lang.Object 类,也就是说在运行的时候,java.lang.Object 这个类需要被加载到 Java 虚拟机中。如果这个加载过程由 Java 应用自己的类加载器来完成的话,很可能就存在多个版本的 java.lang.Object 类,而这些类之间是不兼容的。通过双亲委派模型,对于 Java 核心库的类加载工作由引导类加载器来统一完成,保证了 Java 应用所使用的都是同一个版本的 Java 核心库的类,是互相兼容的。

类加载器在成功加载某个类之后,会把得到的 java.lang.Class 类的实例缓存起来。下次再请求加载该类的时候,类加载器会直接使用缓存的类的实例,而不会尝试再次加载。

3、线程上下文类加载器

线程上下文类加载器可通过 java.lang.Thread 中的方法 getContextClassLoader() 获得,可以通过 setContextClassLoader(ClassLoader cl) 来设置线程的上下文类加载器。如果没有通过 setContextClassLoader(ClassLoader cl) 方法进行设置的话,线程将继承其父线程的上下文类加载器。Java 应用运行的初始线程的上下文类加载器是应用程序类加载器。在线程中运行的代码可以通过此类加载器来加载类和资源。

4、SpringBoot 类加载器

由于我是使用 SpringBoot (2.0.x) 开发,且打包成 jar 的形式部署在服务器上,这里有必要了解下 Spring boot 相关的类加载机制,遇到的很多问题就是由于 Spring Boot 的类加载机制导致的。

SpringBoot 的可执行jar包又称 fat jar ,是包含所有第三方依赖的 jar 包,jar 包中嵌入了除 java 虚拟机以外的所有依赖,是一个 all-in-one jar 包。普通插件 maven-jar-plugin 生成的包和 spring-boot-maven-plugin 生成的包之间的直接区别是, fat jar 中主要增加了两部分,第一部分是 lib 目录,存放的是Maven依赖的jar包文件,第二部分是spring boot 类加载器相关的类。

使用 spring-boot-maven-plugin 插件打包出来的结构

├─BOOT-INF
│  ├─classes
│  │  │  application.yml
│  │  │  bootstrap.yml
│  │  │
│  │  ├─org
│  │  │  └─sunny
│  │  │      └─demo
│  │  │          │  DemoApplication.class
│  │
│  └─lib
│          spring-boot-starter-2.0.6.RELEASE.jar
│          undertow-core-1.4.26.Final.jar
│          ...
│
├─META-INF
│  │  MANIFEST.MF
│  │  spring-autoconfigure-metadata.properties
│  │
│  └─maven
│      └─org.sunny
│          └─sunny-demo
│                  pom.properties
│                  pom.xml
│
└─org
    └─springframework
        └─boot
            └─loader
                │  JarLauncher.class
                │  LaunchedURLClassLoader.class
                │  .......
                │
                ├─archive
                │      Archive.class
                │      ExplodedArchive.class
                │      .......
                │
                ├─data
                │      RandomAccessData.class
                │      .......
                │
                ├─jar
                │      JarEntry.class
                │      JarFile.class
                │      .....
                │
                └─util
                        SystemPropertyUtils.class
View Code

MANIFEST.MF 的内容 

从生成的 MANIFEST.MF 文件中,可以看到两个关键信息 Main-Class 和 Start-Class。说明程序的启动入口并不是我们 SpringBoot 中定义的启动类的 main,而是 JarLauncher#main。

为了不解压就能启动 SpringBoot 程序,在 JarLauncher 内部,会读取 /BOOT-INF/lib/ 下的 jar 文件以及 /BOOT-INF/classes/ 构造一个 URL 数组,并用这个数组来构造 SpringBoot 的自定义类加载器 LaunchedURLClassLoader,该类继承了 java.net.URLClassLoader,其父类加载器是应用程序类加载器。

LaunchedURLClassLoader 创建好之后,会通过反射来启动我们写的启动类中的 main 函数,并设置当前线程上下文类加载器为 LaunchedURLClassLoader。

5、Javaagent 类加载器

javaagent 的代码永远都是被应用类加载器( Application ClassLoader)所加载,和应用代码的真实加载器无关。比如,当前运行在 undertow 中的代码是 LaunchedURLClassLoader  加载的,如果启动参数加上 -javaagent,这个 javaagent 还是在 Application ClassLoader 中加载的。

6、资料参考

其它一些深入详细的资料可以参考下面的一些文章:

深入探讨 Java 类加载器

深入理解Java ClassLoader及在 JavaAgent 中的应用

Java类加载机制

真正理解线程上下文类加载器

彻底透析SpringBoot jar可执行原理

spring boot应用启动原理分析

五、使用 Javassist 扫描类方法

首先我们来看下如何扫描出服务中指定包下的类及方法信息的。由于源码不开放,只贴出部分核心代码逻辑。

1、读取资源

要在程序运行期间读取资源文件,可以注入 ResourceLoader 来读取,MetadataReaderFactory 可以用来从 Resource 中读取元数据信息。

 1 public class DefaultApiScanService implements ResourceLoaderAware, InitializingBean {
 2 
 3     private ResourceLoader resourceLoader;
 4     private ResourcePatternResolver resolver;
 5     private MetadataReaderFactory metadataReader;
 6 
 7     @Override
 8     public void setResourceLoader(@NotNull ResourceLoader resourceLoader) {
 9         this.resourceLoader = resourceLoader;
10     }
11 
12     @Override
13     public void afterPropertiesSet() throws Exception {
14         Assert.notNull(this.resourceLoader, "resourceLoader should not be null");
15         this.resolver = ResourcePatternUtils.getResourcePatternResolver(resourceLoader);
16         this.metadataReader = new MethodsMetadataReaderFactory(resourceLoader);
17     }
18 }

读取类元数据信息

1 String packagePattern = "org.test.demo.app.service.impl";
2 // 读取资源文件
3 Resource[] resources = resolver.getResources("classpath*:" + packagePattern + "/**/*.class");
4 for (Resource resource : resources) {
5     MetadataReader reader = metadataReader.getMetadataReader(resource);
6     // 读取类元数据信息
7     ClassMetadata classMetadata = reader.getClassMetadata();
8 }

2、使用 Javassist 解析方法信息

// 创建新的 ClassPool,避免内存溢出
ClassPool classPool = new ClassPool(true);
// 将当前类加载路径加入 ClassPool 的 ClassPath 中,避免找不到类
classPool.insertClassPath(new ClassClassPath(this.getClass()));
// 使用 ClassPool 加载类
CtClass ctClass = classPool.get(classMetadata.getClassName());
// 去除接口、注解、枚举、原生、数组等类型的类,以及代理类不解析
if (ctClass.isInterface() || ctClass.isAnnotation() || ctClass.isEnum() || ctClass.isPrimitive() || ctClass.isArray() || ctClass.getSimpleName().contains("$")) {
    return;
}
// 获取所有声明的方法
CtMethod[] methods = ctClass.getDeclaredMethods();
for (CtMethod method : methods) {
    // 代理方法不解析
    if (method.getName().contains("$")) {
        continue;
    }
    // 包名
    String packageName = ctClass.getPackageName();
    // 类名
    String className = ctClass.getSimpleName();
    // 方法名
    String methodName = method.getName();
    // 参数:method.getLongName() 返回格式:com.test.TestService.selectOrder(java.lang.String,java.util.List,com.test.Order),所以截取括号中的即可
    String methodSignature = StringUtils.defaultIfBlank(StringUtils.substringBetween(method.getLongName(), "(", ")"), null);
}

六、动态编译源码

我们在最开始的规则中,已经维护好了一个业务处理类的源代码,但首先需要将其编译成字节码才能被使用,所以就涉及到如何动态编译源码了。

1、Java Compile API

JavaCompiler:表示java编译器,run方法执行编译操作.,还有一种编译方式是先生成编译任务(CompilationTask),然后调用 CompilationTask 的 call 方法执行编译任务

JavaFileObject:表示一个java源文件对象

JavaFileManager:Java源文件管理类, 管理一系列JavaFileObject

Diagnostic:表示一个诊断信息

DiagnosticListener:诊断信息监听器,编译过程触发

动态编译相关的API在 tools.jar 包里,所以需要在 pom 中引入 tools.jar 

1 <dependency>
2     <groupId>jdk.tools</groupId>
3     <artifactId>jdk.tools</artifactId>
4     <version>1.8</version>
5     <scope>system</scope>
6     <systemPath>${env.JAVA_HOME}/lib/tools.jar</systemPath>
7 </dependency>

2、动态编译

下面这段代码先从源码中解析出包名和类名,并将源码文件写入到磁盘中,然后使用 JavaCompiler 编译源码。注意再次编译同样类名时,名称不能相同,否则编译不通过,因为 JVM 已经加载过此实例了,类名可以加上个随机数避免重复。

 1 private void createAndCompileJavaFile(String sourceCode) throws Exception {
 2     // 从源码中解析包名
 3     String packageName = StringUtils.trim(StringUtils.substringBetween(sourceCode, "package", ";"));
 4     // 从源码中解析类名
 5     String className = StringUtils.trim(StringUtils.substringBetween(sourceCode, "class", "implements"));
 6     // 类全名
 7     String classFullName = packageName + "." + className;
 8 
 9     // 将源码写入 java 文件
10     File javaFile = new File(CUSTOMIZE_SRC_DIR + StringUtils.replace(classFullName, ".", File.separator) + ".java");
11     FileUtils.writeByteArrayToFile(javaFile, sourceCode.getBytes());
12 
13     // 使用 JavaCompiler 编译java文件
14     JavaCompiler javac = ToolProvider.getSystemJavaCompiler();
15     // 编译,实际上底层就是调用 javac 命令执行编译工作
16     int result = javac.run(null, null, null, javaFile.getAbsolutePath());
17 
18     if (result != 0) {
19         System.out.println("compile failure.");
20     } else {
21         System.out.println("compile success.");
22     }
23 }

 在IDEA中启动服务,这段代码没有任何问题,可以正常编译通过,可以看到 class 文件也编译出来了。

但是,一旦打成 jar 包运行,就不能正常编译了,会出现如下错误:程序包 xxx 不存在、找不到符号等。

实际上,这个错误也很好理解,javac.run(null, null, null, javaFile.getAbsolutePath()) 这行代码可以看成直接使用 javac 命令编译源文件一样,如果不指定  classpath ,肯定无法找到代码中引用的其它类。

那为何IDEA中可以,jar 包运行就不可以呢?这实际上是因为 springboot jar 的特殊性,springboot jar 是 all-in-one,classes 和 lib 都在 jar 包内,IDEA 中的 classes 都在 target 包下,能够直接被访问到。

3、基于 classpath 编译

如果是这样,那我们可以将 /BOOT-INF/classes/ 以及 /BOOT-INF/lib/ 下的文件加入到编译时的 classpath 路径下就没问题了。

首先,jar 包中的内容无法直接访问,比较次的方法就是将 jar 包解压,然后将路径拼接好之后再编译。

① 解压缩包

 1 File file = new File("app.jar");
 2 // 得到 JarFile
 3 JarFile jarFile = new JarFile(file);
 4 
 5 // 解压 jar 包
 6 for (Enumeration<JarEntry> e = jarFile.entries(); e.hasMoreElements();) {
 7     JarEntry je = e.nextElement();
 8     String outFileName = CUSTOMIZE_LIB_DIR + je.getName();
 9     File f = new File(outFileName);
10 
11     if(je.isDirectory()){
12         if (!f.exists()) {
13             f.mkdirs();
14         }
15     } else{
16         File pf = f.getParentFile();
17         if(!pf.exists()){
18             pf.mkdirs();
19         }
20 
21         try (InputStream in = jarFile.getInputStream(je);
22              OutputStream out = new BufferedOutputStream(new FileOutputStream(f))) {
23             byte[] buffer = new byte[2048];
24             int b = 0;
25             while ((b = in.read(buffer)) > 0) {
26                 out.write(buffer, 0, b);
27             }
28             out.flush();
29         }
30     }
31 }

② 拼接 classpath

 1 String bootLib = StringUtils.join(CUSTOMIZE_LIB_DIR, "BOOT-INF", File.separator, "lib");
 2 String bootLibPath = StringUtils.join(bootLib, File.separator);
 3 String bootClasses = StringUtils.join(CUSTOMIZE_LIB_DIR, "BOOT-INF", File.separator, "classes");
 4 
 5 File libDir = new File(bootLib);
 6 File[] libs = libDir.listFiles();
 7 // 拼接 classpath
 8 StringBuilder classpath = new StringBuilder(StringUtils.join(bootClasses, File.pathSeparator));
 9 for (File lib : libs) {
10     classpath.append(bootLibPath).append(lib.getName()).append(File.pathSeparator);
11 }
12 return classpath.toString();

③ 编译

javac 命令只需通过 -cp 参数指定 classpath 即可,这样就可以编译成功了。

1 // 使用 JavaCompiler 编译java文件
2 JavaCompiler javac = ToolProvider.getSystemJavaCompiler();
3 // 编译,实际上底层就是调用 javac 命令执行编译工作
4 int result = javac.run(null, null, null, "-cp", classpath, javaFile.getAbsolutePath());

4、优雅的动态编译

① Arthas 内存编译

上面的方式需要解压 jar 包得到 classpath,否则无法编译,很不优雅,只能算是一种备选方案。通过参考 Arthas 的源码发现,其中有一个内存编译模块,可以轻松的实现动态编译的能力。

通过学习它的源码发现,底层还是使用 JavaCompiler 相关的API完成编译工作,不同的是它在获取源码中引用类的方式上。

首先继承 ForwardingJavaFileManager 实现自定义查找 JavaFileObject。然后可以看到它会使用自定义的 PackageInternalsFinder 来查找类,可以看出,它还是会从 jar 包中去查找相关的类。更多的大家可以自行阅读其源码。

② 使用

首先在 pom 中引入 arthas-memorycompiler 的依赖。

1 <dependency>
2     <groupId>com.taobao.arthas</groupId>
3     <artifactId>arthas-memorycompiler</artifactId>
4     <version>3.1.1</version>
5 </dependency>

使用方式

 1 // 使用 Arthas 动态编译
 2 DynamicCompiler dynamicCompiler = new DynamicCompiler(Thread.currentThread().getContextClassLoader());
 3 dynamicCompiler.addSource(className, sourceCode);
 4 Map<String, byte[]> byteCodes = dynamicCompiler.buildByteCodes();
 5 
 6 File outputDir = new File(CUSTOMIZE_CLZ_DIR);
 7 
 8 for (Map.Entry<String, byte[]> entry : byteCodes.entrySet()) {
 9     File byteCodeFile = new File(outputDir, StringUtils.replace(entry.getKey(), ".", File.separator) + ".class");
10     FileUtils.writeByteArrayToFile(byteCodeFile, entry.getValue());
11 }

5、资料参考

Arthas Github

Java 类运行时动态编译技术

七、代码切入方法

 源代码编译成字节码已经完成了,接下来就看如何切入到要拦截的方法中。

1、 加载字节码,定义 Class 实例

首先,需要将字节码加载到 JVM 中,创建 Class 实例这个类才能被使用。

 1 // 读取字节码文件
 2 CtClass executeClass = classPool.makeClass(new FileInputStream("..../DemoBeforeHandler.class"));
 3 // 当前上下文类加载器
 4 System.out.println("----> current thread context classLoader : " + Thread.currentThread().getContextClassLoader().toString());
 5 // 当前上下文类加载器的父类加载器
 6 System.out.println("----> current thread context classLoader's parent classLoader : " + Thread.currentThread().getContextClassLoader().getParent().toString());
 7 // 应用程序类加载器
 8 System.out.println("----> application classLoader : " + ClassLoader.getSystemClassLoader().toString());
 9 // 定义 Class 实例
10 Class clazz = executeClass.toClass();

toClass 不传参数时,其内部实际是使用当前上下文类加载器来加载字节码的,也可以自己传入类加载器。

不同的容器,这个当前上下文类加载器可能不同。我这里使用的是 undertow 容器,上下文类加载器是 LaunchedURLClassLoader;当使用 tomcat 容器时,运行时上下文类加载器是 TomcatEmbeddedWebappClassLoader,其父类加载器是 LaunchedURLClassLoader。在IDEA中运行时,上下文类加载器是 AppClassLoader,即应用程序类加载器。

----> current thread context classLoader : org.springframework.boot.loader.LaunchedURLClassLoader@20ad9418
----> current thread context classLoader's parent classLoader : sun.misc.Launcher$AppClassLoader@5c647e05
----> application classLoader : sun.misc.Launcher$AppClassLoader@5c647e05

这里有个坑需要注意,在程序启动期间调用时,这里的上下文类加载器是 LaunchedURLClassLoader;但是在运行期间调用时,如果使用 tomcat 容器,这里的上下文类加载器是 TomcatEmbeddedWebappClassLoader,是个代理类加载器。

此时如果使用这个类加载器来定义 Class 实例,能定义成功,但是在后面使用的时候,就会发现报错:NoClassDefFoundError。

这是因为实际请求时,上下文类加载器是 LaunchedURLClassLoader,是 TomcatEmbeddedWebappClassLoader 的父类加载器,类定义在子类加载器中定义,在父类加载器中使用肯定就找不到咯。

----> current thread context classLoader : TomcatEmbeddedWebappClassLoader
  context: ROOT
  delegate: true
----------> Parent Classloader:
org.springframework.boot.loader.LaunchedURLClassLoader@20ad9418

----> current thread context classLoader's parent classLoader : org.springframework.boot.loader.LaunchedURLClassLoader@20ad9418
----> application classLoader : sun.misc.Launcher$AppClassLoader@5c647e05

因此在调用 toClass 时需要传入 LaunchedURLClassLoader 类加载器,不能使用子类加载器。

 1 final String LAUNCHED_CLASS_LOADER = "org.springframework.boot.loader.LaunchedURLClassLoader";
 2 // 上下文类加载器
 3 ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
 4 if (!LAUNCHED_CLASS_LOADER.equals(contextClassLoader.getClass().getName())) {
 5     if (LAUNCHED_CLASS_LOADER.equals(contextClassLoader.getParent().getClass().getName())) {
 6         contextClassLoader = contextClassLoader.getParent();
 7     } else {
 8         contextClassLoader = ClassLoader.getSystemClassLoader();
 9     }
10 }
11 // 传入类加载器
12 executeClass.toClass(contextClassLoader, null);

2、 构建代码块

最简单的方式,就是直接创建 handler 的一个实例对象,然后切入到方法中去使用。

 1 private void builderExecuteBody(CtClass executeClass, boolean returnValue) {
 2     StringBuilder executeBody = new StringBuilder("{").append("\r\n");
 3     // 效果:org.test.DemoHandler DemoHandler = new org.test.DemoHandler();
 4     executeBody
 5             .append(executeClass.getName()) // 类型
 6             .append(" ")
 7             .append(executeClass.getSimpleName()) // 变量名称
 8             .append(" = ")
 9             .append("new ").append(executeClass.getName()).append("();")
10             .append("\r\n");
11     // 如果有返回值,则使用临时变量存储
12     if (returnValue) {
13         executeBody.append("Object result = ");
14     }
15     // 效果:DemoHandler.execute($$);
16     executeBody
17             .append(executeClass.getSimpleName()).append(".execute($args);")
18             .append("\r\n");
19     if (returnValue) {
20         executeBody.append("return ($r) result;").append("\r\n");
21     }
22     executeBody.append("}").append("\r\n");
23 }

生成的效果:

1 {
2 org.test.demo.app.service.impl.DemoBeforeHandler DemoBeforeHandler = new org.test.demo.app.service.impl.DemoBeforeHandler();
3 DemoBeforeHandler.execute($args);
4 }

3、 插入代码块

 1 String targetClassName = point.getPackageName() + "." + point.getClassName();
 2 // 目标类
 3 CtClass targetClass = classPool.get(targetClassName);
 4 // 根据参数类型构建 CtClass 数组
 5 CtClass[] params = buildParams(classPool, point);
 6 // 目标方法
 7 CtMethod targetMethod = targetClass.getDeclaredMethod(point.getMethodName(), params);
 8 
 9 // 前置规则
10 String beforeCode = "{...}";
11 
12 // 替换规则
13 String replaceCode = "{...}";
14 
15 // 后置规则
16 String afterCode = "{...}";
17 
18 // 替换方法
19 targetMethod.setBody(replaceCode);
20 
21 // 在方法体前插入代码块
22 targetMethod.insertBefore(beforeCode);
23 
24 // 在方法体后插入代码块
25 targetMethod.insertAfter(afterCode);

八、动态创建代理程序、实现类重载

方法体已经修改好了,剩下的就是怎么让 JVM 重载这个类,达到动态更改源码的目的。

1、Javassist HotSwapAgent

javassist 提供了一个 HotSwapAgent 的代理,可以使用它的 redefine 方法重新定义类,但是这个工具基本无法使用。

javassist.util.HotSwapAgent.redefine(Class.forName(targetClassName), targetClass);

首先我们来看下 javassist.util.HotSwapAgent 重载类的原理。

在 redefine 方法里面,首先会调用 startAgent 方法动态加载代理程序,然后通过 instrumentation 来重定义类,instrumentation 即 java.lang.instrument.Instrumentation。

在 startAgent 方法内,首先判断如果 instrumentation 已经有了,就不再加载动态代理程序。如果没有,首先动态创建代理程序 jar 包,然后使用 VirtualMachine attach 到当前虚拟机上,然后加载代理程序。

在创建代理程序包内,首先创建 MAINFEST 文件,并指定 Premain-Class、Agent-Class 为 javassist.util.HotSwapAgent,接着将 javassist.util.HotSwapAgent.java 的字节码文件写入 javassist.util.HotSwapAgent.class,最终打成  agent.jar 。

HotSwapAgent premain 与 agentmain,代理程序都是为了能够得到 Instrumentation 的实例,这个实例在加载代理程序时由虚拟机传入。

默认生成的 agent.jar 是在用户目录下一个临时目录下,agent.jar 目录结构如下。

以上就是动态创建代理程序并加载代理程序的过程,这个功能要是能直接用就完美了,可惜不能。

2、IDEA 方式运行

首先我们来看看 IDEA 中使用 javassist.util.HotSwapAgent 的问题,在开始之前,先来看看 IDEA 中启动服务的几种方式。

user-localnone 是一样的:这是默认选项,这种方式会把所有依赖的 jar 包拼接后通过 -classpath 参数指定,命令行参数会很长。如果命令行参数长度超出了OS限制,会报错:Command line is too long。

JAR manifest:将所有依赖的 jar 包拼接好后,创建一个临时的 jar 文件,写入 META-INF/MANIFEST.MF 文件的 Class-Path 参数中,然后通过 -classpath 参数指定这个 jar 包,这样的目的是缩短命令行。

classpath file:将所有依赖的 jar 包拼接好后,将其写入一个临时的文件,然后通过 com.intellij.rt.execution.CommandLineWrapper 来启动。

这三种方式的一个区别就是他们启动时的上线文类加载器不一样:

user-local:sun.misc.Launcher$AppClassLoader@18b4aac2,应用程序类加载器

JAR manifest:sun.misc.Launcher$AppClassLoader@18b4aac2,应用程序类加载器

classpath file:java.net.URLClassLoader@7cbd213e,URLClassLoader

之前说过,代理程序在加载时使用的是应用程序类加载器,所以,使用 user-local、JAR manifest 方式启动时,可以正确加载代理程序,他们的类加载器是一样的。如果使用 classpath 启动时,就会报 NoClassDefFoundError。这是因为在 javassist 这个 jar 包的类是由 URLClassLoader 类加载器加载,而应用程序类加载器是加载不到这个lib 的类的。

java.lang.NoClassDefFoundError: javassist/NotFoundException
    at java.lang.Class.getDeclaredMethods0(Native Method)
    at java.lang.Class.privateGetDeclaredMethods(Class.java:2701)
    at java.lang.Class.getDeclaredMethod(Class.java:2128)
    at sun.instrument.InstrumentationImpl.loadClassAndStartAgent(InstrumentationImpl.java:327)
    at sun.instrument.InstrumentationImpl.loadClassAndCallAgentmain(InstrumentationImpl.java:411)
Caused by: java.lang.ClassNotFoundException: javassist.NotFoundException
    at java.net.URLClassLoader.findClass(URLClassLoader.java:382)
    at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
    at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:349)
    at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
    ... 5 more

3、JAR 包方式运行

当我们打成 jar 包运行时,实际上还是会报同样的错误,因为 JAR 包运行时上下文类加载器是 org.springframework.boot.loader.LaunchedURLClassLoader@20ad9418,其父类加载是应用程序类加载器。

总结起来就是,根据双亲委派模型,子类加载器可以加载父类加载器中的类;但父类加载器无法加载子类加载器中的类。

4、自定义代理程序加载

由于 javassist.util.HotSwapAgent 在加载时使用的是应用程序类加载器,所以代理程序在进入 agentmain 方法设置 instrumentation 变量时,实际是在应用程序类加载器中。

而程序启动时使用的是其子类加载器加载的 HotSwapAgent,所以这里实际上有两个不同的 HotSwapAgent 类实例,虽然类名一样,但是使用的类加载器是不一样的。所以在程序运行期间还是得不到 instrumentation 实例对象。

我这里用了一个比较简单粗暴的方法解决这个问题:

① 覆盖 javassist.util.HotSwapAgent 方法(我是重写的,将 startAgent 和 agentmain 分开)

② 增加静态的 setInstrumentation 方法

③ 在 agentmain 方法中,得到程序运行时的上下文类加载器(LaunchedURLClassLoader)

④ 通过 LaunchedURLClassLoader 找到程序中加载的  javassist.util.HotSwapAgent 类实例

⑤ 通过反射的方式调用 setInstrumentation 方法将JVM传进来的 Instrumentation 设置进去。

⑥ 之后我们就可以在程序运行期间调用 HotSwapClient.redefine 来重载类了。

 1 private static final String CLASS_LOADER = "org.springframework.boot.loader.LaunchedURLClassLoader";
 2 private static final String SWAP_CLIENT = "javassist.util.HotSwapClient";
 3 private static final String SWAP_CLIENT_SETTER = "setInstrumentation";
 4 
 5 // 增加一个静态 setInstrumentation 方法,由 agentmain 设置 Instrumentation
 6 public static void setInstrumentation(Instrumentation instrumentation) {
 7     HotSwapAgent.instrumentation = instrumentation;
 8 }
 9 
10 public static void agentmain(String agentArgs, Instrumentation inst) throws Throwable {
11     if (!inst.isRedefineClassesSupported())
12         throw new RuntimeException("this JVM does not support redefinition of classes");
13 
14     instrumentation = inst;
15 
16     // 得到程序类加载器 LaunchedURLClassLoader
17     ClassLoader classLoader = getClassLoader(inst);
18     // 得到 LaunchedURLClassLoader 类加载器中的 javassist.util.HotSwapClient 类实例
19     Class<?> clientClass = classLoader.loadClass(SWAP_CLIENT);
20     // 通过反射方式设置 Instrumentation
21     clientClass.getMethod(SWAP_CLIENT_SETTER, Instrumentation.class).invoke(null, inst);
22 }
23 
24 private static ClassLoader getClassLoader(Instrumentation inst) {
25     // 获取所有已加载的类
26     Class[] loadedClasses = inst.getAllLoadedClasses();
27     // 找出 LaunchedURLClassLoader
28     return Arrays.stream(loadedClasses)
29             .filter(c -> c.getClassLoader() != null && c.getClassLoader().getClass() != null)
30             .filter(c -> CLASS_LOADER.equals(c.getClassLoader().getClass().getName()))
31             .map(Class::getClassLoader)
32             .findFirst()
33             .orElse(Thread.currentThread().getContextClassLoader());
34 }

九、结果验证及局限性

1、结果验证

可以看到已经成功在要拦截的方法前后加入了定制化的代码逻辑了,也可以动态地再次更新代码,再重新应用规则。至此,动态切面的功能基本就实现了。

2、局限性

① 客制化代码时,由于是创建的一个对象,然后通过方法调用的形式插入方法体中的,所以客制化代码的结构必须固定。

② 客制化代码中,不能使用 @Autowired 等方式直接注入 Spring 容器对象,目前没有处理这种情况。

由于 Instrumentation 本身的局限性,我们只能更改方法体,不能更改方法的定义,不能向类中增加方法、字段,否则重载失败。

十、附:使用 Arthas 诊断Java问题

在开发这个功能的过程中,简单了解了下 Arthas 的源码原理,以及如何使用 Arthas 来诊断一些线上问题,这里仅列出官方的一些文档,看文档很容易上手。 

Arthas 是 阿里巴巴开源出来的一个针对 java 的工具,主要是针对 java 的问题进行诊断!详细内容可以参考官方文档。

① IDEA 安装插件:通过Cloud Toolkit插件使用Arthas一键诊断远程服务器

② 使用入门:Arthas 快速入门

③ 命令列表:Arthas 命令列表

④ Attach 失败:Attach 时报 ERROR

⑤ Github 源码:Arthas Github

 

posted on 2019-09-02 01:03  bojiangzhou  阅读(17600)  评论(9编辑  收藏  举报