1、动态编译

动态编译,简单来说就是在Java程序运行时编译源代码。

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

静态编译:编译时就把所有用到的Java代码全都编译成字节码,是一次性编译。

动态编译:在Java程序运行时才把需要的Java代码的编译成字节码,是按需编译。

静态编译示例:

静态编译实际上就是在程序运行前将所有代码进行编译,我们在运行程序前用Javac命令或点击IDE的编译按钮进行编译都属于静态编译。

比如,我们编写了一个xxx.java文件,里面是一个功能类,如果我们的程序想要使用这个类,就必须在程序启动前,先调用Javac编译器来生成字节码文件。

如果使用动态编译,则可以在程序运行过程中再对xxx.java文件进行编译,之后再通过类加载器对编译好的类进行加载,同样能正常使用这个功能类。

动态编译示例:

JDK提供了对应的JavaComplier接口来实现动态编译(rt.jar中的javax.tools包提供的编译器接口,使用的是JDK自带的Javac编译器)。

一个用来进行动态编译的类:

public class TestHello {
    public void sayHello(){
        System.out.println("hello word");
    }
}

编写一个程序来对它进行动态编译:

public class TestDynamicCompilation {
    public static void main(String[] args) {
        //获取Javac编译器对象
        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
        //获取文件管理器:负责管理类文件的输入输出
        StandardJavaFileManager fileManager = compiler.getStandardFileManager(null,null,null);
        //获取要被编译的Java源文件
        File file = new File("/project/test/TestHello.java");
        //通过源文件获取到要编译的Java类源码迭代器,包括所有内部类,其中每个类都是一个JavaFileObject
        Iterable<? extends JavaFileObject> compilationUnits = fileManager.getJavaFileObjects(file);
        //生成编译任务
        JavaCompiler.CompilationTask task = compiler.getTask(null, fileManager, null, null, null, compilationUnits);
        //执行编译任务
        task.call();
    }
}

启动main函数,会发现在程序运行过程中,使用了Javac编译器对类TestHello进行了编译,并生成了字节码文件TestHello.class。

以上就是动态编译的简单使用。如果我们想使用这个类TestHello,也可以在程序运行中通过类加载器对这个已经编译的类进行加载。

使用JavaComplier接口来实现动态编译时JDK1.6才引入的,在此之前,也可以通过如下方式实现动态编译:

Runtime run = Runtime.getRuntime(); 
Process process = run.exec("javac -cp e:/project/test/TestHello.java");

该方法的本质是启动一个新的进程来使用Javac进行编译。

2、动态编译的应用

(1)、从源码文件编译得到字节码文件

刚才我们使用动态编译完成了输入一个Java源文件(.java),再到输出字节码文件(.class)的操作。这是从源码文件编译得到字节码文件的方式,实质上也是从磁盘输入,再输出到磁盘的方式。

(2)、从源码字符串编译得到字节码文件

假如现在有一串字符串形式的Java代码,那如何使用动态编译将这些字符串代码编译成字节码文件?这是从源码字符串编译得到字节码文件的方式,实质上也是从内存中得到源码,再输出到磁盘的方式。

根据刚才的代码,我们知道编译任务getTask()这个方法一共有 6 个参数,它们分别是:

  • Writer out:编译器的一个额外的输出 Writer,为 null 的话就是 System.err;
  • JavaFileManager fileManager:文件管理器;
  • DiagnosticListener<? super JavaFileObject> diagnosticListener:诊断信息收集器;
  • Iterable<String> options:编译器的配置;
  • Iterable<String> classes:需要被 annotation processing 处理的类的类名;
  • Iterable<? extends JavaFileObject> compilationUnits:要被编译的单元们,就是一堆 JavaFileObject。

根据getTask()的参数,我们知道编译器执行编译所需要的对象类型并不是文件File对象,而是JavaFileObject对象。因此,要实现从字符串源码编译得到字节码文件,只需要把字符串源码变为JavaFileObject对象即可。

JavaFileObject是一个接口,它的标准实现类SimpleJavaFileObject提供的一些方法是面向类源码文件(.java)和字节码文件(.class)的,而我们进行动态编译时输入的是字符串源码,所以我们需要自行实现JavaFileObject,以使JavaFileObject对象能装入我们的字符串源码。

具体的实现方法就是可以直接继承SimpleJavaFileObject类,再重写其中的一些方法使它能够装入字符串即可。

可以通过查看compiler.getTask().call() 的源代码来查看具体用到了SimpleJavaFileObject 的那些方法,这样我们才知道需要重写 SimpleJavaFileObject 的哪些方法。

一篇大佬分析getTask().call()源代码执行流程的文章介绍得很十分详细,强烈推荐:Java 类运行时动态编译技术.https://seanwangjs.github.io/2018/03/13/java-runtime-compile.html

简单的流程如下:

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

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

public class SourceJavaFileObject extends SimpleJavaFileObject {
    private String source; //源码字符串

    //返回源码字符串
    public SourceJavaFileObject(String name, String sourceStr){ 
            super(URI.create("String:///" + name + Kind.SOURCE.extension),Kind.SOURCE);
            this.source = sourceStr;
    }
    @Override
    public CharSequence getCharContent(boolean ignoreEncodingErrors) throws IOException{
        if(source == null) throw new IllegalArgumentException("source == null");
        else return source;
    }
}

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

//使用重写getCharContent方法后的JavaFileObject构造参数
JavaFileObject sourceFileObject = new SourceJavaFileObject(className, source);
//执行编译
Boolean result = compiler.getTask(null, fileManager, null, null, null, Arrays.asList(sourceFileObject)).call(); 

由于我们自定了JavaFileObject,文件管理器 fileManager更像是一个工具类用于把 File对象数组自动转换成JavaFileObject 列表,换成手动生成 compilationUnits列表并传入也是可行的。(上述代码就是使用了Arrays.asList()手动生成 compilationUnits列表)。

至此,只需要调用getTask().call()就能将字符串形式的源码编译成字节码文件了。

(3)、从源码字符串编译得到字节码数组

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

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

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

构造ClassFileObject,实现定制的JavaFileObject对象,用于存储编译后得到的字节码:

public static class ClassFileObject extends SimpleJavaFileObject {
    private ByteArrayOutputStream byteArrayOutputStream; //字节数组输出流
    //编译完成后会回调OutputStream,回调成功后,我们就可以通过下面的getByteCode()方法获取编译后的字节码字节数组
    @Override
    public OutputStream openOutputStream() throws IOException {
        return byteArrayOutputStream;
    }
    //将输出流中的字节码转换为字节数组
    public byte[] getCompiledBytes() {
        return byteArrayOutputStream.toByteArray();
    }
}

这样,我们就拥有了自定义的用于存储字节码的JavaFileObject。同时还通过添加getByteCode()方法来获得JavaFileObject对象中用于存放字节码的输出流,并将其转换为字节数组。

接下来,就需要重写JavaFileManager,使编译器编译完成后,将字节码存放在我们的ClassFileObject。具体做法是直接继承ForwardingJavaFileManager,再重写需要的getJavaFileForOutput()方法即可。

public static class MyJavaObjectManager extends ForwardingJavaFileManager<JavaFileManager>{
     private ClassFileObject classObject; //我们自定义的JavaFileObject
     //重写该方法,使其返回我们的ClassJavaFileObject
    @Override
    public JavaFileObject getJavaFileForOutput(Location location, String className, JavaFileObject.Kind kind,
                                               FileObject sibling) throws IOException {
        classObject= new ClassJavaFileObject (className, kind);
        return classObject;
    }
}

构造完毕,接下来直接传入getTask执行即可:

//执行编译
Boolean result = compiler.getTask(null,new MyJavaObjectManager(), null, null, null, Arrays.asList(sourceFileObject)).call(); 

注意这里传入的JavaFileObject,是前面构造的存储字符串源码的sourceFileObject,而不是我们用来存储字节码的sourceFileObject

至此,我们使用动态编译完成了将字符串源码编译成字节码数组。随后我们可以使用类加载器加载 byte[]中的字节码即可。

3、总结

动态编译是在Java程序运行时编译源代码,动态编译配合类加载器就可以在程序运行时编译源代码,并动态加载。

JDK提供了对应的JavaComplier接口来实现动态编译。

动态编译中存放源码和字节码的对象都是JavaFileObject ,因此如果我们想要修改源码的输入方式或者字节码的输出方式的,可以自主实现JavaFileObject 接口。同时,由于编译器是通过JavaFileManager来管理输入输出的,因此也需要自主实现JavaFileManager接口。

由于能力有限,可能存在错误,感谢指出。以上内容为本人在学习过程中所做的笔记。参考的书籍、文章或博客如下:
[1]seanwangjs. Java 类运行时动态编译技术.https://seanwangjs.github.io/2018/03/13/java-runtime-compile.html
[2]Throwable.深入理解Java的动态编译.博客园.https://www.cnblogs.com/throwable/p/13053582.html
[3]执笔记忆的空白.java动态编译实现.腾讯云云社区.https://cloud.tencent.com/developer/article/1764721?from=information.detail