
最近在折腾一个在线代码执行的小服务前端把 Java 源代码贴进来后端编译、运行、把控制台输出回传。最早我脑子里的方案很简单直接 Runtime.exec(javac ...)但真动手之后发现这条路又丑又难维护后来换成了 JDK 内置的 javax.tools.JavaCompiler直接在程序里动态编译 Java 源码不需要额外拉起 javac 进程效率高了不少还能把 class 字节码直接留在内存里。这篇文章就把我用 JavaCompiler 做“程序内动态编译”的完整过程写下来项目代号就叫“豆包”代码可以直接抄走也顺便聊聊我踩过的那些坑。适合谁看如果你在做在线判题、规则引擎、代码生成器、热更新或者单纯想在 Java 程序里灵活编译一段用户代码这篇应该对你有用。下面内容不依赖 Spring、不依赖多余第三方库纯 JDK 自带 APIJDK 8 以上都能跑。1. 理解 JavaCompiler它到底是什么、为什么值得用1.1 隐藏在 javax.tools 里的编译入口Java 标准库在 javax.tools 包下藏了一整套“以编程方式调用编译器”的 API核心就是 JavaCompiler 接口。这个接口不是外挂也不是第三方工具包而是 JDK 自己在编译阶段就有的实现。你可以通过 ToolProvider.getSystemJavaCompiler() 拿到当前 JDK 的编译实例然后像命令行调用 javac 一样把源码文件、编译选项、输出位置传进去完成一次完整的 Java 源码编译。需要注意的是这个 API 依赖的是 JDK 自带的编译器实现而不是 JRE 运行时。如果程序跑在纯 JRE 环境ToolProvider 拿到的很可能是 null。这一点在部署的时候特别容易踩后面我单独说。在 JDK 9 之前相关实现放在 tools.jar 里之后随着模块化改造挪到了 jdk.compiler 模块但只要运行环境是完整 JDK代码层面用法基本一致。我第一次用这个接口时的感受是原来 javac 不是我以前理解的“外面一个命令”它内部就是一套类似这种 API 的流程。JavaCompiler 相当于把编译器的入口公开给了我们而命令行那层壳只是它的一个客户端。1.2 哪些场景必须靠“程序内编译”动态编译并不是为了炫技它解决的是一类“代码直到运行期才确定”的问题。最常见的场景就是我开头说的在线判题系统用户的代码是字符串背后要拿到 class 文件并执行。如果你不引入任何脚本引擎又想限制用户只能用 Java 语法、享受编译期类型检查那 JavaCompiler 就是很自然的选择。第二个典型场景是代码生成器。比如根据数据库表结构自动生成 POJO、Mapper、工厂类生成的源码需要马上在同一个 JVM 里用这个“马上编译马上用”的过程如果通过外部 javac 子进程 文件 IO 来做链路非常繁琐。JavaCompiler 可以把生成的 String 源码直接塞进编译器产出的字节码落在内存里加载后就能调用。第三个场景是插件体系和规则引擎。很多中间件允许用户上传一段 Java 代码作为插件或编写一个“规则函数”系统在运行期动态编译并注册。这种场景往往还伴随频繁新增、卸载对字节码的隔离和类加载器管理要求比较高。JavaCompiler 配合自定义 ClassLoader 能做到按需编译、按需加载。还有一类是教学、工具类应用比如 IDE 插件里的“编译并运行当前代码”本质上也是程序内编译。另外像动态代理、AOP 增强这类场景通常借助字节码增强库但在某些需要真实源码编译的环境里JavaCompiler 是更符合直觉的方案。1.3 为什么不用外部 javac 进程如果只是临时编译一次用 Runtime.exec 调 javac 确实能跑但放到工程里问题不少。首先是进程创建和 JVM 启动开销每次编译都要额外开一个进程在线服务高峰期根本顶不住。我在早期原型里实测过一次同样一个小文件用外部 javac 平均耗时 800 毫秒左右其中不少时间浪费在进程启动和 JVM 初始化上换成 JavaCompiler 后在同一个 JVM 里编译平均能降到几十毫秒级别差距非常明显。其次是传参和结果处理。外部 javac 需要你仔细拼接命令行参数尤其是 classpath 很长的时候Windows 下还可能遇到命令行长度限制编译错误要解析 javac 输出到 stderr 的文本格式依赖本地语言环境处理起来很别扭。JavaCompiler 则通过 DiagnosticListener 返回结构化诊断信息行号、列号、源码片段都能拿到定位错误也方便很多。第三是中间文件。外部 javac 编译通常要把源码写入临时文件编译完再落一堆 class 文件如果程序还要负责清理很容易留垃圾。而 JavaCompiler 可以完全在内存中操作不落盘干净利落。另外如果你想控制编译时的类加载上下文或者和已有的 ClassLoader 体系集成内部 API 显然比外部进程灵活得多。2. 完整示例把源码字符串变成可运行类2.1 示例目标与整体结构下面这个示例的目标很明确输入一个类名和对应的源码字符串程序编译它加载它反射调用一个方法。整个过程不用创建任何临时 .java 文件也不用创建任何临时 .class 文件编译产生的字节码全部放在内存的 Map 里。整体分四块第一块写一个 SimpleJavaFileObject 的子类把源码字符串伪装成一个编译单元第二块定义一个内存字节码文件对象编译输出直接写到 ByteArrayOutputStream第三块用 ForwardingJavaFileManager 拦截编译器的输出请求最后写一个自定义 ClassLoader从内存 Map 里加载 class 字节码。我会先讲关键类的写法最后给一个可以直接复制到项目里用的完整工具类。示例里的工具类我用了 DoubaoCompiler 作为名字也就是前面说的“豆包”项目代号和业务无关纯粹为了好记。2.2 源码包装用 SimpleJavaFileObject 冒充磁盘文件javax.tools.JavaFileObject 是编译器眼里“一个文件”的统一抽象。StandardJavaFileManager 会把它和磁盘文件关联但我们并不想把源码写到磁盘上所以写了下面的内部类它只是一个在内存中保存源码字符串的 JavaFileObjectstatic class StringSourceFileObject extends SimpleJavaFileObject { private final String code; StringSourceFileObject(String className, String code) { super(URI.create(string:/// className.replace(., /) Kind.SOURCE.extension), Kind.SOURCE); this.code code; } Override public CharSequence getCharContent(boolean ignoreEncodingErrors) { return code; } }重点在于 URI 和 Kind。编译器会根据 URI 推断文件名如果类名是 com.example.Hello对应的 URI 路径就是 string:///com/example/Hello.java。URI 的 scheme 不是 http、file 都没关系只要唯一、符合格式就行。Kind.SOURCE 表示这是一个源码文件extension 是 .java。getCharContent 是编译器读取源码内容的入口我们直接返回内存里的字符串。还可以在 ignoreEncodingErrors 参数上做点文章如果为 true编译器会忽略字符编码错误但源码内容已经是 String不存在读取字节流的编码问题所以这里直接返回就行。整个类的代码量不大但它决定了我们能不能“跳过磁盘文件”这一步。2.3 输出重定向用文件管理器把字节码写进内存编译器默认会把 .class 文件写进磁盘目录。要拦截这个输出就必须在文件管理器上做文章。javax.tools.ForwardingJavaFileManager 是个现成的“代理类”它包装另一个文件管理器默认所有方法都转发给被代理对象。我们只需要重写 getJavaFileForOutput就能俘获编译器的输出请求。先准备一个内存版 class 文件对象static class MemoryByteFileObject extends SimpleJavaFileObject { private final ByteArrayOutputStream outputStream new ByteArrayOutputStream(); private final String className; MemoryByteFileObject(String className, Kind kind) { super(URI.create(byte:/// className.replace(., /) kind.extension), kind); this.className className; } Override public OutputStream openOutputStream() { return outputStream; } byte[] getBytes() { return outputStream.toByteArray(); } String getClassName() { return className; } }当编译器需要输出一个 class 文件时会调用文件管理器的 getJavaFileForOutput我们返回这个 MemoryByteFileObject。编译器接着调用 openOutputStream() 拿到输出流把字节码写进 ByteArrayOutputStream。这样字节码就不会落到磁盘上。文件管理器的写法如下ListMemoryByteFileObject outputs new ArrayList(); StandardJavaFileManager standardFileManager compiler.getStandardFileManager(null, null, null); JavaFileManager fileManager new ForwardingJavaFileManagerStandardJavaFileManager(standardFileManager) { Override public JavaFileObject getJavaFileForOutput(Location location, String className, JavaFileObject.Kind kind, FileObject sibling) throws IOException { MemoryByteFileObject byteFileObject new MemoryByteFileObject(className, kind); outputs.add(byteFileObject); return byteFileObject; } };这里有两个容易忽略的点。第一每次编译一定要 new 一个独立的 outputs 列表和 fileManager不要搞成静态共享否则并发编译时列表会串数据。第二编译器默认输出的可能是多于一个文件比如内部类、匿名类都会触发额外的 getJavaFileForOutput 调用收集到列表里就能完整拿到所有 class。2.4 编译、诊断与类加载准备工作都齐了下面看完整的编译方法public static MapString, byte[] compile(String className, String sourceCode) throws Exception { JavaCompiler compiler ToolProvider.getSystemJavaCompiler(); if (compiler null) { throw new IllegalStateException(当前环境不是完整 JDK找不到系统 JavaCompiler); } DiagnosticCollectorJavaFileObject diagnostics new DiagnosticCollector(); StandardJavaFileManager standardFileManager compiler.getStandardFileManager(diagnostics, null, null); ListMemoryByteFileObject outputs new ArrayList(); JavaFileManager fileManager new ForwardingJavaFileManagerStandardJavaFileManager(standardFileManager) { Override public JavaFileObject getJavaFileForOutput(Location location, String className, JavaFileObject.Kind kind, FileObject sibling) throws IOException { MemoryByteFileObject byteFileObject new MemoryByteFileObject(className, kind); outputs.add(byteFileObject); return byteFileObject; } }; ListString options new ArrayList(); options.add(-encoding); options.add(UTF-8); String classpath System.getProperty(java.class.path); if (classpath ! null !classpath.isEmpty()) { options.add(-classpath); options.add(classpath); } JavaFileObject sourceFileObject new StringSourceFileObject(className, sourceCode); ListJavaFileObject compilationUnits Collections.singletonList(sourceFileObject); JavaCompiler.CompilationTask task compiler.getTask(null, fileManager, diagnostics, options, null, compilationUnits); boolean success task.call(); fileManager.close(); if (!success) { StringBuilder errorBuilder new StringBuilder(编译失败\n); for (Diagnostic? extends JavaFileObject diagnostic : diagnostics.getDiagnostics()) { if (diagnostic.getKind() Diagnostic.Kind.ERROR) { errorBuilder.append(String.format( 文件 %s 第 %d 行第 %d 列%s%n, diagnostic.getSource() null ? className : diagnostic.getSource().getName(), diagnostic.getLineNumber(), diagnostic.getColumnNumber(), diagnostic.getMessage(null))); } } throw new IllegalStateException(errorBuilder.toString()); } MapString, byte[] bytecodeMap new HashMap(); for (MemoryByteFileObject byteFileObject : outputs) { if (byteFileObject.getKind() JavaFileObject.Kind.CLASS) { bytecodeMap.put(byteFileObject.getClassName(), byteFileObject.getBytes()); } } return bytecodeMap; }这段代码有几个细节值得展开。第一compiler.getTask 的第一个参数是编译过程输出信息的 Writer传 null 会让编译器把进度信息写到 System.err但我们的编译结果不是由它控制的真正影响成败的是返回的 CompilationTask调用 call() 会执行整个编译流程返回值表示是否成功。第二options 里我固定加了-encoding UTF-8这个要和源码字符串的实际字符一致。字符串本身是 Unicode理论上和编码关系不大但在复杂环境下显式指定可以避免编译器用默认编码去处理临时文件时产生的乱码。第三诊断信息通过 DiagnosticCollector 收集。只有 call() 返回 false 时才有必要遍历。注意 format 里的 getLineNumber、getColumnNumber 返回的是 long用 %d 没有问题。getSource().getName() 返回的是我们设置的 URI 字符串比如 string:///Hello.java可能有点丑但不影响定位。编译完成后outputs 列表里有编译期创建的所有文件对象其中 Kind.CLASS 的才是字节码。按 className 存进 Map 后就可以交给类加载器了。类加载器是整个链路的最后一环。编译出来的字节码必须通过 ClassLoader.defineClass 变成 Class 对象static class MemoryClassLoader extends ClassLoader { private final MapString, byte[] bytecodeMap; MemoryClassLoader(MapString, byte[] bytecodeMap) { super(DoubaoCompiler.class.getClassLoader()); this.bytecodeMap bytecodeMap; } Override protected Class? findClass(String name) throws ClassNotFoundException { byte[] bytes bytecodeMap.get(name); if (bytes null) { return super.findClass(name); } return defineClass(name, bytes, 0, bytes.length); } Override public Class? loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { Class? loadedClass findLoadedClass(name); if (loadedClass null) { if (bytecodeMap.containsKey(name)) { loadedClass findClass(name); } else { loadedClass super.loadClass(name, false); } } if (resolve) { resolveClass(loadedClass); } return loadedClass; } } }findClass 是标准写法重写 loadClass 是为了让这个类加载器“优先加载自己内存里的字节码”而不是先问父加载器。如果不重写双亲委派模型会先让父加载器尝试加载同名类一旦你的项目 classpath 里恰好存在同名类就会加载到旧版本这是一个很容易被忽略的坑。注意这个 loadClass 的 synchronized 写法getClassLoadingLock 是 JDK 内置的锁对象避免多线程同时加载同一个动态类时重复 defineClass。2.5 跑起来反射调用编译后的方法有了上面这些组件就可以写一个简单入口public static void main(String[] args) throws Exception { String className Hello; String sourceCode public class Hello {\n public static String greet(String name) {\n return \hello, \ name;\n }\n }; MapString, byte[] bytecodeMap compile(className, sourceCode); Class? helloClass new MemoryClassLoader(bytecodeMap).loadClass(className); Method greetMethod helloClass.getMethod(greet, String.class); Object result greetMethod.invoke(null, 豆包); System.out.println(result); }输出会是hello, 豆包。这里的 greet 是静态方法所以 invoke 的第一个参数传 null。如果方法是非静态的就需要先 newInstance 拿对象再传进去。反射调用时会碰到签名不一致、构造器私有等一系列问题建议代码生成时约定规则要么全用静态方法要么提供无参构造器这样运行期反射会简单很多。3. 关键参数与内存编译原理深挖3.1 getTask 参数逐个拆解JavaCompiler 接口中最核心的方法是 getTask它的签名里藏着整套编译流程的控制点。我做过一张记忆表方便随时查阅参数类型作用outWriter编译过程信息输出到哪传 null 表示使用系统标准错误输出fileManagerJavaFileManager管理源码读取与 class 输出是内存编译的切入点diagnosticListenerDiagnosticListener接收编译诊断不传会用默认实现打印到 stderroptionsIterablejavac 命令行参数比如 -classpath、-source、-targetclassesIterable需要处理的注解处理器类名通常传 nullcompilationUnitsIterable待编译的源码单元列表至少要有一个 JavaFileObject这六个参数里fileManager 是自由度最大的一个。如果你传入 null编译器会创建一个默认的 StandardJavaFileManager从磁盘读源码、往磁盘写 class那就退化成了“在程序里调用 javac”的普通玩法。想实现内存编译就必须操作 fileManager。diagnosticListener 不要省在线服务里把编译错误结构化收集起来比解析默认输出要可靠得多。DiagnosticCollector 是官方提供的简单收集器内部维护一个 List你可以在编译后遍历。如果需要实时处理也可以实现 DiagnosticListener 接口编译器每发现一个问题都会回调 report 方法。out 参数一般不会带给用户看的内容。javac 在编译时偶尔会输出一些提示比如“警告”如果不想污染日志可以传入一个丢弃所有写入的 Writer或者干脆传 null 让它走 System.err问题不大。3.2 常用编译选项有哪些、怎么选options 参数等价于 javac 命令行参数但不需要带命令本身。最常用的是下面这几个-classpath手动指定编译期依赖路径不设置时可能加载不到外部 jar。-sourcepath告诉编译器去哪里找源码内存编译场景一般用不到。-dclass 输出目录配置了自定义 fileManager 后这个参数可以忽略。-encoding指定源码编码传 UTF-8 最稳妥。-source/-target限制源码语法版本与字节码版本比如-source 8 -target 8。--releaseJDK 9 起推荐使用同时限制源码版本、字节码版本和 API 面。-proc:none跳过注解处理能稍微提升编译速度。-Xlint:none关闭所有 lint 警告让诊断信息更干净。-source 和 -target 单独使用时很容易出现“用了高版本 JDK 编译却引用低版本 JDK 没有的 API”的情况所以 JDK 9 以后官方推荐用--release。--release 8会同时把源码语言特性和可用的平台 API 限定在 Java 8 水平。比如你在 JDK 17 环境里编译一段代码但又想让它能跑在 Java 8 上用-source 8 -target 8仍然可能在代码里用到 String.isBlank 这种 Java 11 才有的方法编译期还不报错运行期才炸--release 8就不会让你用过新的 API。在线动态编译场景里我一般会默认加-encoding UTF-8和-proc:none如果用户代码可能有外部依赖再加-classpath参数。--release要不要加取决于你的目标运行环境如果是服务内部自用直接用当前 JDK 版本编译即可不需要限制。3.3 文件管理器到底在编译中扮演什么角色把编译过程类比成一个“吃源码、吐 class”的黑盒文件管理器就是这个黑盒的“消化系统”。javac 不会硬编码如何读取和写出文件而是通过 JavaFileManager 接口来抽象。默认实现 StandardJavaFileManager 与磁盘交互而我们的 ForwardingJavaFileManager 则可以重写关键方法把“输出到磁盘”替换为“输出到内存”。这个设计很有价值。因为 javac 内部对 JavaFileObject 的需求远不止源码和 class分析注解要读资源文件生成源代码时要返回 Source 类型对象写 class 时要 Class 类型对象。你只需要在 ForwardingJavaFileManager 里精确重写 getJavaFileForOutput就能在不影响其他逻辑的情况下完成字节码的“内存化”。这也是为什么我推荐用 Forwarding 包装而不是直接实现整个 JavaFileManager 接口后者方法太多容易漏。还有一个容易忽略的点getJavaFileForOutput 的 className 参数不一定是你在源码里写的 public 类名比如内部类、Lambda 生成类都会产生额外调用。所以收集 outputs 时不要只按预期类名去找要把列表里所有 Kind.CLASS 的对象都收进 Map否则加载主类时会因为缺少内部类而 NoClassDefFoundError。3.4 类加载器与字节码容器的联动拿到 bytecodeMap 后类加载器是唯一能把它变成 Class 对象的工具。ClassLoader.defineClass 是一个 protected 方法通常通过自定义 ClassLoader 的 findClass 间接调用。我们上面重写了 loadClass是因为想优先加载内存里的同名类。类加载器的父子关系也很重要。MemoryClassLoader 的父加载器是 DoubaoCompiler.class.getClassLoader()这样动态编译出来的类如果需要引用你工程里的第三方类比如 Spring、Guava就能顺利委派到上层加载器找到。反过来如果父加载器设置为 null动态类将无法加载任何应用类通常不是我们想要的行为。每次编译都建议 new 一个 MemoryClassLoader而不是复用同一个。否则多个版本的同名类会相互覆盖或者因为旧类被引用导致 Metaspace 不回收。动态编译本质上是在运行期不断产生“新的类”类一旦被类加载器加载就无法卸载直到加载器本身被回收。所以只要你不长期持有旧 ClassLoader 引用每编译一次换一个新的是比较稳妥的做法。4. 实际使用中踩过的坑与排查技巧4.1 找不到编译器ToolProvider 返回 null这是我遇到的第一个坑。当时部署环境用的是裁剪过的 JRE一启动就报 ToolProvider.getSystemJavaCompiler() 返回 null动态编译完全跑不起来。原因很简单JavaCompiler 属于 JDK 的编译模块纯 JRE 里根本没有。解决思路有两个一个是部署环境改成完整 JDK这是最省事的另一个是在 JDK 8 及更早版本里尝试把 JAVA_HOME/lib/tools.jar 加进 classpath。JDK 9 之后模块化JRE 基本已经被合并到 JDK但市面上仍有不少运行时镜像以精简方式打包部署前一定要确认。保险起见工具类里应该对 null 做显式判断给出清晰错误而不是等到执行时抛 NPE。4.2 classpath 丢失导致编译失败动态编译的源码如果引用了项目里的类但编译任务没有继承当前 JVM 的 classpath就会报“找不到符号”或“程序包不存在”。我在第一个版本里就没传 classpath结果只要源码里 import 了第三方类就失败而 IDE 里还没法直接看出来因为 IDE 的编译 classpath 和运行时 classpath 是两套体系。解决方法是拿到编译任务前把系统 classpath 拼到 options 里。前面代码里我写的是System.getProperty(java.class.path)但在 Spring Boot 等场景下这个属性可能只包含启动器 jar不包含真正业务 jar需要根据情况补充。更稳妥的办法是遍历当前线程的上下文类加载器把能拿到的 URL 都转成路径再拼进 -classpath。具体工具类可以用 URLClassLoader 的 getURLs 方法或者直接反射调用。如果你实在不想处理 classpath也可以让动态编译的代码不依赖任何项目外部类只使用 JDK 基础类。但这在实际业务里很难做到所以 classpath 这块建议一次性做扎实。4.3 内存文件管理器并发、资源泄漏问题把 outputs 列表设计成每次编译都新建而不是作为静态变量是避免并发问题的核心。JavaCompiler 不是线程安全的但每次 getTask 返回的 CompilationTask 一般可以独立运行只要不同任务之间不共享同一个 fileManager 和 outputs 就行。我的工具类里所有状态都在 compile 方法内创建天然线程隔离实测多线程并发编译没什么问题。资源泄漏主要发生在 fileManager 没有 close 上。StandardJavaFileManager 实现了 AutoCloseable在使用完或者异常时都要 close。如果不 close可能会有临时文件句柄或者缓存资源占着不放。用 try-with-resources 包一层最省心不过因为我们用 ForwardingJavaFileManager 包装要确保外层 close 能转发到内层这个 Forwarding 默认是支持的。4.4 编码、BOM、多文件编译等小毛病编码问题最常见的是源码字符串开头带了 BOM 头编译器在解析第一个字符时直接报“非法字符: \ufeff”。如果你从编辑器或 HTTP 请求里拿到源码不妨先检查并剔除开头的 \ufeff避免这种低级错误。另外在 Windows 上如果源码文件是用 GBK 保存的而编译 options 里没指定 encoding容易出现乱码。我们直接传入 String不经历字节流转理论上没问题但如果后续你扩展为从文件读取还是要统一使用 UTF-8 并设置-encoding UTF-8。多文件编译也很简单compilationUnits 参数支持传多个 JavaFileObject。我一开始误以为一次只能编译一个类后来发现只需构建一个 List 放进所有 SourceFileObject 即可。它们之间如果有相互依赖javac 会自动解析不需要额外传 -sourcepath。生成的内部类、匿名类都会出现在 outputs 列表里注意全部收集。还有个经验如果源码里有 package 声明className 参数必须是全限定名比如 com.example.Hello而不是 Hello。URI 和 getJavaFileForOutput 返回的 className 会保持一致MemoryClassLoader 加载时也要用全限定名。很多第一次用的人在这里栽跟头报 ClassNotFoundException其实不是编译问题是类名没对齐。4.5 常见问题速查表现象可能原因排查建议compiler 为 null运行环境不是 JDK换 JDK 部署JDK 8 尝试加 tools.jar编译失败找不到符号classpath 未配置给 options 加 -classpath传入运行时依赖编译成功但加载不到类类名不是全限定名检查 package编译、加载统一用全限定名加载类但缺内部类只收集了主类的字节码遍历所有 Kind.CLASS 输出全部放入 Map编译后使用旧类类加载器双亲委派优先重写 loadClass先查本类加载器内存中的类反复编译后 Metaspace 涨ClassLoader 被长期持有每次 new ClassLoader不保存旧引用源码首字符报非法字符BOM 头残留裁掉 \ufeff统一 UTF-8多线程编译串数据fileManager/outputs 被共享每个编译任务创建独立对象5. 我的几点经验和扩展建议5.1 整合后的工具类代码把前面拆开的代码合到一起就是一个可以直接复制到项目里的工具类。类名我保留了 DoubaoCompiler你完全可以改成你自己项目的名字。为了控制篇幅下面的版本去掉了注释核心逻辑和上面讲解一致main 方法里附了一个用法示例。import javax.tools.*; import java.io.*; import java.net.URI; import java.util.*; public class DoubaoCompiler { public static void main(String[] args) throws Exception { String className Hello; String sourceCode public class Hello { public static String greet(String name) { return \hello, \ name; } }; MapString, byte[] bytecodeMap compile(className, sourceCode); Class? clazz new MemoryClassLoader(bytecodeMap).loadClass(className); Object result clazz.getMethod(greet, String.class).invoke(null, 豆包); System.out.println(result); } public static MapString, byte[] compile(String className, String sourceCode) throws Exception { JavaCompiler compiler ToolProvider.getSystemJavaCompiler(); if (compiler null) { throw new IllegalStateException(当前环境不是完整 JDK找不到系统 JavaCompiler); } DiagnosticCollectorJavaFileObject diagnostics new DiagnosticCollector(); StandardJavaFileManager standardFileManager compiler.getStandardFileManager(diagnostics, null, null); ListMemoryByteFileObject outputs new ArrayList(); JavaFileManager fileManager new ForwardingJavaFileManagerStandardJavaFileManager(standardFileManager) { Override public JavaFileObject getJavaFileForOutput(Location location, String className, JavaFileObject.Kind kind, FileObject sibling) throws IOException { MemoryByteFileObject byteFileObject new MemoryByteFileObject(className, kind); outputs.add(byteFileObject); return byteFileObject; } }; ListString options new ArrayList(); options.add(-encoding); options.add(UTF-8); String classpath System.getProperty(java.class.path); if (classpath ! null !classpath.isEmpty()) { options.add(-classpath); options.add(classpath); } ListJavaFileObject compilationUnits Collections.singletonList( new StringSourceFileObject(className, sourceCode)); boolean success compiler.getTask(null, fileManager, diagnostics, options, null, compilationUnits).call(); fileManager.close(); if (!success) { StringBuilder sb new StringBuilder(编译失败\n); for (Diagnostic? extends JavaFileObject d : diagnostics.getDiagnostics()) { if (d.getKind() Diagnostic.Kind.ERROR) { sb.append(String.format( 文件 %s 第 %d 行第 %d 列%s%n, d.getSource() null ? className : d.getSource().getName(), d.getLineNumber(), d.getColumnNumber(), d.getMessage(null))); } } throw new IllegalStateException(sb.toString()); } MapString, byte[] bytecodeMap new HashMap(); for (MemoryByteFileObject byteFileObject : outputs) { if (byteFileObject.getKind() JavaFileObject.Kind.CLASS) { bytecodeMap.put(byteFileObject.getClassName(), byteFileObject.getBytes()); } } return bytecodeMap; } static class StringSourceFileObject extends SimpleJavaFileObject { private final String code; StringSourceFileObject(String className, String code) { super(URI.create(string:/// className.replace(., /) Kind.SOURCE.extension), Kind.SOURCE); this.code code; } Override public CharSequence getCharContent(boolean ignoreEncodingErrors) { return code; } } static class MemoryByteFileObject extends SimpleJavaFileObject { private final ByteArrayOutputStream outputStream new ByteArrayOutputStream(); private final String className; MemoryByteFileObject(String className, Kind kind) { super(URI.create(byte:/// className.replace(., /) kind.extension), kind); this.className className; } Override public OutputStream openOutputStream() { return outputStream; } byte[] getBytes() { return outputStream.toByteArray(); } String getClassName() { return className; } } static class MemoryClassLoader extends ClassLoader { private final MapString, byte[] bytecodeMap; MemoryClassLoader(MapString, byte[] bytecodeMap) { super(DoubaoCompiler.class.getClassLoader()); this.bytecodeMap bytecodeMap; } Override protected Class? findClass(String name) throws ClassNotFoundException { byte[] bytes bytecodeMap.get(name); if (bytes null) { return super.findClass(name); } return defineClass(name, bytes, 0, bytes.length); } Override public Class? loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { Class? loadedClass findLoadedClass(name); if (loadedClass null) { if (bytecodeMap.containsKey(name)) { loadedClass findClass(name); } else { loadedClass super.loadClass(name, false); } } if (resolve) { resolveClass(loadedClass); } return loadedClass; } } } }这段代码在 JDK 8、11、17 上我都跑过主要逻辑不变。真正部署时如果你需要对编译过程做更多控制可以在此基础上扩展比如把 classpath 提取逻辑改得更健壮、把编译结果缓存起来、把 ClassLoader 挂到某个父加载器下。5.2 性能与替代方案什么时候不该用 JavaCompilerJavaCompiler 不是银弹。它确实比外部 javac 进程快但每次编译都还是一整套完整的编译流程源码越大、类越多耗时越明显。如果需要频繁编译很小的表达式比如规则引擎里“a 1 b 2”这种片段JavaCompiler 的复杂度明显偏高用 Janino、Groovy 表达式引擎会更轻量。Janino 是一个纯内存的 Java 编译器API 简单适合表达式和简单类Groovy 的 GroovyShell 则能直接执行脚本缺点是又引入了脚本引擎。如果是大型系统里的代码生成需要编译的类可能很多我的建议是先对比一次编译多个类的耗时再决定要不要用 JavaCompiler。实际项目中我一般是混合方案用户提交的完整 Java 类走 JavaCompiler规则表达式走轻量脚本引擎两者各司其职。性能方面还有一个小技巧JavaCompiler 实例本身开销不大可以复用但每个 CompilationTask 不要复用。创建 fileManager、outputs 这些对象很轻不需要特别优化。真正耗时的是编译动作本身没法省除非把编译结果做缓存。5.3 安全提醒动态执行用户代码要谨慎最后必须提醒一句JavaCompiler 能编译任意 Java 源码配合反射和自定义 ClassLoader等于给了你在运行期执行任意代码的能力。如果你的输入来自不可信用户这本身就是一个巨大的安全边界。反射可以访问非私有成员ClassLoader 能加载程序集内部类如果开了危险 API甚至能穿透模块封装攻击面非常大。在开放给外部用户使用之前至少要做几件事严格限制源码大小和编译时间防止恶意类拖垮服务考虑使用单独的类加载器配合安全管理器虽然 JDK 17 以后安全管理器有变化或者干脆把动态编译执行放到一个独立进程中只对外开放一个受控接口。如果只是内部工具或者自己学习用那可以暂时不考虑这些但心里要清楚边界在哪。我在做这个“豆包”项目时规定动态编译的代码只能在一个受限的环境里运行而且不允许加载 agent、不允许读写任意文件所有外部依赖都单独配置不直接放给用户。技术实现是一回事安全设计是另一回事别只盯着 API 能跑通就把它暴露到公网上了。个人体会是JavaCompiler 这套 API 初见会觉得有点绕尤其是文件管理器和类加载器配合的部分。但只要把 SimpleJavaFileObject、ForwardingJavaFileManager、MemoryClassLoader 这三个概念吃透后面再遇到“动态生成源码并执行”的场景都会非常顺畅。最后分享一个小技巧调试时可以先把 outputs 里收集到的字节码写到临时 .class 文件用 javap 反编译看看生成的类长什么样能帮你快速定位很多类加载和字节码层面的问题。希望这篇能让你少踩几个坑。