本文将带你用 300 行代码,亲手实现 JVM 级别的动态代码加载能力,并看清它的代价。
全程只使用 javax.tools.JavaCompiler 和标准 ClassLoader,零第三方依赖,JDK 8+ 开箱即用。
一、同一个问题,不同的解法
项目中经常有这样的需求:运营配置一段规则脚本,系统能直接执行。
java
String rule = "if (amount > 100) amount * 0.8";
Object result = engine.execute(rule); // 怎么让字符串变成可执行代码?
上一篇文章《QLExpress 如何做到不编译就能执行?》讲了解释执行的方案:把脚本解析成 AST 树,用 Java 代码遍历执行,不生成 class 文件。
这一篇换个思路:能不能把脚本编译成真正的 class 文件,然后加载执行?
听起来很酷,但有三个问题:
java
String code = "public class Script { ... }";
// 问题1:怎么编译?Java 源码变成字节码
byte[] classBytes = compile(code);
// 问题2:怎么加载?字节码变成 Class 对象
Class<?> clazz = load(classBytes);
// 问题3:怎么执行?调用 Class 的方法
Object result = clazz.execute();
花了一天手写 300 行代码验证,这个思路完全可行。后来发现:这就是 Groovy 的做法。
Groovy 是什么? 一门运行在 JVM 上的动态语言,最常见的场景是 Jenkins Pipeline 和 Gradle 构建脚本。它的核心能力就是动态编译执行。
本文手写的实现称为 DynamicScriptRunner,展示 Groovy 等脚本引擎的底层原理,全程只用 JDK 自带工具,零依赖。
二、整体流程
把脚本执行分成五个阶段:
举个例子:
java
// 输入脚本
"if (amount > 100) MyMath.discount(amount, 0.8)"
// 上下文
amount = 150
MyMath = MyMath.class
// 执行流程
脚本字符串 → 包装成Java类 → 编译成字节码 → 加载成Class → 反射执行 → 返回120.0
三、关键技术点详解
技术点一:脚本包装成 Java 源码
原始脚本:
java
if (amount > 100) MyMath.discount(amount, 0.8)
包装后的 Java 源码:
java
public class Script_1702345678 {
public static Object execute(
java.util.Map<String, Object> context,
java.util.Map<String, Class<?>> functions
) throws Exception {
// 步骤1:从 context 提取变量并声明
Double amount = (Double) context.get("amount");
// 步骤2:从 functions 提取函数类
Class<?> MyMath = functions.get("MyMath");
// 步骤3:插入脚本(需要转换)
return (amount > 100) ?
(Double) MyMath.getMethod("discount", double.class, double.class)
.invoke(null, amount, 0.8)
: null;
}
}
为什么要包装?
脚本片段不能直接编译
必须包装成完整的类
才能交给 JavaCompiler
包装三步走:
详细说明:
- 提取变量:从 context 中提取 amount,生成
Double amount = ... - 提取函数:从 functions 中提取 MyMath,生成
Class<?> MyMath = ... - 转换调用:把
MyMath.discount(...)转换成反射调用
关键代码:
java
private String wrapScript(String className, String script) {
StringBuilder sb = new StringBuilder();
// 类定义
sb.append("public class ").append(className).append(" {\n");
sb.append(" public static Object execute(\n");
sb.append(" java.util.Map<String, Object> context,\n");
sb.append(" java.util.Map<String, Class<?>> functions\n");
sb.append(" ) throws Exception {\n");
// 注入变量
for (String varName : context.getVariables().keySet()) {
Object value = context.getVariables().get(varName);
String type = getJavaType(value); // Double, Integer, String
sb.append(" ").append(type).append(" ").append(varName)
.append(" = (").append(type).append(") context.get(\"")
.append(varName).append("\");\n");
}
// 注入函数类
for (String funcName : context.getFunctions().keySet()) {
sb.append(" Class<?> ").append(funcName)
.append(" = functions.get(\"").append(funcName).append("\");\n");
}
// 转换并插入脚本
String transformedScript = transformScript(script);
sb.append(" return ").append(transformedScript).append(";\n");
sb.append(" }\n");
sb.append("}\n");
return sb.toString();
}
技术点二:函数调用转换
问题:脚本里怎么调用 MyMath.discount?
java
// 脚本中
MyMath.discount(100, 0.8)
// 但此时 MyMath 是 Class<?> 类型
Class<?> MyMath = functions.get("MyMath");
// 不能直接调用方法
MyMath.discount(100, 0.8); // 编译错误!
解决方案:转换成反射调用
java
// 转换后
(Double) MyMath.getMethod("discount", double.class, double.class)
.invoke(null, 100.0, 0.8)
转换逻辑:
java
private String transformFunctionCalls(String script) {
// 正则匹配:ClassName.methodName(args)
String pattern = "(\\w+)\\.(\\w+)\\(([^)]*)\\)";
Pattern p = Pattern.compile(pattern);
Matcher m = p.matcher(script);
StringBuffer result = new StringBuffer();
while (m.find()) {
String className = m.group(1); // MyMath
String methodName = m.group(2); // discount
String args = m.group(3); // 100, 0.8
String[] argArray = args.split(",");
// 构造反射调用
StringBuilder repl = new StringBuilder();
repl.append("(Double) ").append(className)
.append(".getMethod(\"").append(methodName).append("\"");
// 添加参数类型
for (int i = 0; i < argArray.length; i++) {
repl.append(", double.class");
}
repl.append(").invoke(null, ").append(args).append(")");
m.appendReplacement(result, repl.toString());
}
m.appendTail(result);
return result.toString();
}
技术点三:JavaCompiler 动态编译
核心 API:
java
// 获取系统编译器(需要 JDK,JRE 没有)
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
if (compiler == null) {
throw new RuntimeException("需要 JDK,不能用 JRE");
}
编译四步骤:
关键代码:
java
public byte[] compile(String className, String javaSource) throws Exception {
// 1. 获取编译器
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
// 2. 诊断收集器(收集编译错误)
DiagnosticCollector<JavaFileObject> diagnostics =
new DiagnosticCollector<>();
// 3. 文件管理器(拦截输出到内存)
InMemoryFileManager fileManager = new InMemoryFileManager(
compiler.getStandardFileManager(diagnostics, null, null)
);
// 4. 源码对象
JavaFileObject sourceFile =
new StringSourceJavaFileObject(className, javaSource);
// 5. 编译任务
Iterable<? extends JavaFileObject> compilationUnits =
Arrays.asList(sourceFile);
JavaCompiler.CompilationTask task = compiler.getTask(
null, // 输出(null = 不输出)
fileManager, // 文件管理器
diagnostics, // 诊断收集器
null, // 编译选项
null, // 注解处理器
compilationUnits // 源文件
);
// 6. 执行编译
boolean success = task.call();
if (!success) {
// 收集错误
StringBuilder errors = new StringBuilder();
for (Diagnostic<?> d : diagnostics.getDiagnostics()) {
errors.append(d.getMessage(null)).append("\n");
}
throw new RuntimeException("编译失败:\n" + errors);
}
// 7. 获取字节码
return fileManager.getCompiledClass(className);
}
技术点四:内存编译(不写磁盘)
问题:JavaCompiler 默认会把 .class 写到磁盘
bash
# 默认行为
javac Script_123.java
# 生成 Script_123.class 文件
目标:编译后的字节码保存在内存,不写磁盘
解决方案:自定义 FileManager 拦截输出
核心原理:
编译器调用 getJavaFileForOutput() 时,我们返回自己的内存对象,而不是真实文件。编译器会把字节码写到这个内存对象里。
关键代码:
java
public class InMemoryFileManager
extends ForwardingJavaFileManager<JavaFileManager> {
// 保存编译后的字节码
private Map<String, ByteArrayJavaFileObject> compiledClasses =
new HashMap<>();
@Override
public JavaFileObject getJavaFileForOutput(
Location location,
String className,
JavaFileObject.Kind kind,
FileObject sibling
) throws IOException {
if (kind == JavaFileObject.Kind.CLASS) {
// 拦截 .class 输出
ByteArrayJavaFileObject fileObject =
new ByteArrayJavaFileObject(className);
compiledClasses.put(className, fileObject);
return fileObject; // 返回内存对象
}
// 其他类型交给父类处理
return super.getJavaFileForOutput(location, className, kind, sibling);
}
public byte[] getCompiledClass(String className) {
ByteArrayJavaFileObject fileObject = compiledClasses.get(className);
return fileObject != null ? fileObject.getBytes() : null;
}
}
ByteArrayJavaFileObject:
java
public class ByteArrayJavaFileObject extends SimpleJavaFileObject {
private ByteArrayOutputStream baos = new ByteArrayOutputStream();
public ByteArrayJavaFileObject(String className) {
super(
URI.create("bytes:///" + className.replace('.', '/') + ".class"),
Kind.CLASS
);
}
@Override
public OutputStream openOutputStream() {
return baos; // 编译器写入这里,从内存加载字节码,绕过磁盘IO
}
public byte[] getBytes() {
return baos.toByteArray(); // 获取字节码
}
}
技术点五:自定义 ClassLoader 加载
问题:有了字节码,怎么加载成 Class 对象?
java
byte[] classBytes = compiler.compile(className, javaSource);
// 怎么变成 Class<?> 对象?
答案:自定义 ClassLoader
核心方法:
java
// defineClass:字节码数组 → Class 对象
Class<?> clazz = defineClass(name, classBytes, 0, classBytes.length);
双亲委派机制:
vbnet
先问父类能不能加载
↓ 不能
再调用自己的 findClass
↓
从内存字节码数组加载
↓
返回 Class 对象
关键代码:
java
public class ScriptClassLoader extends ClassLoader {
// 存储字节码
private Map<String, byte[]> classBytesMap = new HashMap<>();
public ScriptClassLoader() {
super(ScriptClassLoader.class.getClassLoader()); // 设置父类加载器
}
// 注册字节码
public void defineClass(String className, byte[] classBytes) {
classBytesMap.put(className, classBytes);
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 从内存获取字节码(JDK 编译器 SPI 接口生成的)
byte[] classBytes = classBytesMap.get(name);
if (classBytes != null) {
// defineClass:从内存字节数组加载,绕过磁盘IO
return defineClass(name, classBytes, 0, classBytes.length);
}
throw new ClassNotFoundException(name);
}
}
为什么每次生成新类名?
java
String className = "Script_" + System.currentTimeMillis();
原因:
markdown
1. 同一个 ClassLoader 不能加载同名类
2. 重复加载会报错:LinkageError
解决方案:
- 方案1:每次生成新类名(当前做法)
- 方案2:每次创建新 ClassLoader
- 方案3:缓存(相同脚本复用 Class)
技术点六:反射执行
问题:有了 Class 对象,怎么执行?
java
Class<?> scriptClass = loader.loadClass("Script_1702345678");
// 怎么调用 execute 方法?
答案:反射
java
// 1. 获取 execute 方法
Method executeMethod = scriptClass.getMethod(
"execute",
Map.class, // 第一个参数:context
Map.class // 第二个参数:functions
);
// 2. 准备参数
Map<String, Object> context = new HashMap<>();
context.put("amount", 150.0);
Map<String, Class<?>> functions = new HashMap<>();
functions.put("MyMath", MyMath.class);
// 3. 调用(静态方法,第一个参数传 null)
Object result = executeMethod.invoke(
null, // 静态方法
context, // 第一个参数
functions // 第二个参数
);
// 4. 返回结果
return result; // 120.0
四、完整执行流程示例
输入:
java
String script = "if (amount > 100) MyMath.discount(amount, 0.8)";
Context:
amount = 150.0
MyMath = MyMath.class
执行流程:
生成的 Java 源码:
java
public class Script_1702345678 {
public static Object execute(
java.util.Map<String, Object> context,
java.util.Map<String, Class<?>> functions
) throws Exception {
Double amount = (Double) context.get("amount");
Class<?> MyMath = functions.get("MyMath");
return (amount > 100) ?
(Double) MyMath.getMethod("discount", double.class, double.class)
.invoke(null, amount, 0.8)
: null;
}
}
编译后的字节码指令(伪代码):
vbnet
execute方法:
ALOAD context
LDC "amount"
INVOKEINTERFACE get
CHECKCAST Double
ASTORE amount
ALOAD amount
INVOKEVIRTUAL doubleValue
LDC 100.0
DCMPG
IFLE label_else ← 真正的 JVM if 指令!
ALOAD MyMath
LDC "discount"
...
INVOKEVIRTUAL invoke
GOTO label_end
label_else:
ACONST_NULL
label_end:
ARETURN
关键对比:
javascript
QLExpress:
用 Java 的 if 包装:if ((Boolean) cond) { ... }
MiniGroovy:
生成 JVM 的 if 指令:IFLE label_else
五、与 QLExpress 的对比
不深入对比(下一篇文章的事),简单说几点差异:
| 维度 | QLExpress | DynamicScriptRunner |
|---|---|---|
| if 语句 | Java 代码模拟 | JVM 字节码指令 |
| 生成 class | 不生成 | 生成 |
| 启动速度 | 快 | 慢(编译耗时) |
| 运行速度 | 慢 | 快(JIT 优化) |
| 内存占用 | 低 | 高(每次生成新类) |
核心差异:
arduino
QLExpress:
脚本 → AST → 用 Java 代码遍历执行
DynamicScriptRunner:
脚本 → Java 源码 → 编译成 class → JVM 执行
六、黑科技与警告
黑科技:动态加载 Controller
理论上,可以用这个技术动态加载 Controller:
java
// 写一个 HTTP 接口接收代码
@PostMapping("/dynamicController")
public String addController(@RequestBody String code) {
// 编译代码
byte[] classBytes = compiler.compile("DynamicController", code);
// 加载类
Class<?> clazz = loader.loadClass("DynamicController");
// 注册到 Spring
// ...(省略注册逻辑)
return "Controller 已加载,无需重启!";
}
想象一下:
不重启服务器
发送一段代码
新功能立刻生效
听起来很酷?
警告:千万别在线上用
为什么不能用?
风险一:类泄漏导致 OOM
每次加载生成新类
不释放会占满 Metaspace
最终 OutOfMemoryError
真实案例:某团队在线上使用类似机制热更新业务规则,因类加载器泄漏未及时清理,72 小时后 Metaspace 占满,Full GC 频发,服务雪崩。
监控:
bash
jmap -clstats <pid> | grep Script_
输出:
Script_1702345678
Script_1702345679
Script_1702345680
...
Script_1702399999 # 几万个类!
风险二:安全漏洞
java
// 用户提交恶意代码
String maliciousCode = "System.exit(0)";
// 或
String maliciousCode = "Runtime.getRuntime().exec(\"rm -rf /\")";
// 你的服务:直接挂了
风险三:编译耗时影响性能
JavaCompiler 编译一次:100-200ms
高并发下:所有请求都在等编译
服务响应变慢
风险四:难以追踪和调试
线上出了问题
看日志:Script_1702345678 报错
去哪找这个类的代码?
已经不在了,只在内存里存在过
对线上要有敬畏之心
diff
动态编译很强大
但也很危险
线上环境:
- 稳定压倒一切
- 可追溯压倒一切
- 安全压倒一切
这种黑科技:
- 学习可以
- 本地玩可以
- 线上绝对不行
替代方案:
diff
需要动态能力?
- 用配置中心(Apollo、Nacos)
- 用规则引擎(Drools、QLExpress)
- 用脚本引擎(但要沙箱隔离)
- 用插件系统(OSGi、JPMS)
需要热部署?
- 用灰度发布
- 用蓝绿部署
- 用滚动更新
安全使用场景:
这项技术并非一无是处,在可控环境下有其价值:
diff
适合使用:
- 本地调试脚本
- 规则测试沙箱
- 教学演示系统
- 低频配置热加载(配合严格白名单)
- 内部工具平台(非核心业务)
前提条件:
- 严格的权限控制
- 完善的监控告警
- 清晰的生命周期管理
- 定期的类加载器清理
七、完整测试
测试代码
java
public class Main {
public static void main(String[] args) throws Exception {
DynamicScriptRunner runner = new DynamicScriptRunner();
runner.register("MyMath", MyMath.class);
runner.getContext().put("amount", 150.0);
test(runner, "2 + 3 * 4");
test(runner, "if (amount > 100) amount * 0.8");
test(runner, "MyMath.discount(100, 0.8)");
test(runner, "if (amount > 100) MyMath.discount(amount, 0.8)");
}
static void test(DynamicScriptRunner runner, String script) throws Exception {
Object result = runner.execute(script);
System.out.println("Result: " + result);
}
}
测试输出
makefile
Result: 14.0
Result: 120.0
Result: 80.0
Result: 120.0
验证:
bash
# 运行时查看加载的类
jps
jmap -clstats <pid> | grep Script_
# 应该看到
Script_1702345678
Script_1702345679
Script_1702345680
Script_1702345681
# 每次执行生成一个新类
八、核心代码展示
DynamicScriptRunner 主入口
java
public class DynamicScriptRunner {
private Context context = new Context();
private InMemoryCompiler compiler = new InMemoryCompiler();
public void register(String name, Class<?> clazz) {
context.registerFunction(name, clazz);
}
public Object execute(String script) throws Exception {
// 1. 生成唯一类名
String className = "Script_" + System.currentTimeMillis();
// 2. 包装成 Java 源码
String javaSource = wrapScript(className, script);
// 3. 动态编译
byte[] classBytes = compiler.compile(className, javaSource);
// 4. 加载类
ScriptClassLoader loader = new ScriptClassLoader();
loader.defineClass(className, classBytes);
Class<?> scriptClass = loader.loadClass(className);
// 5. 反射执行
Method executeMethod = scriptClass.getMethod("execute", Map.class, Map.class);
return executeMethod.invoke(null, context.getVariables(), context.getFunctions());
}
private String wrapScript(String className, String script) {
// 包装逻辑(前面已展示)
}
}
InMemoryCompiler 编译器
java
public class InMemoryCompiler {
public byte[] compile(String className, String javaSource) throws Exception {
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
DiagnosticCollector<JavaFileObject> diagnostics = new DiagnosticCollector<>();
InMemoryFileManager fileManager = new InMemoryFileManager(
compiler.getStandardFileManager(diagnostics, null, null)
);
JavaFileObject sourceFile = new StringSourceJavaFileObject(className, javaSource);
JavaCompiler.CompilationTask task = compiler.getTask(
null, fileManager, diagnostics, null, null, Arrays.asList(sourceFile)
);
boolean success = task.call();
if (!success) {
StringBuilder errors = new StringBuilder();
for (Diagnostic<?> d : diagnostics.getDiagnostics()) {
errors.append(d.getMessage(null)).append("\n");
}
throw new RuntimeException("编译失败:\n" + errors);
}
return fileManager.getCompiledClass(className);
}
}
ScriptClassLoader 类加载器
java
public class ScriptClassLoader extends ClassLoader {
private Map<String, byte[]> classBytesMap = new HashMap<>();
public void defineClass(String className, byte[] classBytes) {
classBytesMap.put(className, classBytes);
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] classBytes = classBytesMap.get(name);
if (classBytes != null) {
return defineClass(name, classBytes, 0, classBytes.length);
}
throw new ClassNotFoundException(name);
}
}
九、总结
核心机制
动态编译执行的本质是:
markdown
1. 脚本包装成完整 Java 类
2. JavaCompiler 动态编译(JDK 原生能力)
3. InMemoryFileManager 拦截输出到内存
4. ScriptClassLoader 从内存加载字节码
5. 反射调用执行
零依赖实现 :全程只使用 JDK 自带的 javax.tools.JavaCompiler 和标准 ClassLoader,无需引入任何第三方包。
关键技术点
markdown
1. JavaCompiler API:JDK 自带的动态编译工具
2. FileManager 机制:拦截编译输出(JDK 编译器 SPI 接口)
3. ClassLoader 机制:从内存加载字节码
4. defineClass 方法:字节码数组 → Class 对象
5. 反射调用:执行生成的类
与 QLExpress 的核心差异
kotlin
QLExpress:
用 Java 代码模拟 if
不生成 class
MiniGroovy:
生成真正的 JVM if 指令
会生成 class(在内存里)
学到了什么
markdown
1. 动态编译不神秘,JavaCompiler 就能做
2. class 文件可以只在内存里存在
3. ClassLoader 可以从内存加载字节码
4. 强大的技术要谨慎使用
5. 对线上要有敬畏之心
写在最后
拆了两个轮子:
上一篇 QLExpress:
- 不生成 class
- 用 Java 代码模拟执行
- 轻量、快速
本篇动态编译方案:
- 动态编译生成 class
- 真正的 JVM 字节码
- 强大、灵活
这套机制就是 Groovy、JSR-223 ScriptEngine 等工具的底层原理。理解了它,就理解了 Java 世界里动态能力的本质。
本文代码\] ([gitee.com/sh_wangwanb...](https://link.juejin.cn?target=https%3A%2F%2Fgitee.com%2Fsh_wangwanbao%2Fql-express-mini-groovy "https://gitee.com/sh_wangwanbao/ql-express-mini-groovy")) 下一篇计划:深入对比这两种方案,讲清楚什么场景用哪个。