Skip to main content
The Java Native Interface (JNI) is the bridge that allows Java and Kotlin code to call and be called by native C/C++ code. It’s the fundamental mechanism that enables NDK development on Android.

What is JNI?

JNI is a standardized API that provides:
  • Two-way communication: Java/Kotlin can call C/C++ functions, and C/C++ can call back into Java/Kotlin
  • Type system mapping: Conversions between Java types and C types
  • Object access: Native code can create, inspect, and modify Java objects
  • Exception handling: Propagate and handle exceptions across the language boundary
  • Memory management: Coordinate between garbage-collected Java heap and manually-managed native memory
JNI is a standard Java feature, not Android-specific. However, Android makes heavy use of JNI for its native components.

How JNI works

Basic architecture

The JNI layer handles:
  1. Function dispatch: Routing Java native method calls to C/C++ implementations
  2. Type marshaling: Converting data between Java and C representations
  3. Reference management: Tracking object references across the boundary
  4. Thread synchronization: Ensuring thread-safe access to JVM state

Native method declaration

In Java/Kotlin, declare methods with the native keyword:
class NativeLib {
    // Simple native method
    external fun computeHash(input: String): Long
    
    // Native method with primitives
    external fun processArray(data: IntArray): IntArray
    
    // Static native method
    companion object {
        external fun initialize()
        
        init {
            System.loadLibrary("native-lib")
        }
    }
}

Native implementation

C/C++ implementations follow a naming convention:
#include <jni.h>
#include <string.h>

// Function name format: Java_<package>_<class>_<method>
JNIEXPORT jlong JNICALL
Java_com_example_NativeLib_computeHash(JNIEnv* env, jobject thiz, jstring input) {
    // Get UTF-8 string from Java string
    const char* str = (*env)->GetStringUTFChars(env, input, NULL);
    if (str == NULL) {
        return 0; // Out of memory
    }
    
    // Compute hash (simple example)
    jlong hash = 0;
    for (int i = 0; str[i] != '\0'; i++) {
        hash = hash * 31 + str[i];
    }
    
    // Release the string
    (*env)->ReleaseStringUTFChars(env, input, str);
    
    return hash;
}
C++ can use a cleaner syntax with the -&gt; operator instead of (*env)-&gt;. C++ also allows automatic JNI method registration.

JNI types and signatures

Type mappings

Java types map to corresponding C types:
Java TypeNative TypeSignature
booleanjbooleanZ
bytejbyteB
charjcharC
shortjshortS
intjintI
longjlongJ
floatjfloatF
doublejdoubleD
voidvoidV
ObjectjobjectLjava/lang/Object;
StringjstringLjava/lang/String;
ClassjclassLjava/lang/Class;
Object[]jobjectArray[Ljava/lang/Object;
int[]jintArray[I
byte[]jbyteArray[B

Method signatures

Method signatures encode parameter and return types:
// Java: long method(int n, String s, int[] arr)
// Signature: "(ILjava/lang/String;[I)J"
//            (parameters)return_type

// Find method ID using signature
jmethodID mid = (*env)->GetMethodID(env, clazz, "method", 
                                    "(ILjava/lang/String;[I)J");
Incorrect signatures will cause runtime errors. Use javap -s to see the exact signature of Java methods.

JNIEnv interface

The JNIEnv* pointer provides access to all JNI functions:

Common operations

// Java String to C string
jstring jstr = /* from Java */;
const char* cstr = (*env)->GetStringUTFChars(env, jstr, NULL);
// Use cstr...
(*env)->ReleaseStringUTFChars(env, jstr, cstr);

// C string to Java String
const char* message = "Hello from native";
jstring result = (*env)->NewStringUTF(env, message);
return result;

// For large strings, use direct buffer access
const jchar* chars = (*env)->GetStringCritical(env, jstr, NULL);
jsize len = (*env)->GetStringLength(env, jstr);
// Process chars...
(*env)->ReleaseStringCritical(env, jstr, chars);
// Access primitive array
jintArray arr = /* from Java */;
jsize len = (*env)->GetArrayLength(env, arr);
jint* elements = (*env)->GetIntArrayElements(env, arr, NULL);

// Modify elements
for (int i = 0; i < len; i++) {
    elements[i] *= 2;
}

// Commit changes back to Java array (0 = copy back and free)
(*env)->ReleaseIntArrayElements(env, arr, elements, 0);

// Create new array
jintArray newArr = (*env)->NewIntArray(env, 10);
jint buffer[10] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
(*env)->SetIntArrayRegion(env, newArr, 0, 10, buffer);
// Find class
jclass stringClass = (*env)->FindClass(env, "java/lang/String");

// Get field ID and access field
jfieldID fid = (*env)->GetFieldID(env, clazz, "count", "I");
jint value = (*env)->GetIntField(env, obj, fid);
(*env)->SetIntField(env, obj, fid, value + 1);

// Call method
jmethodID mid = (*env)->GetMethodID(env, clazz, "toString", 
                                    "()Ljava/lang/String;");
jstring result = (*env)->CallObjectMethod(env, obj, mid);

// Create object
jmethodID constructor = (*env)->GetMethodID(env, clazz, "<init>", "()V");
jobject newObj = (*env)->NewObject(env, clazz, constructor);
// Check for exceptions
jstring str = (*env)->NewStringUTF(env, "text");
if ((*env)->ExceptionCheck(env)) {
    (*env)->ExceptionDescribe(env);  // Print to logcat
    (*env)->ExceptionClear(env);
    return NULL;
}

// Throw exception
jclass exceptionClass = (*env)->FindClass(env, 
                        "java/lang/IllegalArgumentException");
(*env)->ThrowNew(env, exceptionClass, "Invalid argument");
return NULL;  // Must return from native method

Reference management

JNI has three types of object references:

Local references

Automatically managed within native method scope:
JNIEXPORT jobject JNICALL
Java_Example_createObject(JNIEnv* env, jobject thiz) {
    jclass clazz = (*env)->FindClass(env, "java/lang/String");
    // clazz is a local reference, automatically freed when method returns
    
    jstring str = (*env)->NewStringUTF(env, "hello");
    // Can explicitly free to save memory in long methods
    // (*env)->DeleteLocalRef(env, clazz);
    
    return str;  // Return value is caller's responsibility
}
Local references are freed when the native method returns. Do not store them for later use. The JVM has a limit on local references (typically 512).

Global references

Persist across native method calls:
static jclass gCachedClass = NULL;

JNIEXPORT void JNICALL
Java_Example_initialize(JNIEnv* env, jobject thiz) {
    jclass localClass = (*env)->FindClass(env, "com/example/MyClass");
    // Create global reference to keep class after method returns
    gCachedClass = (*env)->NewGlobalRef(env, localClass);
    (*env)->DeleteLocalRef(env, localClass);
}

JNIEXPORT void JNICALL
Java_Example_cleanup(JNIEnv* env, jobject thiz) {
    if (gCachedClass != NULL) {
        (*env)->DeleteGlobalRef(env, gCachedClass);
        gCachedClass = NULL;
    }
}

Weak global references

Like global references but don’t prevent garbage collection:
static jweak gWeakRef = NULL;

JNIEXPORT void JNICALL
Java_Example_setCallback(JNIEnv* env, jobject thiz, jobject callback) {
    if (gWeakRef != NULL) {
        (*env)->DeleteWeakGlobalRef(env, gWeakRef);
    }
    gWeakRef = (*env)->NewWeakGlobalRef(env, callback);
}

JNIEXPORT void JNICALL
Java_Example_invokeCallback(JNIEnv* env, jobject thiz) {
    jobject callback = (*env)->NewLocalRef(env, gWeakRef);
    if (callback != NULL) {
        // Object still alive, use it
        (*env)->DeleteLocalRef(env, callback);
    } else {
        // Object was garbage collected
    }
}

Thread considerations

JNIEnv is thread-local

Each thread has its own JNIEnv*:
Never cache JNIEnv* across threads. Each thread must use its own JNIEnv*.
// WRONG - Don't do this!
static JNIEnv* gEnv;  // BAD: JNIEnv is thread-specific

JNIEXPORT void JNICALL
Java_Example_method(JNIEnv* env, jobject thiz) {
    gEnv = env;  // WRONG: Will crash on other threads
}

Attaching native threads

Native threads must attach to access JNI:
#include <pthread.h>

void* thread_func(void* arg) {
    JavaVM* jvm = (JavaVM*)arg;
    JNIEnv* env;
    
    // Attach thread
    (*jvm)->AttachCurrentThread(jvm, &env, NULL);
    
    // Now can use JNI
    jclass clazz = (*env)->FindClass(env, "java/lang/String");
    
    // Detach when done
    (*jvm)->DetachCurrentThread(jvm);
    return NULL;
}

// Cache JavaVM* during JNI_OnLoad
static JavaVM* gJvm = NULL;

JNIEXPORT jint JNICALL
JNI_OnLoad(JavaVM* vm, void* reserved) {
    gJvm = vm;
    return JNI_VERSION_1_6;
}

Performance considerations

JNI call overhead

Crossing the JNI boundary has cost:
// Anti-pattern: JNI overhead dominates
external fun add(a: Int, b: Int): Int

fun sumArray(arr: IntArray): Int {
    var sum = 0
    for (value in arr) {
        sum = add(sum, value)  // JNI call per element!
    }
    return sum
}

Direct buffers

For large data transfer, use direct ByteBuffers:
// Java side
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024);
nativeProcess(buffer);
// Native side - zero-copy access
JNIEXPORT void JNICALL
Java_Example_nativeProcess(JNIEnv* env, jobject thiz, jobject buffer) {
    void* data = (*env)->GetDirectBufferAddress(env, buffer);
    jlong capacity = (*env)->GetDirectBufferCapacity(env, buffer);
    
    // Direct access to buffer memory, no copying
    uint8_t* bytes = (uint8_t*)data;
    for (int i = 0; i < capacity; i++) {
        bytes[i] = process(bytes[i]);
    }
}

Critical sections

For short, performance-critical array access:
jbyte* data = (*env)->GetPrimitiveArrayCritical(env, jarray, NULL);
if (data != NULL) {
    // CRITICAL: No JNI calls allowed here!
    // GC may be disabled, must be very fast
    process_data(data, length);
    (*env)->ReleasePrimitiveArrayCritical(env, jarray, data, 0);
}
Between GetPrimitiveArrayCritical and ReleasePrimitiveArrayCritical, do not:
  • Call any JNI functions
  • Block or wait
  • Allocate memory
This may disable the garbage collector.

Best practices

Check for errors

Always check for NULL and exceptions:
jclass clazz = (*env)->FindClass(env, "com/example/MyClass");
if (clazz == NULL) {
    return NULL;  // Exception already pending
}

jstring str = (*env)->NewStringUTF(env, "text");
if (str == NULL) {
    return NULL;  // Out of memory
}

Use helper macros

Simplify error checking:
#define CHECK_NULL(val) if ((val) == NULL) return NULL
#define CHECK_EXCEPTION(env) if ((*env)->ExceptionCheck(env)) return NULL

JNIEXPORT jstring JNICALL
Java_Example_method(JNIEnv* env, jobject thiz) {
    jclass clazz = (*env)->FindClass(env, "java/lang/String");
    CHECK_NULL(clazz);
    
    jmethodID mid = (*env)->GetMethodID(env, clazz, "<init>", "()V");
    CHECK_NULL(mid);
    
    return (*env)->NewObject(env, clazz, mid);
}

Cache class and method IDs

Lookups are expensive, cache them:
static jclass gStringClass = NULL;
static jmethodID gStringInit = NULL;

JNIEXPORT jint JNICALL
JNI_OnLoad(JavaVM* vm, void* reserved) {
    JNIEnv* env;
    if ((*vm)->GetEnv(vm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) {
        return JNI_ERR;
    }
    
    jclass localClass = (*env)->FindClass(env, "java/lang/String");
    gStringClass = (*env)->NewGlobalRef(env, localClass);
    (*env)->DeleteLocalRef(env, localClass);
    
    gStringInit = (*env)->GetMethodID(env, gStringClass, "<init>", "()V");
    
    return JNI_VERSION_1_6;
}

Next steps

Now that you understand JNI:
  • Learn about ABIs for multi-architecture builds
  • Understand bionic C library specifics
  • Explore build systems for compiling native code
For complete JNI reference, see Oracle’s JNI Specification.

Build docs developers (and LLMs) love