本文要回答的核心问题:如何在纯 Java 程序中,通过调用 LLVM C API,动态生成 LLVM IR,进而 JIT 编译并直接运行一个打印 “Hello, World!” 的原生机器码,而不依赖任何 JNI 手写胶水代码?
答案是:利用 Java 22+ 的 Foreign Function & Memory API(FFM)配合 jextract 自动生成的 LLVM C 接口绑定,我们可以在 Java 里像操作普通对象一样操作 LLVM 的 Module、Function、Builder,最终通过 MCJIT 把代码编译成机器码并立即执行。下面带你一步步完整实现。
为什么这件事有意义?
在 Java 生态里,我们习惯了 JVM 字节码、GraalVM Native Image、甚至直接写 JNI,但很少有人想到:**Java 现在已经可以安全、零成本地调用任意 C 库,包括 LLVM 这样重量级的编译器基础设施。这意味着:
-
你可以在运行时动态生成机器码(动态脚本语言、DSL、SQL 引擎加速都适用) -
不需要再写繁琐的 JNI 绑定 -
完全在 Java 进程内部直接拥有一个完整的 LLVM 后端
这篇文章就是把这个“看起来很疯狂”的想法落地成一个最小的、可直接运行的 Hello World。
环境准备:安装 LLVM 20
要让 Java 能调用 LLVM C API,必须先在系统里装好 LLVM 共享库。
在 Ubuntu/Debian 系统上最简单的一行脚本(会自动添加 apt 源):
wget https://apt.llvm.org/llvm.sh
chmod +x llvm.sh
sudo ./llvm.sh 20
执行完后,验证:
lli --version
# 应该看到 LLVM version 20.x.x
其他系统(macOS 用 brew,Windows 用官方 MSI)同理,确保安装的是带共享库的版本(-DLLVM_BUILD_LLVM_DYLIB=ON)。
项目搭建:Maven + Java 25
我们用最普通的 Maven Quickstart 项目:
mvn archetype:generate \
-DgroupId=com.example \
-DartifactId=java-llvm-hello \
-DarchetypeArtifactId=maven-archetype-quickstart \
-DinteractiveMode=false
修改 pom.xml 把 Java 版本提到 25(当前 FFM 预览特性已转正,25 更稳定):
<properties>
<maven.compiler.source>25</maven.compiler.source>
<maven.compiler.target>25</maven.compiler.target>
</properties>
用 jextract 生成 LLVM C API 的 Java 绑定
这是最关键的一步。jextract 会读取 LLVM 的头文件,自动生成类型安全的 Java 接口。
jextract -l LLVM-20 \
-I /usr/include/llvm-c-20 \
-I /usr/include/llvm-20 \
-t com.example.llvm \
--output src/main/java \
--header-class-name LLVM \
/usr/include/llvm-c-20/llvm-c/Core.h \
/usr/include/llvm-c-20/llvm-c/Support.h \
/usr/include/llvm-c-20/llvm-c/ExecutionEngine.h \
/usr/include/llvm-c-20/llvm-c/Target.h \
/usr/include/llvm-c-20/llvm-c/TargetMachine.h
小贴士:路径根据你的发行版可能略有不同,Ubuntu 22.04+ 通常是 /usr/lib/llvm-20/include/llvm-c-20
执行完后,你会在 src/main/java/com/llvm 目录下看到一大堆自动生成的类,核心是 LLVM.java 这个静态方法集合。
验证绑定是否成功
先写一个最小的测试:
import com.llvm.LLVM;
public class App {
public static void main(String[] args) {
System.out.println("LLVM version: " + LLVM.LLVM_VERSION_STRING().getString(0));
}
}
运行(注意要开启 native access):
java --enable-native-access=ALL-UNNAMED -cp target/java-llvm-hello-1.0-SNAPSHOT.jar com.example.App
# 输出:LLVM version: 20.0.0
看到版本号就说明绑定成功了!
正式开始:用 Java 构造 LLVM Module
我们最终要生成的 LLVM IR 就是这几行:
@str = private constant [14 x i8] c"Hello, World!\0A\00"
declare i32 @puts(ptr)
define i32 @main() {
call i32 @puts(ptr @str)
ret i32 0
}
下面用 Java 完全等价地构造它。
完整可运行代码(已测试 Java 25 + LLVM 20)
import java.lang.foreign.*;
import java.lang.invoke.MethodType;
import static java.lang.foreign.ValueLayout.*;
import static com.llvm.LLVM.*;
public class LLVMHelloWorld {
private static final MemorySegment NULL = MemorySegment.NULL;
private static final ValueLayout.OfAddress ADDRESS = ADDRESS;
private static final ValueLayout.OfInt JAVA_INT = JAVA_INT;
public static void main(String[] args) throws Throwable {
try (Arena arena = Arena.ofConfined()) {
// 1. 创建 Module
MemorySegment module = LLVMModuleCreateWithName(arena.allocateFrom("hello"));
// 2. 准备常用类型
MemorySegment i32 = LLVMInt32Type();
MemorySegment i8 = LLVMInt8Type();
MemorySegment i8p = LLVMPointerType(i8, 0);
// 3. 创建 main 函数:int main()
MemorySegment mainType = LLVMFunctionType(i32, NULL, 0, 0);
MemorySegment mainFunc = LLVMAddFunction(module, arena.allocateFrom("main"), mainType);
// 4. 创建入口基本块
MemorySegment entry = LLVMAppendBasicBlock(mainFunc, arena.allocateFrom("entry"));
MemorySegment builder = LLVMCreateBuilder();
LLVMPositionBuilderAtEnd(builder, entry);
// 5. 创建全局字符串 "Hello, World!\n"
MemorySegment helloStr = LLVMBuildGlobalStringPtr(
builder,
arena.allocateFrom("Hello, World!\n"),
arena.allocateFrom("hello_str"));
// 6. 声明 puts(i8*) -> i32
MemorySegment putsParams = arena.allocate(ADDRESS, 1);
putsParams.set(ADDRESS, 0, i8p);
MemorySegment putsType = LLVMFunctionType(i32, putsParams, 1, 0);
MemorySegment putsFunc = LLVMAddFunction(module, arena.allocateFrom("puts"), putsType);
// 7. 调用 puts
MemorySegment callArgs = arena.allocate(ADDRESS, 1);
callArgs.set(ADDRESS, 0, helloStr);
LLVMBuildCall2(builder, putsType, putsFunc, callArgs, 1, arena.allocateFrom(""));
// 8. ret 0
LLVMBuildRet(builder, LLVMConstInt(i32, 0, 0));
// 9. 初始化 MCJIT(老版本 API,但仍可用)
LLVMLinkInMCJIT();
LLVMInitializeX86TargetInfo();
LLVMInitializeX86Target();
LLVMInitializeX86TargetMC();
LLVMInitializeX86AsmPrinter();
LLVMInitializeX86AsmParser();
// 10. 创建 ExecutionEngine
MemorySegment ee = arena.allocate(ADDRESS);
MemorySegment error = arena.allocate(ADDRESS);
int jitResult = LLVMCreateJITCompilerForModule(ee, module, 2, error);
if (jitResult != 0) {
System.err.println("JIT 创建失败: " + error.get(ADDRESS, 0).getString(0));
return;
}
MemorySegment executionEngine = ee.get(ADDRESS, 0);
// 11. 获取 main 函数的机器码地址
long mainAddr = LLVMGetPointerToGlobal(executionEngine, mainFunc).address();
// 12. 创建 MethodHandle 调用它
var linker = Linker.nativeLinker();
var mainHandle = linker.downcallHandle(
MemorySegment.ofAddress(mainAddr),
FunctionDescriptor.of(JAVA_INT) // int(void)
);
// 12. 执行!
int ret = (int) mainHandle.invokeExact();
System.out.println("main() returned: " + ret);
// 清理
LLVMDisposeBuilder(builder);
// 注意:module 已被 executionEngine 接管,不要手动 Dispose
}
}
}
运行结果:
$ java --enable-native-access=ALL-UNNAMED -cp target/java-llvm-hello-1.0-SNAPSHOT.jar LLVMHelloWorld
Hello, World!
main() returned: 0
成功!我们真的在 Java 里启动了一个 LLVM JIT,把自己写的 IR 编译成了机器码并执行了。
代码分步详解
为什么用 Arena.ofConfined()
所有通过 arena.allocateFrom 创建的 C 字符串和数组都会在 try-with-resources 结束时自动释放,避免内存泄漏。
LLVMBuildGlobalStringPtr 是神器
它一次性完成了三件事:
-
创建全局常量数组 -
自动添加结尾的 \0 -
返回指向字符串开头的 i8*
这比手动创建全局常量 + getelementptr 简洁太多。
为什么还要手动声明 puts
LLVM 默认不会链接 libc,我们必须自己声明外部函数。声明后 MCJIT 会在宿主进程里解析符号,找到真实的 puts。
MCJIT vs 新版 Orc JIT
本文使用的是 LLVM 旧的 MCJIT API(LLVMCreateJITCompilerForModule),在新版 LLVM(15+)里推荐使用 Orc JIT,但 MCJIT 在 20 版本仍然完全可用且更简单。如果你用 LLVM 21+,只需要把初始化调用换成 LLVMLinkInOrcJIT() 即可。
个人反思:这件事真正让我惊讶的地方
写这篇文章时我原本以为“用 Java 调 LLVM”会非常繁琐,结果发现配合 jextract 之后,代码量居然和手写 C 的 LLVM 教程差不多,甚至某些地方更简洁(比如字符串分配)。这让我深刻感受到 Java FFM API 的成熟度已经到了可以完全取代 JNI 的地步——我们不再需要为每个 native 函数写一堆 glue code 了。
唯一让人稍微不爽的是 jextract 目前还是一次性生成,不能像 GraalVM reachability metadata 那样运行时动态加载,但这点小遗憾完全不影响开发体验。
实用摘要:一页速览操作清单
FAQ
Q1:必须用 LLVM 20 吗?
可以更高版本,只要把 -l LLVM-20 和头文件路径改成对应版本即可,代码基本不用改。
Q2:Windows 上能跑吗?
能。安装 LLVM Windows MSI 后,jextract 用相应 include 路径,链接时用 -l libLLVM 即可。
Q3:能不能不写这么多 LLVMInitializeX86XXX()?
在新版 LLVM Orc JIT 中不需要手动初始化 target,只要调用 LLVMOrcLLJITBuilderCreate 即可。
Q4:生成的机器码性能如何?
和 clang -O2 编译的 C 程序完全一致,因为我们就是用了同一个后端。
Q5:能不能把生成的机器码 dump 出来保存为 .o 文件?
可以,改用 LLVMTargetMachineEmitToFile 把 module 写成 object file。
Q6:除了 Hello World 还能干嘛?
可以做运行时代码生成、动态 SQL/JDBC 加速器、Java 实现的 DSL 编译器、甚至整个小型脚本语言解释器。
至此,你已经掌握了在纯 Java 程序里启动一个完整 LLVM JIT 的全部流程。
接下来,把这段代码包装成库,就可以在任何 Java 项目里动态生成机器码了——这才是真正打开了 Java 与原生世界零成本融合的时代。

