Tips untuk JNI

JNI adalah singkatan dari Java Native Interface. Antarmuka ini menentukan cara bagi bytecode yang dikompilasi Android dari kode terkelola (ditulis dalam bahasa pemrograman Java atau Kotlin) untuk berinteraksi dengan kode native (yang ditulis dalam C/C++). JNI tidak dibatasi untuk vendor tertentu, memiliki dukungan untuk memuat kode dari library bersama dinamis, dan cukup efisien walaupun terkadang rumit.

Catatan: Karena Android mengompilasi Kotlin ke bytecode yang sesuai untuk ART dengan cara yang mirip dengan bahasa pemrograman Java, Anda dapat menerapkan panduan di halaman ini pada bahasa pemrograman Kotlin dan Java dalam hal arsitektur JNI dan konsekuensi yang terkait. Untuk mempelajari lebih lanjut, lihat Kotlin dan Android.

Jika Anda belum terbiasa dengan JNI, baca Spesifikasi Java Native Interface untuk mengetahui cara kerjanya dan fitur-fitur yang tersedia. Beberapa aspek antarmuka ini mungkin tidak dapat langsung dipahami saat pertama kali dibaca. Jadi, beberapa bagian selanjutnya mungkin akan berguna bagi Anda.

Untuk mempelajari referensi JNI global serta melihat tempatnya dibuat dan dihapus, gunakan tampilan heap JNI pada Memory Profiler di Android Studio 3.2 dan yang lebih tinggi.

Tips Umum

Cobalah untuk meminimalkan jejak lapisan JNI Anda. Ada beberapa dimensi yang perlu dipertimbangkan di sini. Solusi JNI Anda harus mencoba mengikuti panduan berikut (yang dicantumkan di bawah sesuai urutan tingkat kepentingannya, mulai dari yang paling penting):

  • Minimalkan marshalling resource di seluruh lapisan JNI. Marshalling di seluruh lapisan JNI memerlukan biaya besar. Cobalah untuk mendesain antarmuka yang meminimalkan jumlah data yang perlu Anda marshall dan frekuensi marshalling data yang harus dilakukan.
  • Hindari komunikasi asinkron antara kode yang ditulis dalam bahasa pemrograman terkelola dan kode yang ditulis dalam C++ jika memungkinkan. Ini akan memudahkan pemeliharaan antarmuka JNI. Anda biasanya dapat menyederhanakan update UI asinkron dengan menyamakan bahasa update asinkron dan bahasa UI. Misalnya, alih-alih memanggil fungsi C++ dari UI thread dalam kode Java melalui JNI, sebaiknya lakukan callback antara dua thread dalam bahasa pemrograman Java, dengan salah satu thread yang melakukan panggilan C++ pemblokiran lalu memberi tahu UI thread saat panggilan pemblokiran tersebut selesai.
  • Minimalkan jumlah thread yang perlu menyentuh atau disentuh oleh JNI. Jika Anda memang perlu menggunakan kumpulan thread dalam bahasa Java dan C++, cobalah untuk mempertahankan komunikasi JNI antara pemilik kumpulan, bukan antara masing-masing thread pekerja.
  • Simpan kode antarmuka Anda di sejumlah kecil lokasi sumber C++ dan Java yang mudah diidentifikasi untuk memudahkan pemfaktoran ulang di masa mendatang. Pertimbangkan untuk menggunakan library pembuatan otomatis JNI sesuai kebutuhan.

JavaVM dan JNIEnv

JNI menentukan dua struktur data utama, "JavaVM" dan "JNIEnv". Kedua struktur ini pada dasarnya merupakan pointer ke pointer tabel fungsi. (Dalam versi C++, keduanya adalah class dengan pointer ke tabel fungsi dan fungsi anggota untuk setiap fungsi JNI yang mengarah melalui tabel tersebut.) JavaVM menyediakan fungsi "antarmuka pemanggilan", yang memungkinkan Anda membuat dan merusak JavaVM. Secara teori, Anda dapat memiliki beberapa JavaVM per proses, tetapi Android hanya mengizinkan satu JavaVM.

JNIEnv menyediakan sebagian besar fungsi JNI. Semua fungsi native Anda menerima JNIEnv sebagai argumen pertama, kecuali untuk metode @CriticalNative, melihat panggilan native yang lebih cepat.

JNIEnv digunakan untuk penyimpanan lokal thread. Karena alasan ini, Anda tidak dapat membagikan JNIEnv antar-thread. Jika tidak ada cara lain bagi suatu kode untuk mendapatkan JNIEnv miliknya, Anda harus berbagi JavaVM, lalu menggunakan GetEnv untuk menemukan JNIEnv thread. (Dengan asumsi kode tersebut memilikinya; lihat AttachCurrentThread di bawah.)

Deklarasi C untuk JNIEnv dan JavaVM berbeda dengan deklarasi C++. File penyertaan "jni.h" menyediakan berbagai typedef, bergantung apakah file tersebut disertakan dalam C atau C++. Oleh karena itu, sebaiknya jangan sertakan argumen JNIEnv dalam file header yang disertakan oleh kedua bahasa tersebut. (Dengan kata lain: jika file header Anda memerlukan #ifdef __cplusplus, Anda mungkin harus melakukan beberapa pekerjaan tambahan apabila ada sesuatu dalam header tersebut yang mengacu pada JNIEnv.)

Thread

Semua thread adalah thread Linux, yang dijadwalkan oleh kernel. Thread tersebut biasanya dimulai dari kode terkelola (menggunakan Thread.start()), tetapi juga dapat dibuat di tempat lain lalu ditambahkan ke JavaVM. Misalnya, thread yang dimulai dengan pthread_create() atau std::thread dapat ditambahkan menggunakan fungsi AttachCurrentThread() atau AttachCurrentThreadAsDaemon(). Sebelum ditambahkan, thread tidak akan memiliki JNIEnv, dan tidak dapat melakukan panggilan JNI.

Sebaiknya gunakan Thread.start() untuk membuat thread apa pun yang perlu dipanggil ke kode Java. Cara ini akan memastikan Anda memiliki ruang stack yang cukup, menggunakan ThreadGroup yang benar, dan menggunakan ClassLoader yang sama dengan kode Java Anda. Selain itu, akan lebih mudah untuk menetapkan nama thread guna melakukan proses debug di Java dibandingkan dengan menetapkan dari kode native (lihat pthread_setname_np() jika Anda memiliki pthread_t atau thread_t, dan std::thread::native_handle() jika Anda memiliki std::thread dan menginginkan pthread_t).

Menambahkan thread yang dibuat secara native menyebabkan objek java.lang.Thread dibuat dan ditambahkan ke ThreadGroup "utama", sehingga membuatnya terlihat oleh debugger. Memanggil AttachCurrentThread() pada thread yang sudah ditambahkan merupakan tindakan tanpa pengoperasian.

Android tidak menangguhkan thread yang mengeksekusi kode native. Jika pembersihan sampah memori sedang berlangsung, atau debugger mengeluarkan permintaan penangguhan, Android akan menjeda thread saat berikutnya melakukan panggilan JNI.

Thread yang ditambahkan melalui JNI harus memanggil DetachCurrentThread() sebelum keluar. Jika coding objek ini secara langsung dianggap tidak memungkinkan, Anda dapat menggunakan pthread_key_create() di Android 2.0 (Eclair) dan yang lebih tinggi untuk menentukan fungsi destruktor yang akan dipanggil sebelum thread keluar, lalu memanggil DetachCurrentThread() dari sana. (Gunakan kunci tersebut dengan pthread_setspecific() untuk menyimpan JNIEnv dalam penyimpanan lokal thread; dengan demikian, kunci akan diteruskan ke destruktor Anda sebagai argumen.)

jclass, jmethodID, dan jfieldID

Jika ingin mengakses kolom objek dari kode native, Anda perlu melakukan tindakan berikut:

  • Mendapatkan referensi objek class untuk class dengan FindClass
  • Mendapatkan ID kolom untuk kolom dengan GetFieldID
  • Mendapatkan konten kolom dengan sesuatu yang sesuai, seperti GetIntField

Demikian pula, untuk memanggil metode, Anda perlu mendapatkan referensi objek class terlebih dahulu, lalu ID metode. ID tersebut terkadang hanyalah pointer ke struktur data runtime internal. Pencarian ID mungkin memerlukan beberapa pembandingan string, tetapi begitu ditemukan, panggilan sebenarnya untuk mendapatkan kolom atau memanggil metode dapat dilakukan dengan sangat cepat.

Jika performa menjadi pertimbangan penting, sebaiknya cari nilai tersebut sekali lagi lalu cache hasilnya dalam kode native Anda. Karena ada batas satu JavaVM per proses, maka wajar untuk menyimpan data ini dalam struktur lokal statis.

Referensi class, ID kolom, dan ID metode dijamin valid hingga class diurai. Class hanya akan diurai jika semua class yang terkait dengan ClassLoader dapat dibersihkan sampah memorinya, yang jarang terjadi tetapi tidak mustahil di Android. Namun, perlu diketahui bahwa jclass adalah referensi class dan harus dilindungi dengan panggilan ke NewGlobalRef (lihat bagian berikutnya).

Jika Anda ingin meng-cache ID saat sebuah class dimuat, lalu meng-cache ulang secara otomatis jika class diurai dan dimuat ulang, cara yang benar untuk menginisialisasi ID adalah menambahkan kode seperti berikut ke class yang sesuai:

Kotlin

companion object {
    /*
     * We use a static class initializer to allow the native code to cache some
     * field offsets. This native function looks up and caches interesting
     * class/field/method IDs. Throws on failure.
     */
    private external fun nativeInit()

    init {
        nativeInit()
    }
}

Java

    /*
     * We use a class initializer to allow the native code to cache some
     * field offsets. This native function looks up and caches interesting
     * class/field/method IDs. Throws on failure.
     */
    private static native void nativeInit();

    static {
        nativeInit();
    }

Buat metode nativeClassInit dalam kode C/C++ yang melakukan pencarian ID. Kode akan dieksekusi satu kali, saat class diinisialisasi. Jika class diurai lalu dimuat ulang, maka kode tersebut akan dieksekusi lagi.

Referensi lokal dan global

Setiap argumen yang diteruskan ke metode native, dan hampir setiap objek yang ditampilkan oleh fungsi JNI, merupakan "referensi lokal". Artinya, argumen tersebut hanya valid selama durasi metode native saat ini di thread saat ini. Meskipun objek tersebut tetap aktif setelah metode native ditampilkan, referensinya tidak valid.

Hal ini berlaku untuk semua subclass jobject, termasuk jclass, jstring, dan jarray. (Runtime akan memperingatkan Anda tentang sebagian besar kesalahan penggunaan referensi jika pemeriksaan JNI yang diperluas diaktifkan.)

Satu-satunya cara untuk mendapatkan referensi non-lokal adalah melalui fungsi NewGlobalRef dan NewWeakGlobalRef.

Jika ingin mempertahankan referensi untuk waktu yang lama, Anda harus menggunakan referensi "global". Fungsi NewGlobalRef menggunakan referensi lokal sebagai argumen dan menampilkan referensi global. Referensi global akan dijamin valid hingga Anda memanggil DeleteGlobalRef.

Pola ini biasanya digunakan saat meng-cache jclass, yang ditampilkan dari FindClass, misalnya:

jclass localClass = env->FindClass("MyClass");
jclass globalClass = reinterpret_cast<jclass>(env->NewGlobalRef(localClass));

Semua metode JNI menerima referensi lokal dan global sebagai argumen. Referensi ke objek yang sama mungkin saja memiliki nilai yang berbeda. Misalnya, panggilan yang berurutan ke NewGlobalRef pada objek yang sama mungkin akan menampilkan nilai hasil yang berbeda. Untuk melihat apakah dua referensi mengacu pada objek yang sama, Anda harus menggunakan fungsi IsSameObject. Jangan sekali-kali membandingkan referensi dengan == dalam kode native.

Salah satu konsekuensi dari hal ini adalah Anda tidak boleh menganggap referensi objek bersifat konstan atau unik dalam kode native. Nilai yang mewakili sebuah objek mungkin berbeda dari satu pemanggilan metode ke pemanggilan berikutnya, dan ada kemungkinan dua objek yang berbeda dapat memiliki nilai yang sama pada panggilan berturut-turut. Jangan gunakan Nilai jobject sebagai kunci.

Programmer dianjurkan untuk "tidak membuat alokasi berlebihan" atas referensi lokal. Dalam praktiknya, hal ini berarti bahwa jika Anda membuat banyak referensi lokal, mungkin saat menjalankan array objek, Anda harus menghapusnya secara manual dengan DeleteLocalRef, bukan membiarkan JNI melakukannya. Implementasi tersebut hanya diperlukan untuk menyiapkan slot bagi 16 referensi lokal, sehingga jika memerlukan lebih banyak, Anda harus menghapusnya dalam proses atau menggunakan EnsureLocalCapacity/PushLocalFrame untuk menyiapkan lebih banyak slot.

Perlu diperhatikan bahwa jfieldID dan jmethodID adalah jenis buram, bukan referensi objek, dan tidak boleh diteruskan ke NewGlobalRef. Pointer data mentah yang ditampilkan oleh fungsi seperti GetStringUTFChars dan GetByteArrayElements juga bukan merupakan objek. (Pointer tersebut dapat diteruskan antar-thread, dan valid hingga Release yang cocok melakukan panggilan.)

Ada satu kasus tidak wajar yang perlu disebutkan khusus di sini. Jika Anda menambahkan thread native dengan AttachCurrentThread, kode yang Anda jalankan tidak akan menghapus referensi lokal secara otomatis hingga thread dilepas. Setiap referensi lokal yang Anda buat harus dihapus secara manual. Secara umum, setiap kode native yang menghasilkan referensi lokal dalam sebuah loop kemungkinan perlu melakukan penghapusan manual.

Harap berhati-hati saat menggunakan referensi global. Referensi global tidak dapat dihindari, tetapi sulit di-debug dan dapat menyebabkan (salah) perilaku memori yang sulit didiagnosis. Dengan hal-hal lainnya tetap sama, solusi dengan lebih sedikit referensi global mungkin akan lebih baik.

String UTF-8 dan UTF-16

Bahasa pemrograman Java menggunakan UTF-16. Agar praktis, JNI menyediakan metode yang juga berfungsi dengan UTF-8 Modifikasi. Encoding yang dimodifikasi ini berguna untuk kode C karena mengenkode \u0000 sebagai 0xc0 0x80, bukan 0x00. Sisi positifnya, Anda akan mendapatkan string berakhiran nol bergaya C, yang cocok untuk digunakan dengan fungsi string libc standar. Sisi negatifnya, Anda tidak dapat meneruskan data UTF-8 arbitrer ke JNI dan berharap data tersebut akan berfungsi dengan benar.

Untuk mendapatkan representasi UTF-16 dari String, gunakan GetStringChars. Perlu diperhatikan bahwa string UTF-16 tidak diakhiri dengan nol, dan \u0000 diizinkan, jadi Anda perlu berpegang pada panjang {i>string<i} serta {i>jchar pointer<i}.

Jangan lupa untuk Release (melepas) string yang Anda Get (dapatkan). Fungsi string menampilkan jchar* atau jbyte*, yang merupakan pointer bergaya C ke data primitif, bukan referensi lokal. String tersebut akan dijamin valid hingga Release dipanggil yang berarti string tersebut tidak akan dilepas saat metode native ditampilkan.

Data yang diteruskan ke NewStringUTF harus berformat UTF-8 Modifikasi. Kesalahan yang umum terjadi adalah membaca data karakter dari file atau aliran jaringan dan menyerahkannya ke NewStringUTF tanpa memfilternya. Kecuali Anda yakin bahwa data tersebut adalah MUTF-8 yang valid (atau ASCII 7 bit, yang merupakan subset yang kompatibel), Anda harus menghapus karakter yang tidak valid atau mengonversinya menjadi format UTF-8 Modifikasi yang tepat. Jika tidak, konversi UTF-16 kemungkinan akan memberikan hasil yang tidak terduga. CheckJNI—yang aktif secara default untuk emulator—memindai string dan membatalkan VM jika input yang diterima tidak valid.

Sebelum Android 8, biasanya lebih cepat untuk beroperasi dengan string UTF-16 karena Android tidak memerlukan salinan di GetStringChars, sedangkan GetStringUTFChars memerlukan alokasi dan konversi ke UTF-8. Android 8 mengubah representasi String sehingga menggunakan 8 bit per karakter untuk string ASCII (untuk menghemat memori) dan mulai menggunakan bergerak pembersih sampah memori. Fitur-fitur ini sangat mengurangi jumlah kasus di mana ART dapat memberikan pointer ke data String tanpa membuat salinan, bahkan untuk GetStringCritical. Namun, jika sebagian besar string yang diproses oleh kode yang singkat, alokasi dan dealokasi dapat dihindari dalam banyak kasus dengan menggunakan buffer dengan alokasi stack dan GetStringRegion atau GetStringUTFRegion. Contoh:

    constexpr size_t kStackBufferSize = 64u;
    jchar stack_buffer[kStackBufferSize];
    std::unique_ptr<jchar[]> heap_buffer;
    jchar* buffer = stack_buffer;
    jsize length = env->GetStringLength(str);
    if (length > kStackBufferSize) {
      heap_buffer.reset(new jchar[length]);
      buffer = heap_buffer.get();
    }
    env->GetStringRegion(str, 0, length, buffer);
    process_data(buffer, length);

Array dasar

JNI menyediakan fungsi untuk mengakses konten objek array. Sementara array objek harus diakses satu per satu, array dasar dapat dibaca dan ditulis secara langsung seolah-olah dideklarasikan di C.

Agar antarmuka tetap efisien tanpa membatasi implementasi VM, kumpulan panggilan Get<PrimitiveType>ArrayElements memungkinkan runtime menampilkan pointer ke elemen sebenarnya, atau mengalokasikan beberapa memori dan membuat salinan. Apa pun itu, pointer mentah yang ditampilkan akan dijamin valid hingga panggilan Release yang sesuai dikeluarkan (yang menyiratkan bahwa, jika data tidak disalin, objek array akan disematkan dan tidak dapat dialokasikan ulang sebagai bagian dari proses pemadatan heap). Anda harus Release (melepas) setiap array yang Anda Get (dapatkan). Selain itu, jika panggilan Get gagal, Anda harus memastikan kode Anda nantinya tidak mencoba Release (melepas) pointer NULL.

Anda dapat menentukan apakah data disalin atau tidak dengan meneruskan pointer non-NULL untuk argumen isCopy. Tindakan ini jarang berguna.

Panggilan Release menggunakan argumen mode yang dapat memiliki satu dari tiga nilai. Tindakan yang dijalankan oleh runtime bergantung pada apakah panggilan tersebut menampilkan pointer ke data sebenarnya atau salinannya:

  • 0
    • Sebenarnya: objek array tidak disematkan.
    • Salinan: data disalin kembali. Buffer dengan salinan dibebaskan.
  • JNI_COMMIT
    • Sebenarnya: tidak berfungsi apa pun.
    • Salinan: data disalin kembali. Buffer dengan salinan tidak dibebaskan.
  • JNI_ABORT
    • Sebenarnya: objek array tidak disematkan. Penulisan sebelumnya tidak dibatalkan.
    • Salinan: buffer dengan salinan dibebaskan; setiap perubahan padanya akan hilang.

Salah satu alasan untuk memeriksa flag isCopy adalah guna mengetahui apakah Anda perlu memanggil Release dengan JNI_COMMIT setelah melakukan perubahan pada array; jika Anda berpindah-pindah antara melakukan perubahan dan mengeksekusi kode yang menggunakan konten array, Anda mungkin dapat melewatkan commit tanpa pengoperasian. Alasan memungkinkan lainnya untuk memeriksa flag tersebut adalah untuk menangani JNI_ABORT secara efisien. Misalnya, Anda mungkin ingin mendapatkan suatu array, memodifikasinya di tempat, meneruskan beberapa bagian ke fungsi lain, lalu menghapus perubahannya. Jika mengetahui bahwa JNI membuat salinan baru untuk Anda, Anda tidak perlu membuat salinan yang "dapat diedit" lagi. Jika JNI memberikan yang asli, Anda harus membuat salinan sendiri.

Salah satu kesalahan umum (yang diulang dalam contoh kode) adalah berasumsi bahwa Anda dapat melewati panggilan Release jika *isCopy bernilai salah. Bukan itu masalahnya. Jika tidak ada buffer salinan yang dialokasikan, memori asli harus dipasangi pin dan tidak boleh dipindahkan oleh pembersih sampah memori.

Perlu diperhatikan juga bahwa flag JNI_COMMIT tidak melepas array, dan pada akhirnya Anda harus memanggil Release lagi dengan flag lain.

Panggilan region

Terdapat alternatif untuk panggilan seperti Get<Type>ArrayElements dan GetStringChars yang mungkin akan sangat membantu saat Anda hanya ingin menyalin data ke dalam atau ke luar. Pertimbangkan hal berikut:

    jbyte* data = env->GetByteArrayElements(array, NULL);
    if (data != NULL) {
        memcpy(buffer, data, len);
        env->ReleaseByteArrayElements(array, data, JNI_ABORT);
    }

Tindakan ini mengambil array, menyalin elemen byte len pertama dari array, lalu melepas array. Bergantung pada implementasinya, panggilan Get akan mengunci atau menyalin konten array. Kode tersebut menyalin data (mungkin untuk kedua kalinya), lalu memanggil Release; dalam hal ini, JNI_ABORT memastikan tidak akan ada salinan ketiga.

Anda dapat memperoleh hasil yang sama dengan lebih sederhana:

    env->GetByteArrayRegion(array, 0, len, buffer);

Kode ini memiliki beberapa keunggulan:

  • Memerlukan satu panggilan JNI, bukan 2, sehingga mengurangi overhead.
  • Tidak memerlukan penyematan atau salinan data tambahan.
  • Mengurangi risiko error oleh programmer; tidak ada risiko lupa memanggil Release setelah terjadi kegagalan.

Anda juga dapat menggunakan panggilan Set<Type>ArrayRegion untuk menyalin data ke dalam array, dan GetStringRegion atau GetStringUTFRegion untuk menyalin karakter dari String.

Pengecualian

Anda tidak boleh memanggil sebagian besar fungsi JNI jika masih ada pengecualian yang tertunda. Kode Anda diharapkan mengetahui pengecualian tersebut (melalui nilai hasil fungsi, ExceptionCheck, atau ExceptionOccurred) dan menampilkannya, atau menghapus dan menanganinya.

Fungsi JNI yang boleh Anda panggil selagi masih ada pengecualian yang tertunda dibatasi pada:

  • DeleteGlobalRef
  • DeleteLocalRef
  • DeleteWeakGlobalRef
  • ExceptionCheck
  • ExceptionClear
  • ExceptionDescribe
  • ExceptionOccurred
  • MonitorExit
  • PopLocalFrame
  • PushLocalFrame
  • Release<PrimitiveType>ArrayElements
  • ReleasePrimitiveArrayCritical
  • ReleaseStringChars
  • ReleaseStringCritical
  • ReleaseStringUTFChars

Banyak panggilan JNI dapat menampilkan pengecualian, tetapi sering kali menyediakan cara yang lebih mudah untuk memeriksa kegagalan. Misalnya, jika NewString menampilkan nilai non-NULL, Anda tidak perlu memeriksa keberadaan pengecualian. Namun, jika Anda memanggil suatu metode (menggunakan fungsi seperti CallObjectMethod), Anda harus selalu memeriksa keberadaan pengecualian, karena nilai hasil tidak akan valid jika ada pengecualian yang ditampilkan.

Perlu diketahui bahwa pengecualian yang ditampilkan oleh kode terkelola tidak melepaskan stack native {i>frame<i}. (Dan pengecualian C++, umumnya tidak disarankan di Android, tidak boleh ditampilkan melintasi batas transisi JNI dari kode C++ ke kode terkelola.) Petunjuk JNI Throw dan ThrowNew hanya menetapkan pointer pengecualian di thread saat ini. Setelah ditampilkan dari kode native ke kode terkelola, pengecualian akan dicatat dan ditangani dengan tepat.

Kode native dapat "menangkap" pengecualian dengan memanggil ExceptionCheck atau ExceptionOccurred, dan menghapusnya dengan ExceptionClear. Seperti biasa, menghapus pengecualian tanpa menanganinya dapat menyebabkan masalah.

Tidak ada fungsi bawaan untuk memanipulasi objek Throwable sendiri, sehingga jika ingin (katakanlah) mendapatkan string pengecualian, Anda harus menemukan class Throwable, mencari ID metode untuk getMessage "()Ljava/lang/String;", memanggilnya, dan jika hasilnya adalah non-NULL, gunakan GetStringUTFChars untuk mendapatkan sesuatu yang dapat Anda berikan ke printf(3) atau yang setara.

Pemeriksaan diperluas

JNI tidak banyak melakukan pengecekan error. Error biasanya menghasilkan crash. Android juga menawarkan sebuah mode yang disebut CheckJNI, di mana pointer tabel fungsi JavaVM dan JNIEnv dialihkan ke tabel fungsi yang menjalankan serangkaian pemeriksaan tambahan sebelum memanggil implementasi standar.

Pemeriksaan tambahan meliputi:

  • Array: mencoba mengalokasikan array berukuran negatif.
  • Pointer bermasalah: meneruskan jarray/jclass/jobject/jstring bermasalah ke panggilan JNI, atau meneruskan pointer NULL ke panggilan JNI dengan argumen yang non-nullable.
  • Nama class: meneruskan apa saja selain nama class bergaya "java/lang/String" ke panggilan JNI.
  • Panggilan penting: melakukan panggilan JNI antara get "penting" dan release yang terkait.
  • ByteBuffers Langsung: meneruskan argumen buruk ke NewDirectByteBuffer.
  • Pengecualian: melakukan panggilan JNI saat masih ada pengecualian yang tertunda.
  • JNIEnv*: menggunakan JNIEnv* dari thread yang salah.
  • jfieldID: menggunakan jfieldID NULL, atau menggunakan jfieldID untuk menetapkan kolom ke sebuah nilai yang jenisnya salah (misalnya, mencoba menetapkan StringBuilder ke kolom String), atau menggunakan jfieldID kolom statis untuk menetapkan kolom instance atau sebaliknya, atau menggunakan jfieldID dari satu class dengan instance dari class lain.
  • jmethodID: menggunakan jenis jmethodID yang salah saat melakukan panggilan JNI Call*Method: jenis nilai yang ditampilkan tidak tepat, ketidaksesuaian statis/non-statis, jenis yang salah untuk 'ini' (untuk panggilan nonstatis), atau class yang salah (untuk panggilan statis).
  • Referensi: menggunakan DeleteGlobalRef/DeleteLocalRef pada jenis referensi yang salah.
  • Mode release: meneruskan mode release yang buruk ke panggilan release (selain 0, JNI_ABORT, atau JNI_COMMIT).
  • Keamanan jenis: menampilkan jenis yang tidak kompatibel dari metode native (misalnya, menampilkan StringBuilder dari metode yang dinyatakan untuk menampilkan String).
  • UTF-8: meneruskan urutan byte UTF-8 Modifikasi yang tidak valid ke panggilan JNI.

(Aksesibilitas metode dan kolom masih belum diperiksa: pembatasan akses tidak berlaku untuk kode native.)

Ada beberapa cara untuk mengaktifkan CheckJNI.

Jika Anda menggunakan emulator, CheckJNI aktif secara default.

Jika menggunakan perangkat yang telah di-root, Anda dapat menggunakan urutan perintah berikut untuk memulai ulang runtime dengan CheckJNI aktif:

adb shell stop
adb shell setprop dalvik.vm.checkjni true
adb shell start

Pada kasus mana pun, Anda akan melihat kode seperti ini dalam output logcat saat runtime dimulai:

D AndroidRuntime: CheckJNI is ON

Jika menggunakan perangkat reguler, Anda dapat menggunakan perintah berikut:

adb shell setprop debug.checkjni 1

Hal ini tidak akan memengaruhi aplikasi yang telah berjalan, tetapi aplikasi apa pun yang diluncurkan sejak saat itu akan memiliki CheckJNI aktif. (Ubah properti ini ke nilai lain apa pun, atau reboot untuk menonaktifkan lagi CheckJNI.) Dalam hal ini, Anda akan melihat kode seperti ini dalam output logcat saat berikutnya aplikasi dimulai:

D Late-enabling CheckJNI

Anda juga dapat menyetel atribut android:debuggable dalam manifes aplikasi Anda ke mengaktifkan CheckJNI hanya untuk aplikasi Anda. Perhatikan bahwa alat {i>build<i} Android akan melakukan ini secara otomatis untuk jenis build tertentu.

Library native

Anda dapat memuat kode native dari library bersama dengan System.loadLibrary standar.

Dalam praktiknya, Android versi lama memiliki bug di PackageManager yang menyebabkan penginstalan dan update library native tidak dapat diandalkan. Project ReLinker menawarkan solusi untuk hal ini dan masalah pemuatan library native lainnya.

Panggil System.loadLibrary (atau ReLinker.loadLibrary) dari penginisialisasi class statis. Argumennya adalah nama library yang "tanpa dekorasi", sehingga untuk memuat libfubar.so, Anda perlu meneruskan "fubar".

Jika hanya memiliki satu class dengan metode native, cukup masuk akal bahwa panggilan ke System.loadLibrary akan berada pada penginisialisasi statis untuk class tersebut. Jika tidak, Anda mungkin ingin melakukan panggilan dari Application untuk memastikan library selalu dimuat, dan selalu dimuat lebih awal.

Runtime dapat menemukan metode native Anda melalui dua cara. Anda dapat mendaftarkannya secara eksplisit dengan RegisterNatives, atau membiarkan runtime mencarinya secara dinamis dengan dlsym. Keuntungan menggunakan RegisterNatives adalah Anda dapat memeriksa di awal bahwa simbol tersebut ada, serta dapat memiliki library bersama yang berukuran lebih kecil dan lebih cepat tanpa mengekspor apa pun selain JNI_OnLoad. Kelebihan membiarkan runtime menemukan fungsi Anda adalah kode yang ditulis akan sedikit berkurang.

Untuk menggunakan RegisterNatives:

  • Berikan fungsi JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved).
  • Di JNI_OnLoad, daftarkan semua metode native menggunakan RegisterNatives.
  • Buat dengan -fvisibility=hidden sehingga hanya JNI_OnLoad yang diekspor dari library Anda. Ini menghasilkan kode yang lebih cepat dan lebih kecil, serta menghindari potensi bentrok dengan library lain yang dimuat ke dalam aplikasi Anda (tetapi menimbulkan stack trace yang kurang berguna jika aplikasi Anda mengalami error pada kode native).

Penginisialisasi statis terlihat seperti ini:

Kotlin

companion object {
    init {
        System.loadLibrary("fubar")
    }
}

Java

static {
    System.loadLibrary("fubar");
}

Fungsi JNI_OnLoad akan terlihat seperti ini jika ditulis dalam C++:

JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
    JNIEnv* env;
    if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
        return JNI_ERR;
    }

    // Find your class. JNI_OnLoad is called from the correct class loader context for this to work.
    jclass c = env->FindClass("com/example/app/package/MyClass");
    if (c == nullptr) return JNI_ERR;

    // Register your class' native methods.
    static const JNINativeMethod methods[] = {
        {"nativeFoo", "()V", reinterpret_cast<void*>(nativeFoo)},
        {"nativeBar", "(Ljava/lang/String;I)Z", reinterpret_cast<void*>(nativeBar)},
    };
    int rc = env->RegisterNatives(c, methods, sizeof(methods)/sizeof(JNINativeMethod));
    if (rc != JNI_OK) return rc;

    return JNI_VERSION_1_6;
}

Untuk menggunakan "penemuan" metode native saja, Anda harus menamainya dengan cara tertentu (lihat spesifikasi JNI untuk mengetahui detailnya). Artinya, jika tanda tangan metode salah, Anda tidak akan mengetahuinya sampai metode tersebut benar-benar dipanggil untuk pertama kali.

Semua panggilan FindClass yang dilakukan dari JNI_OnLoad akan menyelesaikan class sesuai konteks loader class yang digunakan untuk memuat library bersama. Saat dipanggil dari konteks lain, FindClass akan menggunakan loader class yang terkait dengan metode di bagian atas stack Java, atau jika tidak ada (karena panggilan berasal dari thread native yang baru ditambahkan), loader class "sistem" akan digunakan. Loader class sistem tidak mengetahui class aplikasi sehingga Anda tidak dapat mencari class Anda sendiri dengan FindClass dalam konteks tersebut. Hal ini menjadikan JNI_OnLoad sebagai tempat yang praktis untuk mencari dan meng-cache class: sekali Anda memiliki referensi global jclass yang valid Anda dapat menggunakannya dari utas mana pun yang terpasang.

Panggilan native yang lebih cepat dengan @FastNative dan @CriticalNative

Metode native dapat dianotasi dengan @FastNative atau @CriticalNative (tetapi tidak keduanya) untuk mempercepat transisi antara kode native dan kode terkelola. Namun, anotasi ini disertai dengan perubahan perilaku tertentu yang perlu dipertimbangkan dengan cermat sebelum digunakan. Meskipun kita menyebutkan perubahan ini secara singkat di bawah, lihat dokumentasi untuk mengetahui detailnya.

Anotasi @CriticalNative hanya dapat diterapkan ke metode native yang tidak menggunakan objek terkelola (dalam parameter atau nilai yang ditampilkan, atau sebagai this implisit), dan tindakan ini mengubah ABI transisi JNI. Implementasi native harus mengecualikan Parameter JNIEnv dan jclass dari tanda tangan fungsinya.

Saat menjalankan metode @FastNative atau @CriticalNative, sampah memori koleksi tidak dapat menangguhkan thread untuk pekerjaan penting dan dapat diblokir. Jangan gunakan ini untuk metode yang berjalan lama, termasuk metode yang biasanya cepat, tetapi umumnya tidak terbatas. Secara khusus, kode tidak boleh melakukan operasi I/O yang signifikan atau memperoleh kunci native yang dapat disimpan untuk waktu yang lama.

Anotasi ini diimplementasikan untuk penggunaan sistem karena Android 8 dan menjadi publik dengan uji CTS API di Android 14. Pengoptimalan ini kemungkinan juga akan berfungsi pada perangkat Android 8-13 (meskipun tanpa jaminan CTS yang kuat), namun pencarian dinamis metode native hanya didukung di Android 12+, pendaftaran eksplisit dengan RegisterNatives JNI diwajibkan untuk berjalan di Android versi 8-11. Anotasi ini diabaikan di Android 7-, ketidakcocokan ABI untuk @CriticalNative akan menyebabkan marshalling argumen yang salah dan kemungkinan error.

Untuk metode penting bagi performa yang memerlukan anotasi ini, sebaiknya mendaftarkan metode secara eksplisit dengan RegisterNatives JNI, bukan mengandalkan "penemuan" berbasis nama metode native. Untuk mendapatkan performa startup aplikasi yang optimal, sebaiknya untuk menyertakan pemanggil metode @FastNative atau @CriticalNative dalam profil dasar pengukuran. Sejak Android 12, panggilan ke metode native @CriticalNative dari metode terkelola yang dikompilasi hampir sama lebih murah daripada panggilan non-inline dalam C/C++ selama semua argumen sesuai dengan register (misalnya hingga 8 integral dan hingga 8 argumen floating point di arm64).

Terkadang lebih baik untuk membagi metode native menjadi dua, metode yang sangat cepat yang dapat gagal dan satu lagi yang menangani kasus lambat. Contoh:

Kotlin

fun writeInt(nativeHandle: Long, value: Int) {
    // A fast buffered write with a `@CriticalNative` method should succeed most of the time.
    if (!nativeTryBufferedWriteInt(nativeHandle, value)) {
        // If the buffered write failed, we need to use the slow path that can perform
        // significant I/O and can even throw an `IOException`.
        nativeWriteInt(nativeHandle, value)
    }
}

@CriticalNative
external fun nativeTryBufferedWriteInt(nativeHandle: Long, value: Int): Boolean

external fun nativeWriteInt(nativeHandle: Long, value: Int)

Java

void writeInt(long nativeHandle, int value) {
    // A fast buffered write with a `@CriticalNative` method should succeed most of the time.
    if (!nativeTryBufferedWriteInt(nativeHandle, value)) {
        // If the buffered write failed, we need to use the slow path that can perform
        // significant I/O and can even throw an `IOException`.
        nativeWriteInt(nativeHandle, value);
    }
}

@CriticalNative
static native boolean nativeTryBufferedWriteInt(long nativeHandle, int value);

static native void nativeWriteInt(long nativeHandle, int value);

Pertimbangan 64 bit

Untuk mendukung arsitektur yang menggunakan pointer 64 bit, gunakan kolom long, bukan int, saat menyimpan pointer ke struktur native di kolom Java.

Fitur yang tidak didukung/kompatibilitas mundur

Semua fitur JNI 1.6 didukung, dengan pengecualian berikut:

  • DefineClass tidak diimplementasikan. Android tidak menggunakan file class atau bytecode Java, sehingga meneruskan data class biner tidak akan memberikan hasil.

Untuk kompatibilitas mundur dengan rilis Android yang lebih lama, berikut ini beberapa hal yang mungkin perlu Anda ketahui:

  • Pencarian fungsi native secara dinamis

    Hingga Android 2.0 (Eclair), karakter '$' tidak dikonversi dengan benar menjadi "_00024" selama penelusuran nama metode. Untuk mengatasi hal ini, diperlukan pendaftaran eksplisit atau pemindahan metode native keluar dari class dalam.

  • Melepaskan thread

    Hingga Android 2.0 (Eclair), Anda tidak dapat menggunakan fungsi destruktor pthread_key_create untuk menghindari pemeriksaan "thread harus dilepas sebelum keluar". (Runtime juga menggunakan fungsi destruktor kunci pthread, jadi akan terjadi persaingan mana yang dipanggil pertama.)

  • Referensi weak global

    Hingga Android 2.2 (Froyo), referensi weak global tidak diimplementasikan. Versi yang lebih lama akan menolak dengan keras upaya penggunaan referensi ini. Anda dapat menggunakan konstanta versi platform Android untuk menguji ketersediaan dukungan.

    Hingga Android 4.0 (Ice Cream Sandwich), referensi weak global hanya dapat diteruskan ke NewLocalRef, NewGlobalRef, dan DeleteWeakGlobalRef. (Spesifikasi ini mendorong programmer untuk membuat referensi paksa ke referensi weak global sebelum melakukan apa pun dengannya, jadi hal ini sama sekali tidak membatasi.)

    Mulai dari Android 4.0 (Ice Cream Sandwich) dan seterusnya, referensi weak global dapat digunakan seperti referensi JNI lainnya.

  • Referensi lokal

    Hingga Android 4.0 (Ice Cream Sandwich), referensi lokal yang sebenarnya adalah pointer langsung. Ice Cream Sandwich menambahkan pengalihan yang diperlukan untuk mendukung pembersih sampah memori dengan lebih baik, tetapi ini berarti banyak bug JNI yang tidak terdeteksi pada rilis lama. Lihat Perubahan Referensi Lokal JNI di ICS untuk mengetahui detail selengkapnya.

    Pada versi Android sebelum Android 8.0, jumlah referensi lokal dibatasi sesuai versi. Mulai Android 8.0, Android mendukung referensi lokal tanpa batas.

  • Menentukan jenis referensi dengan GetObjectRefType

    Hingga Android 4.0 (Ice Cream Sandwich), sebagai konsekuensi dari penggunaan pointer langsung (lihat di atas), GetObjectRefType tidak akan dapat diimplementasikan dengan benar. Sebagai gantinya, kami menggunakan heuristik yang mencari pointer tabel referensi weak global, argumen referensi weak global, tabel referensi lokal, tabel referensi global sesuai urutan tersebut. Begitu menemukan pointer langsung, heuristik akan melaporkan bahwa referensi Anda merupakan jenis yang sedang diperiksanya. Ini berarti, misalnya, jika Anda memanggil GetObjectRefType pada jclass global yang kebetulan sama dengan jclass yang diteruskan sebagai argumen implisit ke metode native statis, Anda akan mendapatkan JNILocalRefType, bukan JNIGlobalRefType.

  • @FastNative dan @CriticalNative

    Hingga Android 7, anotasi pengoptimalan ini diabaikan. ABI ketidakcocokan untuk @CriticalNative akan menyebabkan argumen yang salah marshalling dan kemungkinan terjadinya kecelakaan.

    Pencarian dinamis fungsi native untuk @FastNative dan Metode @CriticalNative tidak diterapkan di Android 8-10 dan berisi bug yang diketahui di Android 11. Menggunakan pengoptimalan ini tanpa pendaftaran eksplisit dengan JNI RegisterNatives cenderung yang menyebabkan error di Android 8-11.

  • FindClass menampilkan ClassNotFoundException

    Untuk kompatibilitas mundur, Android akan menampilkan ClassNotFoundException bukan NoClassDefFoundError jika class tidak ditemukan oleh FindClass. Perilaku ini konsisten dengan Java Refleksi API Class.forName(name).

FAQ: Mengapa saya mendapatkan UnsatisfiedLinkError?

Saat menangani kode native, kegagalan seperti ini adalah hal yang biasa:

java.lang.UnsatisfiedLinkError: Library foo not found

Dalam beberapa kasus, pesan itu berarti seperti yang ditampilkan — library tidak ditemukan. Dalam kasus lain, library ada, tetapi tidak dapat dibuka oleh dlopen(3), dan detail kegagalan dapat ditemukan dalam pesan detail pengecualian.

Beberapa alasan umum mengapa Anda menerima pengecualian "library tidak ditemukan":

  • Library tidak ada atau tidak dapat diakses oleh aplikasi. Gunakan adb shell ls -l <path> untuk memeriksa keberadaan dan izinnya.
  • Library tidak dibuat dengan NDK. Hal ini dapat menimbulkan dependensi pada fungsi atau library yang tidak ada pada perangkat.

Class kegagalan UnsatisfiedLinkError lainnya terlihat seperti berikut:

java.lang.UnsatisfiedLinkError: myfunc
        at Foo.myfunc(Native Method)
        at Foo.main(Foo.java:10)

Dalam logcat, Anda akan melihat:

W/dalvikvm(  880): No implementation found for native LFoo;.myfunc ()V

Ini berarti runtime mencoba menemukan metode yang cocok tetapi tidak berhasil. Beberapa alasan umumnya adalah:

  • Library belum dimuat. Periksa output logcat untuk menemukan pesan tentang pemuatan library.
  • Metode tidak ditemukan karena ketidakcocokan nama atau tanda tangan. Hal ini biasanya disebabkan oleh:
    • Untuk pencarian metode lambat, kegagalan untuk mendeklarasikan fungsi C++ dengan extern "C" dan visibilitas yang sesuai (JNIEXPORT). Perlu diperhatikan bahwa sebelum Ice Cream Sandwich, makro JNIEXPORT salah, sehingga penggunaan GCC baru dengan jni.h lama tidak akan berhasil. Anda dapat menggunakan arm-eabi-nm untuk melihat simbol tersebut saat muncul di library; jika terlihat tidak jelas (seperti _Z15Java_Foo_myfuncP7_JNIEnvP7_jclass, bukan Java_Foo_myfunc), atau jika jenis simbol adalah huruf 't' kecil, bukan T besar, Anda harus menyesuaikan deklarasinya.
    • Untuk pendaftaran eksplisit, error minor saat memasukkan tanda tangan metode. Pastikan apa yang Anda teruskan ke panggilan pendaftaran sesuai dengan tanda tangan dalam file log. Harap diingat bahwa 'B' adalah byte dan 'Z' adalah boolean. Komponen nama class dalam tanda tangan dimulai dengan 'L', diakhiri dengan ';', menggunakan '/' untuk memisahkan nama paket/class, dan menggunakan '$' untuk memisahkan nama class dalam (misalnya, Ljava/util/Map$Entry;).

Penggunaan javah untuk membuat header JNI secara otomatis dapat membantu Anda menghindari beberapa masalah.

FAQ: Mengapa FindClass tidak dapat menemukan class saya?

(Sebagian besar saran ini juga berlaku untuk kegagalan dalam menemukan metode dengan GetMethodID atau GetStaticMethodID, atau kolom dengan GetFieldID atau GetStaticFieldID.)

Pastikan string nama class menggunakan format yang benar. Nama class JNI diawali dengan nama paket dan dipisahkan dengan garis miring, seperti java/lang/String. Jika mencari class array, Anda harus memulai dengan jumlah kurung siku yang sesuai serta harus menggabungkan class dengan 'L' dan ';', sehingga array satu dimensi String akan menjadi [Ljava/lang/String;. Jika mencari class dalam, gunakan '$', bukan '.'. Secara umum, menggunakan javap pada file .class adalah cara yang baik untuk mengetahui nama internal class Anda.

Jika mengaktifkan penyingkatan kode, pastikan Anda mengonfigurasi kode mana yang harus dipertahankan. Penting untuk mengonfigurasi aturan yang tepat guna mempertahankan kode, karena penyingkat kode mungkin akan menghapus class, metode, atau kolom yang hanya digunakan dari JNI.

Jika nama class sudah benar, Anda mungkin mengalami masalah loader class. FindClass ingin memulai penelusuran class di loader class yang terkait dengan kode Anda. Semua stack panggilan akan diperiksa, yang akan terlihat seperti:

    Foo.myfunc(Native Method)
    Foo.main(Foo.java:10)

Metode paling atas adalah Foo.myfunc. FindClass menemukan objek ClassLoader yang terkait dengan class Foo dan menggunakannya.

Metode ini biasanya berfungsi sesuai harapan. Anda dapat mengalami masalah jika membuat thread sendiri (mungkin dengan memanggil pthread_create lalu menambahkannya dengan AttachCurrentThread). Sekarang, tidak ada frame stack dari aplikasi Anda. Jika Anda memanggil FindClass dari thread ini, JavaVM akan dimulai di loader class "sistem", bukan yang terkait dengan aplikasi Anda, sehingga upaya untuk menemukan class khusus aplikasi akan gagal.

Ada beberapa cara untuk mengatasi masalah ini:

  • Lakukan pencarian FindClass sekali, di JNI_OnLoad, lalu cache referensi class untuk digunakan nanti. Setiap panggilan FindClass yang dilakukan sebagai bagian dari eksekusi JNI_OnLoad akan menggunakan loader class yang terkait dengan fungsi yang memanggil System.loadLibrary (ini merupakan aturan khusus untuk mempermudah inisialisasi library). Jika kode aplikasi Anda memuat library, FindClass akan menggunakan loader class yang benar.
  • Teruskan instance class ke dalam fungsi yang memerlukannya, dengan mendeklarasikan metode native Anda agar menerima argumen Class, lalu meneruskan Foo.class.
  • Cache referensi ke objek ClassLoader di tempat yang mudah diakses, dan keluarkan panggilan loadClass secara langsung. Proses ini memerlukan sedikit usaha.

FAQ: Bagaimana cara berbagi data mentah dengan kode native?

Anda mungkin akan menemukan situasi saat Anda perlu mengakses buffer data mentah berukuran besar dari kode native dan kode terkelola. Contoh umumnya mencakup manipulasi bitmap atau sampel suara. Ada dua pendekatan dasar.

Anda dapat menyimpan data di byte[]. Cara ini memungkinkan akses yang sangat cepat dari kode terkelola. Namun, pada sisi native, tidak ada jaminan bahwa Anda dapat mengakses data tanpa menyalinnya. Dalam beberapa implementasi, GetByteArrayElements dan GetPrimitiveArrayCritical akan menampilkan pointer sebenarnya ke data mentah dalam heap terkelola, tetapi dalam implementasi lainnya, keduanya akan mengalokasikan buffer pada heap native dan menyalin data tersebut.

Cara lainnya adalah dengan menyimpan data dalam buffer byte langsung. Anda dapat membuatnya dengan java.nio.ByteBuffer.allocateDirect, atau fungsi NewDirectByteBuffer JNI. Tidak seperti buffer byte reguler, penyimpanan tidak dialokasikan pada heap terkelola, dan selalu dapat diakses secara langsung dari kode native (dapatkan alamatnya dengan GetDirectBufferAddress). Bergantung pada cara implementasi akses buffer byte langsung, proses akses data dari kode terkelola dapat berjalan sangat lambat.

Pilihan yang akan digunakan bergantung pada dua faktor:

  1. Apakah sebagian besar akses data akan terjadi dari kode yang ditulis di Java atau di C/C++?
  2. Jika data pada akhirnya diteruskan ke API sistem, format apa yang harus digunakan? (Misalnya, jika pada akhirnya data diteruskan ke fungsi yang menggunakan byte[], mungkin akan kurang tepat untuk melakukan pemrosesan dalam ByteBuffer langsung.)

Jika tidak ada jawaban pasti, gunakan buffer byte langsung. Dukungan untuk buffer byte langsung sudah terintegrasi dalam JNI, dan performanya akan meningkat dalam rilis-rilis mendatang.