JNI に関するヒント

JNI(Java Native Interface)では、Android で(C/C++ で作成された)ネイティブ コードを操作するために(Java または Kotlin プログラミング言語で作成された)マネージコードからコンパイルするバイトコードのスタイルを定義します。JNI はベンダーに依存せず、動的な共有ライブラリからコードをロードすることができます。時として扱いにくいこともありますが、かなり効率的です。

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

まだ JNI に慣れ親しんでいない方は、JNI の仕組みや利用できる機能について、まず、Java Native Interface 仕様をご覧ください。仕様を一読しただけではわかりにくい要素もあるため、以下で説明する実践的なヒントをご活用ください。

グローバル JNI 参照を閲覧し、それらが生成され削除される位置を確認するには、Android Studio 3.2 以降で、Memory Profiler の [JNI heap] ビューを使用します。

一般的なヒント

JNI レイヤのフットプリントは最小限に抑えるようにします。その際、いくつかの点について考慮する必要があります。JNI ソリューションで、以下のガイドラインに準拠するようにしてください(以下のヒントは、重要度の高い順に記載されています)。

  • JNI レイヤ全体でリソースのマーシャリングを最小限に抑える。JNI レイヤ全体でのマーシャリングにかかるコストは少なくありません。インターフェースを設計する際は、マーシャリングの必要があるデータの量と、データをマーシャリングする頻度を最小限に抑えるようにしてください。
  • 可能な限り、マネージ プログラミング言語で記述したコードと C++ で記述したコードとの間で非同期通信を行わないようにする。これにより、JNI インターフェースの管理が容易になります。一般に、非同期アップデートを UI と同じ言語に維持することで、非同期 UI アップデートをシンプルにすることができます。たとえば、Java コードの UI スレッドから JNI 経由で C++ 関数を呼び出すのではなく、Java プログラミング言語の 2 つのスレッド間でコールバックを実行し、そのうちの 1 つが C++ のブロッキング呼び出しを行い、ブロッキング呼び出しが完了したら UI スレッドに通知する方が適切です。
  • JNI とやりとりする必要があるスレッドの数を最小限に抑える。Java 言語と C++ 言語の両方でスレッドプールを利用する必要がある場合、JNI 通信はプールオーナー間だけで行うようにし、個々のワーカー スレッド間では行わないようにしてください。
  • インターフェースのコードを、見つけやすいように C++ と Java のソースコードのなるべく上の方に配置すると、将来のリファクタリングが容易になる。必要に応じて、JNI 自動生成ライブラリを使用することをおすすめします。

JavaVM と JNIEnv

JNI では 2 つの主要なデータ構造(「JavaVM」と「JNIEnv」)が定義されています。基本的に、これらはどちらも関数テーブルへのポインタのポインタです(C++ バージョンでは、関数テーブルへのポインタと、テーブルを介して間接的に呼び出される各 JNI 関数のメンバー関数へのポインタを持つクラスです)。JavaVM の「Invocation(呼び出し)インターフェース」関数を使用すると、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 を参照するものが 1 つでもあれば、おそらく追加の作業が必要になるでしょう)。

スレッド

すべてのスレッドは、カーネルによってスケジュール設定される 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() を呼び出しても、何の処理も行われません(no-op)。

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 は、内部のランタイム データ構造体への単なるポインタであることがほとんどです。ID を調べるために文字列の比較を数回行わなければならないこともありますが、いったん ID を取得すると、実際のフィールドの取得やメソッドの呼び出しを非常にすばやく行えるようになります。

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

クラス参照、フィールド ID、メソッド ID は、クラスがアンロードされるまで有効であることが保証されます。クラスがアンロードされるのは、クラスローダーに関連付けられているすべてのクラスに対してガベージ コレクションが可能な場合に限られます。この状況は 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 でも動作するメソッドが用意されています。Modified UTF-8 エンコーディングは、\u0000 を 0x00 ではなく 0xc0 0x80 としてエンコードするため、C 言語コードで便利です。メリットは、C 言語スタイルのゼロ終端文字列を使用できることで、標準の libc 文字列関数で使用するのに適しています。デメリットは、JNI に渡した UTF-8 データがすべて正しく動作すると期待できない点です。

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 の表現を変更し(メモリを節約するため)、移動ガベージ コレクタの使用を開始しました。これらの機能により、GetStringCritical の場合でも、コピーを作成せずに String データへのポインタを ART が提供できるケースが大幅に減少します。ただし、コードで処理される文字列のほとんどが短い場合は、スタック割り当てバッファと GetStringRegion または GetStringUTFRegion を使用することで、ほとんどの場合に割り当てと割り当て解除を回避できます。次に例を示します。

    constexpr size_t kStackBufferSize = 64u;
    jchar stack_buffer[kStackBufferSize];
    std::unique_ptr<jchar[]> heap_buffer;
    jchar* buffer = stack_buffer;
    jsize length = env->GetStringLength(str);
    if (length > kStackBufferSize) {
      heap_buffer.reset(new jchar[length]);
      buffer = heap_buffer.get();
    }
    env->GetStringRegion(str, 0, length, buffer);
    process_data(buffer, length);

プリミティブ配列

JNI には、配列オブジェクトのコンテンツにアクセスするための関数が用意されています。オブジェクト配列の場合、一度に 1 つずつ、エントリにアクセスする必要があるのに対し、プリミティブ配列の場合、C 言語で宣言されているかのように直接読み取りと書き込みを行うことができます。

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

isCopy 引数に非 null ポインタを渡すことにより、データがコピーされたかどうかを判断できます。ただし、これが役に立つことはほとんどありません。

Release 呼び出しは、mode 引数を受け取ります。この引数には、以下の 3 つの値のいずれかを指定できます。ランタイムが実行するアクションは、実際のデータへのポインタが返された場合と、データのコピーが返された場合で異なります。

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

isCopy フラグをチェックする理由の 1 つは、配列に変更を加えた後に JNI_COMMIT を指定して Release を呼び出す必要があるかどうかを確認することです。配列の変更と、配列のコンテンツを使用するコードの実行を交互に行う場合、no-op(何もしない)コミットをスキップできる場合があります。このフラグをチェックする理由としてもう 1 つ考えられるのは、JNI_ABORT の処理を効率化するということです。たとえば、配列を取得してそこで変更を加え、その断片を他の関数に渡した後、変更を破棄したくなる場合があります。JNI が新しいコピーを作成することがわかっていれば、「編集可能」なコピーをもう 1 つ作成する必要はなく、JNI からオリジナルが渡されるなら、独自のコピーを作成する必要があります。

*isCopy が false であれば Release 呼び出しをスキップできる」と考えるのは、よくあるミスです(サンプルコードでたびたび見られます)。これは誤りです。コピーバッファが割り当てられていない場合、オリジナルのメモリは固定され、ガベージ コレクタによって移動することはできません。

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

領域呼び出し

データをコピーするだけの場合は、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 関数を呼び出すことができません。コード内で関数の戻り値や ExceptionCheckExceptionOccurred から例外を認識して戻るか、例外をクリアして処理する必要があります。

例外の保留中に呼び出すことができる 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 には Package Manager にバグがあり、それが原因でネイティブ ライブラリのインストールやアップデートの信頼性が低下していました。この問題や他のネイティブ ライブラリのロードに関する問題については、ReLinker プロジェクトから対応策が提供されています。

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

ネイティブ メソッドを持つクラスが 1 つだけの場合、そのクラスの静的イニシャライザ内で System.loadLibrary 呼び出しを行うことは理にかなっています。ただし、そのようなクラスが 1 つだけでない場合は、Application から呼び出しを行うことをおすすめします。そうすれば、ライブラリは必ず、しかも常に早い段階でロードされます。

ランタイムがネイティブ メソッドを見つける方法には、RegisterNatives を使用して、ネイティブ メソッドを明示的に登録する方法と、dlsym を使用して、ランタイムに動的にルックアップさせる方法の 2 つがあります。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;
}

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

FindClass 呼び出しを JNI_OnLoad から行うと、共有ライブラリのロードに使用されたクラスローダーのコンテキスト内でクラスが解決されます。他のコンテキストから呼び出された場合、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 になりました。これらの最適化は、Android 8 ~ 13 のデバイスでも機能する可能性があります(ただし、CTS の保証は適用されません)。ただし、ネイティブ メソッドの動的ルックアップは Android 12 以降でのみサポートされ、Android バージョン 8 ~ 11 で実行するには JNI RegisterNatives による明示的な登録が厳密に必要です。これらのアノテーションは Android 7 以前では無視されます。@CriticalNative の ABI の不一致は、引数のマーシャリングの誤りにつながり、クラッシュを引き起こす可能性があります。

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

ネイティブ メソッドを 2 つに分割する方が望ましい場合があります。1 つは失敗する可能性のある非常に高速なメソッドで、もう 1 つは遅いケースを処理するメソッドです。次に例を示します。

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 に対して GetObjectRefType を呼び出したときに、それがたまたま静的ネイティブ メソッドに暗黙的引数として渡された jclass と同じだった場合、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 マクロに誤りがあったため、新しい GCC と古い jni.h を併用することができませんでした。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 でクラスを見つけられませんでした。なぜですか?

(ここで示すアドバイスのほとんどは、GetMethodIDGetStaticMethodID を使用してメソッドを検出できない場合や、GetFieldIDGetStaticFieldID を使用してフィールドを検出できない場合にも当てはまります。)

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

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

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

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

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

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

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

  • FindClass のルックアップを JNI_OnLoad 内で 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 に直接組み込まれており、将来のリリースでパフォーマンスが向上していくと考えられます。