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 namednative_math.javac -hcreates the class file and the authoritative C declaration.GCC compiles a matching C function into
libnative_math.soon 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
jintresult.
Confirm a full JDK and C compiler
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 foundDerive headers from the javac you will use
A JRE alone is insufficient because the build needs
javacand JNI headers.command -v javaclocates the selected compiler andreadlink -fresolves alternatives/symlinks.The parent of its
bindirectory 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
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
nativedeclares a method whose implementation the JVM must resolve outside Java bytecode.staticmeans the native function receives ajclass; an instance method would receive the invokingjobject.System.loadLibrary("native_math")uses a logical name without Linux’slibprefix or.sosuffix.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
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.hJNIEXPORT jint JNICALL Java_demo_NativeMath_add
(JNIEnv *, jclass, jint, jint);Never hand-type the generated signature
-dplaces class files under a package-shaped output tree.-hgenerates 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
#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
JNIEXPORTgives the symbol required visibility andJNICALLsupplies 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 receivesjobjectinstead.jintis the JNI type corresponding to Javaint; 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
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_addbuild/native/libnative_math.so: ELF 64-bit ... shared object ...
... T Java_demo_NativeMath_addEach compiler option has a native-loading purpose
The generic include directory contains
jni.h; the OS-specific directory containsjni_md.h.-fPICemits position-independent code suitable for a shared object.-sharedlinks a dynamic library rather than an executable.filecatches an architecture or object-type mismatch before the JVM tries to load it.nm -Dverifies 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
java --enable-native-access=ALL-UNNAMED \
-Djava.library.path=build/native \
-cp build/classes demo.NativeMath20 + 22 = 42Loading and calling are separate resolution steps
java.library.pathtells this JVM launch where to search for the named native library.-cp build/classeslocates 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.sois 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
jstringandjobjectare 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
NewGlobalRefand later released withDeleteGlobalRef.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 *, callAttachCurrentThread, 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
.souse different bitness or target architectures.`undefined symbol` while loading: the
.sohas an unresolved native dependency or link-order problem; inspect withlddandreadelf -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
JNI specification is the authoritative interface, type, reference, invocation, and function contract.
JNI design overview covers native naming, interface pointers, references, exceptions, and linking.
javac tool documentation documents native header generation with
-h.System.loadLibrary API) defines name restrictions, loading behavior, native-access policy, and failures.
Comments and corrections