Java运行期动态能力
从宏观上看,Java程序可以分为两大阶段:编译期,运行期。
编译期过程大致分为三阶段:
- 前端编译。如javac
- 后端运行时编译。如JIT
- 静态提前编译期。如AOT、GraalVM
编译器的动态能力很有限,除了JIT编译的几个参数,就只有JSR 269提供的插入式注解处理器对JDK编译子系统的行为产生一些影响。比如著名的Lombok就是基于JSR 269 API。
而在运行期,Java提供了多方面的动态能力,这一块是今天文章的重点。
本文将按照以下几个方面来介绍Java运行期的动态能力:
- 类加载器
- 反射
- 动态代理
- 字节码生成
- AOP
- Java Agent
下面我们一一来谈。
# 类加载器
先来介绍一下类加载器。
下面直接演示一下自定义类加载器的使用:从网络动态加载class文件
public class NetworkClassLoader extends ClassLoader {
private String rootUrl;
public NetworkClassLoader(String rootUrl) {
super();
this.rootUrl = rootUrl;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
Class clazz = null;
byte[] classData = getClassData(name); //根据类的二进制名称,获得该class文件的字节码数组
if (classData == null) {
throw new ClassNotFoundException();
}
clazz = defineClass(name, classData, 0, classData.length); //将class的字节码数组转换成Class类的实例
return clazz;
}
private byte[] getClassData(String name) {
InputStream is = null;
try {
String path = classNameToPath(name);
URL url = new URL(path);
byte[] buff = new byte[1024 * 4];
int len = -1;
is = url.openStream();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
while ((len = is.read(buff)) != -1) {
baos.write(buff, 0, len);
}
return baos.toByteArray();
} catch (Exception e) {
e.printStackTrace();
} finally {
if (is != null) {
try {
is.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return null;
}
private String classNameToPath(String name) {
return rootUrl + "/" + name.replace(".", "/") + ".class";
}
}
测试:
public static void main(String[] args) {
try {
String rootUrl = "http://localhost:8090/";
NetworkClassLoader networkClassLoader = new NetworkClassLoader(rootUrl);
String classname = "classLoad.Test";
Class clazz = networkClassLoader.loadClass(classname);
System.out.println(clazz.getClassLoader()); //打印类加载器
Object newInstance = clazz.newInstance();
clazz.getMethod("getStr").invoke(newInstance); //调用方法
} catch (Exception e) {
e.printStackTrace();
}
}
关于类加载器的应用还是比较常见的,例如Tomcat中对于Jsp的处理,就是把Jsp转换为了class类,然后使用自定义类加载器去加载对应的Jsp class。
类加载器本身有很多细节,详细介绍可以参考:类加载器机制详解 (opens new window)
# 反射
Java运行时第二种动态能力,就是反射(Reflection)。它允许运行中的 Java 程序获取自身的信息,并且可以操作类或对象的内部属性。
例如我们要动态的调用类方法:
Class<?> klass = MethodClass.class;
//创建methodClass的实例
Object obj = klass.newInstance();
//获取methodClass类的add方法
Method method = klass.getMethod("add",int.class,int.class);
//调用method对应的方法 => add(1,4)
Object result = method.invoke(obj,1,4);
System.out.println(result);
或者动态修改字段的值:
//获取Class对象引用
Class<?> clazz = Class.forName("reflect.Student");
Student st= (Student) clazz.newInstance();
//获取父类public字段并赋值
Field ageField = clazz.getField("age");
ageField.set(st,18);
Java反射的体系很完备,可以参考:反射机制详解 (opens new window)
Java 7之后新增了MethodHandlers API,用于方法的查找、适配和调用,参考:Method Handles in Java (opens new window)
# 动态代理
代理模式是一种设计模式,提供了对目标对象额外的访问方式,即通过代理对象访问目标对象,这样可以在不修改原目标对象的前提下,提供额外的功能操作,扩展目标对象的功能。
我们常用到的接口、实现类属于静态代理,JDK本身还提供了基于接口的动态代理。
比如有IUserDao接口,要实现动态代理其所有方法:
public interface IUserDao {
void save();
User findById();
}
public class ProxyFactory {
private Class<?> targetClass;
public ProxyFactory(Class<?> targetClass) {
this.targetClass = targetClass;
}
public Object getProxyInstance() {
return Proxy.newProxyInstance(targetClass.getClassLoader(), new Class[] {targetClass},
new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if(method.getName() == "save"){
System.out.println("save method");
} else if (method.getName() == "findById") {
return new User();
}
return null;
}
});
}
}
public class TestProxy {
@Test
public void testDynamicProxy (){
IUserDao proxy = (IUserDao) new ProxyFactory(IUserDao.class).getProxyInstance();
System.out.println(proxy.getClass());
proxy.save();
System.out.println(proxy.findById());
}
}
当然,你也可以代理接口的实现类。请参考:Java代理模式 (opens new window)
# 字节码生成
class文件是JVM通用格式文件,如果我们能操作和手动生成class文件,那么我们将获得强大的动态能力。
ASM就是这样一种工具:通用的Java字节码操作和分析框架,可以用于修改现有的class文件或动态生成class文件。
不过ASM的使用就不太友好了,需要理解汇编等知识的基础上,自己手撸字节码。感兴趣看以参考:简易ASM教程 (opens new window)
基于ASM的上层封装库在API友好度上有很大提升,业界流行的库有:Javaassist、Cglib、ByteBuddy
上一节讲到的JDK动态代理,只能代理接口,不能代理类。现在通过Cglib演示一下如何代理一个普通的Java类:
public class UserDao{
public void save() {
System.out.println("保存数据");
}
}
public class ProxyFactory implements MethodInterceptor{
private Object target;//维护一个目标对象
public ProxyFactory(Object target) {
this.target = target;
}
//为目标对象生成代理对象
public Object getProxyInstance() {
//工具类
Enhancer en = new Enhancer();
//设置父类
en.setSuperclass(target.getClass());
//设置回调函数
en.setCallback(this);
//创建子类对象代理
return en.create();
}
@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
System.out.println("开启事务");
// 执行目标对象的方法
Object returnValue = method.invoke(target, args);
System.out.println("关闭事务");
return null;
}
}
public class TestProxy {
@Test
public void testCglibProxy(){
//目标对象
UserDao target = new UserDao();
System.out.println(target.getClass());
//代理对象
UserDao proxy = (UserDao) new ProxyFactory(target).getProxyInstance();
System.out.println(proxy.getClass());
//执行代理对象方法
proxy.save();
}
}
可以看到,通过Enhancer
类,我们生成了UserDao
的子类,通过intercept
方法对子类方法进行了逻辑注入,从而完成对普通Java类的动态代理。
Cglib底层使用了ASM,动态生成了class字节码文件,来完成上面的逻辑。
# 与Java Compiler(javac)的区别
javac
是Java的编译器,它将Java源代码转换为字节码,所以我们难免会想到直接使用javac来生成class字节码。但其实他们是有差别的。
字节码生成库与javac
(Java编译器)的主要区别在于它们的设计目标和使用场景。javac
是Java开发工具包(JDK)中的一部分,用于将Java源代码文件(.java)编译成字节码文件(.class),然后这些字节码可以在任何支持Java虚拟机(JVM)的平台上运行。而字节码生成库如ASM、ByteBuddy、Javassist等,则提供了更灵活的方式来直接操作或生成字节码,通常用于需要动态修改类结构或者在运行时创建新类的应用场景。
字节码生成库与javac的区别分为以下几个方面:
灵活性:字节码生成库允许开发者以编程方式定义类结构及方法体等内容,并且可以非常细粒度地控制字节码内容,甚至包括局部变量表、异常处理器等底层细节。这为实现复杂的动态代理、AOP(面向切面编程)、性能监控等功能提供了强大的支持。相比之下,
javac
是一个静态编译过程,它基于固定的源代码输入生成字节码。执行阶段:
javac
是在编译阶段工作的,即在程序部署之前;而字节码生成库往往在运行时被调用,使得应用程序能够根据当前环境或条件动态地改变其行为。输出格式:虽然两者最终都会产生字节码,但
javac
主要关注于从源代码到标准字节码文件的转换,而字节码生成库则可能提供更多样化的输出选项,比如直接加载到JVM中而不保存为文件。易用性 vs 功能性:对于简单的类创建任务,使用
javac
可能更为直观简单,因为它是通过处理人类可读的Java源代码来工作的。但是,当涉及到复杂的类结构调整或是需要高效处理大量数据时,字节码生成库提供的API更加适合进行这样的操作。
为什么很少直接使用javac完成动态类创建:
效率问题:每次使用
javac
编译源代码都需要启动一个新的进程,并且涉及文件I/O操作,这对于需要频繁创建或修改类的情况来说效率较低。灵活性不足:
javac
主要用于静态编译,对于需要根据运行时信息动态调整类定义的情形,它并不提供足够的灵活性。集成复杂性:将
javac
集成进应用中以实现动态类创建会增加额外的复杂性和潜在的安全风险,特别是考虑到不同环境下的兼容性问题。
综上所述,尽管javac
是非常重要的工具,但对于需要高度定制化或实时响应变化的需求而言,专门设计用来处理字节码的库提供了更好的解决方案。这些库通过提供丰富的API支持,使得开发者能够更容易地实现复杂的逻辑,同时保持良好的性能表现。
# Java 22的新特性:ClassFile API(预览)
Java 22引入了一项重要的预览特性——Class-File API,它为开发者提供了一个标准的API来解析、生成和转换Java类文件。这个API位于java.lang.classfile
包下,并且是JDK 22中的一个预览功能。作为Java平台的一部分,Class-File API的出现解决了在处理字节码时需要依赖第三方库(如ASM、ByteBuddy或Javassist)的问题,这些库可能无法及时更新以支持最新的Java版本。由于Java每6个月发布一个新的版本,官方内置的支持能够更好地适应快速变化的语言特性。
Class-File API的主要特点:
- 解析:允许开发者读取现有的
.class
文件并获取其内部结构信息,比如类名、字段、方法等。 - 生成:允许开发者直接创建新的
.class
文件,而不需要从源代码编译。 - 转换:支持对现有类文件进行修改后重新生成,这可以用于实现各种高级功能,例如动态代理、AOP(面向切面编程)以及代码注入等。
以下是一个简单的例子,展示了如何使用Class-File API来读取一个Java类文件的基本信息:
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import jdk.java.classfile.ClassFile;
import jdk.java.classfile.ClassModel;
public class ClassFileDemo {
public static void main(String[] args) throws IOException {
// 指定要读取的类文件路径
Path classFilePath = Paths.get("path/to/your/TestClass.class");
// 解析类文件
ClassModel classModel = ClassFile.of().parse(classFilePath);
// 打印类信息
System.out.println("Class Name: " + classModel.name());
System.out.println("Major Version: " + classModel.majorVersion());
System.out.println("Superclass: " + classModel.superclass().get().name());
// 打印字段信息
for (var field : classModel.fields()) {
System.out.println("Field: " + field.fieldName() + " Type: " + field.fieldType());
}
// 打印方法信息
for (var method : classModel.methods()) {
System.out.println("Method: " + method.methodName() + " Descriptor: " + method.methodType());
}
}
}
在这个例子中,我们首先通过ClassFile.of().parse()
方法加载了指定路径下的类文件,然后打印了该类的一些基本信息,包括类名、主版本号、父类名称以及类内所有字段和方法的信息。这里用到了ClassModel
对象,它是表示类模型的数据结构,包含了类的所有相关信息。
详细介绍,请参考 Java 22: Class-File API (opens new window)。
# AOP
字节码生成技术已经非常强大了,但在它的上一层,业界最佳实践产生了AOP(Aspect Oriented Programing),试图以更自然、更灵活的方式来模拟世界。
来看一个日志记录的例子,体会一下它的优美之处:
@Component
@Aspect
public class LoggingAspect {
@Before("execution(* cc.openhome.model.AccountDAO.*(..))")
public void before(JoinPoint joinPoint) {
Object target = joinPoint.getTarget();
String methodName = joinPoint.getSignature().getName();
Object[] args = joinPoint.getArgs();
Logger.getLogger(target.getClass().getName())
.info(String.format("%s.%s(%s)",
target.getClass().getName(), methodName, Arrays.toString(args)));
}
}
此示例基于Spring框架,@Aspect
定义了这是一个切面,@Before
定义了要对AccountDAO
类中的所有方法进来拦截并代理,before
方法就是对原有类方法的逻辑织入增强。
这么一段代码,很优雅的完成了对现有类的逻辑增强,关于AOP的细节请参考:Spring AOP 官方文档 (opens new window)
# Java Agent
Java Agent提供了一种方式,可以帮助开发人员在 Java 程序运行时,动态修改系统中的 Class 类型。
主要是基于JVMTI (JVM Tool Interface)技术。从Java SE 5开始,可以使用Java的Instrumentation接口(java.lang.instrument)来编写Agent。
来演示一下JVM启动的时候,输入所有加载的类:
public class PreMainTraceAgent {
public static void premain(String agentArgs, Instrumentation inst) {
System.out.println("agentArgs : " + agentArgs);
inst.addTransformer(new DefineTransformer(), true);
}
static class DefineTransformer implements ClassFileTransformer{
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
System.out.println("premain load Class:" + className);
return classfileBuffer;
}
}
}
根据一定的规则,把此类打入agent.jar包中。然后启动你的应用程序,加上如下参数:java -javaagent:agent.jar TestMain
就会在控制台输出:
premain load Class :java/util/concurrent/ConcurrentHashMap$ForwardingNode
premain load Class :sun/nio/cs/ThreadLocalCoders
premain load Class :sun/nio/cs/ThreadLocalCoders$1
premain load Class :sun/nio/cs/ThreadLocalCoders$Cache
premain load Class :sun/nio/cs/ThreadLocalCoders$2
premain load Class :java/util/jar/Attributes
premain load Class :java/util/jar/Manifest$FastInputStream
...
借助上面讲到的字节码生成,就会有无限的扩展性。
实际上,Java Agent广泛应用于线上调试、性能统计、链路追踪等领域。举两个例子:
- Arthas (opens new window)。是一款线上监控诊断产品,通过全局视角实时查看应用 load、内存、gc、线程的状态信息,并能在不修改应用代码的情况下,对业务问题进行诊断,包括查看方法调用的出入参、异常,监测方法执行耗时,类加载信息等,大大提升线上问题排查效率。
- SkyWalking (opens new window)。适用于分布式系统的应用程序性能监控工具
具体细节请参考:Java 动态调试技术原理及实践 (opens new window)。
关于上面例子的完整实现,参考:javaagent使用指南 (opens new window)
# 总结
本文从六个方面探讨了一下Java运行期的动态能力,可以发现他们是存在依赖和关联关系的。
比如字节码织入必然和类加载器一起使用,否则生成的字节码无法真正进入到JVM中使用;比如Java Agent是很强大,但是不和ASM等工具结合,本身也是巧妇难为无米之炊。
除了关联关系之外,对于技术的应用也是需要重点关注的。比如基于字节码生成技术抽象而来的AOP思想和框架的应用,让开发效率、可维护性又更上一层楼。
关注技术之间的关联使用和业界最佳实践,是提升技术能力的不二法门。
祝你变得更强!