Conseils sur JNI

JNI est l'interface Java Native Interface. Il définit une manière pour le bytecode qu'Android compile à partir du code géré (écrit dans les langages de programmation Java ou Kotlin) d'interagir avec le code natif (écrit en C/C++). JNI est indépendant du fournisseur, prend en charge le chargement de code à partir de bibliothèques partagées dynamiques et, bien que parfois fastidieux, est raisonnablement efficace.

Remarque : Étant donné qu'Android compile Kotlin en bytecode compatible avec ART de la même manière que le langage de programmation Java, vous pouvez appliquer les conseils de cette page aux langages de programmation Kotlin et Java en termes d'architecture JNI et de coûts associés. Pour en savoir plus, consultez Kotlin et Android.

Si vous ne le connaissez pas encore, lisez la spécification de l'interface Java Native Interface pour comprendre comment fonctionne JNI et quelles sont les fonctionnalités disponibles. Certains aspects de l'interface ne sont pas immédiatement évidents à la première lecture. Les sections suivantes peuvent donc vous être utiles.

Pour parcourir les références JNI globales et voir où elles sont créées et supprimées, utilisez la vue Tas de mémoire JNI dans le Profileur de mémoire d'Android Studio 3.2 et versions ultérieures.

Conseils généraux

Essayez de minimiser l'empreinte de votre couche JNI. Plusieurs dimensions sont à prendre en compte ici. Votre solution JNI doit essayer de suivre ces consignes (listées ci-dessous par ordre d'importance, en commençant par les plus importantes) :

  • Réduisez la sérialisation des ressources au niveau de la couche JNI. La sérialisation au niveau de la couche JNI entraîne des coûts non négligeables. Essayez de concevoir une interface qui minimise la quantité de données à marshaler et la fréquence à laquelle vous devez marshaler les données.
  • Évitez, si possible, la communication asynchrone entre le code écrit dans un langage de programmation géré et le code écrit en C++. Cela vous permettra de gérer plus facilement votre interface JNI. Vous pouvez généralement simplifier les mises à jour asynchrones de l'UI en conservant la mise à jour asynchrone dans la même langue que l'UI. Par exemple, au lieu d'appeler une fonction C++ à partir du thread d'UI dans le code Java via JNI, il est préférable d'effectuer un rappel entre deux threads dans le langage de programmation Java, l'un d'eux effectuant un appel C++ bloquant, puis informant le thread d'UI lorsque l'appel bloquant est terminé.
  • Réduisez au minimum le nombre de threads qui doivent interagir avec JNI. Si vous devez utiliser des pools de threads dans les langages Java et C++, essayez de maintenir la communication JNI entre les propriétaires de pool plutôt qu'entre les threads de travail individuels.
  • Conservez votre code d'interface dans un petit nombre d'emplacements de code source C++ et Java facilement identifiables pour faciliter les refactorisations futures. Envisagez d'utiliser une bibliothèque de génération automatique JNI, le cas échéant.

JavaVM et JNIEnv

JNI définit deux structures de données clés, "JavaVM" et "JNIEnv". Ces deux éléments sont essentiellement des pointeurs vers des pointeurs vers des tables de fonctions. (Dans la version C++, il s'agit de classes avec un pointeur vers une table de fonctions et une fonction membre pour chaque fonction JNI qui indirecte via la table.) La JavaVM fournit les fonctions d'"interface d'invocation", qui vous permettent de créer et de détruire une JavaVM. En théorie, vous pouvez avoir plusieurs JavaVM par processus, mais Android n'en autorise qu'une seule.

JNIEnv fournit la plupart des fonctions JNI. Toutes vos fonctions natives reçoivent un JNIEnv comme premier argument, à l'exception des méthodes @CriticalNative, voir appels natifs plus rapides.

JNIEnv est utilisé pour le stockage local des threads. Pour cette raison, vous ne pouvez pas partager de JNIEnv entre les threads. Si un élément de code n'a aucun autre moyen d'obtenir son JNIEnv, vous devez partager le JavaVM et utiliser GetEnv pour découvrir le JNIEnv du thread. (en supposant qu'il en ait un, voir AttachCurrentThread ci-dessous).

Les déclarations C de JNIEnv et JavaVM sont différentes des déclarations C++. Le fichier include "jni.h" fournit différents typedefs selon qu'il est inclus dans C ou C++. Pour cette raison, il est déconseillé d'inclure des arguments JNIEnv dans les fichiers d'en-tête inclus par les deux langages. (En d'autres termes, si votre fichier d'en-tête nécessite #ifdef __cplusplus, vous devrez peut-être effectuer un travail supplémentaire si un élément de cet en-tête fait référence à JNIEnv.)

Threads

Tous les threads sont des threads Linux planifiés par le noyau. Ils sont généralement démarrés à partir de code géré (à l'aide de Thread.start()), mais ils peuvent également être créés ailleurs, puis associés au JavaVM. Par exemple, un thread démarré avec pthread_create() ou std::thread peut être associé à l'aide des fonctions AttachCurrentThread() ou AttachCurrentThreadAsDaemon(). Tant qu'un thread n'est pas associé, il ne dispose pas de JNIEnv et ne peut pas effectuer d'appels JNI.

Il est généralement préférable d'utiliser Thread.start() pour créer un thread qui doit appeler du code Java. Vous vous assurerez ainsi de disposer de suffisamment d'espace de pile, d'être dans le bon ThreadGroup et d'utiliser le même ClassLoader que votre code Java. Il est également plus facile de définir le nom du thread pour le débogage en Java qu'à partir du code natif (voir pthread_setname_np() si vous avez un pthread_t ou thread_t, et std::thread::native_handle() si vous avez un std::thread et que vous souhaitez un pthread_t).

L'association d'un thread créé de manière native entraîne la construction d'un objet java.lang.Thread et son ajout au ThreadGroup "principal", ce qui le rend visible pour le débogueur. L'appel de AttachCurrentThread() sur un thread déjà associé est une opération sans effet.

Android ne suspend pas les threads exécutant du code natif. Si le garbage collection est en cours ou si le débogueur a émis une demande de suspension, Android mettra en pause le thread la prochaine fois qu'il effectuera un appel JNI.

Les threads associés via JNI doivent appeler DetachCurrentThread() avant de se fermer. Si le codage direct est difficile, vous pouvez utiliser pthread_key_create() dans Android 2.0 (Eclair) et versions ultérieures pour définir une fonction de destructeur qui sera appelée avant la fin du thread, puis appeler DetachCurrentThread() à partir de là. (Utilisez cette clé avec pthread_setspecific() pour stocker JNIEnv dans le stockage local du thread. De cette façon, il sera transmis à votre destructeur en tant qu'argument.)

jclass, jmethodID et jfieldID

Si vous souhaitez accéder au champ d'un objet à partir du code natif, procédez comme suit :

  • Obtenez la référence d'objet de classe pour la classe avec FindClass.
  • Obtenez l'ID du champ avec GetFieldID.
  • Obtenez le contenu du champ avec un élément approprié, tel que GetIntField.

De même, pour appeler une méthode, vous devez d'abord obtenir une référence d'objet de classe, puis un ID de méthode. Les ID ne sont souvent que des pointeurs vers des structures de données d'exécution internes. La recherche de ces éléments peut nécessiter plusieurs comparaisons de chaînes, mais une fois que vous les avez trouvés, l'appel réel pour obtenir le champ ou appeler la méthode est très rapide.

Si les performances sont importantes, il est utile de rechercher les valeurs une seule fois et de mettre en cache les résultats dans votre code natif. Étant donné qu'il existe une limite d'une JavaVM par processus, il est raisonnable de stocker ces données dans une structure locale statique.

Les références de classe, les ID de champ et les ID de méthode sont garantis valides jusqu'à ce que la classe soit déchargée. Les classes ne sont déchargées que si toutes les classes associées à un ClassLoader peuvent être collectées en tant que déchets, ce qui est rare, mais pas impossible dans Android. Notez toutefois que jclass est une référence de classe et doit être protégé par un appel à NewGlobalRef (voir la section suivante).

Si vous souhaitez mettre en cache les ID lorsqu'une classe est chargée et les remettre automatiquement en cache si la classe est déchargée et rechargée, la méthode appropriée pour initialiser les ID consiste à ajouter un extrait de code semblable à celui-ci à la classe appropriée :

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();
    }

Créez une méthode nativeClassInit dans votre code C/C++ qui effectue les recherches d'ID. Le code sera exécuté une seule fois, lors de l'initialisation de la classe. Si la classe est déchargée puis rechargée, elle sera exécutée à nouveau.

Références locales et mondiales

Chaque argument transmis à une méthode native et presque tous les objets renvoyés par une fonction JNI sont une "référence locale". Cela signifie qu'il est valide pendant la durée de la méthode native actuelle dans le thread actuel. Même si l'objet lui-même continue d'exister après le retour de la méthode native, la référence n'est pas valide.

Cela s'applique à toutes les sous-classes de jobject, y compris jclass, jstring et jarray. (L'environnement d'exécution vous avertira de la plupart des utilisations incorrectes des références lorsque les vérifications JNI étendues sont activées.)

Le seul moyen d'obtenir des références non locales est d'utiliser les fonctions NewGlobalRef et NewWeakGlobalRef.

Si vous souhaitez conserver une référence plus longtemps, vous devez utiliser une référence "globale". La fonction NewGlobalRef prend la référence locale comme argument et renvoie une référence globale. La référence globale est garantie valide jusqu'à ce que vous appeliez le DeleteGlobalRef.

Ce modèle est couramment utilisé lors de la mise en cache d'une jclass renvoyée par FindClass, par exemple :

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

Toutes les méthodes JNI acceptent les références locales et globales comme arguments. Il est possible que des références au même objet aient des valeurs différentes. Par exemple, les valeurs renvoyées par des appels consécutifs à NewGlobalRef sur le même objet peuvent être différentes. Pour vérifier si deux références font référence au même objet, vous devez utiliser la fonction IsSameObject. Ne comparez jamais les références avec == dans le code natif.

Par conséquent, vous ne devez pas supposer que les références d'objet sont constantes ou uniques dans le code natif. La valeur représentant un objet peut être différente d'une invocation de méthode à l'autre, et il est possible que deux objets différents aient la même valeur lors d'appels consécutifs. N'utilisez pas les valeurs jobject comme clés.

Les programmeurs sont tenus de ne pas allouer de références locales de manière excessive. En pratique, cela signifie que si vous créez un grand nombre de références locales, par exemple en parcourant un tableau d'objets, vous devez les libérer manuellement avec DeleteLocalRef au lieu de laisser JNI le faire pour vous. L'implémentation n'est requise que pour réserver des emplacements pour 16 références locales. Si vous en avez besoin de plus, vous devez les supprimer au fur et à mesure ou utiliser EnsureLocalCapacity/PushLocalFrame pour en réserver d'autres.

Notez que les jfieldID et les jmethodID sont des types opaques, et non des références d'objet. Ils ne doivent pas être transmis à NewGlobalRef. Les pointeurs de données brutes renvoyés par des fonctions telles que GetStringUTFChars et GetByteArrayElements ne sont pas non plus des objets. (Ils peuvent être transmis entre les threads et sont valides jusqu'à l'appel Release correspondant.)

Un cas inhabituel mérite une mention à part. Si vous associez un thread natif avec AttachCurrentThread, le code que vous exécutez ne libérera jamais automatiquement les références locales tant que le thread ne sera pas détaché. Toutes les références locales que vous créez devront être supprimées manuellement. En général, tout code natif qui crée des références locales dans une boucle doit probablement effectuer une suppression manuelle.

Faites attention lorsque vous utilisez des références globales. Les références globales peuvent être inévitables, mais elles sont difficiles à déboguer et peuvent entraîner des comportements de mémoire difficiles à diagnostiquer. Toutes choses égales par ailleurs, une solution avec moins de références globales est probablement meilleure.

Chaînes UTF-8 et UTF-16

Le langage de programmation Java utilise UTF-16. Pour plus de commodité, JNI fournit également des méthodes qui fonctionnent avec l'UTF-8 modifié. L'encodage modifié est utile pour le code C, car il encode \u0000 en 0xc0 0x80 au lieu de 0x00. L'avantage est que vous pouvez compter sur des chaînes de style C se terminant par un zéro, adaptées à l'utilisation avec les fonctions de chaîne libc standards. L'inconvénient est que vous ne pouvez pas transmettre de données UTF-8 arbitraires à JNI et vous attendre à ce qu'elles fonctionnent correctement.

Pour obtenir la représentation UTF-16 d'un String, utilisez GetStringChars. Notez que les chaînes UTF-16 ne se terminent pas par zéro et que \u0000 est autorisé. Vous devez donc conserver la longueur de la chaîne ainsi que le pointeur jchar.

N'oubliez pas de Release les chaînes que vous Get. Les fonctions de chaîne renvoient jchar* ou jbyte*, qui sont des pointeurs de style C vers des données primitives plutôt que des références locales. Ils sont garantis valides jusqu'à ce que Release soit appelé, ce qui signifie qu'ils ne sont pas libérés lorsque la méthode native renvoie une valeur.

Les données transmises à NewStringUTF doivent être au format UTF-8 modifié. Une erreur courante consiste à lire des données de caractères à partir d'un fichier ou d'un flux réseau, puis à les transmettre à NewStringUTF sans les filtrer. À moins que vous ne soyez sûr que les données sont au format MUTF-8 valide (ou ASCII 7 bits, qui est un sous-ensemble compatible), vous devez supprimer les caractères non valides ou les convertir au format Modified UTF-8 approprié. Sinon, la conversion UTF-16 risque de donner des résultats inattendus. CheckJNI, qui est activé par défaut pour les émulateurs, analyse les chaînes et interrompt la VM si elle reçoit une entrée non valide.

Avant Android 8, il était généralement plus rapide d'utiliser des chaînes UTF-16, car Android ne nécessitait pas de copie dans GetStringChars, alors que GetStringUTFChars nécessitait une allocation et une conversion en UTF-8. Android 8 a modifié la représentation String pour utiliser 8 bits par caractère pour les chaînes ASCII (afin d'économiser de la mémoire) et a commencé à utiliser un récupérateur de mémoire mobile. Ces fonctionnalités réduisent considérablement le nombre de cas où ART peut fournir un pointeur vers les données String sans en faire de copie, même pour GetStringCritical. Toutefois, si la plupart des chaînes traitées par le code sont courtes, il est possible d'éviter l'allocation et la désallocation dans la plupart des cas en utilisant un tampon alloué à la pile et GetStringRegion ou GetStringUTFRegion. Exemple :

    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);

Tableaux primitifs

JNI fournit des fonctions permettant d'accéder au contenu des objets de tableau. Alors que les tableaux d'objets doivent être consultés une entrée à la fois, les tableaux de primitives peuvent être lus et écrits directement comme s'ils étaient déclarés en C.

Pour rendre l'interface aussi efficace que possible sans contraindre l'implémentation de la VM, la famille d'appels Get<PrimitiveType>ArrayElements permet au runtime de renvoyer un pointeur vers les éléments réels ou d'allouer de la mémoire et d'en faire une copie. Dans les deux cas, le pointeur brut renvoyé est garanti valide jusqu'à l'appel Release correspondant (ce qui implique que, si les données n'ont pas été copiées, l'objet de tableau sera épinglé et ne pourra pas être déplacé lors de la compaction du tas). Vous devez Release chaque tableau que vous Get. De plus, si l'appel Get échoue, vous devez vous assurer que votre code n'essaie pas de Release un pointeur NULL ultérieurement.

Vous pouvez déterminer si les données ont été copiées ou non en transmettant un pointeur non NULL pour l'argument isCopy. Cela est rarement utile.

L'appel Release utilise un argument mode qui peut prendre l'une des trois valeurs suivantes. Les actions effectuées par le runtime dépendent de la réponse (pointeur vers les données réelles ou copie) :

  • 0
    • Réel : l'objet de tableau n'est pas épinglé.
    • Copier : les données sont recopiées. Le tampon contenant la copie est libéré.
  • JNI_COMMIT
    • Réel : ne fait rien.
    • Copier : les données sont recopiées. Le tampon avec la copie n'est pas libéré.
  • JNI_ABORT
    • Réel : l'objet de tableau n'est pas épinglé. Les écritures antérieures ne sont pas abandonnées.
    • Copie : le tampon contenant la copie est libéré et toutes les modifications apportées sont perdues.

L'une des raisons de vérifier l'indicateur isCopy est de savoir si vous devez appeler Release avec JNI_COMMIT après avoir apporté des modifications à un tableau. Si vous alternez entre les modifications et l'exécution de code qui utilise le contenu du tableau, vous pourrez peut-être ignorer le commit no-op. Une autre raison possible de vérifier l'indicateur est la gestion efficace de JNI_ABORT. Par exemple, vous pouvez obtenir un tableau, le modifier sur place, transmettre des éléments à d'autres fonctions, puis supprimer les modifications. Si vous savez que JNI crée une copie pour vous, il n'est pas nécessaire de créer une autre copie "modifiable". Si JNI vous transmet l'original, vous devez créer votre propre copie.

Une erreur courante (répétée dans l'exemple de code) consiste à supposer que vous pouvez ignorer l'appel Release si *isCopy est défini sur "false". Ce n'est pas vrai. Si aucun tampon de copie n'a été alloué, la mémoire d'origine doit être épinglée et ne peut pas être déplacée par le garbage collector.

Notez également que l'indicateur JNI_COMMIT ne libère pas le tableau. Vous devrez donc appeler à nouveau Release avec un autre indicateur.

Appels de région

Il existe une alternative aux appels tels que Get<Type>ArrayElements et GetStringChars, qui peut être très utile lorsque vous souhaitez simplement copier des données. Réfléchissez aux points suivants :

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

Cette opération récupère le tableau, copie les len premiers éléments d'octet et libère le tableau. Selon l'implémentation, l'appel Get épinglera ou copiera le contenu du tableau. Le code copie les données (peut-être une deuxième fois), puis appelle Release. Dans ce cas, JNI_ABORT garantit qu'il n'y aura pas de troisième copie.

Il est possible d'obtenir le même résultat plus simplement :

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

Ce fonctionnement offre plusieurs avantages :

  • Nécessite un seul appel JNI au lieu de deux, ce qui réduit la surcharge.
  • Ne nécessite pas d'épinglage ni de copies de données supplémentaires.
  • Réduit le risque d'erreur de programmation : aucun risque d'oublier d'appeler Release en cas d'échec.

De même, vous pouvez utiliser l'appel Set<Type>ArrayRegion pour copier des données dans un tableau, et GetStringRegion ou GetStringUTFRegion pour copier des caractères à partir d'un String.

Exceptions

Vous ne devez pas appeler la plupart des fonctions JNI lorsqu'une exception est en attente. Votre code est censé détecter l'exception (via la valeur de retour de la fonction, ExceptionCheck ou ExceptionOccurred) et renvoyer ou effacer l'exception et la gérer.

Les seules fonctions JNI que vous êtes autorisé à appeler lorsqu'une exception est en attente sont les suivantes :

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

De nombreux appels JNI peuvent générer une exception, mais offrent souvent un moyen plus simple de vérifier l'échec. Par exemple, si NewString renvoie une valeur non NULL, vous n'avez pas besoin de rechercher une exception. Toutefois, si vous appelez une méthode (à l'aide d'une fonction telle que CallObjectMethod), vous devez toujours vérifier si une exception a été générée, car la valeur renvoyée ne sera pas valide si une exception a été générée.

Notez que les exceptions générées par le code géré ne déroulent pas les frames de pile native. (De plus, les exceptions C++, généralement déconseillées sur Android, ne doivent pas être déclenchées au-delà de la limite de transition JNI du code C++ au code géré.) Les instructions JNI Throw et ThrowNew définissent simplement un pointeur d'exception dans le thread actuel. Lorsque vous revenez au code géré à partir du code natif, l'exception est notée et gérée de manière appropriée.

Le code natif peut "attraper" une exception en appelant ExceptionCheck ou ExceptionOccurred, et l'effacer avec ExceptionClear. Comme d'habitude, ignorer les exceptions sans les gérer peut entraîner des problèmes.

Il n'existe aucune fonction intégrée pour manipuler l'objet Throwable lui-même. Par conséquent, si vous souhaitez (par exemple) obtenir la chaîne d'exception, vous devrez trouver la classe Throwable, rechercher l'ID de méthode pour getMessage "()Ljava/lang/String;", l'appeler et, si le résultat n'est pas NULL, utiliser GetStringUTFChars pour obtenir quelque chose que vous pouvez transmettre à printf(3) ou à un équivalent.

Vérification étendue

JNI effectue très peu de vérifications d'erreurs. Les erreurs entraînent généralement un plantage. Android propose également un mode appelé CheckJNI, dans lequel les pointeurs de table de fonctions JavaVM et JNIEnv sont remplacés par des tables de fonctions qui effectuent une série de vérifications étendues avant d'appeler l'implémentation standard.

Voici les vérifications supplémentaires :

  • Tableaux : tentative d'allocation d'un tableau de taille négative.
  • Pointeurs incorrects : transmission d'un jarray/jclass/jobject/jstring incorrect à un appel JNI ou transmission d'un pointeur NULL à un appel JNI avec un argument non nullable.
  • Noms de classe : transmettre un nom de classe autre que le style "java/lang/String" à un appel JNI.
  • Appels critiques : effectuer un appel JNI entre une récupération "critique" et sa version correspondante.
  • Direct ByteBuffers : transmission d'arguments incorrects à NewDirectByteBuffer.
  • Exceptions : effectuer un appel JNI alors qu'une exception est en attente.
  • JNIEnv*s : utilisation d'un JNIEnv* à partir du mauvais thread.
  • jfieldIDs : utilisation d'un jfieldID NULL, ou d'un jfieldID pour définir un champ sur une valeur de type incorrect (par exemple, en essayant d'attribuer un StringBuilder à un champ String), ou d'un jfieldID pour un champ statique afin de définir un champ d'instance ou inversement, ou d'un jfieldID d'une classe avec des instances d'une autre classe.
  • jmethodIDs : utilisation du mauvais type de jmethodID lors d'un appel JNI Call*Method : type de retour incorrect, incompatibilité statique/non statique, type incorrect pour "this" (pour les appels non statiques) ou classe incorrecte (pour les appels statiques).
  • Références : utilisation de DeleteGlobalRef/DeleteLocalRef sur le mauvais type de référence.
  • Modes de publication : transmission d'un mode de publication incorrect à un appel de publication (autre que 0, JNI_ABORT ou JNI_COMMIT).
  • Sûreté du typage : renvoi d'un type incompatible à partir de votre méthode native (renvoi d'un StringBuilder à partir d'une méthode déclarée pour renvoyer une chaîne, par exemple).
  • UTF-8 : transmission d'une séquence d'octets UTF-8 modifié non valide à un appel JNI.

(L'accessibilité des méthodes et des champs n'est toujours pas vérifiée : les restrictions d'accès ne s'appliquent pas au code natif.)

Il existe plusieurs façons d'activer CheckJNI.

Si vous utilisez l'émulateur, CheckJNI est activé par défaut.

Si votre appareil est rooté, vous pouvez utiliser la séquence de commandes suivante pour redémarrer l'environnement d'exécution avec CheckJNI activé :

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

Dans l'un ou l'autre de ces cas, un message semblable à celui-ci s'affiche dans la sortie logcat au démarrage du runtime :

D AndroidRuntime: CheckJNI is ON

Si vous disposez d'un appareil standard, vous pouvez utiliser la commande suivante :

adb shell setprop debug.checkjni 1

Cela n'aura aucune incidence sur les applications déjà en cours d'exécution, mais toute application lancée à partir de ce moment-là aura CheckJNI activé. (Si vous modifiez la propriété en lui attribuant une autre valeur ou si vous redémarrez simplement l'appareil, CheckJNI sera de nouveau désactivé.) Dans ce cas, la prochaine fois qu'une application démarrera, vous verrez quelque chose comme ceci dans la sortie logcat :

D Late-enabling CheckJNI

Vous pouvez également définir l'attribut android:debuggable dans le fichier manifeste de votre application pour activer CheckJNI uniquement pour votre application. Notez que les outils de compilation Android le feront automatiquement pour certains types de compilation.

Bibliothèques natives

Vous pouvez charger du code natif à partir de bibliothèques partagées avec le System.loadLibrary standard.

En pratique, les anciennes versions d'Android comportaient des bugs dans le gestionnaire de packages qui affectaient la fiabilité de l'installation et de la mise à jour des bibliothèques natives. Le projet ReLinker propose des solutions pour ce problème et d'autres problèmes de chargement de bibliothèques natives.

Appelez System.loadLibrary (ou ReLinker.loadLibrary) à partir d'un initialiseur de classe statique. L'argument est le nom de la bibliothèque "non décoré". Par conséquent, pour charger libfubar.so, vous devez transmettre "fubar".

Si vous n'avez qu'une seule classe avec des méthodes natives, il est logique que l'appel à System.loadLibrary se trouve dans un initialiseur statique pour cette classe. Sinon, vous pouvez passer l'appel depuis Application pour vous assurer que la bibliothèque est toujours chargée, et toujours chargée tôt.

Le runtime peut trouver vos méthodes natives de deux manières. Vous pouvez les enregistrer explicitement avec RegisterNatives ou laisser le temps d'exécution les rechercher de manière dynamique avec dlsym. L'avantage de RegisterNatives est que vous bénéficiez d'une vérification initiale de l'existence des symboles. De plus, vous pouvez obtenir des bibliothèques partagées plus petites et plus rapides en n'exportant que JNI_OnLoad. L'avantage de laisser l'environnement d'exécution découvrir vos fonctions est que vous avez un peu moins de code à écrire.

Pour utiliser RegisterNatives :

  • Fournissez une fonction JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved).
  • Dans votre JNI_OnLoad, enregistrez toutes vos méthodes natives à l'aide de RegisterNatives.
  • Créez votre bibliothèque avec un script de version (méthode recommandée) ou utilisez -fvisibility=hidden pour que seul votre JNI_OnLoad soit exporté. Cela permet de générer un code plus rapide et plus petit, et d'éviter les collisions potentielles avec d'autres bibliothèques chargées dans votre application (mais cela crée des traces de pile moins utiles si votre application plante dans le code natif).

L'initialiseur statique doit se présenter comme suit :

Kotlin

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

Java

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

La fonction JNI_OnLoad devrait se présenter comme suit si elle est écrite en 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;
}

Pour utiliser la "découverte" des méthodes natives, vous devez les nommer d'une manière spécifique (pour en savoir plus, consultez la spécification JNI). Cela signifie que si une signature de méthode est incorrecte, vous ne le saurez que la première fois que la méthode sera réellement appelée.

Tous les appels FindClass effectués à partir de JNI_OnLoad résolvent les classes dans le contexte du chargeur de classe utilisé pour charger la bibliothèque partagée. Lorsqu'il est appelé à partir d'autres contextes, FindClass utilise le chargeur de classe associé à la méthode en haut de la pile Java. S'il n'y en a pas (car l'appel provient d'un thread natif qui vient d'être associé), il utilise le chargeur de classe "système". Le chargeur de classe système ne connaît pas les classes de votre application. Vous ne pourrez donc pas rechercher vos propres classes avec FindClass dans ce contexte. Cela fait de JNI_OnLoad un endroit pratique pour rechercher et mettre en cache des classes : une fois que vous disposez d'une référence globale jclass valide, vous pouvez l'utiliser à partir de n'importe quel thread associé.

Appels natifs plus rapides avec @FastNative et @CriticalNative

Les méthodes natives peuvent être annotées avec @FastNative ou @CriticalNative (mais pas les deux) pour accélérer les transitions entre le code géré et le code natif. Toutefois, ces annotations entraînent certains changements de comportement qui doivent être soigneusement pris en compte avant utilisation. Nous mentionnons brièvement ces modifications ci-dessous, mais veuillez consulter la documentation pour en savoir plus.

L'annotation @CriticalNative ne peut être appliquée qu'aux méthodes natives qui n'utilisent pas d'objets gérés (dans les paramètres ou les valeurs de retour, ou en tant que this implicite). Cette annotation modifie l'ABI de transition JNI. L'implémentation native doit exclure les paramètres JNIEnv et jclass de sa signature de fonction.

Lors de l'exécution d'une méthode @FastNative ou @CriticalNative, le garbage collector ne peut pas suspendre le thread pour les tâches essentielles et peut être bloqué. N'utilisez pas ces annotations pour les méthodes de longue durée, y compris les méthodes généralement rapides, mais généralement illimitées. En particulier, le code ne doit pas effectuer d'opérations d'E/S importantes ni acquérir de verrous natifs pouvant être conservés pendant une longue période.

Ces annotations ont été implémentées pour une utilisation système depuis Android 8 et sont devenues une API publique testée par CTS dans Android 14. Ces optimisations sont susceptibles de fonctionner également sur les appareils Android 8 à 13 (bien que sans les fortes garanties CTS), mais la recherche dynamique des méthodes natives n'est prise en charge que sur Android 12 et versions ultérieures. L'enregistrement explicite avec JNI RegisterNatives est strictement requis pour l'exécution sur les versions d'Android 8 à 11. Ces annotations sont ignorées sur Android 7 et versions antérieures. L'incompatibilité ABI pour @CriticalNative entraînerait un marshaling d'arguments incorrect et probablement des plantages.

Pour les méthodes critiques en termes de performances qui nécessitent ces annotations, il est fortement recommandé d'enregistrer explicitement les méthodes avec JNI RegisterNatives au lieu de s'appuyer sur la "découverte" basée sur le nom des méthodes natives. Pour obtenir des performances de démarrage optimales de l'application, il est recommandé d'inclure les appelants des méthodes @FastNative ou @CriticalNative dans le profil de référence. Depuis Android 12, un appel à une méthode native @CriticalNative à partir d'une méthode gérée compilée est presque aussi peu coûteux qu'un appel non intégré en C/C++, à condition que tous les arguments tiennent dans les registres (par exemple, jusqu'à huit arguments entiers et jusqu'à huit arguments à virgule flottante sur arm64).

Il peut parfois être préférable de diviser une méthode native en deux : une méthode très rapide qui peut échouer et une autre qui gère les cas lents. Exemple :

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);

Considérations liées au 64 bits

Pour prendre en charge les architectures qui utilisent des pointeurs 64 bits, utilisez un champ long plutôt qu'un champ int lorsque vous stockez un pointeur vers une structure native dans un champ Java.

Fonctionnalités non compatibles/Rétrocompatibilité

Toutes les fonctionnalités JNI 1.6 sont acceptées, à l'exception de la suivante :

  • DefineClass n'est pas implémenté. Android n'utilise pas de bytecode ni de fichiers de classe Java. Par conséquent, la transmission de données de classe binaires ne fonctionne pas.

Pour assurer la rétrocompatibilité avec les anciennes versions d'Android, vous devez peut-être tenir compte des points suivants :

  • Recherche dynamique des fonctions natives

    Jusqu'à Android 2.0 (Eclair), le caractère "$" n'était pas correctement converti en "_00024" lors des recherches de noms de méthodes. Pour contourner ce problème, vous devez utiliser l'enregistrement explicite ou déplacer les méthodes natives hors des classes internes.

  • Détacher des fils de discussion

    Jusqu'à Android 2.0 (Eclair), il n'était pas possible d'utiliser une fonction de destructeur pthread_key_create pour éviter la vérification "le thread doit être détaché avant la sortie". (L'environnement d'exécution utilise également une fonction de destructeur de clé pthread. Il s'agit donc d'une course pour voir laquelle est appelée en premier.)

  • Références globales faibles

    Les références globales faibles n'étaient pas implémentées avant Android 2.2 (Froyo). Les anciennes versions refuseront catégoriquement toute tentative d'utilisation. Vous pouvez utiliser les constantes de version de la plate-forme Android pour tester la compatibilité.

    Jusqu'à Android 4.0 (Ice Cream Sandwich), les références globales faibles ne pouvaient être transmises qu'à NewLocalRef, NewGlobalRef et DeleteWeakGlobalRef. (La spécification encourage vivement les programmeurs à créer des références dures aux variables globales faibles avant de les utiliser. Cela ne devrait donc pas être limitatif.)

    À partir d'Android 4.0 (Ice Cream Sandwich), les références globales faibles peuvent être utilisées comme n'importe quelle autre référence JNI.

  • Références locales

    Jusqu'à Android 4.0 (Ice Cream Sandwich), les références locales étaient en fait des pointeurs directs. Ice Cream Sandwich a ajouté l'indirection nécessaire pour prendre en charge de meilleurs collecteurs de déchets, mais cela signifie que de nombreux bugs JNI sont indétectables sur les anciennes versions. Pour en savoir plus, consultez Modifications apportées aux références locales JNI dans ICS.

    Dans les versions d'Android antérieures à Android 8.0, le nombre de références locales est limité à une valeur spécifique à la version. À partir d'Android 8.0, Android prend en charge un nombre illimité de références locales.

  • Déterminer le type de référence avec GetObjectRefType

    Jusqu'à Android 4.0 (Ice Cream Sandwich), il était impossible d'implémenter correctement GetObjectRefType en raison de l'utilisation de pointeurs directs (voir ci-dessus). Nous avons plutôt utilisé une heuristique qui examinait la table des variables globales faibles, les arguments, la table des variables locales et la table des variables globales dans cet ordre. La première fois qu'il trouve votre pointeur direct, il indique que votre référence est du type qu'il examine. Cela signifiait, par exemple, que si vous appeliez GetObjectRefType sur une jclass globale qui se trouvait être la même que la jclass transmise en tant qu'argument implicite à votre méthode native statique, vous obtiendriez JNILocalRefType plutôt que JNIGlobalRefType.

  • @FastNative et @CriticalNative

    Jusqu'à Android 7, ces annotations d'optimisation étaient ignorées. La non-concordance de l'ABI pour @CriticalNative entraînerait un marshaling d'arguments incorrect et probablement des plantages.

    La recherche dynamique des fonctions natives pour les méthodes @FastNative et @CriticalNative n'a pas été implémentée dans Android 8 à 10 et contient des bugs connus dans Android 11. L'utilisation de ces optimisations sans enregistrement explicite avec JNI RegisterNatives est susceptible d'entraîner des plantages sur Android 8 à 11.

  • FindClass throws ClassNotFoundException

    Pour assurer la rétrocompatibilité, Android génère ClassNotFoundException au lieu de NoClassDefFoundError lorsqu'une classe n'est pas trouvée par FindClass. Ce comportement est conforme à l'API de réflexion Java Class.forName(name).

Questions fréquentes : Pourquoi le symbole UnsatisfiedLinkError s'affiche-t-il ?

Lorsque vous travaillez sur du code natif, il n'est pas rare de rencontrer une erreur comme celle-ci :

java.lang.UnsatisfiedLinkError: Library foo not found

Dans certains cas, cela signifie que la bibliothèque n'a pas été trouvée. Dans d'autres cas, la bibliothèque existe, mais n'a pas pu être ouverte par dlopen(3). Les détails de l'échec sont disponibles dans le message de détail de l'exception.

Voici les raisons courantes pour lesquelles vous pouvez rencontrer des exceptions "bibliothèque introuvable" :

  • La bibliothèque n'existe pas ou n'est pas accessible à l'application. Utilisez adb shell ls -l <path> pour vérifier sa présence et ses autorisations.
  • La bibliothèque n'a pas été créée avec le NDK. Cela peut entraîner des dépendances à des fonctions ou des bibliothèques qui n'existent pas sur l'appareil.

Voici un autre type d'échec UnsatisfiedLinkError :

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

Dans logcat, vous verrez :

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

Cela signifie que le runtime a essayé de trouver une méthode correspondante, mais qu'il n'y est pas parvenu. Voici quelques raisons courantes :

  • La bibliothèque ne se charge pas. Consultez la sortie logcat pour obtenir des messages sur le chargement de la bibliothèque.
  • La méthode n'est pas trouvée en raison d'une incompatibilité de nom ou de signature. Cela est généralement dû à l'une de ces raisons :
    • Pour la recherche de méthode différée, il est impossible de déclarer des fonctions C++ avec extern "C" et la visibilité appropriée (JNIEXPORT). Notez qu'avant Ice Cream Sandwich, la macro JNIEXPORT était incorrecte. Par conséquent, l'utilisation d'un nouveau GCC avec un ancien jni.h ne fonctionnera pas. Vous pouvez utiliser arm-eabi-nm pour voir les symboles tels qu'ils apparaissent dans la bibliothèque. S'ils semblent déformés (par exemple, _Z15Java_Foo_myfuncP7_JNIEnvP7_jclass au lieu de Java_Foo_myfunc) ou si le type de symbole est une minuscule "t" au lieu d'une majuscule "T", vous devez ajuster la déclaration.
    • Pour l'enregistrement explicite, erreurs mineures lors de la saisie de la signature de la méthode. Assurez-vous que ce que vous transmettez à l'appel d'enregistrement correspond à la signature dans le fichier journal. N'oubliez pas que "B" correspond à byte et "Z" à boolean. Les composants de nom de classe dans les signatures commencent par "L", se terminent par ";", utilisent "/" pour séparer les noms de package/classe et utilisent "$" pour séparer les noms de classe interne (Ljava/util/Map$Entry;, par exemple).

L'utilisation de javah pour générer automatiquement des en-têtes JNI peut vous aider à éviter certains problèmes.

Questions fréquentes : Pourquoi FindClass n'a-t-il pas trouvé mon cours ?

(La plupart de ces conseils s'appliquent également aux échecs de recherche de méthodes avec GetMethodID ou GetStaticMethodID, ou de champs avec GetFieldID ou GetStaticFieldID.)

Assurez-vous que la chaîne du nom de la classe est au bon format. Les noms de classes JNI commencent par le nom du package et sont séparés par des barres obliques, comme java/lang/String. Si vous recherchez une classe de tableau, vous devez commencer par le nombre approprié de crochets et également encadrer la classe avec "L" et ";". Par exemple, un tableau unidimensionnel de String serait [Ljava/lang/String;. Si vous recherchez une classe interne, utilisez "$" plutôt que ".". En général, l'utilisation de javap sur le fichier .class est un bon moyen de trouver le nom interne de votre classe.

Si vous activez la réduction du code, assurez-vous de configurer le code à conserver. Il est important de configurer des règles de conservation appropriées, car le réducteur de code pourrait sinon supprimer des classes, des méthodes ou des champs qui ne sont utilisés que par JNI.

Si le nom de la classe semble correct, il peut s'agir d'un problème de chargeur de classe. FindClass souhaite lancer la recherche de classe dans le chargeur de classe associé à votre code. Il examine la pile d'appels, qui ressemble à ceci :

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

La méthode la plus élevée est Foo.myfunc. FindClass trouve l'objet ClassLoader associé à la classe Foo et l'utilise.

Cela fait généralement ce que vous voulez. Vous pouvez rencontrer des problèmes si vous créez vous-même un thread (par exemple, en appelant pthread_create, puis en l'associant avec AttachCurrentThread). Il n'y a plus de frames de pile provenant de votre application. Si vous appelez FindClass à partir de ce thread, la JavaVM démarrera dans le chargeur de classe "system" au lieu de celui associé à votre application. Les tentatives de recherche de classes spécifiques à l'application échoueront donc.

Voici quelques façons de contourner ce problème :

  • Effectuez vos recherches FindClass une seule fois, dans JNI_OnLoad, et mettez en cache les références de classe pour une utilisation ultérieure. Tous les appels FindClass effectués lors de l'exécution de JNI_OnLoad utiliseront le chargeur de classe associé à la fonction qui a appelé System.loadLibrary (il s'agit d'une règle spéciale, fournie pour faciliter l'initialisation de la bibliothèque). Si le code de votre application charge la bibliothèque, FindClass utilisera le bon chargeur de classe.
  • Transmettez une instance de la classe aux fonctions qui en ont besoin, en déclarant votre méthode native pour qu'elle accepte un argument de classe, puis en transmettant Foo.class.
  • Mettez en cache une référence à l'objet ClassLoader quelque part à portée de main et émettez des appels loadClass directement. Cela demande un certain effort.

Questions fréquentes : Comment partager des données brutes avec du code natif ?

Vous pouvez vous retrouver dans une situation où vous devez accéder à un grand tampon de données brutes à partir de code géré et natif. Il peut s'agir, par exemple, de la manipulation de bitmaps ou d'échantillons sonores. Il existe deux approches de base.

Vous pouvez stocker les données dans un byte[]. Cela permet un accès très rapide à partir du code géré. Cependant, du côté natif, vous n'êtes pas assuré de pouvoir accéder aux données sans avoir à les copier. Dans certaines implémentations, GetByteArrayElements et GetPrimitiveArrayCritical renvoient des pointeurs réels vers les données brutes dans le tas géré, mais dans d'autres, ils allouent un tampon dans le tas natif et y copient les données.

L'autre option consiste à stocker les données dans un tampon d'octets direct. Ils peuvent être créés avec java.nio.ByteBuffer.allocateDirect ou la fonction JNI NewDirectByteBuffer. Contrairement aux tampons d'octets standards, le stockage n'est pas alloué sur le tas géré et est toujours accessible directement à partir du code natif (obtenez l'adresse avec GetDirectBufferAddress). L'accès aux données à partir du code géré peut être très lent, selon la façon dont l'accès direct aux tampons d'octets est implémenté.

Le choix de l'un ou l'autre dépend de deux facteurs :

  1. La plupart des accès aux données se feront-ils à partir de code écrit en Java ou en C/C++ ?
  2. Si les données sont finalement transmises à une API système, sous quelle forme doivent-elles se présenter ? (Par exemple, si les données sont finalement transmises à une fonction qui accepte un byte[], il peut être imprudent de les traiter dans un ByteBuffer direct.)

S'il n'y a pas de configuration gagnante évidente, utilisez un tampon d'octets direct. La compatibilité avec ces types est intégrée directement à JNI, et les performances devraient s'améliorer dans les prochaines versions.