Memahami Manajemen Referensi Objek pada JNI

Crash pada Java Native Interface atau JNI sering kali bukan berawal dari logika rumit, melainkan dari kesalahan kecil dalam memperlakukan referensi objek. Kode C atau C++ yang berkomunikasi dengan JVM tidak pernah memegang pointer mentah ke objek Java. Ia hanya menerima handle buram, seperti jobject, jclass, jstring, atau jobjectArray, yang menunjuk ke slot dalam tabel referensi milik JVM. Slot itulah yang mengarah ke objek di managed heap. Ketika garbage collector memindahkan objek, JVM memperbarui slot tersebut. Handle tetap tampak valid, selama slotnya belum dibebaskan. Masalah muncul ketika native code menyimpan handle yang masa hidupnya sudah habis, membuat referensi lebih cepat daripada melepasnya, atau memakai referensi pada thread yang tidak berhak menggunakannya. Akibatnya sering sulit dilacak: program dapat berjalan normal, lalu beberapa menit kemudian runtuh di garbage collector atau pada pemanggilan JNI yang tampak tidak berkaitan.

Karakteristik dan Batasan Kapasitas Referensi Lokal

JNI mengenal tiga jenis referensi: lokal, global, dan weak global. Referensi lokal dibuat oleh sebagian besar fungsi JNI yang mengembalikan objek, termasuk FindClass, NewStringUTF, GetObjectArrayElement, CallObjectMethod, dan GetObjectClass. Argumen native method, termasuk thiz atau jclass pada metode statis, juga lokal. Referensi ini hanya sah selama pemanggilan native method yang menciptakannya, pada thread yang sama. Saat native method kembali ke Java, JVM membersihkan semua referensi lokal dalam frame itu. Mekanisme ini nyaman untuk fungsi pendek, tetapi berbahaya dalam loop besar. Spesifikasi JNI hanya menjamin ruang untuk 16 referensi lokal, meski implementasi biasanya memberi lebih banyak. Pada Android lama, tabel lokal pernah dibatasi 512 entri; melewatinya membuat proses abort dengan pesan local reference table overflow. Karena itu, loop yang mengambil elemen array Java harus memanggil DeleteLocalRef setelah selesai memakai tiap elemen, atau memakai PushLocalFrame dan PopLocalFrame agar semua referensi sementara dalam satu iterasi dilepas sekaligus. ReleaseStringUTFChars hanya melepas buffer karakter, bukan referensi jstring itu sendiri.

Kesalahan lain yang sering terjadi ialah menyimpan referensi lokal ke variabel statis. Banyak pustaka native ingin mempercepat akses dengan men-cache class dan method ID saat JNI_OnLoad. Namun FindClass mengembalikan referensi lokal. Jika hasilnya langsung disimpan ke static jclass, handle itu menjadi basi begitu JNI_OnLoad selesai. Pada beberapa JVM, kode seperti ini tampak berjalan karena slot belum tertimpa. Dengan CheckJNI di ART, proses biasanya langsung dihentikan dengan pesan stale local reference. Perbaikannya sederhana: hasil FindClass harus dipromosikan dengan NewGlobalRef, lalu referensi lokal aslinya dihapus. Method ID dan field ID tidak perlu dipromosikan karena bukan referensi objek, tetapi class pemiliknya sebaiknya dipertahankan sebagai referensi global agar tidak dibongkar. Setiap hasil FindClass, GetMethodID, atau operasi JNI lain yang bisa gagal juga harus diperiksa, sebab kegagalan biasanya meninggalkan exception pending yang tidak otomatis menghentikan eksekusi native.

Pengelolaan Referensi Global dan Bahaya Kebocoran Memori

Referensi global dibuat eksplisit dengan NewGlobalRef, berlaku lintas pemanggilan, dapat dipakai dari thread lain, dan mempertahankan objek agar tidak dikoleksi. Karena itu, ia cocok untuk cached class, callback object, atau objek Java yang memiliki sumber daya native. Namun global reference tidak pernah dibersihkan otomatis oleh JVM. Ia tetap menjadi GC root sampai DeleteGlobalRef dipanggil. Kebocoran muncul ketika fungsi registrasi membuat global reference baru setiap kali dipanggil, lalu menimpa handle lama tanpa menghapusnya. Objek listener, Activity, atau Fragment dapat tertahan terus, terutama setelah rotasi layar, sampai memori membengkak atau tabel global penuh. Pola aman: sebelum menyimpan listener baru, hapus referensi global lama; izinkan Java mengirim null untuk membersihkan listener; lindungi akses bersama dengan mutex bila worker thread juga membacanya. Aturan praktisnya tegas: setiap NewGlobalRef harus punya lokasi DeleteGlobalRef yang jelas. Jika titik itu tidak bisa ditunjuk, kebocoran hampir pasti terjadi.

Strategi Penggunaan Weak Global Reference Secara Aman

Weak global reference menawarkan kompromi. Ia dibuat dengan NewWeakGlobalRef, bisa disimpan lintas pemanggilan serta lintas thread, tetapi tidak menjaga objek tetap hidup. Cocok untuk native engine yang ingin menghubungi UI tanpa memperpanjang umur UI. Bahayanya, weak reference tidak boleh dipakai langsung setelah sekadar dicek dengan IsSameObject. Garbage collector dapat berjalan di antara pengecekan dan pemanggilan metode, membuat objek hilang tepat sebelum dipakai. Solusi aman ialah mempromosikannya dulu dengan NewLocalRef. Jika hasilnya null, objek sudah dikoleksi; jika tidak null, referensi lokal kuat itu mencegah objek dikoleksi sampai DeleteLocalRef dipanggil. Kesalahan threading sama krusialnya. JNIEnv* terikat pada thread tertentu, membawa state seperti local reference table dan pending exception. Menyimpannya secara global lalu memakainya dari worker thread adalah korupsi state. Yang boleh di-cache ialah JavaVM*. Setiap thread native harus AttachCurrentThread untuk memperoleh JNIEnv* sendiri, melakukan pekerjaan JNI, lalu DetachCurrentThread sebelum keluar. Thread native juga sebaiknya tidak memanggil FindClass untuk class aplikasi; tanpa Java frame, JVM sering memakai system class loader. Class aplikasi sebaiknya dicari di JNI_OnLoad, lalu disimpan sebagai global reference.

Disiplin exception dan alat pemeriksa menjadi pagar terakhir. Jika Java method yang dipanggil dari native melempar exception, exception itu hanya ditandai pending; stack C++ tidak otomatis unwind. Melanjutkan sebagian besar pemanggilan JNI saat exception pending adalah undefined behavior. Karena itu, setelah CallObjectMethod, CallVoidMethod, GetMethodID, atau operasi lain yang bisa melempar, native code harus memanggil ExceptionCheck dan segera kembali ke Java, atau ExceptionClear jika benar-benar menangani error sendiri. Perbandingan referensi juga harus memakai IsSameObject, bukan operator ==, karena dua handle berbeda dapat menunjuk objek Java yang sama. Untuk menemukan kesalahan lebih dini, Android menyediakan CheckJNI, aktif pada aplikasi debuggable dan emulator, atau dapat dipaksa dengan adb shell setprop debug.checkjni 1 lalu restart aplikasi. Di desktop JVM, gunakan -Xcheck:jni. Mode ini memvalidasi stale reference, JNIEnv salah thread, pending exception, mismatched release, dan ID tidak valid. Di C++, RAII wrapper seperti ScopedLocalRef dan ScopedGlobalRef membantu melepas referensi otomatis pada akhir scope, termasuk saat early return. Intinya, pahami kapan slot referensi dibebaskan: lokal saat native method kembali, global saat DeleteGlobalRef, weak global saat DeleteWeakGlobalRef, dengan objek weak bisa hilang kapan saja. Dari aturan kecil itulah sebagian besar crash JNI bisa dicegah.