2. 概述

本教程将学习如何将包含Java源代码的字符串编译成可执行类并运行它。运行时编译代码有诸多应用场景:

  • 动态代码生成 - 基于运行时才能获取或频繁变化的信息生成代码
  • 热替换 - 无需重启应用即可替换代码逻辑
  • 代码存储/注入 - 将业务逻辑存储在数据库中,按需检索执行。需谨慎处理,不用时可卸载自定义类

虽然编译类的方法有多种,但本文重点介绍JavaCompiler API。

3. 工具与策略

javax.tools包包含了我们编译字符串所需的核心抽象。先了解关键组件及整体流程:

Java编译器流程图

  1. 将代码传入JavaCompiler API
  2. FileManager提取源代码供编译器使用
  3. JavaCompiler编译并返回字节码
  4. 自定义ClassLoader将类加载到内存

本文不关注如何生成字符串格式的源码,直接使用硬编码示例:

final static String sourceCode =
  "package com.baeldung.inmemorycompilation;\n" 
    + "public class TestClass {\n" 
    + "@Override\n" 
    + "    public void runCode() {\n" 
    + "        System.out.println(\"code is running...\");\n" 
    + "    }\n" 
    + "}\n";

4. 表示我们的代码(源码和编译后)

首先需要将代码转换为FileManager可识别的格式

Java源文件和类文件的顶层抽象是FileObject。虽然没有现成的完整实现,但我们可以利用SimpleJavaFileObject这个部分实现,只需重写关键方法。

4.1. 源码表示

对于源码,必须定义FileManager如何读取它。需重写getCharContent()方法,该方法期望返回CharSequence。由于代码已是字符串,直接返回即可:

public class JavaSourceFromString extends SimpleJavaFileObject {

    private String sourceCode;

    public JavaSourceFromString(String name, String sourceCode) {
        super(URI.create("string:///" + name.replace('.', '/') + Kind.SOURCE.extension),
            Kind.SOURCE);
        this.sourceCode = requireNonNull(sourceCode, "sourceCode must not be null");
    }

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

4.2. 编译后代码表示

对于编译后的代码,操作相反。需定义FileManager如何写入对象,即重写openOutputStream()并提供OutputStream实现。

使用ByteArrayOutputStream存储字节码,并添加便捷方法供类加载时使用:

public class JavaClassAsBytes extends SimpleJavaFileObject {

    protected ByteArrayOutputStream bos =
        new ByteArrayOutputStream();

    public JavaClassAsBytes(String name, Kind kind) {
        super(URI.create("string:///" + name.replace('.', '/')
            + kind.extension), kind);
    }

    public byte[] getBytes() {
        return bos.toByteArray();
    }

    @Override
    public OutputStream openOutputStream() {
        return bos;
    }
}

4.3. 顶层接口

虽非必需,但为内存编译创建顶层接口很有用。主要好处:

  1. 明确ClassLoader返回的对象类型,便于安全转换
  2. 解决跨类加载器的对象相等性问题。不同类加载器加载的相同类可能无法相等,共享接口可弥合此鸿沟

Java预定义的函数式接口(如FunctionRunnableCallable)很适合此模式。本文自定义接口:

public interface InMemoryClass {
    void runCode();
}

调整源码实现该接口:

static String sourceCode =
  "package com.baeldung.inmemorycompilation;\n" 
    + "public class TestClass implements InMemoryClass {\n" 
    + "@Override\n" 
    + "    public void runCode() {\n" 
    + "        System.out.println(\"code is running...\");\n" 
    + "    }\n" 
    + "}\n";

5. 管理内存中的代码

代码格式已就绪,现在需要能操作它的FileManager。标准FileManager不适用,且JavaCompiler API未提供默认实现。

幸运的是,tools包包含ForwardingJavaFileManager,它将所有调用转发给底层FileManager可扩展此类并重写特定行为,类似之前处理SimpleJavaFileObject的方式。

首先重写getJavaFileForOutput()JavaCompiler通过此方法获取编译字节码的JavaFileObject,需返回自定义的JavaClassAsBytes实例:

public class InMemoryFileManager extends ForwardingJavaFileManager<JavaFileManager> {

    // 标准构造函数

    @Override
    public JavaFileObject getJavaFileForOutput(Location location, String className, Kind kind,
      FileObject sibling) {
        return new JavaClassAsBytes(className, kind);
    }
}

还需存储编译后的类供后续ClassLoader使用。使用Map存储并提供访问方法:

public class InMemoryFileManager extends ForwardingJavaFileManager<JavaFileManager> {

    private Map<String, JavaClassAsBytes> compiledClasses;

    public InMemoryFileManager(StandardJavaFileManager standardManager) {
        super(standardManager);
        this.compiledClasses = new Hashtable<>();
    }

    @Override
    public JavaFileObject getJavaFileForOutput(Location location,
        String className, Kind kind, FileObject sibling) {

        JavaClassAsBytes classAsBytes = new JavaClassAsBytes(className, kind);
        compiledClasses.put(className, classAsBytes);

        return classAsBytes;
    }

    public Map<String, JavaClassAsBytes> getBytesMap() {
        return compiledClasses;
    }
}

6. 加载内存中的代码

最后一步是创建类加载器。我们将为InMemoryFileManager构建配套的ClassLoader

类加载本身是个复杂话题,超出本文范围。简单来说,我们将自定义ClassLoader挂载到现有委托层次底部,直接从FileManager加载类

内存编译类加载器图

首先创建继承ClassLoader的类,修改构造函数接收InMemoryFileManager

public class InMemoryClassLoader extends ClassLoader {

    private InMemoryFileManager manager;

    public InMemoryClassLoader(ClassLoader parent, InMemoryFileManager manager) {
        super(parent);
        this.manager = requireNonNull(manager, "manager must not be null");
    }
}

然后重写findClass()定义类查找位置。只需检查InMemoryFileManager中的map:

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {

    Map<String, JavaClassAsBytes> compiledClasses = manager.getBytesMap();

    if (compiledClasses.containsKey(name)) {
        byte[] bytes = compiledClasses.get(name).getBytes();
        return defineClass(name, bytes, 0, bytes.length);
    } else {
        throw new ClassNotFoundException();
    }
}

⚠️ **注意:若找不到类则抛出ClassNotFoundException**。因我们位于层次结构底部,此时找不到意味着全局找不到。

完成InMemoryClassLoader后,需修改InMemoryFileManager建立双向关系。先添加ClassLoader成员并修改构造函数:

private ClassLoader loader; 

public InMemoryFileManager(StandardJavaFileManager standardManager) {
    super(standardManager);
    this.compiledClasses = new Hashtable<>();
    this.loader = new InMemoryClassLoader(this.getClass().getClassLoader(), this);
}

然后重写getClassLoader()返回InMemoryClassLoader实例:

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

现在可重用同一组FileManagerClassLoader进行多次内存编译

7. 整合所有部分

最后将各组件整合。通过单元测试展示:

@Test
public void whenStringIsCompiled_ThenCodeShouldExecute() throws ClassNotFoundException, InstantiationException, IllegalAccessException {
    JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
    DiagnosticCollector<JavaFileObject> diagnostics = new DiagnosticCollector<>();
    InMemoryFileManager manager = new InMemoryFileManager(compiler.getStandardFileManager(null, null, null));

    List<JavaFileObject> sourceFiles = Collections.singletonList(new JavaSourceFromString(qualifiedClassName, sourceCode));

    JavaCompiler.CompilationTask task = compiler.getTask(null, manager, diagnostics, null, null, sourceFiles);

    boolean result = task.call();

    if (!result) {
        diagnostics.getDiagnostics()
          .forEach(d -> LOGGER.error(String.valueOf(d)));
    } else {
        ClassLoader classLoader = manager.getClassLoader(null);
        Class<?> clazz = classLoader.loadClass(qualifiedClassName);
        InMemoryClass instanceOfClass = (InMemoryClass) clazz.newInstance();

        Assertions.assertInstanceOf(InMemoryClass.class, instanceOfClass);

        instanceOfClass.runCode();
    }
}

执行测试后控制台输出:

code is running...

字符串源码中的方法成功执行!

8. 结论

本文学习了如何将Java源码字符串编译为可执行类并运行。

⚠️ 警告:操作类加载器需格外谨慎。ClassClassLoader的双向关系使自定义类加载易引发内存泄漏,尤其使用第三方库时,它们可能在后台持有类引用。

本文源码可在GitHub获取。


原始标题:Compiling and Executing Code From a String in Java | Baeldung