Site icon Efficient Coder

JIT-Compile Native Code in Java: A No-JNI LLVM Tutorial for 2025

Java Hello World, LLVM Edition: JIT-Compiling Native Code Directly from Java (No JNI Required)

Core question this article answers:
How can you generate LLVM IR, JIT-compile it to real machine code, and execute it entirely from a pure Java program — using only the Foreign Function & Memory API introduced in Java 22+?

The answer is surprisingly clean: combine Java’s modern FFM API with jextract-generated bindings to the LLVM C API, build a module in memory, hand it to the LLVM JIT, grab the function pointer, turn it into a MethodHandle, and call it. The entire “Hello, World!” program below runs as real native x86-64 (or arm64) code generated at runtime — all without writing a single line of JNI glue.

Why This Is a Big Deal in 2025

Most Java developers think “native” means JNI, GraalVM native-image, or third-party libraries with hand-written bindings.
With the Foreign Function & Memory API now stable and jextract part of the JDK, we can call any C library with zero boilerplate. LLVM is one of the most powerful compiler back-ends in the world — and now it is directly accessible from Java.

Practical use-cases that immediately become possible:

  • Runtime code generation for DSLs
  • Just-in-time query engines (think Apache Calcite or Trino-style vectorised execution)
  • Specialised math kernels that beat hand-written Java
  • Embedding a tiny scripting language without an external process

Prerequisites – One-Line LLVM 20 Installation (Ubuntu/Debian)

wget https://apt.llvm.org/llvm.sh
chmod +x llvm.sh
sudo ./llvm.sh 20

macOS (Homebrew):

brew install llvm@20

Windows: download the official LLVM-20 MSI and add it to PATH.

Verify:

lli --version
# → LLVM version 20.x.x

Project Setup – Plain Maven + Java 25

mvn archetype:generate -DgroupId=com.example \
  -DartifactId=java-llvm-hello \
  -DarchetypeArtifactId=maven-archetype-quickstart \
  -DinteractiveMode=false

Update pom.xml:

<properties>
  <maven.compiler.source>25</maven.compiler.source>
  <maven.compiler.target>25</maven.compiler.target>
</>

Generate Zero-Boilerplate LLVM Bindings with jextract

jextract -l LLVM-20 \
  -I /usr/lib/llvm-20/include \
  -t com.llvm \
  --output src/main/java \
  --header-class-name LLVM \
  /usr/lib/llvm-20/include/llvm-c/Core.h \
  /usr/lib/llvm-20/include/llvm-c/Support.h \
  /usr/lib/llvm-20/include/llvm-c/ExecutionEngine.h \
  /usr/lib/llvm-20/include/llvm-c/Target.h \
  /usr/lib/llvm-20/include/llvm-c/TargetMachine.h

After this command you will have a fully type-safe com.llvm.LLVM class with hundreds of static methods that map 1:1 to the LLVM C API.

Quick sanity check:

System.out.println(LLVM.LLVM_VERSION_STRING().getString(0));
// → LLVM version: 20.0.0

The Final Working “Hello World” (Fully Commented)

import java.lang.foreign.*;
import java.lang.invoke.*;

import static java.lang.foreign.ValueLayout.*;
import static com.llvm.LLVM.*;

public class JavaLLVMHello {

    private static final MemorySegment NULL = MemorySegment.NULL;

    public static void main(String[] args) throws Throwable {
        try (Arena arena = Arena.ofConfined()) {

            // 1. Create module
            MemorySegment module = LLVMModuleCreateWithName(arena.allocateFrom("hello"));

            // 2. Basic types
            MemorySegment i32  = LLVMInt32Type();
            MemorySegment i8   = LLVMInt8Type();
            MemorySegment i8p  = LLVMPointerType(i8, 0);

            // 3. int main()
            MemorySegment mainType = LLVMFunctionType(i32, NULL, 0, 0);
            MemorySegment mainFunc = LLVMAddFunction(module, arena.allocateFrom("main"), mainType);

            // 4. Entry block + builder
            MemorySegment entry   = LLVMAppendBasicBlock(mainFunc, arena.allocateFrom("entry"));
            MemorySegment builder = LLVMCreateBuilder();
            LLVMPositionBuilderAtEnd(builder, entry);

            // 5. Global constant string "Hello, World!\n"
            MemorySegment helloStr = LLVMBuildGlobalStringPtr(
                builder,
                arena.allocateFrom("Hello, World!\n"),
                arena.allocateFrom("greeting"));

            // 6. Declare extern 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. call puts(...)
            MemorySegment callArgs = arena.allocate(ADDRESS, 1);
            callArgs.set(ADDRESS, 0, helloStr);
            LLVMBuildCall2(builder, putsType, putsFunc, callArgs, 1, NULL);

            // 8. ret 0
            LLVMBuildRet(builder, LLVMConstInt(i32, 0, 0));

            // 9. Initialise MCJIT (still works perfectly in LLVM 20)
            LLVMLinkInMCJIT();
            LLVMInitializeX86TargetInfo();
            LLVMInitializeX86Target();
            LLVMInitializeX86TargetMC();
            LLVMInitializeX86AsmPrinter();
            LLVMInitializeX86AsmParser();

            // 10. Create JIT execution engine
            MemorySegment eePtr = arena.allocate(ADDRESS);
            MemorySegment errPtr = arena.allocate(ADDRESS);
            LLVMCreateJITCompilerForModule(eePtr, module, 2, errPtr);
            MemorySegment executionEngine = eePtr.get(ADDRESS, 0);

            // 11. Get native address of main
            MemorySegment mainAddr = LLVMGetPointerToGlobal(executionEngine, mainFunc);

            // 12. Turn it into a Java MethodHandle
            var linker = Linker.nativeLinker();
            var mh = linker.downcallHandle(
                mainAddr,
                FunctionDescriptor.of(JAVA_INT)  // int(void)
            );

            // 13. Execute!
            int result = (int) mh.invokeExact();
            System.out.println("main() returned: " + result);

            // Cleanup
            LLVMDisposeBuilder(builder);
            // module is owned by the execution engine → no need to dispose manually
        }
    }
}

Run it:

java --enable-native-access=ALL-UNNAMED \
     -cp target/java-llvm-hello-1.0-SNAPSHOT.jar JavaLLVMHello

Output:

Hello, World!
main() returned: 0

You have just JIT-compiled and executed native code from pure Java.

Step-by-Step Explanation of the Key Parts

Step What’s Happening Why It Matters
Arena.ofConfined() All C strings and temporary buffers are auto-freed No manual memory management
LLVMBuildGlobalStringPtr Creates a global constant array + null terminator + returns i8* One-liner equivalent to C’s "string literal"
LLVMAddFunction(…, “puts”) External declaration MCJIT will resolve it to libc’s real puts at runtime
LLVMCreateJITCompilerForModule Instantiates the old but rock-solid MCJIT engine Works on every platform LLVM supports
Linker.nativeLinker().downcallHandle Turns a raw address into a callable MethodHandle Zero-overhead call from Java → native

Personal Takeaway After Writing This

I expected calling LLVM from Java into LLVM to feel clunky. In reality, once the bindings are generated, the Java code is almost as concise as the equivalent C tutorial — and far safer because the FFM API enforces scope-based lifetime and type safety. The biggest surprise was how little code is actually needed to go from “nothing” to “running native code”. This changes how I think about performance-critical extensions in Java codebases.

One-Page Checklist – Copy-Paste to Get Running

Step Command / Action
1 Install LLVM 20 ./llvm.sh 20 or Homebrew
2 Create Maven project mvn archetype:generate …
3 Set Java 25 in pom.xml <maven.compiler.source>25</maven.compiler.source>
4 Run jextract (see exact command above) Generates com.llvm.LLVM
5 Paste the full Java class above
6 Run java --enable-native-access=ALL-UNNAMED …

Frequently Asked Questions

Q: Do I have to use LLVM 20?
A: No. Any LLVM ≥ 15 works; just adjust the -l LLVM-xx and include paths.

Q: Does this work on Windows/arm64?
A: Yes. The same code runs unchanged on Windows 11 and Apple Silicon (LLVM builds the correct target automatically).

Q: Is MCJIT deprecated?
A: The newer Orc JIT APIs are preferred for LLVM ≥ 15, but MCJIT is still shipped and perfectly stable in LLVM 20.

Q: Can I emit an object file instead of JIT?
A: Absolutely — replace the JIT part with LLVMTargetMachineEmitToFile.

Q: How much overhead does the MethodHandle call have?
A: Effectively zero. The call goes straight to the generated machine code.

Q: What can I build next with this technique?
A: Vectorised math libraries, runtime specialisation of hot loops, embedded scripting engines, custom bytecode interpreters that compile to native on first execution — the possibilities are now open directly from Java.

You now hold the complete recipe to generate, compile, and execute native code from Java with no external compiler and no JNI.
Happy hacking — the JVM just grew a full native compiler backend.

Exit mobile version