Conseils sur JNI

JNI est l'interface native Java. Il définit un moyen pour le bytecode qu'Android compile à partir de code géré (écrit dans les langages de programmation Java ou Kotlin) d'interagir avec le code natif (écrit en C/C++). JNI est neutre du point de vue du fournisseur, prend en charge le chargement de code à partir de bibliothèques partagées dynamiques, et bien que fastidieux, soit raisonnablement efficace, est parfois raisonnable.

Remarque:Comme 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 ce n'est pas déjà fait, consultez la spécification de l'interface native Java pour vous faire une idée du fonctionnement de JNI et des 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ù ces références 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 réduire l'encombrement de votre couche JNI. Plusieurs dimensions doivent être prises en compte ici. Votre solution JNI doit essayer de suivre ces consignes (répertoriées ci-dessous par ordre d'importance, en commençant par les plus importantes):

  • Minimisez le marshaling des ressources sur la couche JNI. Le marshaling sur la couche JNI entraîne des coûts importants. Essayez de concevoir une interface qui minimise la quantité de données à marshaler et la fréquence à laquelle vous devez le traiter.
  • Évitez, si possible, la communication asynchrone entre le code écrit dans un langage de programmation géré et celui écrit en C++. Votre interface JNI sera ainsi plus facile à gérer. En règle générale, vous pouvez simplifier les mises à jour asynchrones de l'interface utilisateur en conservant la mise à jour asynchrone dans le même langage que l'interface utilisateur. Par exemple, au lieu d'appeler une fonction C++ à partir du thread 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 UI lorsque l'appel bloquant est terminé.
  • Réduisez le nombre de threads qui doivent être tactiles ou tactiles par 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 faible nombre d'emplacements sources C++ et Java facilement identifiés pour faciliter les refactorisations futures. Envisagez d'utiliser une bibliothèque de génération automatique JNI si nécessaire.

JavaVM et JNIEnv

JNI définit deux structures de données clés : "JavaVM" et "JNIEnv". Ces deux exemples sont essentiellement des pointeurs vers des tables de fonctions. (Dans la version C++, ce sont des classes avec un pointeur vers une table de fonctions et une fonction membre pour chaque fonction JNI qui indirectement via la table.) JavaVM fournit les fonctions d'"interface d'appel", 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.

JNIEnv fournit la plupart des fonctions JNI. Vos fonctions natives reçoivent toutes un JNIEnv comme premier argument, à l'exception des méthodes @CriticalNative. Consultez la section Appels natifs plus rapides.

JNIEnv est utilisé pour le stockage local au thread. Pour cette raison, vous ne pouvez pas partager un JNIEnv entre les threads. Si un morceau de code ne dispose d'aucun autre moyen d'obtenir son JNIEnv, vous devez partager la VM Java et utiliser GetEnv pour découvrir l'JNIEnv du thread. (Supposons qu'il en possède une ; voir AttachCurrentThread ci-dessous.)

Les déclarations C de JNIEnv et JavaVM sont différentes des déclarations C++. Le fichier d'inclusion "jni.h" fournit différentes définitions de type selon qu'il est inclus en C ou C++. Par conséquent, 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 des tâches supplémentaires si un élément de cet en-tête fait référence à JNIEnv.)

Fils de discussion

Tous les threads sont des threads Linux, planifiés par le noyau. Elles sont généralement démarrées à partir du code géré (à l'aide de Thread.start()), mais peuvent également être créées ailleurs, puis associées à 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 n'a 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. Cela garantit que vous disposez d'un espace de pile suffisant, que vous vous trouvez dans le bon ThreadGroup et que vous utilisez 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 (consultez pthread_setname_np() si vous disposez d'un pthread_t ou d'un thread_t, et std::thread::native_handle() si vous disposez d'un std::thread et souhaitez un pthread_t).

L'association d'un thread créé de manière native entraîne la création d'un objet java.lang.Thread et l'ajout à la ThreadGroup "principale", ce qui le rend visible par le débogueur. L'appel de AttachCurrentThread() sur un thread déjà associé est une opération no-op.

Android ne suspend pas les threads exécutant du code natif. Si la récupération de mémoire est en cours ou si le débogueur a émis une requête de suspension, Android suspendra 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 n'est pas pratique, dans Android 2.0 (Eclair) ou version ultérieure, vous pouvez utiliser pthread_key_create() pour définir une fonction de destruction 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 thread-local-storage. 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.
  • Récupérez 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 sont souvent de simples pointeurs vers les structures de données de l'environnement d'exécution interne. Leur recherche peut nécessiter plusieurs comparaisons de chaînes, mais une fois que vous les avez obtenues, l'appel réel permettant d'obtenir le champ ou d'appeler la méthode est très rapide.

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

La validité des références de classe, des ID de champ et des ID de méthode est garantie jusqu'au déchargement de la classe. Les classes ne sont déchargées que si toutes les classes associées à un ClassLoader peuvent faire l'objet d'une récupération de mémoire, ce qui est rare, mais ne sera 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 lors du chargement d'une classe et les remettre automatiquement en cache si la classe est déchargée et rechargée un jour, la bonne façon d'initialiser les ID consiste à ajouter un extrait de code semblable à ceci à 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 dans votre code C/C++ une méthode nativeClassInit qui effectue les recherches d'ID. Le code sera exécuté une fois, lors de l'initialisation de la classe. Si la classe est déchargée, puis rechargée, elle sera à nouveau exécutée.

Références locales et internationales

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'elle est valide pour la durée de la méthode native actuelle dans le thread actuel. Même si l'objet lui-même continue de fonctionner 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 avertit 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 en renvoie une référence globale. La référence globale est valide jusqu'à ce que vous appelez DeleteGlobalRef.

Ce modèle est couramment utilisé lors de la mise en cache d'une classe 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 en tant qu'arguments. Il est possible que les 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 voir 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 == en code natif.

L'une des conséquences est que vous ne devez pas supposer que les références d'objets sont constantes ou uniques dans le code natif. La valeur représentant un objet peut être différente d'un appel d'une 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 de valeurs jobject comme clés.

Les programmeurs ne doivent pas allouer excessivement les références locales. Concrètement, cela signifie que si vous créez un grand nombre de références locales, par exemple lorsque vous parcourez 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 nécessaire que pour réserver des emplacements pour 16 références locales. Si vous avez besoin de plus d'emplacements, vous devez donc les supprimer au fur et à mesure ou utiliser EnsureLocalCapacity/PushLocalFrame pour en réserver davantage.

Notez que les éléments jfieldID et jmethodID sont des types opaques, et non des références d'objets, et 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 d'être mentionné. Si vous associez un thread natif avec AttachCurrentThread, le code que vous exécutez ne libère jamais automatiquement des références locales tant que le thread n'est pas dissocié. 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 être supprimé manuellement.

Soyez prudent 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 (incorrects) de la mémoire difficiles à diagnostiquer. Toutes choses égales par ailleurs, une solution avec moins de références globales est probablement préférable.

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 compatibles avec le code 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 type C se terminant par zéro, adaptées aux 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 d'utiliser Release pour 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. Leur validité est garantie jusqu'à l'appel de Release, ce qui signifie qu'elles ne sont pas publiées lorsque la méthode native est renvoyée.

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 et à les transmettre à NewStringUTF sans les filtrer. Sauf si vous savez 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 UTF-8 modifié approprié. Si vous ne le faites pas, 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 abandonne 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 n'avait pas besoin d'une copie dans GetStringChars, tandis que GetStringUTFChars nécessitait une allocation et une conversion au format 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 en mouvement. Ces fonctionnalités réduisent considérablement le nombre de cas où ART peut fournir un pointeur vers les données String sans en créer 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. Par exemple :

    constexpr size_t kStackBufferSize = 64u;
    jchar stack_buffer[kStackBufferSize];
    std::unique_ptr 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 accessibles avec 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 limiter la mise en œuvre de la VM, la famille d'appels Get<PrimitiveType>ArrayElements permet à l'environnement d'exécution de renvoyer un pointeur vers les éléments réels ou d'allouer de la mémoire et de créer une copie. Dans tous les cas, le pointeur brut renvoyé est garanti jusqu'à ce que l'appel Release correspondant soit émis (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é dans le cadre du compactage du tas de mémoire). Vous devez utiliser Release pour chaque tableau Get. De plus, si l'appel Get échoue, vous devez vous assurer que votre code n'essaie pas de Release un pointeur NULL par la suite.

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

L'appel Release utilise un argument mode qui peut avoir l'une des trois valeurs suivantes. Les actions effectuées par l'environnement d'exécution varient selon qu'il a renvoyé un pointeur vers les données réelles ou une copie de celles-ci:

  • 0
    • Réelle: l'objet de tableau n'est plus épinglé.
    • Copy: les données sont copiées. Le tampon contenant la copie est libéré.
  • JNI_COMMIT
    • Réel: ne fait rien.
    • Copy: les données sont copiées. Le tampon contenant la copie n'est pas libéré.
  • JNI_ABORT
    • Réelle: l'objet de tableau n'est plus épinglé. Les écritures antérieures ne sont pas annulé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'option isCopy est de savoir si vous devez appeler Release avec JNI_COMMIT après avoir modifié un tableau. Si vous alternez entre effectuer des modifications et exécuter du code qui utilise le contenu du tableau, vous pouvez peut-être ignorer le commit no-op. Vous pouvez également vérifier l'indicateur pour gérer efficacement 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 effectue une nouvelle 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.

C'est une erreur courante (répétée dans l'exemple de code) de supposer que vous pouvez ignorer l'appel Release si *isCopy est "false". Ce n'est pas le cas. 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 récupérateur de mémoire.

Notez également que l'option JNI_COMMIT ne libère pas le tableau et que vous devrez à terme appeler Release à nouveau avec un indicateur différent.

Appels régionaux

Il existe une alternative aux appels tels que Get<Type>ArrayElements et GetStringChars, qui peut s'avérer très utile lorsque vous n'avez qu'à 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 premiers éléments d'octet len de celui-ci, puis 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 a aucun risque d'une troisième copie.

On peut accomplir la même chose plus simplement:

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

Ce fonctionnement offre plusieurs avantages :

  • Nécessite un appel JNI au lieu de deux, ce qui réduit les frais généraux.
  • Ne nécessite pas d'épinglage ni de copies de données supplémentaires.
  • Réduit le risque d'erreur du programmeur : plus aucun risque d'oublier d'appeler Release en cas de défaillance.

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 d'un String.

Exceptions

Vous ne devez pas appeler la plupart des fonctions JNI tant qu'une exception est en attente. Votre code doit remarquer l'exception (via la valeur renvoyée par la fonction, ExceptionCheck ou ExceptionOccurred) et la renvoyer, ou l'effacer ou la gérer.

Les seules fonctions JNI que vous êtes autorisé à appeler en attendant une exception 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 ils constituent souvent un moyen plus simple de vérifier la présence d'échecs. Par exemple, si NewString renvoie une valeur non nulle, 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 rechercher une exception, car la valeur renvoyée ne sera pas valide si une exception est générée.

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

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

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

Vérification étendue

JNI effectue très peu de vérifications des 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 basculés vers des tables de fonctions qui effectuent une série étendue de vérifications avant d'appeler l'implémentation standard.

Ces vérifications supplémentaires incluent les éléments suivants:

  • 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 transfert d'un pointeur NULL vers un appel JNI avec un argument ne pouvant être nul.
  • Noms de classe: transmission de tout élément autre que le style "java/lang/String" du nom de classe à un appel JNI.
  • Appels critiques: effectuer un appel JNI entre une opération "get" "critique" et sa version correspondante.
  • ByteBuffers directs: transmission d'arguments incorrects à NewDirectByteBuffer
  • Exceptions: effectuer un appel JNI alors qu'une exception est en attente.
  • JNIEnv*s: utilisation d'un JNIEnv* provenant du mauvais thread.
  • jfieldIDs: utilisation d'un jfieldID NULL, d'un jfieldID pour définir un champ sur une valeur de type incorrect (essayer d'attribuer un StringBuilder à un champ String, par exemple), d'un jfieldID pour un champ statique afin de définir un champ d'instance ou inversement, ou jfieldID d'une classe avec les instances d'une autre classe.
  • jmethodIDs: utilisation du mauvais type de jmethodID lors d'un appel JNI Call*Method: type renvoyé incorrect, non-concordance des valeurs statiques/non statiques, 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 version incorrect à un appel de version (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ée non valide à un appel JNI.

(L'accessibilité des méthodes et des champs n'est toujours pas coché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 en mode root, vous pouvez utiliser la séquence de commandes suivante pour redémarrer l'environnement d'exécution avec l'option CheckJNI activée:

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

Dans les deux cas, une sortie semblable à celle-ci s'affiche dans la sortie logcat au démarrage de l'environnement d'exécution:

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'affectera pas les applications en cours d'exécution, mais CheckJNI sera activé pour toutes les applications lancées à partir de ce moment-là. (Remplacez la propriété par une autre valeur ou redémarrez simplement CheckJNI en le redémarrant.) Dans ce cas, une sortie semblable à la suivante s'affichera dans la sortie logcat la prochaine fois qu'une application démarrera:

D Late-enabling CheckJNI

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

Bibliothèques natives

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

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

Appelez System.loadLibrary (ou ReLinker.loadLibrary) à partir d'un initialiseur de classe statique. L'argument est le nom de la bibliothèque "undecorated". 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 effectuer l'appel à partir de Application afin de savoir que la bibliothèque est toujours chargée et chargée en avance.

L'environnement d'exécution peut trouver vos méthodes natives de deux manières. Vous pouvez soit les enregistrer explicitement avec RegisterNatives, soit laisser l'environnement d'exécution les rechercher de manière dynamique avec dlsym. L'avantage de RegisterNatives est que vous pouvez vérifier en amont que les symboles existent, et vous pouvez disposer de bibliothèques partagées plus petites et plus rapides en n'exportant rien d'autre que JNI_OnLoad. L'avantage de laisser l'environnement d'exécution découvrir vos fonctions est qu'il nécessite 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.
  • Compilez avec -fvisibility=hidden afin que seul votre JNI_OnLoad soit exporté depuis votre bibliothèque. Cela produit un code plus rapide et plus petit et évite les conflits potentiels 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 place la "découverte" des méthodes natives, vous devez les nommer d'une manière spécifique (consultez la spécification JNI pour plus de détails). Cela signifie que si une signature de méthode est incorrecte, vous ne le saurez qu'une fois la méthode appelée pour la première fois.

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 "system". 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. JNI_OnLoad est donc un endroit pratique pour rechercher et mettre en cache les 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. Cependant, ces annotations entraînent certains changements de comportement qui doivent être soigneusement examinés avant d'être utilisés. Nous évoquerons brièvement ces changements 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, les valeurs de retour ou en tant que this implicite). Elle modifie l'ABI de transition JNI. L'implémentation native doit exclure les paramètres JNIEnv et jclass de la signature de sa fonction.

Lors de l'exécution d'une méthode @FastNative ou @CriticalNative, la récupération de mémoire ne peut pas suspendre le thread pour une tâche essentielle et peut être bloquée. 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 des verrous natifs pouvant être conservés pendant une longue période.

Ces annotations ont été implémentées pour être utilisées par le système depuis Android 8 et sont devenues une API publique testée CTS sur Android 14. Ces optimisations sont susceptibles de fonctionner également sur les appareils Android 8 à 13 (bien qu'ils ne bénéficient pas de garanties CTS solides), mais la recherche dynamique des méthodes natives n'est compatible qu'avec Android 12 et versions ultérieures. L'enregistrement explicite avec JNI RegisterNatives est strictement requis pour l'exécution sur Android versions 8 à 11. Ces annotations sont ignorées sur Android 7. La non-concordance de l'ABI pour @CriticalNative entraîne un mauvais marshaling des arguments et des plantages probables.

Pour les méthodes critiques en termes de performances nécessitant ces annotations, il est vivement recommandé d'enregistrer explicitement la ou les méthodes avec JNI RegisterNatives au lieu de compter sur la "découverte" des méthodes natives basée sur le nom. Pour obtenir des performances de démarrage optimales de l'application, nous vous recommandons 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 économique qu'un appel non intégré en C/C++ tant que tous les arguments peuvent être intégrés aux registres (par exemple, jusqu'à 8 intégrales et jusqu'à 8 arguments à virgule flottante sur arm64).

Parfois, il peut ê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. Par 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 relatives à l'architecture 64 bits

Pour prendre en charge les architectures utilisant 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 compatibles, à l'exception suivante:

  • DefineClass n'est pas implémenté. Android n'utilise pas de bytecodes ni de fichiers de classe Java. La transmission de données de classe binaire ne fonctionne donc pas.

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

  • Recherche dynamique de fonctions natives

    Avant 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 un enregistrement explicite ou déplacer les méthodes natives hors des classes internes.

  • Dissocier des threads

    Avant Android 2.0 (Eclair), il n'était pas possible d'utiliser une fonction de destruction pthread_key_create pour éviter la vérification du message "le thread doit être dissocié avant de quitter". (L'environnement d'exécution utilise également une fonction de destruction de clés pthread. Il est donc difficile de savoir lequel est appelé en premier.)

  • Références mondiales faibles

    Avant Android 2.2 (Froyo), les références globales faibles n'étaient pas implémentées. Les anciennes versions refuseront vigoureusement les tentatives 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 strictes aux éléments généraux faibles avant d'effectuer quoi que ce soit avec eux. Cela ne devrait donc pas être du tout limite.

    À partir d'Android 4.0 (Ice Cream Sandwich), les références globales faibles peuvent être utilisées comme toute 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 récupérateurs de mémoire, mais cela signifie que de nombreux bugs JNI sont indétectables sur les anciennes versions. Pour en savoir plus, consultez la section 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 limite spécifique à la version. À partir d'Android 8.0, Android accepte 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), en raison de l'utilisation de pointeurs directs (voir ci-dessus), il était impossible d'implémenter correctement GetObjectRefType. Au lieu de cela, nous avons utilisé une heuristique qui examine la table des éléments généraux faibles, les arguments, les tables locales et les tables globales, dans cet ordre. La première fois qu'il a trouvé votre pointeur direct, il a indiqué que votre référence était du type qu'il était en train d'examiner. Par exemple, si vous appelez GetObjectRefType sur une jclass globale qui était identique à la classe jclass transmise en tant qu'argument implicite à votre méthode native statique, vous obtiendrez JNILocalRefType au lieu de JNIGlobalRefType.

  • @FastNative et @CriticalNative

    Jusqu'à Android 7, ces annotations d'optimisation étaient ignorées. La non-concordance des ABI pour @CriticalNative entraînait un mauvais marshalage des arguments et provoquait des plantages.

    La recherche dynamique de 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 risque d'entraîner des plantages sur Android 8 à 11.

Question fréquente: Pourquoi est-ce que je reçois UnsatisfiedLinkError ?

Lorsque vous travaillez sur du code natif, il n'est pas rare que l'erreur suivante se produise:

java.lang.UnsatisfiedLinkError: Library foo not found

Dans certains cas, cela peut signifier que la bibliothèque est introuvable. 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 détaillé de l'exception.

Voici quelques-unes des principales raisons pour lesquelles vous pouvez rencontrer des exceptions "Bibliothèque introuvable" :

  • La bibliothèque n'existe pas ou l'application n'y a pas accès. 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 sur des fonctions ou des bibliothèques qui n'existent pas sur l'appareil.

Une autre classe d'échecs UnsatisfiedLinkError se présente comme suit:

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 l'environnement d'exécution a essayé de trouver une méthode de correspondance, mais sans succès. Voici quelques raisons courantes:

  • La bibliothèque ne se charge pas. Recherchez des messages sur le chargement de la bibliothèque dans la sortie logcat.
  • La méthode est introuvable en raison d'une non-concordance du nom ou de la signature. Cela est généralement dû aux raisons suivantes :
    • Pour la recherche de méthode différée, échec de la déclaration 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 afficher les symboles tels qu'ils apparaissent dans la bibliothèque. S'ils semblent tronqués (par exemple, _Z15Java_Foo_myfuncP7_JNIEnvP7_jclass plutôt que Java_Foo_myfunc) ou si le type de symbole est un "t" en minuscule plutôt qu'un "T" majuscule, vous devez ajuster la déclaration.
    • Pour un 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 du fichier journal. Rappelez-vous que "B" correspond à byte et que "Z" correspond à boolean. Dans les signatures, les composants de nom de classe commencent par la lettre "L", se terminent par un point (";"), / pour séparer les noms de package/classe et "$" pour séparer les noms de classe interne (par exemple, Ljava/util/Map$Entry;).

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

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

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

Assurez-vous que le format de la chaîne du nom de classe est correct. Les noms de classes JNI commencent par le nom du package et sont séparés par des barres obliques (par exemple, java/lang/String). Si vous recherchez une classe de tableau, vous devez commencer par le nombre approprié de crochets et l'encapsuler également avec "L" et ";". Ainsi, un tableau unidimensionnel de String aurait la valeur [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 connaître le nom interne de votre classe.

Si vous activez la minification de code, veillez à configurer le code à conserver. Il est important de configurer des règles de conservation appropriées, car le réducteur de code pourrait supprimer des classes, des méthodes ou des champs qui ne sont utilisés qu'à partir de JNI.

Si le nom de classe vous semble correct, vous rencontrez peut-être un problème lié au chargeur de classe. FindClass souhaite lancer la recherche de classe dans le chargeur de classe associé à votre code. Il examine la pile d'appel, qui se présente comme suit :

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

La méthode la plus élevée est Foo.myfunc. FindClass recherche 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). Votre application ne contient désormais aucun bloc de pile. Si vous appelez FindClass à partir de ce thread, la JavaVM démarrera dans le chargeur de classe "système" au lieu de celui associé à votre application. Par conséquent, les tentatives de recherche de classes spécifiques à l'application échoueront.

Il existe plusieurs 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 utilisent le chargeur de classe associé à la fonction qui a appelé System.loadLibrary (règle spéciale fournie pour faciliter l'initialisation de la bibliothèque). Si le code de votre application charge la bibliothèque, FindClass utilise 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 Class, puis en transmettant Foo.class.
  • Mettez en cache une référence à l'objet ClassLoader quelque part et envoyez directement des appels loadClass. Cela demande un certain effort.

Question fréquente: Comment partager des données brutes avec du code natif ?

Vous pouvez vous retrouver dans une situation où vous devez accéder à un tampon volumineux de données brutes à partir de code géré et natif. La manipulation de bitmaps ou d'échantillons audio en est un exemple courant. 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é. Toutefois, du côté natif, vous n'êtes pas certain 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 du tas de mémoire géré, mais dans d'autres, ils allouent un tampon sur le tas de mémoire natif et copie les données.

L'alternative consiste à stocker les données dans un tampon d'octets direct. Celles-ci peuvent être créées avec java.nio.ByteBuffer.allocateDirect ou avec la fonction JNI NewDirectByteBuffer. Contrairement aux tampons d'octets standards, le stockage n'est pas alloué sur le tas de mémoire géré et est toujours accessible directement à partir du code natif (obtenir l'adresse avec GetDirectBufferAddress). Selon le mode d'implémentation de l'accès direct au tampon d'octets, l'accès aux données à partir du code géré peut être très lent.

Le choix de l'option à utiliser dépend de deux facteurs:

  1. La plupart des accès aux données se feront-ils à partir de code écrit en Java ou C/C++ ?
  2. Si les données sont finalement transmises à une API système, sous quelle forme doivent-elles se trouver ? (Par exemple, si les données finissent par être transmises à une fonction qui prend un byte[], il peut s'avérer déconseillé d'effectuer un traitement dans une ByteBuffer directe.)

En l'absence de gagnant, utilisez un tampon d'octets direct. Leur prise en charge est directement intégrée à JNI, et les performances devraient s'améliorer dans les prochaines versions.