JNI に関するヒント

JNI(Java Native Interface)では、Android がマネージドコード(Java または Kotlin プログラミング言語で記述)からコンパイルするバイトコードを、C / C++ で記述されたネイティブ コード(C / C++ で記述)とやり取りする方法を定義します。JNI はベンダーに依存せず、動的共有ライブラリからのコードの読み込みをサポートしていますが、面倒な場合もあるほど効率的です。

注: Android では Java プログラミング言語と同様の方法で Kotlin が ART 対応のバイトコードにコンパイルされるため、このページのガイダンスは、JNI アーキテクチャと関連コストに関して Kotlin と Java プログラミング言語の両方に適用できます。詳しくは、Kotlin と Android をご覧ください。

JNI についてまだよく理解していない場合は、Java ネイティブ インターフェースの仕様をお読みになり、JNI の仕組みと使用可能な機能を把握してください。インターフェースの一部は、最初に目を通しただけではわかりにくいため、以降のセクションが役に立ちます。

グローバル JNI 参照を参照して、グローバル JNI 参照が作成および削除される場所を確認するには、Android Studio 3.2 以降の Memory ProfilerJNI heap ビューを使用します。

一般的なヒント

JNI レイヤのフットプリントは最小限に抑えるようにします。ここでは、考慮すべき項目がいくつかあります。JNI ソリューションでは、以下のガイドラインに従う必要があります(以下に、重要度の高いものから順に示します)。

  • JNI レイヤ全体でリソースのマーシャリングを最小限に抑える。JNI レイヤ全体のマーシャリングにはかなりの費用がかかります。マーシャリングする必要があるデータの量と、データをマーシャリングする必要がある頻度を最小限に抑えるインターフェースを設計するようにしてください。
  • 可能であれば、マネージド プログラミング言語で記述されたコードと C++ で記述されたコードの間では、非同期通信を行わないようにします。これにより、JNI インターフェースの管理が容易になります。通常は、非同期更新を UI と同じ言語に維持することで、非同期 UI 更新を簡素化できます。たとえば、Java コードの UI スレッドから JNI 経由で C++ 関数を呼び出すのではなく、Java プログラミング言語の 2 つのスレッド間でコールバックを行い、一方のスレッドで C++ のブロッキング呼び出しを行い、そのブロックの呼び出しが完了したら UI スレッドに通知します。
  • JNI でタップまたはタップする必要があるスレッドの数を最小限に抑える。Java 言語と C++ 言語の両方でスレッドプールを利用する必要がある場合は、JNI 通信を個々のワーカー スレッド間ではなく、プール オーナー間で維持するようにしてください。
  • 今後のリファクタリングを容易にするため、簡単に識別できる C++ および Java ソースの場所にインターフェース コードを保持します。必要に応じて、JNI 自動生成ライブラリの使用を検討してください。

JavaVM と JNIEnv

JNI では 2 つの主要なデータ構造(「JavaVM」と「JNIEnv」)が定義されています。これらは基本的に、関数テーブルへのポインタへのポインタです。(C++ 版では、関数テーブルへのポインタと、テーブルを介して間接的に参照する各 JNI 関数のメンバー関数を持つクラスです)。JavaVM には、JavaVM の作成と破棄が可能な「呼び出しインターフェース」関数が用意されています。理論的にはプロセスごとに複数の JavaVM を使用できますが、Android では 1 つしか使用できません。

JNIEnv はほとんどの JNI 関数を提供します。ネイティブ関数はすべて、最初の引数として JNIEnv を受け取ります。ただし、@CriticalNative メソッドを除きます。ネイティブ呼び出しの高速化をご覧ください。

JNIEnv は、スレッド ローカル ストレージ用です。このため、スレッド間で JNIEnv を共有することはできません。コードでその JNIEnv を他に取得する方法がない場合は、JavaVM を共有し、GetEnv を使用してスレッドの JNIEnv を検出する必要があります。(JNIEnv を取得する方法がほかにある場合は、下記の AttachCurrentThread をご覧ください)。

JNIEnv と JavaVM の C 宣言は、C++ 宣言とは異なります。"jni.h" インクルード ファイルは、C と C++ のどちらにインクルードするかに応じて typedef が異なります。このため、両方の言語でインクルードされるヘッダー ファイルに JNIEnv 引数を含めることはおすすめしません。(つまり、ヘッダー ファイルに #ifdef __cplusplus が必要な場合、そのヘッダー内の何かが JNIEnv を参照している場合、追加の作業が必要になることがあります)。

スレッド

すべてのスレッドは、カーネルによってスケジュール設定される Linux スレッドです。通常は(Thread.start() を使用して)マネージドコードから開始しますが、別の場所で作成して JavaVM に接続することもできます。たとえば、pthread_create() または std::thread で開始されたスレッドは、AttachCurrentThread() または AttachCurrentThreadAsDaemon() 関数を使用してアタッチできます。スレッドがアタッチされるまで、JNIEnv は存在せず、JNI 呼び出しを行うことはできません

通常は、Thread.start() を使用して、Java コードを呼び出す必要があるスレッドを作成することをおすすめします。これにより、十分なスタック領域を確保し、正しい ThreadGroup に確実に配置され、Java コードと同じ ClassLoader を使用できます。また、ネイティブ コードからデバッグするよりも、Java でのデバッグ用にスレッド名を設定するほうが簡単です(pthread_t または thread_t を使用する場合は pthread_setname_np() を、std::thread があり pthread_t が必要な場合は std::thread::native_handle() をご覧ください)。

ネイティブに作成されたスレッドをアタッチすると、java.lang.Thread オブジェクトが作成されて「メイン」の ThreadGroup に追加され、デバッガから認識できるようになります。すでにアタッチされているスレッドで AttachCurrentThread() を呼び出しても、何も起こりません。

Android は、ネイティブ コードを実行しているスレッドを停止しません。ガベージ コレクションが進行中である場合、またはデバッガが一時停止リクエストを発行した場合、Android は次に JNI 呼び出しを行うときにスレッドを一時停止します。

JNI を介してアタッチされたスレッドは、終了する前に DetachCurrentThread() を呼び出す必要があります。これを直接コーディングするのが難しい場合、Android 2.0(Eclair)以降では、pthread_key_create() を使用して、スレッドの終了前に呼び出されるデストラクタ関数を定義し、そこから DetachCurrentThread() を呼び出すことができます。(このキーを pthread_setspecific() とともに使用して、JNIEnv をスレッド ローカル ストレージに格納します。そうすると、JNIEnv が引数としてデストラクタに渡されます)。

jclass、jmethodID、jfieldID

ネイティブ コードからオブジェクトのフィールドにアクセスする場合は、以下のように行います。

  • FindClass を使用してクラスのクラス オブジェクト参照を取得する
  • GetFieldID でフィールドのフィールド ID を取得する
  • 適切なもの(GetIntField など)を使用してフィールドの内容を取得します。

同様に、メソッドを呼び出すには、クラス オブジェクト参照を取得して、メソッド ID を取得します。多くの場合、ID は内部のランタイム データ構造へのポインタです。これらを照合するには、いくつかの文字列の比較が必要になる場合がありますが、いったん取得すると、フィールドの取得やメソッドの呼び出しを実際に行うのは非常に簡単です。

パフォーマンスが重要な場合は、値を 1 回検索して、結果をネイティブ コードでキャッシュに保存することをおすすめします。プロセスごとに 1 つの JavaVM という制限があるため、このデータを静的ローカル構造に保存することをおすすめします。

クラス参照、フィールド ID、メソッド ID は、クラスがアンロードされるまで有効であることが保証されます。クラスがアンロードされるのは、ClassLoader に関連付けられたすべてのクラスに対してガベージ コレクションが可能な場合のみです。これは Android ではまれですが、不可能ではありません。ただし、jclass はクラス参照であるため、NewGlobalRef の呼び出しで保護する必要があります(次のセクションを参照)。

クラスの読み込み時に ID をキャッシュに保存し、クラスがアンロードされて再読み込みされた場合に ID を自動的に再キャッシュする場合、ID を初期化する正しい方法は、次のようなコードを適切なクラスに追加することです。

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

C / C++ コード内に ID のルックアップを実行する nativeClassInit メソッドを作成します。このコードは、クラスの初期化時に 1 回実行されます。クラスがアンロードされてから再読み込みされると、再度実行されます。

ローカル参照とグローバル参照

ネイティブ メソッドに渡されるすべての引数と、JNI 関数から返されるほぼすべてのオブジェクトは「ローカル参照」です。つまり、現在のスレッド内の現在のネイティブ メソッドの存続期間中は有効です。ネイティブ メソッドが返された後もオブジェクト自体が存在し続ける場合でも、参照は無効になります。

これは、jobject のすべてのサブクラス(jclassjstringjarray など)に適用されます。(拡張 JNI チェックが有効になっている場合、ランタイムは参照の誤使用のほとんどについて警告します)。

非ローカル参照を取得する唯一の方法は、NewGlobalRef 関数と NewWeakGlobalRef 関数を使用することです。

参照を長期間保持する場合は、「グローバル」参照を使用する必要があります。NewGlobalRef 関数は、ローカル参照を引数として受け取り、グローバル参照を返します。グローバル参照は、DeleteGlobalRef を呼び出すまで有効であることが保証されます。

このパターンは、FindClass から返された jclass をキャッシュに保存するときによく使用されます。次に例を示します。

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

すべての JNI メソッドは、ローカル参照とグローバル参照の両方を引数として受け取ります。同じオブジェクトに対する参照の値が異なる可能性があります。たとえば、同じオブジェクトに対して NewGlobalRef を連続して呼び出した場合、戻り値が異なる可能性があります。2 つの参照が同じオブジェクトを参照しているかどうかを確認するには、IsSameObject 関数を使用する必要があります。ネイティブ コード内で == を使用して参照を比較しないでください。

結果として、ネイティブ コードではオブジェクト参照が不変または一意であると想定してはいけません。オブジェクトを表す値はメソッドの呼び出しごとに異なる可能性があります。また、2 つの異なるオブジェクトが連続した呼び出しで同じ値を持つ可能性もあります。jobject 値をキーとして使用しないでください。

プログラマーは、ローカル参照を「過度に割り当てない」ようにする必要があります。具体的には、多数のローカル参照を作成する場合(おそらくオブジェクトの配列を実行しながら)は、JNI に実行させるのではなく、DeleteLocalRef を使用して手動で解放する必要があります。この実装で必要なスロットは 16 個のローカル参照用のスロットのみです。これ以上のローカル参照が必要な場合は、必要に応じて削除するか、EnsureLocalCapacity/PushLocalFrame を使用して追加を予約する必要があります。

jfieldIDjmethodID はオブジェクト参照ではなく不透明型のため、NewGlobalRef に渡さないでください。GetStringUTFCharsGetByteArrayElements などの関数から返される元データ ポインタもオブジェクトではありません。(これらはスレッド間で渡されることがあり、一致する Release 呼び出しまで有効です)。

注意すべき特殊な状況について言及しておきます。AttachCurrentThread を使用してネイティブ スレッドをアタッチすると、実行中のコードは、スレッドがデタッチされるまでローカル参照を自動的に解放しません。作成したローカル参照は手動で削除する必要があります。一般に、ループ内でローカル参照を作成するネイティブ コードは、おそらく手動で削除する必要があります。

グローバル参照は気を付けて使用してください。グローバル参照は避けられない場合がありますが、デバッグは難しく、メモリ(誤った)動作の診断が困難になる可能性があります。他の条件がすべて同じであれば、グローバル参照の少ないソリューションの方がおそらく適切です。

UTF-8 文字列と UTF-16 文字列

Java プログラミング言語は UTF-16 を使用します。便宜上、JNI には Modified UTF-8 で動作するメソッドも用意されています。修正後のエンコードは、\u0000 が 0x00 ではなく 0xc0 0x80 としてエンコードされるため、C コードでは有用です。この利点は、C スタイルのゼロ終端文字列を使用できることです。標準の libc 文字列関数での使用に適しています。マイナス面は、任意の UTF-8 データを JNI に渡して正しく動作しないことです。

String の UTF-16 表現を取得するには、GetStringChars を使用します。UTF-16 文字列はゼロ終端ではなく、\u0000 が許可されるため、文字列の長さと jchar ポインタに注意する必要があります。

Get で取得した文字列は、必ず Release で解放するようにしてください。文字列関数は jchar* または jbyte* を返します。これは、ローカル参照ではなく、プリミティブ データへの C スタイルのポインタです。これらは、Release が呼び出されるまで有効であることが保証されます。つまり、ネイティブ メソッドから戻っても解放されることはありません。

NewStringUTF に渡すデータは Modified UTF-8 形式にする必要があります。よくあるミスとして、ファイルまたはネットワーク ストリームから文字データを読み取り、フィルタリングせずに NewStringUTF に渡すというものがあります。データが有効な MUTF-8(または互換性のあるサブセットである 7 ビット ASCII)であることがわかっている場合を除き、無効な文字を削除するか、適切な Modified UTF-8 形式に変換する必要があります。そうしないと、UTF-16 変換で予期しない結果が生じる可能性があります。CheckJNI(エミュレータではデフォルトで有効になっています)は文字列をスキャンし、無効な入力を受け取ると VM を中止します。

Android 8 より前は、通常は UTF-16 文字列で処理する方が高速でした。Android では GetStringChars でのコピーが不要なのに対し、GetStringUTFChars では割り当てと UTF-8 への変換が必要でした。Android 8 では、ASCII 文字列で 1 文字あたり 8 ビットを使用するように String 表現が変更され(メモリを節約するため)、移動ガベージ コレクタの使用を開始しました。こうした機能により、ART が GetStringCritical の場合でも、コピーを作成せずに String データへのポインタを提供できる回数が大幅に削減されます。ただし、コードによって処理されるほとんどの文字列が短い場合、スタック割り当てバッファと GetStringRegion または GetStringUTFRegion を使用することで、ほとんどの場合は割り当てと割り当て解除を回避できます。次に例を示します。

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

プリミティブ配列

JNI には、配列オブジェクトの内容にアクセスするための関数が用意されています。オブジェクトの配列には一度に 1 つのエントリでアクセスする必要がありますが、プリミティブの配列は、C で宣言されているかのように直接読み書きできます。

VM 実装を制限せずにインターフェースを可能な限り効率的にするために、Get<PrimitiveType>ArrayElements ファミリーの呼び出しでは、ランタイムが実際の要素へのポインタを返すか、メモリを割り当ててコピーを作成できます。どちらの場合も、返される未加工ポインタは、対応する Release 呼び出しが実行されるまで有効であることが保証されます(つまり、データがコピーされていない場合、配列オブジェクトが固定され、ヒープの圧縮の一環として再配置することはできません)。Get で取得した配列は、必ず Release で解放する必要があります。また、Get の呼び出しが失敗した場合、後でコードが NULL ポインタを Release しようとしないようにする必要があります。

isCopy 引数に NULL 以外のポインタを渡すことで、データがコピーされたかどうかを判断できます。これはほとんど役に立ちません。

Release 呼び出しは、次の 3 つの値のいずれかを持つ mode 引数を取ります。ランタイムが実行するアクションは、実際のデータへのポインタを返したか、データのコピーを返したかによって異なります。

  • 0
    • 実際: 配列オブジェクトの固定が解除されます。
    • コピー: データがコピーバックされます。コピーを格納していたバッファが解放されます。
  • JNI_COMMIT
    • 実際: 何もしません。
    • コピー: データがコピーバックされます。コピーを格納していたバッファは解放されません
  • JNI_ABORT
    • ポインタ: 配列オブジェクトの固定が解除されます。それ以前の書き込みは中止されません
    • コピー: コピーを格納していたバッファが解放されます。変更内容はすべて失われます。

isCopy フラグをチェックする理由の 1 つは、配列の変更後に JNI_COMMITRelease を呼び出す必要があるかどうかを確認することです。配列の内容を使用するコードの変更と実行を交互に行うと、no-op commit をスキップできる場合があります。このフラグを確認するもう 1 つの理由は、JNI_ABORT を効率的に処理するためです。たとえば、配列を取得してその場で変更し、ピースを他の関数に渡してから、変更を破棄できます。JNI が新しいコピーを作成していることがわかっている場合は、編集可能なコピーを新たに作成する必要はありません。JNI からオリジナルが渡される場合は、独自のコピーを作成する必要があります。

*isCopy が false であれば Release 呼び出しをスキップできると想定するのはよくある間違いです(サンプルコードで繰り返されています)。これは誤りです。コピーバッファが割り当てられていない場合、元のメモリは固定する必要があり、ガベージ コレクタで移動することはできません。

また、JNI_COMMIT フラグを指定しても配列は解放されないため、最終的に別のフラグで Release を再度呼び出す必要があります。

配列領域(Region)の呼び出し

Get<Type>ArrayElementsGetStringChars などの呼び出しの代わりに、データをコピーするだけの場合に非常に役立つことがあります。以下を検討してください。

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

これにより、配列を取得し、その配列から最初の len バイトの要素をコピーしてから、配列を解放します。Get 呼び出しは実装に応じて、配列の内容を固定またはコピーします。コードは、データを(おそらく 2 回目など)コピーしてから、Release を呼び出します。この場合、JNI_ABORT により、3 回目のコピーが行われないようにします。

次のコードなら同じ処理をもっと簡単に達成できます。

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

この方法には次のようなメリットがあります。

  • JNI 呼び出しが 2 回ではなく 1 回で済むため、オーバーヘッドを削減できます。
  • 固定や追加のデータコピーは必要ありません。
  • プログラマーによるエラーのリスクを軽減できます。エラーが発生した後に Release の呼び出しを忘れるリスクがありません。

同様に、Set<Type>ArrayRegion 呼び出しを使用してデータを配列にコピーし、GetStringRegion または GetStringUTFRegion を使用して String から文字をコピーできます。

例外

例外の保留中は、ほとんどの JNI 関数を呼び出すことはできません。コードでは、(関数の戻り値、ExceptionCheck または ExceptionOccurred を介して)例外を検出して戻るか、例外をクリアして処理する必要があります。

例外の保留中に呼び出すことができる JNI 関数は次のとおりです。

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

JNI 呼び出しの多くは例外をスローできますが、多くの場合、より簡単に失敗をチェックする方法が提供されます。たとえば、NewString が NULL 以外の値を返す場合、例外をチェックする必要はありません。ただし、(CallObjectMethod などの関数を使用して)メソッドを呼び出す場合は、例外がスローされると戻り値が有効にならないため、常に例外を確認する必要があります。

マネージコードによってスローされた例外では、ネイティブ スタック フレームはアンワインドされません。(また、C++ 例外は、Android では一般的に推奨されません。C++ コードからマネージド コードへの JNI 遷移境界を越えてスローしないでください)。JNI の Throw 命令と ThrowNew 命令は、現在のスレッド内に例外ポインタを設定するだけです。ネイティブ コードからマネージドに戻ると、例外が記録され、適切に処理されます。

ネイティブ コードは、ExceptionCheck または ExceptionOccurred を呼び出して例外を「キャッチ」し、ExceptionClear で例外をクリアできます。通常どおり、例外を処理せずに破棄すると、問題が発生する可能性があります。

Throwable オブジェクト自体を操作する組み込み関数はありません。たとえば、例外文字列を取得するには、Throwable クラスを見つけて getMessage "()Ljava/lang/String;" のメソッド ID を検索して呼び出し、結果が NULL でない場合は GetStringUTFChars を使用して、printf(3) または同等のものを取得する必要があります。

拡張チェック機能

JNI ではエラーのチェックをほとんど行いません。エラーはたいてい、クラッシュを引き起こします。Android には CheckJNI と呼ばれるモードも用意されています。このモードでは、標準の実装を呼び出す前に、JavaVM および JNIEnv 関数テーブル ポインタが、一連の拡張されたチェックを実行する関数テーブルに切り替えられます。

追加されたチェックには以下のものがあります。

  • 配列: 負のサイズの配列を割り当てようとしている。
  • 不正なポインタ: 不正な jarray、jclass、jobject、jstring を JNI 呼び出しに渡している、または、NULL 非許容の引数で NULL ポインタを JNI 呼び出しに渡している。
  • クラス名: 「java/lang/String」スタイルのクラス名以外を JNI 呼び出しに渡している。
  • クリティカルな呼び出し: 「クリティカル」な Get とそれに対応する Release との間で JNI 呼び出しを行っている。
  • 直接バイトバッファ: 正しくない引数を NewDirectByteBuffer に渡している。
  • 例外: 保留中の例外があるときに JNI 呼び出しを行っている。
  • JNIEnv*: 不適切なスレッドから JNIEnv* を使用している。
  • jfieldID: null の jfieldID を使用している。jfieldID を使用してフィールドに不適切な型の値を設定している(たとえば、文字列フィールドに StringBuilder を割り当てようとしている)。静的フィールド用の jfieldID を使用して、インスタンス フィールドを設定している(あるいはその逆)。あるクラスの jfieldID を別のクラスのインスタンスで使用している。
  • jmethodID: Call*Method JNI 呼び出しを行う際に、不適切なタイプの jmethodID を使用している(正しくない戻り値型、静的 / 非静的の不一致、「this」に対する不適切な型(非静的呼び出しの場合)、不適切なクラス(静的呼び出しの場合)など)。
  • 参照: 不適切なタイプの参照に対して DeleteGlobalRef / DeleteLocalRef を使用している。
  • Release のモード: Release 呼び出しに対して正しくないモード(0JNI_ABORTJNI_COMMIT 以外のモード)を渡している。
  • 型安全性: ネイティブ メソッドから互換性のない型を返している(たとえば、String を返すように宣言されているメソッドから StringBuilder を返している)。
  • UTF-8: Modified UTF-8 の無効なバイト シーケンスを JNI 呼び出しに渡している。

(メソッドおよびフィールドへのアクセスが可能かどうかは、今でもチェックされていません。アクセスの制限はネイティブ コードには適用されません。)

CheckJNI を有効にする方法はいくつかあります。

エミュレータを使用する場合、CheckJNI はデフォルトで有効になっています。

ユーザーに root 権限のあるデバイスでは、以下の一連のコマンドを使用することにより、CheckJNI を有効にしてランタイムを再起動できます。

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

いずれの場合でも、ランタイムの起動時に、logcat に次のようなメッセージが出力されます。

D AndroidRuntime: CheckJNI is ON

通常のデバイスでは、次のコマンドを使用できます。

adb shell setprop debug.checkjni 1

このコマンドを実行しても、すでに実行中のアプリでは CheckJNI は有効になりませんが、その後に起動したアプリでは CheckJNI が有効になります(プロパティを他の値に変更するか、再起動すると、CheckJNI が再び無効になります)。この場合、次回アプリを起動したときに、logcat に次のようなメッセージが出力されます。

D Late-enabling CheckJNI

また、アプリのマニフェストで android:debuggable 属性を設定して、アプリに対してのみ CheckJNI を有効にすることもできます。ただし、Android ビルドツールは、特定のビルドタイプに対して自動的に CheckJNI をオンにします。

ネイティブ ライブラリ

標準の System.loadLibrary を使用して、共有ライブラリからネイティブ コードを読み込むことができます。

実際には、古いバージョンの Android では PackageManager にバグがあり、ネイティブ ライブラリのインストールと更新の信頼性が低下していました。ReLinker プロジェクトでは、この問題やその他のネイティブ ライブラリの読み込みに関する問題の回避策を提供しています。

静的クラス イニシャライザから System.loadLibrary(または ReLinker.loadLibrary)を呼び出します。引数は「装飾されていない」ライブラリ名であるため、libfubar.so を読み込むには "fubar" を渡します。

ネイティブ メソッドを持つクラスが 1 つしかない場合は、System.loadLibrary の呼び出しをそのクラスの静的イニシャライザ内で行うのが合理的です。それ以外の場合は、Application から呼び出しを行い、ライブラリが常に読み込まれ、常に早期に読み込まれるようにします。

ランタイムがネイティブ メソッドを見つける方法には、RegisterNatives で明示的に登録することも、ランタイムが dlsym で動的に検索できるようにすることもできます。RegisterNatives の利点は、シンボルが存在することを前もって確認できることと、JNI_OnLoad のみをエクスポートしないことで、共有ライブラリをより小さく高速にできることです。ランタイムに関数を検出させる利点は、記述するコードを若干少なくできることです。

RegisterNatives を使用するには:

  • JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) 関数を指定します。
  • JNI_OnLoad で、RegisterNatives を使用してすべてのネイティブ メソッドを登録します。
  • ライブラリから JNI_OnLoad のみがエクスポートされるように、-fvisibility=hidden を使用してビルドします。これにより、コードが高速で小さくなり、アプリに読み込まれる他のライブラリとの競合を回避できます(ただし、ネイティブ コードでアプリがクラッシュした場合、有用性が低下します)。

静的イニシャライザは次のようになります。

Kotlin

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

Java

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

C++ で記述した場合、JNI_OnLoad 関数は次のようになります。

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

代わりにネイティブ メソッドの「検出」を使用するには、特定の方法でネイティブ メソッドに名前を付ける必要があります(詳細については、JNI の仕様をご覧ください)。つまり、メソッドのシグネチャに誤りがあると、メソッドが実際に初めて呼び出されるまで、それを知ることができません。

JNI_OnLoad から FindClass を呼び出すと、共有ライブラリのロードに使用されたクラスローダーのコンテキストでクラスが解決されます。FindClass は、他のコンテキストから呼び出されると、Java スタックの一番上にあるメソッドに関連付けられたクラスローダーを使用します。存在しない場合(アタッチされたばかりのネイティブ スレッドからの呼び出しであるため)は、「system」クラスローダーを使用します。システム クラスローダーはアプリのクラスを認識しないため、そのコンテキストでは FindClass を使用して独自のクラスを検索することはできません。これにより、JNI_OnLoad はクラスの検索とキャッシュに便利な場所になります。有効な jclass グローバル参照を取得したら、接続された任意のスレッドから使用できます。

@FastNative@CriticalNative でネイティブ呼び出しの高速化

ネイティブ メソッドには、@FastNative または @CriticalNative(両方ではなく)のアノテーションを付けることで、マネージド コードとネイティブ コードの間の移行を高速化できます。ただし、こうしたアノテーションの動作にはある程度の変更が伴うため、使用する前に慎重に検討する必要があります。以下ではこれらの変更について簡単に説明しますが、詳細についてはドキュメントをご覧ください。

@CriticalNative アノテーションは、マネージド オブジェクトを使用しないネイティブ メソッドにのみ(パラメータまたは戻り値で、または暗黙的な this として)適用でき、このアノテーションは JNI 遷移 ABI を変更します。ネイティブ実装では、関数シグネチャから JNIEnv パラメータと jclass パラメータを除外する必要があります。

@FastNative メソッドまたは @CriticalNative メソッドの実行中、ガベージ コレクションは重要な処理のためにスレッドを一時停止できず、ブロックされる可能性があります。これらのアノテーションは、長時間実行されるメソッド(通常は高速だが一般的に制限がないメソッドなど)には使用しないでください。特に、大規模な I/O オペレーションを実行したり、長時間保持できるネイティブ ロックを取得したりしないように注意してください。

これらのアノテーションは、Android 8 以降のシステムで使用するために実装され、Android 14 で CTS テスト対象の公開 API になりました。これらの最適化は、CTS による強力な保証はありませんが、Android 8 ~ 13 デバイスでも機能する可能性があります。ただし、ネイティブ メソッドの動的検索は Android 12 以降でのみサポートされています。Android バージョン 8 ~ 11 で実行するには、JNI RegisterNatives への明示的な登録が厳密に必要です。Android 7 では、このようなアノテーションは無視されます。@CriticalNative の ABI の不一致は、誤った引数のマーシャリングを引き起こし、クラッシュを引き起こす可能性があります。

これらのアノテーションを必要とするパフォーマンス重視のメソッドの場合、ネイティブ メソッドの名前ベースの「検出」に依存するのではなく、JNI RegisterNatives にメソッドを明示的に登録することを強くおすすめします。アプリの起動パフォーマンスを最適化するには、@FastNative メソッドまたは @CriticalNative メソッドの呼び出し元をベースライン プロファイルに含めることをおすすめします。Android 12 以降では、コンパイル済みマネージド メソッドからの @CriticalNative ネイティブ メソッドの呼び出しは、すべての引数がレジスタに収まる限り、C/C++ の非インライン呼び出しとほぼ同じくらい低コストです(たとえば、arm64 では最大 8 個の整数と最大 8 個の浮動小数点引数)。

場合によっては、ネイティブ メソッドを、失敗する可能性のある非常に高速なメソッドと遅いケースを処理する別のメソッドの 2 つに分割することをおすすめします。次に例を示します。

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

64 ビットに関する注意事項

64 ビットポインタを使用するアーキテクチャをサポートするには、Java フィールドにネイティブ構造体へのポインタを格納するときに、int ではなく long フィールドを使用します。

サポートされていない機能と後方互換性

JNI 1.6 の機能はすべてサポートされています。ただし、以下の例外があります。

  • DefineClass は実装されていません。Android は Java バイトコードまたはクラスファイルを使用しないため、バイナリクラスのデータを渡しても機能しません。

古い Android リリースとの下位互換性を維持するため、以下の点に注意する必要があります。

  • ネイティブ関数の動的ルックアップ

    Android 2.0(Eclair)までは、メソッド名の検索時に文字「$」が「_00024」に正しく変換されませんでした。この問題を回避するには、明示的な登録を使用するか、ネイティブ メソッドを内部クラスから移動する必要があります。

  • スレッドのデタッチ

    Android 2.0(Eclair)までは、pthread_key_create デストラクタ関数を使用して「終了前にスレッドをデタッチする必要がある」チェックを回避することができませんでした。(ランタイムは pthread キーのデストラクタ関数も使用するため、どちらが最初に呼び出されるかは競合します)。

  • 弱いグローバル参照

    Android 2.2(Froyo)までは、弱いグローバル参照は実装されていませんでした。古いバージョンでは、弱いグローバル参照を使用しようとすると明確に拒否されます。Android プラットフォームのバージョン定数を使用して、サポートの有無をテストできます。

    Android 4.0(Ice Cream Sandwich)までは、弱いグローバル参照は NewLocalRefNewGlobalRefDeleteWeakGlobalRef にのみ渡すことができます。(この仕様では、弱いグローバルに対してなんらかの処理を行う前に、弱いグローバルへのハード参照を作成することがプログラマーに強く推奨されているため、これはまったく制限されません)。

    Android 4.0(Ice Cream Sandwich)以降では、弱いグローバル参照を他の JNI 参照と同様に使用できます。

  • ローカル参照

    Android 4.0(Ice Cream Sandwich)までは、ローカル参照は実際には直接ポインタでした。Ice Cream Sandwich では、より優れたガベージ コレクタをサポートするために必要な間接機能が追加されましたが、これは、古いリリースでは多くの JNI バグが検出できないことを意味します。詳しくは、 ICS での JNI ローカル参照の変更をご覧ください。

    Android 8.0 より前のバージョンの Android では、ローカル参照の数にバージョン固有の上限があります。Android 8.0 以降、Android では無制限のローカル参照がサポートされています。

  • GetObjectRefType による参照タイプの判別

    Android 4.0(Ice Cream Sandwich)までは、直接ポインタが使用されていたため(上記参照)、GetObjectRefType を正しく実装することはできませんでした。代わりに、弱いグローバル テーブル、引数、ローカル テーブル、グローバル テーブルをこの順序で確認するヒューリスティックを使用しました。ダイレクト ポインタを初めて検出したときに、その参照がたまたま調べたタイプのものであることをレポートします。たとえば、静的なネイティブ メソッドに暗黙的な引数として渡された jclass と誤ってグローバル jclass で GetObjectRefType を呼び出した場合、JNIGlobalRefType ではなく JNILocalRefType が返されます。

  • @FastNative@CriticalNative

    Android 7 以前では、これらの最適化アノテーションは無視されていました。@CriticalNative の ABI の不一致は、誤った引数のマーシャリングにつながり、クラッシュする可能性があります。

    @FastNative メソッドと @CriticalNative メソッドのネイティブ関数の動的ルックアップは Android 8 ~ 10 では実装されておらず、Android 11 では既知のバグがあります。JNI RegisterNatives に明示的に登録せずにこれらの最適化を使用すると、Android 8 ~ 11 でクラッシュが発生する可能性があります。

  • FindClassClassNotFoundException をスローする

    下位互換性を確保するため、FindClass でクラスが見つからない場合、Android は NoClassDefFoundError ではなく ClassNotFoundException をスローします。この動作は Java リフレクション API の Class.forName(name) と一致します。

よくある質問: UnsatisfiedLinkError エラーが発生します。なぜですか?

ネイティブ コードの処理中に、次のようなエラー メッセージが表示されることがよくあります。

java.lang.UnsatisfiedLinkError: Library foo not found

このエラー メッセージは、ライブラリが見つからなかった場合や、また、ライブラリは存在しているが dlopen(3) で開けなかった場合、エラーの詳細は例外の詳細メッセージで確認できます。

「ライブラリが見つからない」例外が発生する理由としては、主に以下のようなものがあります。

  • ライブラリが存在しないか、アプリからアクセスできません。adb shell ls -l <path> を使用してライブラリの有無と権限を確認します。
  • ライブラリの構築に NDK が使用されていない。その結果、デバイスに存在しない関数やライブラリに依存することになります。

別のクラスの UnsatisfiedLinkError エラーとして、以下のようなメッセージが表示されることがあります。

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

logcat には、次のようなメッセージが出力されます。

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

これは、ランタイムが一致するメソッドを見つけようとしましたが、失敗したことを意味します。このエラーが発生する一般的な理由には以下のようなものがあります。

  • ライブラリがロードされていない。logcat 出力で、ライブラリの読み込みに関するメッセージを確認します。
  • 名前またはシグネチャの不一致により、メソッドが見つからない。一般的に、次のような原因が考えられます。
    • メソッド ルックアップの遅延で、extern "C" と適切な可視性(JNIEXPORT)を使用して C++ 関数を宣言できない。Ice Cream Sandwich より前のバージョンでは JNIEXPORT マクロが正しくないため、古い jni.h で新しい GCC を使用すると機能しません。arm-eabi-nm を使用すると、ライブラリに表示されるシンボルを確認できます。マングリングされているように見える場合(Java_Foo_myfunc ではなく _Z15Java_Foo_myfuncP7_JNIEnvP7_jclass など)、またはシンボルタイプが大文字の「T」ではなく小文字の「T」の場合は、宣言を調整する必要があります。
    • 明示的な登録の場合、メソッド シグネチャの入力時に発生する軽微なエラーです。登録呼び出しに渡すものが、ログファイル内の署名と一致していることを確認してください。「B」は byte、「Z」は boolean です。 シグネチャ内のクラス名コンポーネントは、「L」で始まり、「;」で終わります。パッケージ名やクラス名を区切るには「/」を使用し、内部クラス名を区切るには「$」を使用します(Ljava/util/Map$Entry; など)。

javah を使用して JNI ヘッダーを自動的に生成すると、問題の発生を回避できる場合があります。

よくある質問: FindClass でクラスを見つけられませんでした。なぜですか?

(このアドバイスのほとんどは、GetMethodID または GetStaticMethodID を含むメソッド、または GetFieldID または GetStaticFieldID を含むフィールドを検出できない場合にも同様に適用されます)。

クラス名文字列の形式が正しいか確認してください。JNI クラス名はパッケージ名で始まり、スラッシュで区切られます(例: java/lang/String)。配列クラスを検索する場合は、適切な数の角かっこで始め、「L」と「;」でクラスをラップする必要があります。したがって、String の 1 次元配列は [Ljava/lang/String; になります。内部クラスを検索する場合は、「.」ではなく「$」を使用します。一般に、クラスの内部名を確認するには、.class ファイルで javap を使用することをおすすめします。

コード圧縮を有効にする場合は、必ず保持するコードを構成してください。適切な keep ルールを構成することが重要です。そうしないと、JNI からのみ使用されるクラス、メソッド、フィールドがコード圧縮ツールによって削除される可能性があります。

クラス名が正しい場合は、クラスローダーで問題が発生している可能性があります。FindClass は、コードに関連付けられたクラスローダーでクラス検索を開始するよう要求しています。コールスタックの内容を調べます。

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

最初のメソッドは Foo.myfunc です。FindClass は、Foo クラスに関連付けられている ClassLoader オブジェクトを見つけて使用します。

通常はこれで間に合いますが、自分でスレッドを作成すると(pthread_create を呼び出して AttachCurrentThread でアタッチするなど)、問題が生じることがあります。これで、アプリからスタック フレームがなくなります。このスレッドから FindClass を呼び出すと、JavaVM はアプリケーションに関連付けられたクラスローダーではなく「system」クラスローダーで起動するため、アプリ固有のクラスを検出しようとしても失敗します。

この問題に対しては、次のような対応策があります。

  • JNI_OnLoadFindClass のルックアップを 1 回実行し、後で使用できるようにクラス参照をキャッシュに保存します。JNI_OnLoad の実行の一環として行われる FindClass 呼び出しでは、System.loadLibrary を呼び出した関数に関連付けられたクラスローダーが使用されます(これは、ライブラリの初期化を容易にするための特別なルールです)。 アプリコードでライブラリを読み込む場合、FindClass は適切なクラスローダーを使用します。
  • クラスのインスタンスを必要とする関数にクラスのインスタンスを渡します。そのためには、Class 引数を受け取るネイティブ メソッドを宣言し、Foo.class を渡します。
  • ClassLoader オブジェクトへの参照をどこか便利な場所にキャッシュして、loadClass 呼び出しを直接発行します。これには多少の労力が必要です。

よくある質問: 生データをネイティブ コードと共有するにはどのようにすればよいですか?

マネージド コードとネイティブ コードの両方から元データの大きなバッファにアクセスしなければならない場合があります。一般的な例としては、ビットマップや音声サンプルの操作があります。基本的なアプローチは 2 つあります

1 つ目のアプローチとしては、データを byte[] に格納します。これにより マネージドコードから 非常に高速にアクセスできますただし、ネイティブ サイドでは、データをコピーせずにデータにアクセスできるとは限りません。GetByteArrayElementsGetPrimitiveArrayCritical は、マネージヒープ内の元データへの実際のポインタを返すものもあれば、ネイティブ ヒープにバッファを割り当ててデータをコピーするものもあります。

もう 1 つのアプローチでは、データを直接バイトバッファに格納します。これは、java.nio.ByteBuffer.allocateDirect または JNI NewDirectByteBuffer 関数を使用して作成できます。通常のバイトバッファとは異なり、ストレージはマネージドヒープに割り当てられないため、常にネイティブ コードから直接アクセスできます(GetDirectBufferAddress でアドレスを取得します)。直接バイトバッファ アクセスの実装方法によっては、マネージド コードからのデータへのアクセスが非常に遅くなることがあります。

どちらのアプローチを選択するのかは、以下の 2 つの要因によって決まります。

  1. データアクセスのほとんどが Java または C/C++ で記述されたコードから発生しているか。
  2. データが最終的にシステム API に渡される場合、どのような形式にする必要がありますか。(たとえば、データが最終的に byte[] を受け取る関数に渡される場合、直接 ByteBuffer で処理を行うのは適切でない場合があります)。

どちらのアプローチが優れているか明確でない場合は、直接バイトバッファを使用してください。これらに対するサポートは JNI に直接組み込まれており、今後のリリースでパフォーマンスが向上するはずです。