JNI is where Java’s safety rails meet C’s sharp edges. It is invaluable when a mature native library, device API, or performance-critical routine must live inside the JVM process—but an invalid pointer can now crash that whole process. A good first example should therefore teach not only how to print a result, but also what the generated header, loader, ABI, and object-reference rules are protecting.

What this example builds

  • A Java class declares native int add(int left, int right) and loads a library named native_math.

  • javac -h creates the class file and the authoritative C declaration.

  • GCC compiles a matching C function into libnative_math.so on Linux.

  • The JVM maps the logical library name to the platform filename and resolves the generated JNI symbol.

  • Java calls C in the same process and receives a JNI jint result.

Confirm a full JDK and C compiler

Ubuntu terminalbash
java --version
javac --version
gcc --version | sed -n '1p'
java_home=$(dirname "$(dirname "$(readlink -f "$(command -v javac)")")")
printf 'JAVA_HOME=%s\n' "$java_home"
test -f "$java_home/include/jni.h" && echo 'jni.h found'
openjdk ...
javac ...
gcc ...
JAVA_HOME=/usr/lib/jvm/...
jni.h found

Derive headers from the javac you will use

  • A JRE alone is insufficient because the build needs javac and JNI headers.

  • command -v javac locates the selected compiler and readlink -f resolves alternatives/symlinks.

  • The parent of its bin directory is the matching JDK home.

  • Use one target architecture throughout: JVM, compiler, objects, and shared library must agree.

  • Install distribution packages only if these read-only checks show a missing tool.

Declare the native method in Java

src/demo/NativeMath.javajava
package demo;
 
public final class NativeMath {
    static {
        System.loadLibrary("native_math");
    }
 
    private NativeMath() {}
 
    public static native int add(int left, int right);
 
    public static void main(String[] args) {
        int result = add(20, 22);
        System.out.println("20 + 22 = " + result);
    }
}

The declaration is part of the native ABI

  • native declares a method whose implementation the JVM must resolve outside Java bytecode.

  • static means the native function receives a jclass; an instance method would receive the invoking jobject.

  • System.loadLibrary("native_math") uses a logical name without Linux’s lib prefix or .so suffix.

  • Class initialization loads the library once for the defining class loader or fails with UnsatisfiedLinkError.

  • The Java package participates in the default generated C symbol name.

Generate the class and JNI header

Project rootbash
mkdir -p build/classes build/include build/native
javac -h build/include -d build/classes src/demo/NativeMath.java
sed -n '1,160p' build/include/demo_NativeMath.h
JNIEXPORT jint JNICALL Java_demo_NativeMath_add
  (JNIEnv *, jclass, jint, jint);

Never hand-type the generated signature

  • -d places class files under a package-shaped output tree.

  • -h generates headers for classes containing native declarations.

  • The generated include guard and function declaration encode the package, class, method, static receiver, and JNI types.

  • Regenerate the header whenever the Java native declaration changes.

  • Commit policy varies, but the Java declaration should remain the source of truth and CI should detect stale generated output.

Implement the exact generated function in C

native/native_math.cc
#include "demo_NativeMath.h"
 
JNIEXPORT jint JNICALL
Java_demo_NativeMath_add(JNIEnv *env, jclass clazz, jint left, jint right)
{
    (void)env;
    (void)clazz;
    return left + right;
}

JNI supplies two leading parameters

  • JNIEXPORT gives the symbol required visibility and JNICALL supplies the platform calling convention.

  • JNIEnv * is the current thread’s interface to JVM services; it must not be reused from another thread.

  • A static native receives its declaring jclass; an instance native receives jobject instead.

  • jint is the JNI type corresponding to Java int; do not assume every C primitive maps identically on every ABI.

  • The casts explicitly acknowledge unused parameters under warning-enabled builds.

Compile a position-independent Linux shared library

Project rootbash
java_home=$(dirname "$(dirname "$(readlink -f "$(command -v javac)")")")
gcc -std=c17 -Wall -Wextra -Wpedantic -fPIC \
  -I"$java_home/include" -I"$java_home/include/linux" \
  -Ibuild/include -shared native/native_math.c \
  -o build/native/libnative_math.so
file build/native/libnative_math.so
nm -D --defined-only build/native/libnative_math.so | grep Java_demo_NativeMath_add
build/native/libnative_math.so: ELF 64-bit ... shared object ...
... T Java_demo_NativeMath_add

Each compiler option has a native-loading purpose

  • The generic include directory contains jni.h; the OS-specific directory contains jni_md.h.

  • -fPIC emits position-independent code suitable for a shared object.

  • -shared links a dynamic library rather than an executable.

  • file catches an architecture or object-type mismatch before the JVM tries to load it.

  • nm -D verifies that the exact generated JNI entry point is exported.

  • Use a build system such as CMake/Gradle for multi-platform production builds instead of baking one JDK path into source.

Run with an explicit Java library path

Project rootbash
java --enable-native-access=ALL-UNNAMED \
  -Djava.library.path=build/native \
  -cp build/classes demo.NativeMath
20 + 22 = 42

Loading and calling are separate resolution steps

  • java.library.path tells this JVM launch where to search for the named native library.

  • -cp build/classes locates the packaged Java class.

  • Current Java releases restrict native access; classpath code belongs to the unnamed module, hence ALL-UNNAMED. Older supported JDKs may not require or recognize this option, so align commands with the selected JDK.

  • Finding libnative_math.so is only the first step; the JVM must also find a compatible method symbol.

  • Prefer launch-time configuration over globally appending the current directory to LD_LIBRARY_PATH.

Understand JNI references before handling objects

  • Arguments such as jstring and jobject are JNI references, not C structs to dereference.

  • Local references normally remain valid only for the duration of the native call and consume a per-thread local-reference table.

  • A reference retained after return must generally be promoted with NewGlobalRef and later released with DeleteGlobalRef.

  • Weak global references can disappear under garbage collection and require careful liveness checks.

  • Raw pointers returned by string/array access functions have matching release functions and specific copying/pinning semantics.

Exceptions must cross the boundary deliberately

Many JNI functions can leave a pending Java exception. Native code must check where required, clean up acquired resources, and return without continuing arbitrary JNI calls. C failures should be converted to a suitable Java exception with ThrowNew or an application-specific error contract; never let a C++ exception unwind across a JNI C boundary.

Native threads need JVM attachment

  • A thread created by Java enters a native method with a valid thread-local JNIEnv *.

  • A thread created in C is not automatically attached to the JVM. Obtain JavaVM *, call AttachCurrentThread, and detach before the native thread exits.

  • Do not store one thread’s JNIEnv * in global state for another thread.

  • Synchronize native global state explicitly; the JVM does not make a C library thread-safe.

  • Long blocking native calls can tie up Java carrier/platform threads and complicate cancellation.

Diagnose UnsatisfiedLinkError by message

  • `no native_math in java.library.path`: the JVM did not find a loadable library in its search path.

  • `wrong ELF class` or architecture error: the JVM and .so use different bitness or target architectures.

  • `undefined symbol` while loading: the .so has an unresolved native dependency or link-order problem; inspect with ldd and readelf -d.

  • `Native method not found`: library loading succeeded, but the generated symbol/signature does not match; regenerate the header and inspect nm -D.

  • Permission denied or noexec: filesystem/mount policy prevents mapping the library.

  • Illegal native access warning/error: enable native access for the correct named or unnamed module under the active JDK policy.

  • JVM crash: treat it as memory corruption or native undefined behavior; reproduce with symbols, sanitizers where compatible, and the JVM fatal-error log.

Use JNI only when the boundary earns its cost

  • Native code bypasses Java memory safety and can crash or corrupt the process.

  • Every supported OS/architecture needs a compatible binary, packaging path, and CI test.

  • The boundary adds type conversion, lifecycle, threading, exception, observability, and deployment complexity.

  • Prefer a pure-Java API when it meets requirements; consider the current Foreign Function and Memory API for suitable foreign-library access on modern Java.

  • Use JNI when callbacks, existing JNI-oriented APIs, JVM embedding, or fine-grained VM interaction make it the right contract.

Production hardening checklist

  • Pin and test supported JDK, compiler, libc, architecture, and native dependency versions.

  • Build with warnings and debug symbols; separate unstripped release artifacts from symbol storage.

  • Validate all lengths, indexes, null references, and conversions at the boundary.

  • Release every acquired JNI/native resource on success and failure paths.

  • Avoid executing JNI work in critical static initializers when a recoverable initialization API is possible.

  • Load only trusted, integrity-controlled libraries from non-writable deployment locations.

  • Test repeated calls, multiple threads, GC pressure, exceptions, shutdown, and class-loader reload scenarios.

Primary references