JNI è l'interfaccia nativa Java. Definisce un modo per il bytecode che Android compila dal codice gestito (scritto nei linguaggi di programmazione Java o Kotlin) per interagire con il codice nativo (scritto in C/C++). JNI è indipendente dal fornitore, supporta il caricamento del codice da librerie condivise dinamiche e, sebbene a volte ingombrante, è ragionevolmente efficiente.
Nota: poiché Android compila Kotlin in bytecode compatibile con ART in modo simile al linguaggio di programmazione Java, puoi applicare le indicazioni riportate in questa pagina sia ai linguaggi di programmazione Kotlin che a Java in termini di architettura JNI e costi associati. Per scoprire di più, consulta Kotlin e Android.
Se non lo conosci già, leggi le specifiche dell'interfaccia nativa Java per avere un'idea del funzionamento di JNI e delle funzionalità disponibili. Alcuni aspetti dell'interfaccia non sono immediatamente evidenti alla prima lettura, pertanto potresti trovare utili le sezioni successive.
Per sfogliare i riferimenti JNI globali e vedere dove vengono creati ed eliminati i riferimenti JNI globali, utilizza la visualizzazione heap JNI in Memory Profiler in Android Studio 3.2 e versioni successive.
Suggerimenti generali
Prova a ridurre al minimo l'impatto del livello JNI. Ci sono diverse dimensioni da considerare qui. La tua soluzione JNI deve seguire queste linee guida (elencate di seguito in ordine di importanza, iniziando con la più importante):
- Riduci al minimo il marshalling delle risorse nel livello JNI. Il marshalling attraverso il livello JNI ha costi non banali. Prova a progettare un'interfaccia che riduce al minimo la quantità di dati necessaria per il marshall e la frequenza di marshalling dei dati.
- Evita la comunicazione asincrona tra il codice scritto in un linguaggio di programmazione gestito e il codice scritto in C++, se possibile. In questo modo sarà più facile gestire l'interfaccia JNI. In genere è possibile semplificare gli aggiornamenti asincroni dell'interfaccia utente mantenendo l'aggiornamento asincrono nella stessa lingua dell'interfaccia utente. Ad esempio, invece di richiamare una funzione C++ dal thread dell'interfaccia utente nel codice Java tramite JNI, è meglio eseguire un callback tra due thread nel linguaggio di programmazione Java, con uno di questi che effettua una chiamata C++ di blocco e poi invia una notifica al thread dell'UI quando la chiamata di blocco è completata.
- Riduci al minimo il numero di fili che devono essere toccati o toccati da JNI. Se hai bisogno di utilizzare pool di thread in entrambi i linguaggi Java e C++, prova a mantenere la comunicazione JNI tra i proprietari del pool anziché tra i singoli thread di worker.
- Mantieni il codice dell'interfaccia in un numero ridotto di posizioni di origine C++ e Java facilmente identificate per facilitare i refactoring futuri. Prendi in considerazione l'utilizzo di una libreria di generazione automatica JNI, a seconda dei casi.
JavaVM e JNIEnv
JNI definisce due strutture di dati chiave: "JavaVM" e "JNIEnv". Entrambi sono essenzialmente puntatori a puntatori alle tabelle delle funzioni. (nella versione C++, sono classi con un puntatore a una tabella di funzioni e una funzione membro per ogni funzione JNI che indirizza tramite la tabella). JavaVM fornisce le funzioni di "interfaccia di chiamata", che consentono di creare ed eliminare una JavaVM. In teoria si possono avere più JavaVM per processo, ma Android ne consente solo una.
JNIEnv fornisce la maggior parte delle funzioni JNI. Tutte le tue funzioni native ricevono un JNIEnv come primo argomento, ad eccezione dei metodi @CriticalNative
, vedi chiamate native più veloci.
JNIEnv viene utilizzato per l'archiviazione locale nei thread. Per questo motivo, non puoi condividere un file JNIEnv tra thread.
Se una porzione di codice non ha altro modo per ottenere la sua JNIEnv, devi condividere
la JavaVM e utilizzare GetEnv
per scoprire il JNIEnv del thread. Supponendo che ne sia uno, vedi AttachCurrentThread
di seguito.
Le dichiarazioni C di JNIEnv e JavaVM sono diverse dalle dichiarazioni C++. Il file di inclusione "jni.h"
fornisce definizioni di tipo diverse a seconda che sia incluso in C o C++. Per questo motivo non è una buona idea includere argomenti JNIEnv nei file di intestazione inclusi da entrambi i linguaggi. In altri termini: se il file di intestazione richiede #ifdef __cplusplus
, potrebbe essere necessario eseguire ulteriori operazioni se qualcosa nell'intestazione si riferisce a JNIEnv.
Thread
Tutti i thread sono thread Linux, pianificati dal kernel. In genere vengono avviate da un codice gestito (utilizzando Thread.start()
), ma possono anche essere create altrove e poi associate a JavaVM
. Ad esempio, un thread iniziato con pthread_create()
o std::thread
può essere collegato utilizzando le funzioni AttachCurrentThread()
o AttachCurrentThreadAsDaemon()
. Fino a quando un thread non è allegato, non ha JNIEnv e non può effettuare chiamate JNI.
In genere è meglio utilizzare Thread.start()
per creare thread che devono richiamare codice Java. In questo modo ti assicurerai di avere spazio sufficiente per lo stack, di essere nel codice ThreadGroup
corretto e di utilizzare lo stesso ClassLoader
del codice Java. Inoltre, è più facile impostare il nome del thread per il debug in Java anziché dal codice nativo (vedi pthread_setname_np()
se hai un pthread_t
o thread_t
e std::thread::native_handle()
se hai un std::thread
e vuoi un pthread_t
).
Il collegamento di un thread creato in modo nativo comporta la creazione e l'aggiunta di un oggetto java.lang.Thread
al ThreadGroup
"principale", il che lo rende visibile al debugger. Chiamare AttachCurrentThread()
su un thread già allegato è un'operazione autonoma.
Android non sospende i thread che eseguono codice nativo. Se la garbage collection è in corso o se il debugger ha inviato una richiesta di sospensione, Android metterà in pausa il thread la prossima volta che effettua una chiamata JNI.
I thread collegati tramite JNI devono chiamare
DetachCurrentThread()
prima di uscire.
Se non è possibile scrivere direttamente questo codice, in Android 2.0 (Eclair) e versioni successive puoi utilizzare pthread_key_create()
per definire una funzione distruttore che verrà chiamata prima dell'uscita del thread e richiamare DetachCurrentThread()
da lì. Utilizza questa chiave con pthread_setspecific()
per archiviare JNIEnv in thread-local-storage; in questo modo verrà passato al tuo distruttore come argomento.
jclass, jmethodID e jfieldID
Per accedere al campo di un oggetto dal codice nativo:
- Recupera il riferimento all'oggetto di classe per la classe con
FindClass
- Ottieni l'ID campo per il campo con
GetFieldID
- Recupera i contenuti del campo con un elemento appropriato, come
GetIntField
Allo stesso modo, per chiamare un metodo, devi prima ottenere un riferimento all'oggetto di classe e poi un ID metodo. Gli ID sono spesso solo riferimenti a strutture di dati di runtime interne. La loro ricerca potrebbe richiedere diversi confronti tra stringhe, ma una volta ottenuti, la chiamata effettiva per ottenere il campo o richiamare il metodo è molto rapida.
Se le prestazioni sono importanti, è utile cercare i valori una volta e memorizzare nella cache i risultati nel codice nativo. Poiché esiste un limite di una JavaVM per processo, è ragionevole archiviare questi dati in una struttura locale statica.
I riferimenti alle classi, gli ID campo e gli ID metodo sono garantiti e validi fino all'unload della classe. Le classi vengono unload solo se tutte le classi associate a un ClassLoader possono essere garbage collection, un'operazione rara, ma non impossibile in Android. Tuttavia, tieni presente che jclass
è un riferimento di classe e deve essere protetto con una chiamata a NewGlobalRef
(vedi la sezione successiva).
Se vuoi memorizzare gli ID nella cache quando viene caricata una classe e memorizzarli automaticamente nella cache se la classe viene scaricata e ricaricata, il modo corretto per inizializzare gli ID è aggiungere alla classe appropriata una porzione di codice simile alla seguente:
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(); }
Crea un metodo nativeClassInit
nel codice C/C++ che esegua le ricerche degli ID. Il codice verrà eseguito una volta, all'inizializzazione della classe. Se la classe viene scaricata e poi ricaricata, verrà eseguita di nuovo.
Riferimenti locali e globali
Ogni argomento passato a un metodo nativo e quasi ogni oggetto restituito da una funzione JNI è un "riferimento locale". Ciò significa che è valido per la durata del metodo nativo attuale nel thread corrente. Anche se l'oggetto continua a essere attivo dopo il ritorno del metodo nativo, il riferimento non è valido.
Questo si applica a tutte le sottoclassi di jobject
, tra cui
jclass
, jstring
e jarray
.
Quando sono abilitati controlli JNI estesi, il runtime ti avvisa della maggior parte degli utilizzi errati dei riferimenti.
L'unico modo per ottenere riferimenti non locali è tramite le funzioni NewGlobalRef
e NewWeakGlobalRef
.
Se vuoi conservare un riferimento per un periodo più lungo, devi utilizzare un riferimento "globale". La funzione NewGlobalRef
prende il riferimento locale come argomento e restituisce uno globale.
La validità del riferimento globale è garantita fino a quando non chiami DeleteGlobalRef
.
Questo pattern viene comunemente utilizzato durante la memorizzazione nella cache di una jclass restituita da FindClass
, ad esempio:
jclass localClass = env->FindClass("MyClass"); jclass globalClass = reinterpret_cast<jclass>(env->NewGlobalRef(localClass));
Tutti i metodi JNI accettano come argomenti sia riferimenti locali che globali.
I riferimenti allo stesso oggetto possono avere valori diversi.
Ad esempio, i valori restituiti dalle chiamate consecutive a NewGlobalRef
per lo stesso oggetto potrebbero essere diversi.
Per verificare se due riferimenti fanno riferimento allo stesso oggetto,
devi utilizzare la funzione IsSameObject
. Non confrontare mai i riferimenti con ==
nel codice nativo.
Una conseguenza di ciò è che non devi presupporre che i riferimenti agli oggetti siano costanti o univoci nel codice nativo. Il valore che rappresenta un oggetto può essere diverso da una chiamata a un metodo all'altra ed è possibile che due oggetti diversi abbiano lo stesso valore in chiamate consecutive. Non utilizzare i valori jobject
come chiavi.
I programmatori sono tenuti a "non allocare in modo eccessivo" riferimenti locali. In pratica, questo significa che se stai creando un numero elevato di riferimenti locali, ad esempio durante l'esecuzione di un array di oggetti, devi liberarli manualmente con DeleteLocalRef
, invece di lasciare che sia JNI a farlo per te. L'implementazione è necessaria solo per prenotare slot per 16 riferimenti locali, quindi se te ne occorrono altri devi eliminarli man mano che procedi o utilizzare EnsureLocalCapacity
/PushLocalFrame
per prenotarne altri.
Tieni presente che gli elementi jfieldID
e jmethodID
sono tipi opachi, non riferimenti a oggetti, e non devono essere trasmessi a NewGlobalRef
. Anche i puntatori dati non elaborati restituiti da funzioni come GetStringUTFChars
e GetByteArrayElements
non sono oggetti. (Possono essere passati da un thread all'altro e sono validi fino alla chiamata Release corrispondente).
Un caso insolito merita una menzione separata. Se colleghi un thread nativo con AttachCurrentThread
, il codice in esecuzione non svincola mai automaticamente riferimenti locali finché il thread non si scollega. I riferimenti locali creati dovranno essere eliminati manualmente. In generale, è probabile che per qualsiasi codice nativo che crei riferimenti locali in un loop debba eseguire un'eliminazione manuale.
Fai attenzione all'utilizzo di riferimenti globali. I riferimenti globali possono essere inevitabili, ma sono difficili da eseguire il debug e possono causare comportamenti della memoria difficili da diagnosticare. A parità di altri fattori, una soluzione con meno riferimenti globali è probabilmente migliore.
Stringhe UTF-8 e UTF-16
Il linguaggio di programmazione Java utilizza la codifica UTF-16. Per praticità, JNI fornisce anche metodi compatibili con Modified UTF-8. La codifica modificata è utile per il codice C perché codifica \u0000 come 0xc0 0x80 anziché 0x00. L'aspetto positivo è che puoi contare su stringhe con terminazione zero in stile C, adatte all'uso con le funzioni delle stringhe libc standard. Il lato negativo è che non puoi passare i dati UTF-8 arbitrari a JNI e aspettarti che funzionino correttamente.
Per ottenere la rappresentazione UTF-16 di un elemento String
, utilizza GetStringChars
.
Tieni presente che le stringhe UTF-16 non hanno terminazione zero e \u0000 è consentito, quindi devi rimanere sulla lunghezza della stringa e sul puntatore jchar.
Non dimenticare di Release
le stringhe Get
. Le funzioni stringa restituiscono jchar*
o jbyte*
, che sono puntatori in stile C ai dati primitivi anziché a riferimenti locali. La loro validità è garantita fino alla chiamata di Release
, il che significa che non vengono rilasciate quando viene restituito il metodo nativo.
I dati passati a NewStringUTF devono essere in formato UTF-8 modificato. Un errore comune è leggere i dati dei caratteri da un file o uno stream di rete e passarli a NewStringUTF
senza filtrarli.
A meno che tu non sappia che i dati sono MUTF-8 validi (o ASCII a 7 bit, che è un sottoinsieme compatibile), devi eliminare i caratteri non validi o convertirli nel formato UTF-8 modificato corretto.
In caso contrario, è probabile che la conversione UTF-16 fornisca risultati imprevisti.
CheckJNI, attivo per impostazione predefinita per gli emulatori, analizza le stringhe e interrompe la VM se riceve input non validi.
Prima di Android 8, in genere era più veloce utilizzare stringhe UTF-16 in quanto Android non richiedeva una copia in GetStringChars
, mentre GetStringUTFChars
richiedeva un'allocazione e una conversione in UTF-8.
Android 8 ha modificato la rappresentazione String
in modo da utilizzare 8 bit per carattere per le stringhe ASCII (per risparmiare memoria) e ha iniziato a utilizzare un garbage collector in movimento. Queste funzionalità riducono notevolmente il numero di casi in cui ART può fornire un puntatore ai dati String
senza creare una copia, anche per GetStringCritical
. Tuttavia, se la maggior parte delle stringhe elaborate dal codice sono brevi, nella maggior parte dei casi è possibile evitare l'allocazione e la deallocation utilizzando un buffer allo stack e GetStringRegion
o GetStringUTFRegion
. Ad esempio:
constexpr size_t kStackBufferSize = 64u; jchar stack_buffer[kStackBufferSize]; std::unique_ptrheap_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 primitivi
JNI fornisce funzioni per l'accesso ai contenuti degli oggetti array. Sebbene sia necessario accedere agli array di oggetti una voce alla volta, le matrici di primitive possono essere lette e scritte direttamente come se fossero dichiarate in C.
Per rendere l'interfaccia il più efficiente possibile senza vincolare l'implementazione della VM, la famiglia di chiamate Get<PrimitiveType>ArrayElements
consente al runtime di restituire un puntatore agli elementi effettivi o di allocare una parte di memoria e creare una copia. In ogni caso, il puntatore non elaborato restituito sarà valido fino all'emissione della chiamata Release
corrispondente, il che implica che, se i dati non sono stati copiati, l'oggetto array verrà bloccato e non potrà essere spostato durante la compattazione dell'heap.
Devi Release
ogni array che Get
. Inoltre, se la chiamata Get
non va a buon fine, devi assicurarti che il codice non tenti di Release
un puntatore NULL in un secondo momento.
Puoi determinare se i dati sono stati copiati o meno passando un puntatore non NULL per l'argomento isCopy
. Questo è raramente
utile.
La chiamata Release
accetta un argomento mode
che può
avere uno di tre valori. Le azioni eseguite dal runtime dipendono dal fatto che abbia restituito un puntatore ai dati effettivi o a una copia degli stessi:
0
- Effettivo: l'oggetto array non è bloccato.
- Copia: i dati vengono copiati. Il buffer con la copia viene liberato.
JNI_COMMIT
- Effettivo: non fa nulla.
- Copia: i dati vengono copiati. Il buffer con la copia non viene liberato.
JNI_ABORT
- Effettivo: l'oggetto array non è bloccato. Le scritture precedenti non vengono interrotte.
- Copia: il buffer con la copia viene liberato; le eventuali modifiche andranno perse.
Un motivo per controllare il flag isCopy
è sapere se
devi chiamare Release
con JNI_COMMIT
dopo aver apportato modifiche a un array: se cambi ed esegui codice che utilizza i contenuti dell'array, potresti essere
in grado di saltare il commit no-op. Un altro possibile motivo per il controllo del flag è la gestione efficiente di JNI_ABORT
. Ad esempio, potresti voler ottenere un array, modificarlo, passare parti ad altre funzioni e quindi ignorare le modifiche. Se sai che JNI sta creando una nuova copia per
te, non c'è bisogno di crearne un'altra "modificabile". Se JNI ti trasmette l'originale, devi crearne una tua.
È un errore comune (ripetuto nel codice di esempio) presumere di poter saltare la chiamata a Release
se *isCopy
è false. Non è così. Se non è stato allocato alcun buffer di copia, la memoria originale deve essere bloccata e non può essere spostata dal garbage collector.
Tieni inoltre presente che il flag JNI_COMMIT
non rilascia l'array e alla fine dovrai chiamare di nuovo Release
con un flag diverso.
Chiamate regionali
Esiste un'alternativa a chiamate come Get<Type>ArrayElements
e GetStringChars
che può essere molto utile quando vuoi
solo copiare i dati dentro o fuori. Tieni in considerazione:
jbyte* data = env->GetByteArrayElements(array, NULL); if (data != NULL) { memcpy(buffer, data, len); env->ReleaseByteArrayElements(array, data, JNI_ABORT); }
Questa operazione recupera l'array, copia i primi len
elementi di byte
da quell'array e poi rilascia l'array. A seconda dell'implementazione, la chiamata Get
blocca o copia i contenuti dell'array.
Il codice copia i dati (ad esempio, una seconda volta), poi chiama Release
; in questo caso
JNI_ABORT
garantisce che non sia possibile ricevere una terza copia.
La stessa cosa può essere eseguita in modo più semplice:
env->GetByteArrayRegion(array, 0, len, buffer);
Questa opzione offre diversi vantaggi:
- Richiede una chiamata JNI anziché due, riducendo l'overhead.
- Non richiede il blocco o copie di dati aggiuntive.
- Riduce il rischio di errore del programmatore: non rischi di dimenticarti di chiamare
Release
dopo che l'operazione non va a buon fine.
Allo stesso modo, puoi utilizzare la chiamata Set<Type>ArrayRegion
per copiare i dati in un array e la chiamata GetStringRegion
o
GetStringUTFRegion
per copiare i caratteri da
String
.
Eccezioni
Non devi chiamare la maggior parte delle funzioni JNI mentre è in attesa un'eccezione.
Il tuo codice dovrebbe notare l'eccezione (tramite il valore restituito dalla funzione, ExceptionCheck
o ExceptionOccurred
) e tornare oppure cancellare l'eccezione e gestirla.
Le uniche funzioni JNI che puoi chiamare mentre un'eccezione è in sospeso sono:
DeleteGlobalRef
DeleteLocalRef
DeleteWeakGlobalRef
ExceptionCheck
ExceptionClear
ExceptionDescribe
ExceptionOccurred
MonitorExit
PopLocalFrame
PushLocalFrame
Release<PrimitiveType>ArrayElements
ReleasePrimitiveArrayCritical
ReleaseStringChars
ReleaseStringCritical
ReleaseStringUTFChars
Molte chiamate JNI possono generare un'eccezione, ma spesso offrono un modo più semplice per verificare l'eventuale presenza di errori. Ad esempio, se NewString
restituisce
un valore non NULL, non è necessario verificare se è presente un'eccezione. Tuttavia, se chiami un metodo (utilizzando una funzione come CallObjectMethod
), devi sempre controllare se è presente un'eccezione, perché il valore restituito non è valido se viene generata un'eccezione.
Tieni presente che le eccezioni generate dal codice gestito non sbloccano gli stack frame nativi. (Inoltre, le eccezioni C++, generalmente sconsigliate su Android, non devono superare il confine di transizione JNI dal codice C++ al codice gestito.)
Le istruzioni Throw
e ThrowNew
di JNI impostano semplicemente un puntatore di eccezione nel thread corrente. Quando torni a gestito da codice nativo, l'eccezione verrà annotata e gestita in modo appropriato.
Il codice nativo può "acquisire" un'eccezione chiamando ExceptionCheck
o ExceptionOccurred
e cancellarla con ExceptionClear
. Come di consueto, scartare le eccezioni senza gestirle può creare problemi.
Non ci sono funzioni integrate per manipolare l'oggetto Throwable
stesso, quindi se vuoi (ad esempio) ottenere la stringa di eccezione, dovrai trovare la classe Throwable
, cercare l'ID metodo per getMessage "()Ljava/lang/String;"
, chiamarlo e, se il risultato non è NULL, utilizza GetStringUTFChars
per ottenere qualcosa che puoi inviare a printf(3)
o equivalente.
Controllo esteso
JNI effettua pochissimi controlli degli errori. In genere gli errori causano un arresto anomalo. Android offre inoltre una modalità chiamata CheckJNI, in cui i puntatori delle tabelle delle funzioni JavaVM e JNIEnv vengono trasferiti in tabelle di funzioni che eseguono una serie estesa di controlli prima di chiamare l'implementazione standard.
I controlli aggiuntivi includono:
- Array: tentativo di allocare un array di dimensioni negative.
- Puntatori non validi: passare un jarray/jclass/jobject/jstring non valido a una chiamata JNI o passare un puntatore NULL a una chiamata JNI con un argomento non nullable.
- Nomi di classi: passare qualsiasi cosa tranne lo stile "java/lang/String" del nome della classe a una chiamata JNI.
- Chiamate critiche: fare una chiamata JNI tra una pubblicità "critica" e la release corrispondente.
- Bytebus diretti: passaggio di argomenti non validi a
NewDirectByteBuffer
. - Eccezioni: effettuare una chiamata JNI in attesa di un'eccezione.
- JNIEnv*s: utilizzo di un JNIEnv* dal thread sbagliato.
- jfieldIDs: utilizzo di un jfieldID NULL o di un jfieldID per impostare un campo su un valore di tipo errato (ad esempio, un tentativo di assegnare un StringBuilder a un campo String) o un jfieldID per un campo statico per impostare un campo di istanza o viceversa oppure utilizzo di un jfieldID di una classe con istanze di un'altra classe.
- jmethodIDs: utilizzo del tipo sbagliato di jmethodID quando si effettua una chiamata JNI
Call*Method
: tipo restituito errato, mancata corrispondenza statico/non statico, tipo errato per "this" (per le chiamate non statiche) o classe errata (per le chiamate statiche). - Riferimenti: uso di
DeleteGlobalRef
/DeleteLocalRef
nel tipo di riferimento sbagliato. - Modalità di rilascio: passaggio di una modalità di rilascio non valida a una chiamata di rilascio (diversa da
0
,JNI_ABORT
oJNI_COMMIT
). - Sicurezza dei tipi: restituzione di un tipo incompatibile dal metodo nativo (ad esempio, la restituzione di un StringBuilder da un metodo dichiarato per restituire una stringa).
- UTF-8: passare una sequenza di byte Modified UTF-8 non valida a una chiamata JNI.
L'accessibilità di metodi e campi non è ancora selezionata: le limitazioni di accesso non si applicano al codice nativo.
Esistono diversi modi per attivare CheckJNI.
Se utilizzi l'emulatore, CheckJNI è attivo per impostazione predefinita.
Se hai un dispositivo rooted, puoi utilizzare la seguente sequenza di comandi per riavviare il runtime con CheckJNI abilitato:
adb shell stop adb shell setprop dalvik.vm.checkjni true adb shell start
In entrambi i casi, all'avvio del runtime verrà visualizzato qualcosa di simile nell'output di logcat:
D AndroidRuntime: CheckJNI is ON
Se hai un dispositivo normale, puoi utilizzare il seguente comando:
adb shell setprop debug.checkjni 1
Questa operazione non inciderà sulle app già in esecuzione, ma per qualsiasi app avviata da quel momento in poi verrà attivato CheckJNI. Se modifichi la proprietà impostandone un altro o il semplice riavvio disattiverà di nuovo CheckJNI. In questo caso, all'avvio successivo dell'app, nell'output logcat vedrai un codice simile al seguente:
D Late-enabling CheckJNI
Puoi anche impostare l'attributo android:debuggable
nel file manifest della tua applicazione per attivare CheckJNI solo per la tua app. Tieni presente che gli strumenti di creazione di Android eseguono questa operazione automaticamente per determinati tipi di build.
Librerie native
Puoi caricare codice nativo da librerie condivise con System.loadLibrary
standard.
In pratica, le versioni precedenti di Android presentavano bug in PackageManager che rendevano inaffidabili l'installazione e l'aggiornamento delle librerie native. Il progetto ReLinker offre soluzioni alternative per questo e altri problemi di caricamento delle librerie native.
Chiama System.loadLibrary
(o ReLinker.loadLibrary
) da un inizializzatore di classi statico. L'argomento è il nome della libreria "non decorato", quindi per caricare libfubar.so
passerai in "fubar"
.
Se hai una sola classe con metodi nativi, ha senso che la chiamata a System.loadLibrary
sia in un inizializzatore statico per quella classe. In caso contrario, potresti voler effettuare la chiamata da Application
in modo da sapere che la libreria è sempre caricata e sempre caricata in anticipo.
Esistono due modi in cui il runtime può trovare i metodi nativi. Puoi registrarli esplicitamente con RegisterNatives
oppure consentire al runtime di cercarli in modo dinamico con dlsym
. Il vantaggio di RegisterNatives
consiste nel fatto che puoi controllare in anticipo l'esistenza dei simboli e che puoi avere librerie condivise più piccole e più rapide esportando solo JNI_OnLoad
. Il vantaggio di consentire al runtime di rilevare le tue funzioni è che riduce leggermente la quantità di codice da scrivere.
Per usare RegisterNatives
:
- Fornisci una funzione
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved)
. - Nel tuo
JNI_OnLoad
, registra tutti i tuoi metodi nativi utilizzandoRegisterNatives
. - Crea con
-fvisibility=hidden
in modo che solo il tuoJNI_OnLoad
venga esportato dalla tua libreria. Questo produce un codice più rapido e di dimensioni inferiori ed evita potenziali collisioni con altre librerie caricate nella tua app (ma crea analisi dello stack meno utili se l'app ha un arresto anomalo nel codice nativo).
L'inizializzatore statico dovrebbe avere il seguente aspetto:
Kotlin
companion object { init { System.loadLibrary("fubar") } }
Java
static { System.loadLibrary("fubar"); }
Se scritta in C++, la funzione JNI_OnLoad
dovrebbe essere simile alla seguente:
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; }
Se preferisci utilizzare l'"individuazione" dei metodi nativi, devi assegnare loro un nome specifico (per i dettagli, consulta le specifiche JNI). Ciò significa che se la firma di un metodo non è corretta, non ne avrai informazioni a riguardo fino alla prima volta che il metodo viene effettivamente richiamato.
Qualsiasi chiamata a FindClass
effettuata da JNI_OnLoad
risolverà le classi nel
contesto del caricatore di classi utilizzato per caricare la libreria condivisa. Quando viene chiamato da altri contesti, FindClass
utilizza il caricatore di classi associato al metodo nella parte superiore dello stack Java oppure, se non è presente (perché la chiamata proviene da un thread nativo appena collegato), utilizza il caricatore di classi "system". Il caricatore della classe di sistema non conosce le classi dell'applicazione, quindi non potrai cercare le tue classi con FindClass
in questo contesto. Questo rende JNI_OnLoad
un posto pratico per cercare e memorizzare le classi nella cache: una volta che hai un riferimento globale jclass
valido, puoi utilizzarlo da qualsiasi thread allegato.
Chiamate native più veloci con @FastNative
e @CriticalNative
I metodi nativi possono essere annotati con @FastNative
o @CriticalNative
(ma non con entrambi) per velocizzare le transizioni tra codice gestito e codice nativo. Tuttavia, queste annotazioni presentano alcune modifiche di comportamento che devono essere considerate attentamente prima dell'uso. Ricordiamo
brevemente queste modifiche di seguito, ma consulta la documentazione per i dettagli.
L'annotazione @CriticalNative
può essere applicata solo ai metodi nativi che non utilizzano oggetti gestiti (in parametri o valori restituiti o come this
implicito) e questa annotazione cambia l'ABI di transizione JNI. L'implementazione nativa deve escludere i parametri JNIEnv
e jclass
dalla firma della funzione.
Durante l'esecuzione di un metodo @FastNative
o @CriticalNative
, la garbage collection non può sospendere il thread per operazioni essenziali e potrebbe venire bloccata. Non utilizzare queste annotazioni per i metodi a lunga esecuzione, inclusi quelli generalmente veloci, ma generalmente non limitati.
In particolare, il codice non deve eseguire operazioni di I/O significative o acquisire blocchi nativi che possono essere conservati per molto tempo.
Queste annotazioni sono state implementate per l'utilizzo del sistema da Android 8 e sono state testate dal CTS come API pubblica in Android 14. È probabile che queste ottimizzazioni funzionino anche sui dispositivi Android 8-13 (anche se senza le forti garanzie CTS), ma la ricerca dinamica dei metodi nativi è supportata solo su Android 12 e versioni successive. La registrazione esplicita con JNI RegisterNatives
è rigorosamente richiesta per l'esecuzione sulle versioni di Android 8-11. Queste annotazioni vengono ignorate su Android 7- e la mancata corrispondenza delle ABI
per @CriticalNative
causerebbe marshalling di argomenti errati e probabili arresti anomali.
Per i metodi critici per le prestazioni che richiedono queste annotazioni, consigliamo vivamente di registrare esplicitamente i metodi con JNI RegisterNatives
anziché fare affidamento sul "rilevamento" dei metodi nativi basato sui nomi. Per ottenere prestazioni ottimali all'avvio dell'app, ti consigliamo di includere i chiamanti dei metodi @FastNative
o @CriticalNative
nel profilo di riferimento. A partire da Android 12, una chiamata a un metodo nativo @CriticalNative
da un metodo gestito compilato è quasi economica di una chiamata non in linea in C/C++, purché tutti gli argomenti rientrino nei registri (ad esempio fino a 8 argomenti integrali e fino a 8 argomenti in virgola mobile su arm64).
A volte è preferibile suddividere un metodo nativo in due, un metodo molto veloce che può non riuscire e un altro che gestisce i casi lenti. Ad esempio:
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);
Considerazioni sul 64 bit
Per supportare le architetture che utilizzano puntatori a 64 bit, usa un campo long
anziché un int
quando memorizzi un puntatore a una struttura nativa in un campo Java.
Funzionalità non supportate/compatibilità con le versioni precedenti
Sono supportate tutte le funzionalità di JNI 1.6, con la seguente eccezione:
DefineClass
non è implementato. Android non utilizza bytecode o file di classe Java, quindi il trasferimento di dati di classi binarie non funziona.
Per la compatibilità con le versioni precedenti di Android, potrebbe essere necessario tenere presente quanto segue:
- Ricerca dinamica di funzioni native
Fino ad Android 2.0 (Eclair), il carattere "$" non è stato convertito correttamente in "_00024" durante le ricerche dei nomi dei metodi. Per ovviare a questo problema, è necessario utilizzare una registrazione esplicita o spostare i metodi nativi dalle classi interne.
- Scollegamento dei thread
Fino ad Android 2.0 (Eclair), non era possibile utilizzare una funzione distruttore
pthread_key_create
per evitare il controllo "Il thread deve essere scollegato prima dell'uscita". (Il runtime utilizza anche una funzione distruttore della chiave pthread, quindi sarebbe preferibile vedere quale viene chiamata per prima). - Riferimenti globali deboli
Fino ad Android 2.2 (Froyo), non sono stati implementati riferimenti globali deboli. Le versioni precedenti rifiutano con decisione qualsiasi tentativo di utilizzo. Puoi utilizzare le costanti di versione della piattaforma Android per testare l'assistenza.
Fino ad Android 4.0 (Ice Cream Sandwich), i riferimenti globali deboli potevano essere trasmessi solo a
NewLocalRef
,NewGlobalRef
eDeleteWeakGlobalRef
. (la specifica incoraggia vivamente i programmatori a creare riferimenti a elementi globali deboli prima di intraprendere qualsiasi azione, quindi questo non dovrebbe essere affatto limitante.)A partire da Android 4.0 (Ice Cream Sandwich), i riferimenti globali deboli possono essere utilizzati come qualsiasi altro riferimento JNI.
- Riferimenti locali
Fino ad Android 4.0 (Ice Cream Sandwich), i riferimenti locali erano in realtà puntatori diretti. Ice Cream Sandwich ha aggiunto l'indirezione necessaria per supportare i garbage collector migliori, ma questo significa che molti bug JNI non sono rilevabili nelle release precedenti. Per ulteriori dettagli, consulta le modifiche ai riferimenti locali JNI in ICS.
Nelle versioni di Android precedenti ad Android 8.0, il numero di riferimenti locali è limitato a un limite specifico della versione. A partire da Android 8.0, Android supporta un numero illimitato di riferimenti locali.
- Determinazione del tipo di riferimento con
GetObjectRefType
Fino ad Android 4.0 (Ice Cream Sandwich), in seguito all'uso dei puntatori diretti (vedi sopra), era impossibile implementare
GetObjectRefType
correttamente. Abbiamo invece utilizzato un'euristica che esamina la tabella delle globali deboli, gli argomenti, la tabella delle persone del posto e la tabella delle globali in questo ordine. La prima volta che trovava il puntatore diretto, indicava che il riferimento era del tipo che stava esaminando. Ciò significava, ad esempio, che se hai chiamatoGetObjectRefType
su una jclass globale che corrispondeva alla jclass passata come argomento implicito al tuo metodo nativo statico, avresti ottenutoJNILocalRefType
anzichéJNIGlobalRefType
. @FastNative
e@CriticalNative
Fino ad Android 7, queste annotazioni di ottimizzazione venivano ignorate. La mancata corrispondenza dell'ABI per
@CriticalNative
porterebbe a un marshalling di argomenti errati e probabili arresti anomali.La ricerca dinamica delle funzioni native per i metodi
@FastNative
e@CriticalNative
non è stata implementata in Android 8-10 e contiene bug noti in Android 11. L'utilizzo di queste ottimizzazioni senza una registrazione esplicita con JNIRegisterNatives
potrebbe causare arresti anomali su Android 8-11.
Domande frequenti: perché ricevo UnsatisfiedLinkError
?
Quando si lavora sul codice nativo, non è raro vedere un errore come il seguente:
java.lang.UnsatisfiedLinkError: Library foo not found
In alcuni casi significa che la raccolta non è stata trovata. In altri casi, la libreria esiste, ma non è stato possibile aprirla da dlopen(3)
. I dettagli dell'errore sono disponibili nel messaggio dettagliato dell'eccezione.
Motivi comuni per cui potresti trovare eccezioni "Libreria non trovata":
- La libreria non esiste o non è accessibile all'app. Utilizza
adb shell ls -l <path>
per verificarne la presenza e le autorizzazioni. - La biblioteca non è stata creata con l'NDK. Ciò può causare dipendenze da funzioni o librerie che non esistono sul dispositivo.
Un'altra classe di UnsatisfiedLinkError
errori ha il seguente aspetto:
java.lang.UnsatisfiedLinkError: myfunc at Foo.myfunc(Native Method) at Foo.main(Foo.java:10)
In logcat vedrai:
W/dalvikvm( 880): No implementation found for native LFoo;.myfunc ()V
Ciò significa che il runtime ha tentato di trovare un metodo di corrispondenza, ma l'operazione non è riuscita. Ecco alcuni motivi comuni:
- La libreria non viene caricata. Controlla se nell'output di logcat sono presenti messaggi sul caricamento della libreria.
- Il metodo non è stato trovato a causa di una mancata corrispondenza del nome o della firma. In genere questo problema è causato da:
- Per la ricerca con metodo lazy, impossibile dichiarare le funzioni C++ con
extern "C"
e la visibilità appropriata (JNIEXPORT
). Tieni presente che prima di Ice Cream Sandwich la macro JNIEXPORT non era corretta, quindi utilizzare una nuova GCC con unajni.h
precedente non funzionerà. Puoi utilizzarearm-eabi-nm
per vedere i simboli così come appaiono nella libreria; se sembrano danneggiati (ad esempio,_Z15Java_Foo_myfuncP7_JNIEnvP7_jclass
anzichéJava_Foo_myfunc
) o se il tipo di simbolo è una "t" minuscola anziché una "T" maiuscola, devi modificare la dichiarazione. - In caso di registrazione esplicita, errori minori durante l'inserimento della firma del metodo. Assicurati che ciò che trasmetti alla
chiamata di registrazione corrisponda alla firma nel file di log.
Ricorda che "B" è
byte
e "Z" èboolean
. I componenti dei nomi delle classi nelle firme iniziano con "L", terminano con ";", usa "/" per separare i nomi di pacchetti/classi e usa "$" per separare i nomi delle classi interne (ad esempioLjava/util/Map$Entry;
).
- Per la ricerca con metodo lazy, impossibile dichiarare le funzioni C++ con
L'utilizzo di javah
per generare automaticamente intestazioni JNI può aiutare a evitare alcuni problemi.
Domande frequenti: perché FindClass
non ha trovato il mio corso?
La maggior parte di questi consigli si applica anche agli errori di ricerca dei metodi con GetMethodID
o GetStaticMethodID
o ai campi con GetFieldID
o GetStaticFieldID
.
Assicurati che il formato della stringa del nome della classe sia corretto. I nomi delle classi JNI iniziano con il nome del pacchetto e sono separati da barre, ad esempio java/lang/String
. Se cerchi una classe di array, devi iniziare con il numero appropriato di parentesi quadre e devi anche racchiudere la classe tra "L" e ";", quindi una matrice unidimensionale di String
sarà [Ljava/lang/String;
.
Se stai cercando una classe interna, utilizza "$" invece di ".". In generale,
l'utilizzo di javap
nel file .class è un buon modo per scoprire il
nome interno del tuo corso.
Se abiliti la riduzione del codice, assicurati di configurare il codice da conservare. La configurazione di regole di conservazione appropriate è importante perché lo strumento di riduzione del codice potrebbe rimuovere classi, metodi o campi utilizzati solo da JNI.
Se il nome del corso è corretto, è possibile che si sia verificato un problema con il caricamento dei corsi. FindClass
vuole avviare la ricerca del corso nel caricatore dei corsi associato al tuo codice. Esamina lo stack di chiamate,
che avrà un aspetto simile a questo:
Foo.myfunc(Native Method) Foo.main(Foo.java:10)
Il metodo più alto è Foo.myfunc
. FindClass
trova l'oggetto ClassLoader
associato alla classe Foo
e lo utilizza.
Di solito fa ciò che vuoi. Puoi avere problemi se crei un thread autonomamente (ad esempio chiamando pthread_create
e collegandolo con AttachCurrentThread
). Ora non ci sono stack frame nella tua applicazione.
Se chiami FindClass
da questo thread, JavaVM verrà avviato nel caricatore di classi "system" anziché in quello associato alla tua applicazione, quindi i tentativi di trovare classi specifiche dell'app non andranno a buon fine.
Esistono alcuni modi per ovviare a questo problema:
- Esegui le ricerche
FindClass
una volta inJNI_OnLoad
e memorizza nella cache i riferimenti delle classi per utilizzarli in seguito. Qualsiasi chiamata aFindClass
effettuata durante l'esecuzione diJNI_OnLoad
utilizzerà il caricatore di classi associato alla funzione che ha chiamatoSystem.loadLibrary
(si tratta di una regola speciale fornita per rendere più comoda l'inizializzazione della libreria). Se il codice dell'app sta caricando la libreria,FindClass
utilizzerà il caricatore dei corsi corretto. - Passa un'istanza della classe nelle funzioni che ne hanno bisogno dichiarando il tuo metodo nativo per prendere un argomento Class e poi passare
Foo.class
. - Memorizza nella cache un riferimento all'oggetto
ClassLoader
in un punto qualsiasi ed esegui direttamente le chiamateloadClass
. Questa operazione richiede un po' di impegno.
Domande frequenti: come faccio a condividere i dati non elaborati con il codice nativo?
Potresti trovarti in una situazione in cui devi accedere a un grande buffer di dati non elaborati sia da codice gestito che da codice nativo. Gli esempi più comuni includono la manipolazione di bitmap o campioni audio. Ci sono due approcci di base.
Puoi archiviare i dati in un byte[]
. Ciò consente un accesso
molto rapido dal codice gestito. Sul lato nativo, tuttavia, non è garantito che tu possa accedere ai dati senza doverli copiare. In alcune implementazioni, GetByteArrayElements
e GetPrimitiveArrayCritical
restituiranno puntatori effettivi ai dati non elaborati nell'heap gestito, ma in altre allocano un buffer nell'heap nativo e copiano i dati.
L'alternativa è archiviare i dati in un buffer byte diretto. Questi
possono essere creati con java.nio.ByteBuffer.allocateDirect
o
con la funzione NewDirectByteBuffer
JNI. A differenza dei normali buffer di byte, lo spazio di archiviazione non è allocato nell'heap gestito ed è sempre accessibile direttamente dal codice nativo (recupera l'indirizzo con GetDirectBufferAddress
). A seconda di come viene implementato l'accesso diretto al buffer del byte, l'accesso ai dati dal codice gestito può essere molto lento.
La scelta di quale utilizzare dipende da due fattori:
- La maggior parte degli accessi ai dati avverrà da codice scritto in Java o in C/C++?
- Se i dati vengono passati a un'API di sistema, in quale formato devono essere? Ad esempio, se i dati vengono passati a una funzione che prende un byte[], l'elaborazione in un
ByteBuffer
diretto potrebbe non essere saggio.
Se non esiste un vincitore chiaro, utilizza un buffer di byte diretto. Il supporto è integrato direttamente in JNI e le prestazioni dovrebbero migliorare nelle release future.