skip to content

jni

the old way to call native code, and why it's being fenced in.

chapter
chapter 15.08
reading time
8 min read

the Java Native Interface (JNI) is the original way to call C from java: declare a method native, generate a C header with javac -h, implement the function in C against the JNIEnv API, compile a shared library per platform, and load it with System.loadLibrary. it's been in java since 1.1 and runs a huge amount of existing code.

since java 24 (JEP 472) loading a native library is a restricted operation: it prints a warning today, and is planned to need --enable-native-access in the future. for new code, the FFM API does the same job without the C glue.

the picture

with FFM, java learned to speak C. with JNI, you hire a translator who lives in C: a small layer of C functions with very particular names, written by you, compiled by you, for every operating system and CPU you ship to. the translator can talk back to the JVM through a phone book of about 230 functions (JNIEnv): "give me this string's characters", "make a new array", "call this java method", "throw this exception".

the whole round trip

here's a real JNI library built from scratch on a mac: a native int add(int, int) and a native String greet(String).

  1. →
  2. →
  3. →
  4. →
  5. →
Greeter.java
public class Greeter {    static {        System.loadLibrary("greeter");    }    static native int add(int a, int b);    static native String greet(String name);     public static void main(String[] args) {        System.out.println(add(40, 2));        System.out.println(greet("ada"));    }}

the java side declares methods with no body, marked native, and loads a library that will provide them.

step 1/6
step through it, or click a stage. every file and every line of output is real (javac 25, clang, java 25 on an apple M4 Pro). the red lines are the JVM's warnings and errors about native access.

a few things to notice in there:

  • the names are the contract. Java_Greeter_add is found by name when the method is first called (or registered explicitly with RegisterNatives). rename the class or move it to a package and the C function name must change too, which is why you regenerate the header.
  • primitives cross for free, objects don't. jint is just an int. a jstring is an opaque handle; C has to ask the JVM for a copy of the characters and give it back.
  • the library is per platform. libgreeter.dylib on macOS, libgreeter.so on linux, greeter.dll on windows, each built separately, often for two CPU architectures each. libraries like sqlite-jdbc or netty's native transports ship a dozen binaries inside the jar and pick one at runtime.

crossing the boundary

what actually happens when java calls add(40, 2):

hover a stage.

hover a stage. the transitions are why even a trivial native call costs a few nanoseconds.

that's the overhead: not the C code (an add is one instruction), but the bookkeeping around it. measured with JMH, calling the same add from the same library:

plain java: a + b0.472 ±0.005 ns/op
FFM downcall, critical2.161 ±0.1 ns/op
FFM downcall2.5 ±0.062 ns/op
JNI native method3.126 ±0.05 ns/op
ns per call, jdk 25, apple M4 Pro, 2 forks × 5 iterations. same C function, same library. JNI is the slowest way in.
quick check
your C function calls a java method through env->CallVoidMethod, and that java method throws. what happens in the C code?

why it's being fenced in

JNI code runs in the same process as the JVM with no checks at all. a native library can read or write any memory, corrupt the heap, or crash the whole JVM (you get an hs_err_pid<n>.log and the process is gone). and until java 24, any library on the class path could do that silently: a dependency of a dependency could load native code without anyone in the application deciding it should.

JEP 472 (java 24) makes native access an explicit decision, the same way strong encapsulation made deep reflection one (reflection):

jdk 23 and earlierjdk 24 and 25planned (a future release)
System.loadLibrary, declaring native methodsallowed, silentallowed, warning once per modulethrows IllegalCallerException unless enabled
FFM downcalls and upcallswarning (since 22)warning once per modulesame
opt inn/a--enable-native-access=ALL-UNNAMED (or module names), or Enable-Native-Access in the jar manifestsame

and since java 24 there's a tool to find out who needs it before you flip the switch, jnativescan:

terminal
$ jnativescan --class-path greeter.jar
greeter.jar (ALL-UNNAMED):
  Greeter:
    Greeter::add(int,int)int is a native method declaration
    Greeter::greet(String)String is a native method declaration
    Greeter::<clinit>()void references restricted methods:
      java.lang.System::loadLibrary(String)void

run it over your application's class path and you get the list of jars that do native things, which is the list of modules to name in --enable-native-access.

one level deeper:where JNI still makes sense+

for new code, FFM covers almost everything JNI does: calling C functions, passing structs and arrays, callbacks, and with jextract you don't write descriptors by hand. JNI still shows up where you need to work with java objects from native code in ways FFM doesn't aim for (calling back into arbitrary java methods with java objects as arguments, from inside a native library), in embedding scenarios (a C program that creates a JVM with the invocation API, JNI_CreateJavaVM), and in the long tail of existing libraries that work and that nobody is going to rewrite. the JDK itself still has plenty of JNI inside. and the restriction is about consent, not removal: JNI isn't deprecated.

keylines
  • JNI: native methods, javac -h, C against JNIEnv, a shared library per platform, System.loadLibrary.
  • primitives cross cheaply; objects need JNI calls, and every acquire needs a release.
  • a native bug crashes the whole JVM. -Xcheck:jni while developing.
  • since java 24, native access warns. opt in with --enable-native-access; jnativescan finds who needs it.
  • for new code, use the FFM API: no C glue, and faster calls.