ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Java动态编译实战:从字符串源码到可执行类的完整链路

Java动态编译实战:从字符串源码到可执行类的完整链路 写Java应用时最容易被问崩的一个需求是我有一段字符串形式的Java源码能不能不落盘、不重启直接在运行期把它编译成类并执行第一次听到这个需求我也觉得有点反直觉——Java不是编译型语言吗源码不该先写完再交给javac么怎么还带运行时“现写现编译”的但等你真的接过在线判题、规则引擎热更新、低代码平台这类项目后就会明白这个能力有多刚需。说白了一句话Java动态编译源码是字符串形式就是用JDK自带的Compiler API把运行时拼出来的Java代码当输入编译成字节码再用类加载器加载进来最终像调用普通类一样执行它。这个技术点也是各大厂Java面试的高频考点因为这个过程横跨了编译原理、类加载机制、反射调用、JVM内存模型好几块硬知识。这篇文章不绕弯直接把我从零实现、线上踩坑、再到优化演进的全过程拆开讲适合正在做在线判题、规则平台、动态网关或者纯粹想搞懂“字符串怎么变成可执行类”的朋友。1. 场景剖析什么时候会用到“字符串即源码”的编译1.1 先把需求想清楚你要的是“动态代码”不是“动态代理”在动手写代码之前我建议所有团队先把需求边界划清楚。很多人一说“动态”就想到JDK动态代理、CGLIB但动态代理的本质是“在运行期生成代理类”它解决的是方法拦截、AOP切面这种问题源码是写死的流程是固定的。而需求方真正想要的往往是“这段业务规则今天由产品说了算可能明天就要改”或者是“用户把代码贴到浏览器里我要在服务端跑出结果给他看”。这两种需求相差十万八千里前者用动态代理可以凑合后者就必须上“源码即字符串”的编译方案。我遇到过最典型的场景是在线判题系统。用户提交的是一段Java代码字符串我需要动态编译并运行还要捕获异常、限制执行时间、统计内存峰值。这类需求下字符串源码是每次请求的核心输入编译是必经链路。另一个典型场景是规则引擎业务人员通过界面拖拽或写脚本生成一段规则类系统拿到这段字符串编译加载后反射调用规则变更时无需发版。就是这两个场景把“字符串形式的Java源码”从冷门技能变成了刚需技能。1.2 为什么不用脚本引擎为什么不用反射硬写可能有朋友会问用Groovy、JavaScript引擎不也能动态执行代码吗为什么非要编译Java源码我的回答是看你的团队约束和性能要求。Groovy脚本确实灵活语法接近Java引擎内置了缓存但引入了一个重量级依赖而且脚本引擎的类加载机制和宿主应用之间经常出现ClassLoader隔离问题。JavaScript引擎在JDK15之后被移除Nashorn已经是历史包袱。更重要的是如果你团队明确要求“用户提交的就是标准Java代码”那你必须用JavaCompiler来做原生的编译而不是脚本引擎绕路。那“反射硬写”行不行不行。反射只能操作已经编译好的类你想动态生成一个新逻辑没有源码就没有类反射再强大也无米下锅。所以这条技术路线的本质是字符串源码 → 编译为字节码 → 类加载 → 反射或接口调用四个环节缺一不可。Java动态编译的价值就是让你的Java应用具备了“像脚本语言一样热更新逻辑”的能力同时保持标准的Java生态和类型体系。1.3 动态编译和静态编译的区别理解这一点才算入门静态编译时javac读入.java文件产出.class文件再由JVM加载。动态编译本质没变变的只是两个输入源一是源码不再来自文件而是内存中的字符串二是编译时机从“构建期”变成了“运行期”。这意味着动态编译必须自己接管“源码如何交给编译器”“编译产物放到哪里”“如何加载这个产物”三个问题。很多教程只讲一半给你一个JavaCompiler的demo却没有讲明白FileManager和ClassLoader在这条链路里的角色导致读者一换场景就抓瞎。后面我会把这三块拆开保证你看完能写出自己的版本。2. Java Compiler API核心组件深度解构2.1 入口ToolProvider和系统编译器Java动态编译的入口是javax.tools.ToolProvider调用getSystemJavaCompiler()拿到一个JavaCompiler实例。注意这个方法返回的是系统编译器底层就是JDK自带的javac。如果你在纯JRE环境下运行这个方法会返回null因为编译器属于jdk.compiler模块裁剪过的运行时环境根本没有这个模块。这是第一个大坑后面我会详细讲怎么排查。JavaCompiler compiler ToolProvider.getSystemJavaCompiler(); if (compiler null) { throw new IllegalStateException(当前环境不是完整JDK找不到系统编译器); }JavaCompiler接口看起来很简洁核心方法就一个getTask但它背后牵扯出的StandardJavaFileManager、CompilationTask、DiagnosticListener都是理解动态编译的关键。很多人第一次接触时觉得API绕其实是没把这些对象之间的关系理顺。简单来说编译器像一个工厂getTask是工厂的流水线入口文件管理器负责原料JavaFileObject和产出class文件诊断监听器负责质检报告。2.2 文件管理器的核心地位为什么FileManager不能随便用JavaCompiler.getStandardFileManager返回一个StandardJavaFileManager它的职责有两个一是把编译单元源文件交给编译器二是决定编译后的class文件输出到哪里。默认实现里源文件来自磁盘路径class文件输出到磁盘目录。但对于“源码是字符串”这个需求源头不在磁盘所以你不能直接拿默认实现喂给编译器而是要自定义一个JavaFileObject把字符串包装成编译器能识别的源文件。这个包装就是动态编译和普通javac调用最大的区别。普通java文件编译时编译器通过文件名推断类名字符串编译时你必须显式告知编译器“我叫什么类源码是什么”。我一开始没搞懂这个点直接把字符串塞进getTask的编译单元列表编译报了一堆“类xxx是公共的应在名为xxx.java的文件中声明”的错误。后来才明白编译器始终认定编译单元是JavaFileObject你需要把字符串伪装成“内存中的.java文件”。2.3 把字符串伪装成源文件SimpleJavaFileObject的魔法javax.tools.SimpleJavaFileObject是干这个事的现成基类。我们需要继承它重写getCharContent方法返回我们准备好的源码字符串。还有一个细节不能忽视——URI编译器会通过URI后缀识别文件类型源码文件必须是.java后缀因此URI通常写成string:///类名.java的形式。class StringSourceFile extends SimpleJavaFileObject { private final String code; StringSourceFile(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; } }这个类非常小巧但它是整个字符串编译方案的地基。每当你需要编译一段新代码就new一个这样的“内存源文件”然后把它当作编译单元交给编译任务。有人会问多个类怎么办很简单一个类一个StringSourceFile全放进编译单元List里一起交给编译器编译器会自行处理类之间的引用关系。2.4 编译错误的捕捉DiagnosticCollector的妙用在线判题系统和规则平台里编译报错是家常便饭而报错信息必须准确、友好。JavaCompiler不会像控制台javac那样把错误打到标准输出你需要通过DiagnosticListener自己收集。最省事的做法是用DiagnosticCollector编译结束后从里面拉出所有诊断信息包括错误行号、列号、错误类型和具体描述。DiagnosticCollectorJavaFileObject diagnostics new DiagnosticCollector();把诊断收集器传给getTask的第三个参数编译结束后遍历diagnostics.getDiagnostics()就能拿到完整的编译报告。我在做在线判题系统时还专门做了一个格式化工具把Diagnostic信息拼成“第X行第Y列错误类型错误内容”的可读文本直接展示给用户。这一步看起来不起眼但对用户体验的提升是决定性的尤其是针对初学者用户报错信息前置了行号列号他们就能快速定位自己的代码问题。3. 一版可直接抄走的最小实现3.1 完整工具类代码编译字符串源码到指定目录理论部分讲再多不如一段能跑的代码实在。我直接给出一版封装好的工具类这个类在我自己的规则引擎项目里迭代过好几个版本去掉了业务耦合留下了最通用的编译能力。它做的事情是接收类名、源码字符串、输出目录完成编译并加载返回Class对象。import javax.tools.*; import java.io.File; import java.io.IOException; import java.net.URI; import java.net.URL; import java.net.URLClassLoader; import java.util.ArrayList; import java.util.Collections; import java.util.List; public class StringCompiler { public static Class? compile(String className, String sourceCode, File outputDir) throws Exception { if (!outputDir.exists() !outputDir.mkdirs()) { throw new IOException(无法创建输出目录: outputDir); } JavaCompiler compiler ToolProvider.getSystemJavaCompiler(); if (compiler null) { throw new IllegalStateException(当前环境不是完整JDK找不到系统编译器); } DiagnosticCollectorJavaFileObject diagnostics new DiagnosticCollector(); try (StandardJavaFileManager fileManager compiler.getStandardFileManager(diagnostics, null, null)) { ListString options new ArrayList(); options.add(-classpath); options.add(System.getProperty(java.class.path)); options.add(-d); options.add(outputDir.getAbsolutePath()); JavaFileObject sourceFile new StringSourceFile(className, sourceCode); JavaCompiler.CompilationTask task compiler.getTask( null, fileManager, diagnostics, options, null, Collections.singletonList(sourceFile)); boolean success task.call(); if (!success) { throw new IllegalArgumentException(formatDiagnostics(diagnostics)); } } URLClassLoader loader new URLClassLoader( new URL[]{outputDir.toURI().toURL()}, Thread.currentThread().getContextClassLoader()); return Class.forName(className, true, loader); } private static String formatDiagnostics(DiagnosticCollectorJavaFileObject diagnostics) { StringBuilder sb new StringBuilder(编译失败:\n); for (Diagnostic? extends JavaFileObject d : diagnostics.getDiagnostics()) { sb.append(d.getKind()).append(: ) .append(d.getLineNumber()).append(行,) .append(d.getColumnNumber()).append(列: ) .append(d.getMessage(null)).append(\n); } return sb.toString(); } static class StringSourceFile extends SimpleJavaFileObject { private final String code; StringSourceFile(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; } } }这段代码有几点值得注意。第一try-with-resources包住了fileManager确保编译结束资源被释放。第二-classpath显式传入了java.class.path这个非常关键很多人在自己项目里编译字符串源码时总是报“找不到符号”就是没加这一行。第三加载类时用的是双参数Class.forName(className, true, loader)第一个参数是类名第二个参数是是否初始化第三个参数才是真正加载的类加载器缺了第三个参数类就加载不到。3.2 调用示例编译一个HelloWorld并执行工具类写好后调用非常简单。我写个Demo先编译一个字符串形式的类再用反射调用它的方法。public class DynamicCompileDemo { public static void main(String[] args) throws Exception { String className HelloDynamic; String code public class HelloDynamic { public String hi(String name) { return \hello \ name; } }; Class? clazz StringCompiler.compile(className, code, new File(./dynamic-classes)); Object instance clazz.getDeclaredConstructor().newInstance(); Object result clazz.getMethod(hi, String.class).invoke(instance, world); System.out.println(result); } }运行结果不出意外会输出hello world。到这里一条完整的动态编译执行链路已经跑通了。这个Demo的代码量很少但每一个环节都是经过了上面的设计思考才定下来的特别是“编译成功后返回Class而不是直接返回实例”这样处理是因为调用方可能需要多次实例化或者需要把Class传给其他组件做依赖注入。3.3 执行阶段直接反射还是定义接口更优雅反射调用虽然能用但在工程化落地时我强烈建议定义一个统一接口让动态编译出来的类去实现它。这样做的好处至少有三个第一调用方代码不再依赖字符串方法名编译期就能发现错误第二性能更好接口方法的分派比反射调用快一个数量级第三代码可读性更强后续维护的人能一眼看出动态类的行为契约。具体做法就是预定义一个顶层接口比如叫DynamicRule里面声明一个Object execute(MapString, Object context)方法。用户提交的源码字符串必须实现这个接口然后我们编译加载后把实例直接强转成DynamicRule来调用绕开反射。这个模式在规则引擎项目里特别好用规则逻辑五花八门但对外暴露的能力统一便于上层统一管理。public interface DynamicRule { Object execute(MapString, Object context); }动态类侧代码形如public class MyRule implements DynamicRule { public Object execute(MapString, Object context) { String name (String) context.get(name); return rule result: name; } }编译后强转DynamicRule rule (DynamicRule) clazz.getDeclaredConstructor().newInstance(); MapString, Object ctx new HashMap(); ctx.put(name, zhangsan); System.out.println(rule.execute(ctx));这个方案在线上用了很久稳定性很好充分说明了“接口约束 动态实现”是一种既灵活又可控的组合。如果你的场景就是要完全动态、连方法签名都不能固定那再用反射兜底也不迟但主链路我建议走接口。4. 实操中踩过的坑与排查技巧4.1 最经典的坑编译报“找不到符号”罪魁祸首竟是classpath我在第一次把这个工具类集成到Spring Boot项目时编译一个引用项目内其他类的动态源码结果一直报“找不到符号”定位到它找不到我项目里已有的领域模型类。检查了很久才发现Spring Boot通过java -jar启动时系统属性java.class.path可能只包含启动器相关的jar而不是项目全量依赖甚至可能出现路径信息不完整的情况。编译器看不到项目里的类自然报“找不到符号”。解决方案有两种。第一种就是我在工具类里用的显式把java.class.path传给-classpath这能覆盖大多数场景。第二种在IDE里测试没问题、但打包后出问题的场景需要在启动脚本里追加-Djava.class.path的完整路径或者读取当前类所在jar的路径然后拼进去。如果你在容器环境里跑还需要考虑容器类加载器和应用类加载器的差异。我的经验是先用最简单的方式打印一下System.getProperty(java.class.path)看看动态编译时的类路径全不全再决定用哪种方案。4.2 类加载器与内存泄漏为什么你的元空间一直在涨动态编译最隐蔽的坑不在编译而在类加载。很多人图省事把动态类交给系统类加载器或者应用类加载器加载然后每次编译同样的类名比如每来一次请求就编译一个Main类。问题来了——同一个类加载器不能重复加载同名类第二次加载会直接报LinkageError。有人改用Class.forName反复加载结果发现元空间持续增长最终OOM。这个问题的根源是JVM的类回收依赖类加载器的可达性如果动态类一直被系统类加载器引用着它永远不会被回收。正确做法是每次编译都创建独立的URLClassLoader用完了让loader连同它加载的类一起变得不可达等待GC回收。我在前面的工具类里就是在调用方每次编译时new一个loader,这样动态类之间天然隔离同名类互不干扰内存也能释放。4.3 JDK版本带来的坑模块化之后的编译器去哪了JDK9之前编译器的实现放在tools.jar里你甚至可能需要手动把tools.jar加到classpath。JDK9引入模块化之后编译器所在模块是jdk.compiler完整JDK环境默认携带ToolProvider.getSystemJavaCompiler()可以正常工作。但如果你的运行环境是裁剪过的镜像或者用了某些针对JRE的Docker镜像那这个方法极有可能返回null。应对方法很直接动态编译能力依赖完整JDK运行环境必须保证是JDK而不是JRE。在Docker镜像里别用eclipse-temurin:17-jre这类瘦身镜像换成-jdk后缀的镜像。这不算什么高深技术但确实是我踩过并导致线上编译功能突然全部不可用的真实教训。4.4 资源释放fileManager不关会怎样StandardJavaFileManager底层持有文件句柄如果你频繁编译却不关闭文件描述符会一点一点耗尽最终导致“Too many open files”。我在编码时用了try-with-resources这能保证fileManager正常关闭。try (StandardJavaFileManager fileManager compiler.getStandardFileManager(diagnostics, null, null)) { // 编译任务 }说实话这个坑在Windows上不容易暴露文件句柄释放比较宽松但在Linux服务器上跑一段时间必现。排查过程也比较麻烦因为报错信息是IO层面的和编译代码本身没有直接关系。所以奉劝各位不管代码多简单资源释放的习惯一开始就要养好。4.5 多类同时编译时的一个隐藏细节真实项目中用户提交的往往不止一个类。在线判题时用户可能提交一个Main类还附带了几个辅助类。这时候把多个StringSourceFile放进编译单元列表即可。但这里有一个隐藏细节类名和文件名的对应关系仍然存在如果你的源码里声明了一个public class Foo而你传入的className是Bar编译器会报“类Foo是公共的应在名为Foo.java的文件中声明”。所以多类编译时每个StringSourceFile的URI中的类名必须和源码中声明的公共类名一致。处理方案是解析源码或要求调用方传入准确的类名。我在在线判题系统里用的是最简单粗暴的办法让编译器自己解析我提供一个自定义的工具方法用正则从源码中提取public class后面的类名然后把它作为className传给编译工具。这个方法不完美但对于约定好的判题代码规范来说足够用。4.6 编译错误的格式化给用户看什么决定了你的工具是否好用动态编译的错误信息直接暴露给用户时原本的Diagnostic信息里包含一些不必要的噪音。比如Diagnostic.getMessage(null)里会附带完整的源码片段这在判题场景下往往太长还会把用户的代码重复展示一遍。我后来做的格式化工具是这样的前几行输出错误类型、行号、列号、简要信息最后才附上相关源码行并且限制输出长度超过一定行数就截断。for (Diagnostic? extends JavaFileObject d : diagnostics.getDiagnostics()) { if (d.getKind() Diagnostic.Kind.ERROR) { sb.append(第).append(d.getLineNumber()).append(行, ) .append(第).append(d.getColumnNumber()).append(列: ) .append(d.getMessage(Locale.CHINA)).append(\n); } }还有一个小技巧getMessage(Locale.CHINA)可以获取本地化的错误信息对中文用户更友好。之前没传Locale时某些JDK版本会输出英文错误对初学者用户非常不友好。5. 进阶优化内存编译、类隔离与安全边界5.1 不落盘的内存编译方案如果你不想在磁盘上生成.class文件可以自定义JavaFileManager把编译产物直接写到内存中的字节数组里。这样做的场景通常是敏感环境不允许写入临时文件或者单纯想让编译过程更干净。其实实现的原理并不复杂继承ForwardingJavaFileManager重写getJavaFileForOutput让它返回一个自定义的SimpleJavaFileObject这个对象的输出流指向一个ByteArrayOutputStream。class MemoryFileManager extends ForwardingJavaFileManagerStandardJavaFileManager { private final MapString, ByteArrayOutputStream buffers new HashMap(); protected MemoryFileManager(StandardJavaFileManager fileManager) { super(fileManager); } Override public JavaFileObject getJavaFileForOutput(Location location, String className, JavaFileObject.Kind kind, FileObject sibling) { return new SimpleJavaFileObject( URI.create(mem:/// className.replace(., /) Kind.CLASS.extension), Kind.CLASS) { Override public OutputStream openOutputStream() { ByteArrayOutputStream baos new ByteArrayOutputStream(); buffers.put(className, baos); return baos; } }; } }配合一个内存ClassLoader从buffers里取出字节数组定义Class。这套方案不落盘、无临时文件适合安全和性能要求更高的场景。代码复杂度比落盘方案高一些但理解了FileManager的工作机制后就顺理成章了。5.2 类隔离同名类互不干扰新旧版本和谐共存动态编译的类如果都交给系统类加载器同名类会互相覆盖旧的也回收不掉。我在规则引擎里做过一个典型的类隔离方案每个规则包一个独立URLClassLoader规则包的类都由它加载不同规则包之间尽管可能包含同名类但互不可见互不干扰。升级某个规则时直接新创建一个loader业务引用切换到新loader即可旧的loader如果不再被引用就会被GC回收。这个“通过更换类加载器实现代码升级”的思路是动态编译场景下实现热更新的核心手段。它能避免重启进程也能避免类冲突但要注意一个前提你的动态类不能和宿主的类产生交叉依赖否则类加载器的隔离性会被打破。换句话说动态类只能依赖你暴露给它的接口、工具类不能反过来操作宿主内部的大部分类。5.3 安全边界动态编译等同于执行任意代码别拿安全当儿戏必须提醒大家动态编译能力是一把双刃剑。它能让你的系统灵活到极致但如果你接收的源码来自不可信用户又没有做任何安全防护那等于把整个服务器的代码执行权限交给了对方。一个恶意用户完全可以提交Runtime.getRuntime().exec(rm -rf /)这样的代码系统直接沦陷。所以在开放给用户使用之前至少要考量几层防护一是准入控制只允许可信用户使用动态编译能力二是运行环境隔离让动态代码跑在独立的容器或沙箱里三是行为限制可以使用SecurityManager配合权限策略或者用自定义ClassLoader限制动态代码的权限范围四是资源管控包括编译超时、执行超时、内存上限、CPU配额。在线判题系统在这方面做了大量工作不是简单一个demo能覆盖的。如果你的系统要开放给外部用户强烈建议把安全设计放到第一优先级。5.4 动态编译与脚本引擎怎么选一张表看清楚技术选型没有银弹动态编译也不是所有动态化需求的唯一解。我在项目里总结过一张粗略的对比表每次团队讨论技术方案时直接拿出来参考。对比维度JavaCompiler动态编译Groovy脚本引擎动态代理/CGLIB动态程度编译期确定但可在运行期编译新代码运行期解释/编译脚本运行期生成代理类结构相对固定类型安全编译期强类型检查弱类型或动态类型强类型性能编译成本高执行性能接近原生Java有脚本引擎开销执行性能高生成代理略慢依赖JDK自带无额外依赖需要引入Groovy依赖Spring/CGLIB依赖适用场景在线判题、规则引擎固定接口快速脚本、灵活规则AOP、方法增强从表格能看出如果你的需求是“用户提交标准Java代码且对执行性能有要求”JavaCompiler是首选。如果只是想写点快速脚本Groovy更省事。如果只是方法拦截那根本不用上动态编译动态代理就够了。分清场景再选型少走弯路。最后再说几句实操心得动态编译这个能力我前前后后在不同项目里应用了将近三年最大的感受是它不是一个拿来炫技的API而是一整套围绕“运行期代码生成与加载”的工程体系。编译API本身几百行代码就能写出来真正决定系统稳不稳的是类加载器的设计、classpath的传递、资源的释放、内存和安全的边界这些看不见的细节。你现在看到这篇文章如果正准备把动态编译引入自己的项目我建议第一步先写一个最小Demo跑通链路第二步再补上类加载器隔离和错误处理第三步才是考虑性能优化和部署环境适配。不要一上来就追求花哨的内存编译和热更新先把地基打牢后面无论怎么扩展都不会走样。从面试角度多说一句如果面试官问你“Java能否动态编译字符串源码”你不仅要回答“能用javax.tools.JavaCompiler”更要能讲清楚背后的JavaFileObject、FileManager、ClassLoader三者的协作关系。能把这些讲透才算是真正理解了Java动态编译而不是背了一个demo。
返回列表