ORNEW

JNIコードを書くときに知らねばならないC++とJavaの違い

Share on Facebook
Pocket

はじめに

JNIコードを書くには、JavaとC++の両方への正しい理解が必要となる。この記事ではC++とJavaの違いに注目して重要事項をピックアップした。

寿命問題

JavaとC++ではメモリの管理方法が根本的に異なる。メモリ管理のほとんどを自ら操作できるC++に対して、Javaのオブジェクトは基本的にGC(ガベージコレクタ)によって管理されている。

JavaはGCが動作するため、インスタンスのメモリアドレスが実行中に変化する可能性がある。JavaからJNIコールを行うとき、Javaはローカル参照を作りGCに登録する。その参照が保持されている間、関連インスタンスはGCによる再配置や削除が行われないのだが、その参照はJNIコールを抜けると破棄される。つまり、C++側でJNI関数に渡されたJavaオブジェクトへのアクセスが保証されるのは、その関数の内部のみであるということだ。引数のJNIEnvjobjectをグローバル変数に保持し、関数呼び出し後にアクセスすると動作は未定義となる。もしどうしてもC++側の変数で保持したい場合は、必ず手動でグローバル参照を作成する必要がある。この参照は自ら破棄するまで消えることはないため、std::shared_ptr等のRAIIイディオムで管理すべきである。

Java側からもC++のインスタンスの寿命を意識せねばならない。共有ライブラリの初期化と破棄の正確なタイミングはJava側からは一切関知できないという点を抑える必要がある。共有ライブラリがロードされる時、C++はグローバル変数(!)などの初期化を行うが、これはあくまでも共有ライブラリがロードされたタイミングで実行されるため、すでにメモリにロードされていたりすると実行されない。初期化関数を作り、必ずそれをJavaから実行し、その中で初期化済みかチェックするなど、何かしら対策をする必要がある。これはC++のグローバル変数の初期化順序が未定義であることの対策にもなる(だからこそ、そもそもグローバル変数を使うべきではないのだが)。同共有ライブラリはプロセスメモリに直接割り当てられるため、複数スレッドから同時に呼び出されることも考慮せねばならない。マルチスレッドについては次のスレッド安全性についての項で詳しく書く。共有ライブラリがプロセスメモリにロードされるということは、プロセスが終了した時にメモリが開放される「かもしれない」ということだ。「かもしれない」というのは、消される可能性が出てくるが、それは保証されているわけではないからだ。JavaはC++とは異なりデストラクタなどの概念がない。ゆえに、初期化関数と同様に後始末を行う関数も用意し、明示的に行う必要がある。

スレッド安全性

C++とJavaではスレッド安全性のルールが異なる。おそらくほとんどのJavaプログラマはC++のスレッド安全性の保証について理解していないと思われるが、JNIを使うときは両言語のスレッド安全性に気を使わなくてはならない。

まず、共有ライブラリはマルチスレッドで動作することを考慮しなくてはならない。前述の通り、共有ライブラリはプロセスメモリにロードされるため、通常と同様に複数のスレッドからアクセスされる可能性がある。初期化チェックの際も、ミューテックス等で排他制御を行ったり、アトミックな変数を使うなど適切な処理をする必要がある。

C++において、グローバル変数のスレッド安全性を保証するのは非常に大変な作業であり(不可能ではない)、グローバル変数はスレッド安全性の観点からも使うべきではない。(※C++でのスレッド安全については長くなりすぎる上に「JavaとC++の違い」という本来の当記事の趣旨と少しずれるので別記事にまとめることにした)。

std::shared_ptrはC++でスレッド安全な実装をするにあたって役に立つ。JNIではJavaのオブジェクトを管理したり、逆にJavaからのアクセスを考慮したメモリ管理をする場合にも応用できる。std::shared_ptrは参照カウンタ形式のスマートポインタだが、このstd::shared_ptrのインスタンスの参照カウンタに関する処理はスレッド安全であることが規格で決められている。つまり、別のスレッドへコピーするのもスレッド安全である。C++のネイティブコード内部でスレッドを使う場合、std::shared_ptrを使うことで簡潔にスレッド安全なコードが書ける1。また、Java側から参照カウンタを操作するような実装をすれば、Java側が参照している間はC++のインスタンスが開放されないようにしたり、逆にJavaの参照が終わったら開放するようにするなど、Java側でC++インスタンスの寿命を管理できるだろう。

処理系依存

JNIで検索して出てくるコードの多くは処理系依存コードを書いている。一番多いのは型のサイズの決め打ちだろう。Javaではプリミティブ型のサイズが仕様で明言されているため、Javaプログラマは知らないのかもしれないが、C++の組み込み型のサイズは処理系に依存する2。ポインタ型をjlongとしてJavaに受け渡すコードを多く見かけるが、ポインタ型が64bitである保証は一切ない3。近い将来、128bitアーキテクチャも現れるだろうし、32bitで動かさなきゃいけない局面もまだ存在しており、ポータビリティがなくなる。ポインタ型のサイズを決め打ちするのは、C++ではご法度だ。もしすべてのアーキテクチャに対応するなら、バイト配列で受け渡すべきだし、それが面倒だったり特定のアーキテクチャに固定するとしても、切り替えられるような設計でかつサイズチェックを行うべきだ。

メモリトラブル

GetByteArrayRegion等でsignal 11が出る場合

少し具体的な話になってしまうが、JNIでJavaオブジェクトを使うときはローカル変数に気をつける。C++でもローカル変数はスレッドローカル(スレッドのスタックごとに積まれるため)であるので、他のスレッドからは操作できない。JNIのAPIでオブジェクトを取得するときなどに指定するメモリは、プロセスのヒープメモリにしておいたほうがいい。

JNIEXPORT void JNICALL func(JNIEnv* env, jbyteArray jbuf) {
    jsize len = env->GetArrayLength(env, jbuf);
    char buf[len];
    env->GetByteArrayRegion(env, jbuf, 0, len, buf);   // signal 11
}

上記コードは、下記参考先のリンクで問題になっていたコードと同じ状況のもの。C11で導入された可変長配列を用いているが、この可変長配列はスタックからメモリが確保されるらしい。毎度のことだが、C11は気持ち悪すぎる。

参考: Calling native code through the Android NDK crashes with Fatal signal 11 (SIGSEGV) when called from non UI thread | StackOverflow

参考先の解決ではmalloc-freeを使っているが、C++ならスマートポインタを使い、ちゃんとC++のキャストを書くべきだろう。以下は、バイト列を無理やりT型にキャストする例(ポインタをJava側と受け渡しするときなどに使う)。

JNIEXPORT void JNICALL func(JNIEnv* env, jbyteArray jbuf) {
    jsize len = env->GetArrayLength(env, jbuf);
    auto buf = std::unique_ptr<T>{reinterpret_cast<T*>(new char[static_cast<size_t>(len)])};
    env->GetByteArrayRegion(env, jbuf, 0, len, reinterpret_cast<jbyte*>(buf.get()));
}

おわりに

とりあえず思いついたものだけまとめた。適宜更新する。

TODO: そのうち時間があれば規格の文面の引用を追加する。


  1. なお、当然だがstd::shared_ptrの指している先への捜査は非スレッド安全なので注意。 
  2. 一部の型を除く。例えば文字型(char,char32_t等)はサイズが決まっている。 
  3. ポインタ型のサイズについて規格で保証されているのは、すべての型のポインタ型のサイズが等しいということだけである。