Java 的一个重要特性是动态的类加载机制。通过在运行时动态地加载类,Java 程序可以实现很多强大的功能。下面通过一个具体的实例来说明 Java 程序中,如何动态地编译 Java 源代码、加载类和执行类中的代码。这里的代码示例适用的版本是 Java 8。
示例所实现的功能很简单,就是对表达式求值。输入的是类似 1 + 1 或 3 * (2 + 3) 这样的表达式,返回的是表达式的值。示例的做法是动态创建一个 Java 源文件,编译该文件生成 class 文件,加载 class 文件之后再执行。比如,需要求值的表达式是 1 + 1,那么所生成的 Java 源文件如下所示,其中 1 + 1 的部分是动态的。
public class Calculator {
public static Object calculate() {
return 1 + 1;
}
}
我们只需要编译该源文件,加载编译之后的 class 文件,再通过反射 API 来调用其中的 calculate 方法就可以得到表达式求值的结果。
编译
第一步是动态生成 Java 源代码并编译。生成 Java 源代码比较简单,直接用字符串连接就可以了。当然了,在生成逻辑比较复杂时,推荐的做法是使用字符串模板引擎,如 Handlebars。在下面的代码中,getJavaSource 方法生成 Java 源代码,compile 方法进行编译。
在进行编译的时候,使用的是 JDK 标准的 JavaCompiler 接口。从源代码字符串中创建了一个 JavaFileObject 对象作为编译时的源代码单元。编译时的选项 -d 指定了编译结果的输出路径,这里是一个临时文件夹。compile 方法的返回值是一个 Pair 对象,包含了 class 文件的路径,以及随机生成的 Java 包的名称。
public class DynamicCompilation {
private static final String CLASS_NAME = "Calculator";
public static Pair<Path, String> compile(String expr) throws IOException {
String packageName = "z" + UUID.randomUUID().toString().replace("-", "");
Path outputPath = Files.createTempDirectory("expr");
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
StandardJavaFileManager fileManager = compiler.getStandardFileManager(null,
null, null);
compiler.getTask(null, fileManager, null, ImmutableList.of(
"-d", outputPath.toAbsolutePath().toString()
), null,
Collections.singletonList(
new StringContentJavaFileObject(CLASS_NAME,
getJavaSource(packageName, expr))))
.call();
return Pair.of(outputPath, packageName + "." + CLASS_NAME);
}
private static String getJavaSource(String packageName, String expr) {
return "package " + packageName + "; "
+ "public class " + CLASS_NAME
+ " { public static Object calculate() { "
+ "return " + expr + "; }" +
"}";
}
}
上面的代码用到了一个帮助类 StringContentJavaFileObject,表示从字符串创建的 JavaFileObject 对象。
public class StringContentJavaFileObject extends SimpleJavaFileObject {
private final String content;
public StringContentJavaFileObject(String name, String content) {
super(URI.create("string:///" + name + Kind.SOURCE.extension),
Kind.SOURCE);
this.content = content;
}
@Override
public CharSequence getCharContent(boolean ignoreEncodingErrors) {
return content;
}
}
加载
编译完成之后的第二步是动态加载类。这一步并没有实现自定义的类加载器,而且使用内置的系统类加载器。系统类加载器通过 ClassLoader.getSystemClassLoader() 方法来获取。系统类加载器在 classpath 上查找类。这里用了一个比较 hack 的技巧来动态修改系统类加载器的 classpath。
在下面的代码中,ClasspathUpdater 的 addPath 方法可以把一个 Path 对象表示的路径,添加到系统类加载器的查找路径中。这是因为系统类加载器自身是 URLClassLoader 类型的加载器,其中的 addURL 方法可以添加新的查找路径。只不过 addURL 方法是 protected,这里通过反射 API 来进行调用。
public class ClasspathUpdater {
public static void addPath(Path path) {
URLClassLoader classLoader = (URLClassLoader) ClassLoader.getSystemClassLoader();
try {
Method method = URLClassLoader.class.getDeclaredMethod("addURL",
URL.class);
method.setAccessible(true);
method.invoke(classLoader, path.toUri().toURL());
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
上面介绍的 ClasspathUpdater 类中的使用技巧,只对 Java 8 生效。在 Java 9 引入模块系统时,对系统类加载器进行了修改。系统类加载器被替换成了应用类加载器。应用类加载器不再是 URLClassLoader 类型了,就不能使用这个技巧了。
执行
最后一步就是执行动态加载的 Java 类。这一步比较简单,只需要用 Class.forName 方法来查找 Java 类,再找到对应的 Method 对象,直接调用即可。下面的代码给出了示例。
public class Invoker {
public static Object invoke(String className) {
try {
Method method = Class.forName(className).getDeclaredMethod("calculate");
return method.invoke(null);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
完整的执行过程
最后把整个流程串起来。在下面的代码中,需要求值的表达式是 (1 + 1) * 3 / 5.0。首先调用 DynamicCompilation.compile 方法进行动态编译,得到 class 文件的路径和完整的类名。class 文件的路径通过 ClasspathUpdater.addPath 方法添加到 classpath 中。完整的类名则传递给 Invoker.invoke 方法来执行。最后输出的结果是表达式的值。
public class Main {
public static void main(String[] args) throws IOException {
Pair<Path, String> result = DynamicCompilation.compile("(1 + 1) * 3 / 5.0");
ClasspathUpdater.addPath(result.getLeft());
System.out.println(Invoker.invoke(result.getRight()));
}
}