无涯

无所谓无 无所谓有

Java动态编译

前言

我们都知道Java属于编译型语言,即源码需要经过编译成字节码然后运行于JVM

我们也知道,代码一旦编写完成,编译出的.class文件是一定的。这里也就是静态编译

那我们需要在运行时编译并加载应该怎么办呢,存在如下场景

  • 我们熟知的类似LeetCode这种测评平台,需要执行用户输入的代码。
  • 服务器需要动态加载某些类文件进行编译。

那么我们就要使用Java动态编译能力,在运行时编译代码并加载进jvm

原理

Java 6开始,引入了Java代码重写过的编译器接口,使得我们可以在运行时编译Java源代码,然后再通过类加载器将编译好的类加载进JVM,这种在运行时编译代码的操作就叫做动态编译

主要类库

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

  • JavaFileObject - 表示一个java源文件对象

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

  • Diagnostic - 表示一个诊断信息

  • DiagnosticListener - 诊断信息监听器, 编译过程触发. 生成编译task(JavaCompiler#getTask())或获取FileManager(JavaCompiler#getStandardFileManager())时需要传递DiagnosticListener以便收集诊断信息

流程图

未命名2.001

源码文件 -> 字节码文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void fromJavaFile() {
// 获取Javac编译器对象
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
// 获取文件管理器:负责管理类文件的输入输出
StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, null);
// 获取要被编译的Java源文件
File file = new File("/Users/oneyoung/oneyoung/project/my/code/src/main/java/top/oneyoung/dynamic/TestHello.java");
// 通过源文件获取到要编译的Java类源码迭代器,包括所有内部类,其中每个类都是一个JavaFileObject
Iterable<? extends JavaFileObject> compilationUnits = fileManager.getJavaFileObjects(file);
// 生成编译任务
JavaCompiler.CompilationTask task = compiler.getTask(null, fileManager, null, null, null, compilationUnits);
// 执行编译任务
task.call();
}

我们这里准备了TestHello.java

1
2
3
4
5
public class TestHello {
public static void main(String[] args) {
System.out.println("this is a test");
}
}

执行后在源文件同目录下生成了编译的class文件HelloTest.class

image-20220412152021665

我们试着手动加载该class文件,使用类加载器的defineClass方法,可以直接加载字节码文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static Class<?> loadClassFromDisk(String path) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
// defineClass 为 ClassLoader 类的一个方法,用于加载类
// 但是这个方法是 protected 的,所以需要通过反射的方式获取这个方法的权限
Class<ClassLoader> classLoaderClass = ClassLoader.class;
Method defineClass = classLoaderClass.getDeclaredMethod("defineClass", byte[].class, int.class, int.class);
defineClass.setAccessible(true);
// 读取文件系统的 file 为 byte 数组
File file = new File(path);
byte[] bytes = new byte[(int) file.length()];
try (FileInputStream fileInputStream = new FileInputStream(file)) {
fileInputStream.read(bytes);
} catch (IOException e) {
e.printStackTrace();
}
// 执行 defineClass 方法 返回 Class 对象
return (Class<?>) defineClass.invoke(Thread.currentThread().getContextClassLoader(), bytes, 0, bytes.length);
}

由于TestHello中的方法为静态方法,使用class反射机制执行方法

1
2
3
4
5
6
7
// 执行编译任务
Boolean call = task.call();
if (call) {
Class<?> o = loadClassFromDisk("/Users/oneyoung/oneyoung/project/my/code/src/main/java/top/oneyoung/dynamic/TestHello.class");
Method main = o.getMethod("main", String[].class);
main.invoke(null, new Object[]{new String[]{}});
}
1
this is a test

源码字符串 -> 字节码文件

在流程图中,getTask().call()会通过调用作为参数传入的JavaFileObject对象的getCharContent()方法获得字符串序列,即源码的读取是通过 JavaFileObjectgetCharContent()方法,那我们只需要重写getCharContent()方法,即可将我们的字符串源码装进JavaFileObject了。

构造SourceJavaFileObject实现定制的JavaFileObject对象,用于存储字符串源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class SourceJavaFileObject extends SimpleJavaFileObject {

/**
* The source code of this "file".
*/
private final String code;

SourceJavaFileObject(String name, String code) {
super(URI.create("string:///" + name.replace('.', '/') + Kind.SOURCE.extension), Kind.SOURCE);
this.code = code;
}

@Override
public CharSequence getCharContent(boolean ignoreEncodingErrors) {
return code;
}
}

则创建JavaFileObject对象时,变为了:

1
2
3
4
// 通过源代码字符串获取到要编译的Java类源码迭代器,包括所有内部类,其中每个类都是一个JavaFileObject
SourceJavaFileObject javaFileObject = new SourceJavaFileObject("TestHello", "public class TestHello { public static void main(String[] args) { System.out.println(\"Hello World\"); } }");
// 生成编译任务
JavaCompiler.CompilationTask task = compiler.getTask(null, fileManager, null, null, null, Collections.singleton(javaFileObject));

执行后,同样编译出了class文件,不过由于没有指定编译的class输出路径,他会默认放在源文件的根目录下

image-20220412160252209

后面同样可以通过defindClass加载字节码完成加载。

源码字符串 -> 字节码数组

如果我们进行动态编译时,想要直接输入源码字符串并且输出的是字节码数组,而不是输出字节码文件,又该如何实现?实际上,这是从内存中得到源码,再输出到内存的方式。

getTask().call()源代码执行流程图中,我们可以发现JavaFileObjectopenOutputStream()方法控制了编译后字节码的输出行为,编译完成后会调用openOutputStream获取输出流,并写数据(字节码)。所以我们需要重写JavaFileObjectopenOutputStream()方法。

同时在执行流程图中,我们还发现用于输出的JavaFileObject 对象是JavaFileManagergetJavaFileForOutput()方法提供的,所以为了让编译器编译完成后,将编译得到的字节码输出到我们自己构造的JavaFileObject 对象,我们还需要自定义JavaFileManager`。

这里我使用类委托的方式,把大部分功能委托给了传入的StandardJavaFileManager,主要是重写了getJavaFileForOutput,使输出编译完成的字节码文件为字节数组。

然后增加了方法getBytesByClassName获取编译完成的字节码字节数组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
public class ByteArrayJavaFileManager implements JavaFileManager {
private static final Logger LOG = LoggerFactory.getLogger(ByteArrayJavaFileManager.class);


private final StandardJavaFileManager fileManager;

/**
* synchronizing due to ConcurrentModificationException
*/
private final Map<String, ByteArrayOutputStream> buffers = Collections.synchronizedMap(new LinkedHashMap<>());

public ByteArrayJavaFileManager(StandardJavaFileManager fileManager) {
this.fileManager = fileManager;
}

@Override
public ClassLoader getClassLoader(Location location) {
return fileManager.getClassLoader(location);
}

@Override
public synchronized Iterable<JavaFileObject> list(Location location, String packageName, Set<Kind> kinds, boolean recurse) throws IOException {
return fileManager.list(location, packageName, kinds, recurse);
}

@Override
public String inferBinaryName(Location location, JavaFileObject file) {
return fileManager.inferBinaryName(location, file);
}

@Override
public boolean isSameFile(FileObject a, FileObject b) {
return fileManager.isSameFile(a, b);
}

@Override
public synchronized boolean handleOption(String current, Iterator<String> remaining) {
return fileManager.handleOption(current, remaining);
}

@Override
public boolean hasLocation(Location location) {
return fileManager.hasLocation(location);
}

@Override
public JavaFileObject getJavaFileForInput(Location location, String className, Kind kind) throws IOException {

if (location == StandardLocation.CLASS_OUTPUT) {
boolean success;
final byte[] bytes;
synchronized (buffers) {
success = buffers.containsKey(className) && kind == Kind.CLASS;
bytes = buffers.get(className).toByteArray();
}
if (success) {

return new SimpleJavaFileObject(URI.create(className), kind) {
@Override
public InputStream openInputStream() {
return new ByteArrayInputStream(bytes);
}
};
}
}
return fileManager.getJavaFileForInput(location, className, kind);
}

@Override
public JavaFileObject getJavaFileForOutput(Location location, final String className, Kind kind, FileObject sibling) {
return new SimpleJavaFileObject(URI.create(className), kind) {
@Override
public OutputStream openOutputStream() {
// 字节输出流用与FileManager输出编译完成的字节码文件为字节数组
ByteArrayOutputStream bos = new ByteArrayOutputStream();
// 将每个需要加载的类的输出流进行缓存
buffers.putIfAbsent(className, bos);
return bos;
}
};
}

@Override
public FileObject getFileForInput(Location location, String packageName, String relativeName) throws IOException {
return fileManager.getFileForInput(location, packageName, relativeName);
}

@Override
public FileObject getFileForOutput(Location location, String packageName, String relativeName, FileObject sibling) throws IOException {
return fileManager.getFileForOutput(location, packageName, relativeName, sibling);
}

@Override
public void flush() {
// Do nothing
}

@Override
public void close() throws IOException {
fileManager.close();
}

@Override
public int isSupportedOption(String option) {
return fileManager.isSupportedOption(option);
}

public void clearBuffers() {
buffers.clear();
}


public Map<String, byte[]> getAllBuffers() {
Map<String, byte[]> ret = new LinkedHashMap<>(buffers.size() * 2);
Map<String, ByteArrayOutputStream> compiledClasses = new LinkedHashMap<>(ret.size());
synchronized (buffers) {
compiledClasses.putAll(buffers);
}
compiledClasses.forEach((k, v) -> ret.put(k, v.toByteArray()));
return ret;
}

public byte[] getBytesByClassName(String className) {
return buffers.get(className).toByteArray();
}
}

然后我们修改下之前的执行流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static void fromJavaSourceToByteArray1() throws Exception {
// 获取Javac编译器对象
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
// 获取文件管理器:负责管理类文件的输入输出
StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, null);
// 创建自定义的FileManager
ByteArrayJavaFileManager byteArrayJavaFileManager = new ByteArrayJavaFileManager(fileManager);
// 通过源代码字符串获取到要编译的Java类源码迭代器,包括所有内部类,其中每个类都是一个JavaFileObject
JavaFileObject javaFileObject = new SourceJavaFileObject("TestHello", "public class TestHello { public static void say(String args) { System.out.println(args); } }");

JavaCompiler.CompilationTask task = compiler.getTask(null, byteArrayJavaFileManager, null, null, null, Collections.singletonList(javaFileObject));
// 执行编译任务
Boolean call = task.call();
if (Boolean.TRUE.equals(call)) {
byte[] testHellos = byteArrayJavaFileManager.getBytesByClassName("TestHello");
Class<ClassLoader> classLoaderClass = ClassLoader.class;
Method defineClass = classLoaderClass.getDeclaredMethod("defineClass", byte[].class, int.class, int.class);
defineClass.setAccessible(true);
Object invoke = defineClass.invoke(TestHello.class.getClassLoader(), testHellos, 0, testHellos.length);
Class clazz = (Class) invoke;
clazz.getMethod("say", String.class).invoke(null, "你好");

}
}

成功输出

1
你好