JNI に関するヒント

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

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

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

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

一般的なヒント

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

  • JNI レイヤ全体でリソースのマーシャリングを最小限に抑える。JNI レイヤ全体でのマーシャリングにはかなりの費用がかかります。マーシャリングする必要があるデータの量と、データをマーシャリングする必要がある頻度を最小限に抑えるインターフェースを設計してください。
  • 可能であれば、マネージド プログラミング言語で記述されたコードと C++ で記述したコードの間で非同期通信を使用しないでください。これにより、JNI インターフェースの管理が容易になります。通常は、非同期更新を UI と同じ言語に保つことで、非同期 UI 更新を簡素化できます。たとえば、JNI 経由で Java コードの UI スレッドから 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 つの JavaVM しか使用できません。

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

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 呼び出しは行えません

通常は、Java コードを呼び出す必要があるスレッドを作成する場合は、Thread.start() を使用することをおすすめします。これにより、十分なスタック スペースを確保し、正しい 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 つの JavaVM という制限があるため、このデータを静的ローカル構造に保存することをおすすめします。

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

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

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

Android 8 より前は、Android では GetStringChars でのコピーは不要でしたが、GetStringUTFChars では割り当てと UTF-8 への変換が必要だったため、通常は UTF-16 文字列で処理する方が高速でした。Android 8 では、メモリ節約のために ASCII 文字列に 1 文字あたり 8 ビットが使用されるように String 表現が変更され、移動ガベージ コレクタが使用されるようになりました。こうした機能により、GetStringCritical であっても、ART がコピーを作成せずに 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 呼び出しは、mode 引数を受け取り、3 つの値のいずれかを取ることができます。ランタイムによって実行されるアクションは、実際のデータへのポインタを返すか、データのコピーを返したかによって異なります。

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

isCopy フラグを確認する理由の 1 つは、配列に変更を加えた後に JNI_COMMITRelease を呼び出す必要があるかどうかを確認することです。配列の内容を使用するコードの変更と実行を交互に行う場合は、no-op commit をスキップできる場合があります。このフラグを確認するもう 1 つの理由は、JNI_ABORT を効率的に処理するためです。たとえば、配列を取得してその場で変更し、他の関数に断片を渡して変更を破棄できます。JNI が新しいコピーを作成することがわかっている場合は、「編集可能な」コピーをもう 1 つ作成する必要はありません。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 などの関数を使用して)メソッドを呼び出す場合は、例外がスローされると戻り値が有効にならないため、常に例外を確認する必要があります。

マネージド コードによってスローされた例外によってネイティブ スタック フレームはアンワインドされません。(また、Android では一般的に推奨されない C++ 例外は、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 ビルドツールによって自動的に行われます。

ネイティブ ライブラリ

標準の 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 を使用してすべてのネイティブ メソッドを登録します。
  • -fvisibility=hidden を使用してビルドし、JNI_OnLoad のみがライブラリからエクスポートされるようにします。これにより、コードが高速化され、サイズが小さくなり、アプリに読み込まれた他のライブラリとの競合を回避できます(ただし、アプリがネイティブ コードでクラッシュした場合、有用性が低下するスタック トレースが生成されます)。

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

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 スタックの一番上にあるメソッドに関連付けられているクラスローダーを使用します。また、アタッチされたばかりのネイティブ スレッドからの呼び出しの場合は、クラスローダーがない場合は「システム」クラスローダーを使用します。システム クラスローダーはアプリのクラスを認識しないため、そのコンテキストでは、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 でクラッシュが発生する可能性があります。

よくある質問: 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 を使用することをおすすめします。

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

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

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

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

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

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

  • JNI_OnLoadFindClass のルックアップを 1 回行い、後で使用できるようにクラス参照をキャッシュに保存します。JNI_OnLoad の実行の一環として行われる FindClass 呼び出しでは、System.loadLibrary を呼び出した関数に関連付けられたクラスローダーが使用されます(これは、ライブラリの初期化を容易にするための特別なルールです)。アプリコードでライブラリを読み込む場合、FindClass は正しいクラスローダーを使用します。
  • クラスの引数を必要とする関数にクラスのインスタンスを渡します。クラス引数を受け取るネイティブ メソッドを宣言してから、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 に直接組み込まれており、今後のリリースでパフォーマンスが向上するはずです。