調査の端緒
JUCE on Androidには、メインスレッド以外ではJNI経由でAndroid frameworkとJUCEのクラス以外でまともにJNI呼び出しが行えないという問題が存在する。自分が初めての発見者というわけではないようで、JUCE forumに同様の報告がある。
これは(詳しい分析はここではすっ飛ばして)juce::Thread
がAndroid NDKのドキュメントに沿ってjava.lang.Thread
を適切に生成せずに処理を開始してしまうことに起因する。
https://developer.android.com/training/articles/perf-jni#threads
It's usually best to use
Thread.start()
to create any thread that needs to call in to Java code. Doing so will ensure that you have sufficient stack space, that you're in the correctThreadGroup
, and that you're using the sameClassLoader
as your Java code. It's also easier to set the thread's name for debugging in Java than from native code (seepthread_setname_np()
if you have apthread_t
orthread_t
, andstd::thread::native_handle()
if you have astd::thread
and want apthread_t
).
いったん処理を開始してしまうと、後からjava.lang.Thread
にsetContextClassLoader()
で適切なClassLoaderを設定することができなくなる。だからjuce::Thread
の動作開始前(pthread_create()
の呼び出し前)に対処しておく必要がある。
Android/Dalvik/ART編
OboeのオーディオスレッドでもJNI呼び出しが失敗する
ここまで調べて気がついたが、このような問題はJUCEのみで生じるわけではない。たとえばOboeはネイティブ(C++)オーディオAPIであり、AudioStreamでコールバックスレッドからonAudioReady()
を呼び出す仕組みになっている。このコールバックスレッドはOpenSLESあるいはAAudioが内部的に生成していて、その実体はpthread_create()
で生成されている。ここにはJNI・JVMが一切絡んでこないので、後からJNIEnv*
をJavaVM::AttachCurrentThread()
で取得できたとしても、そのスレッドに対応するjava.lang.Thread
にはClassLoaderが適切に設定されていないのではないか。
この仮説に基づいてひとつ実験してみた。Oboeのhello-oboeサンプルに手を加えて、JNIEnv*
を取得してFindClass("androidx/constraintlayout/widget/ConstraintLayout")
を2箇所で呼び出してみた。ひとつはJNI呼び出しを実装する関数の中で、もうひとつはOboeのonAudioReady()
コールバック実装の中である。わざわざConstraintLayout
クラスを指定しているのは、これがclasses.dex
(いわゆるmain dex)に含まれていないであろうという前提に基づいている。(d8が同一のclasses.dex
にまとめるような動作になっていたら必ずしも成立しないが。)
https://gist.github.com/atsushieno/8e6b0bc82005cdeef65254f9e8bcec1b
果たして予想通り、JNI呼び出しの中ではFindClass()
はnon-nullなjclass
を返し、オーディオコールバックの中ではnullptr
を返した。オーディオコールバックスレッドからJNIをまともに使うのは無理そうだ。
Oboeとしてはこれは想定されている動作であって、公式のgithub discussionsにもそのような議論がある。
実際的な意味では、オーディオコールバックはリアルタイムに処理が行われることが期待されている。JNIで操作されるART VMはリアルタイムセーフではないので、ARTに何らかの動作を期待するのであれば、atomic operationを使ったnon-thread-localな共有メモリの読み書きなど、何らかの間接的な手段が必要になりそうだ。
これはもちろんOboeが暗黙的に想定するリアルタイムのオーディオスレッドという文脈だからJNI非サポートが正当化できるのであり、non-realtimeなjuce::Thread
などでJNIをサポートできないことを正当化することはできない。
AAudioのrealtime audio threadはpthread
調べた順序は書いた順序と逆になるが、OboeというかAAudioの中でコールバックスレッドがどのように作られているか、JUCEの問題と性質が同じなのではないかと思って調べてみた。これは、概ねpthread_create()
から逆引きして突き止めた。ここではcs.android.comのシンボル参照を辿れるCall Hierarchyという機能が便利だ。
この検索結果はAAudioのソースを指している。OboeはOpenSLESとAAudioへのアクセスを共通化する、ある意味(非常に狭い)クロスプラットフォームなAPIであり、その実体を追及する意義はあまり無い。AAudioだけ追及すれば概ね足りるので、OpenSLESのほうは追及していない。
ちなみにほぼ余談だけど、このスクショのようにコールグラフを追ってみた感じ、AAudioの実装はAAudioServiceで、AIDLで規定されたプロトコルに沿って操作しているようだ。BinderはAndroid 8.0でリアルタイム処理も実現できるように改良されていて、オーディオ処理でも使えるようになっている(ただし/dev/binder
には開放されていないようだ)。中にはframeworkのコードなのにOboeServiceなどという名前も使われていたりして(frameworkのほうはAAudioが適切なはず)、歴史が垣間見える。
解決策: java.lang.Threadを使う
冒頭で引用したAndroid NDKのドキュメントにあった通り、java.lang.Thread
を使うのが正解だとしたら、JNIに正しく踏み込めるような処理をC++のnon-mainスレッドで行いたい場合は、java.lang.Thread
を生成して、そのAPIを操作するスタイルで実装するのが正解だろう。実行するRunner
の中で本来スレッドにやらせたかったネイティブはJNI経由で呼び出せるし、もし必要があればその処理の前にpthread_self()
を呼び出せばそのスレッドのpthread_t
ハンドルも取得できる。ただ、java.lang.Thread
側にも固有の状態変数が存在しているので、java.lang.Thread
のAPI経由で操作するほうが安全ではある(たとえばAndroidのojluni実装の場合、setPriority()
ではネイティブ設定の他にjavaフィールドの値も設定するし、getPriority()
ではそのフィールドの値が返される)。
もっとも、スレッドの中止に関しては、Androidに関してはpthread_cancen()
が存在しないので、java.lang.Thread.stop()
もまともに実装されておらず、これに関して心配する意味は無い(!) スレッドに渡す処理のループ条件を適切に管理して「正常終了」させるのが正しい実装アプローチだ。
pthread_create()
でスレッドのpthread_t
を取得してから、それをもとにjava.lang.Thread
のインスタンスを生成する方法は無い。
java.lang.ThreadはどこでClassLoaderを正しく設定しているのか
JUCEもAAudioもなまのpthreadだからClassLoaderが設定できていないらしいことはわかったが、ではどこでどう処置すればjava.lang.Thread
は想定通りのClassLoaderが設定できるのだろうか。
先に結論だけ書くと、実のところjava.lang.Thread
のsetContextClassLoader()
はいつでも呼び出し可能になっている。start()
の時点で(のみ)その設定値が参照され、以降は単に無視されるということだろう。そう理解して深くは追及していない。
もう少し技術調査したい人向けにいくつか情報を補足しておく(今回の調査の目的からは外れるので自分では深入りしていない):
- 正しいClassLoaderが設定されていないと、apkに含まれる
classes*.dex
(classes.dex, classes1.dex, classes3.dex, ...)を期待通りにロードしてclassを解決してくれない。これはどうやらARTでも変わらない。(getSystemClassLoader()
については後で言及) java.*
のAPI実装は、Android 24以降はApache Harmonyからopenjdk libcoreベースの実装に切り替わっており、Android固有の部分はojluniというART向けの実装と組み合わされている部分が多い。- JNIにおいてClassLoaderはthread localの領域に保存される情報なので、スレッドごとにこれを保持しておく必要がある。
- JNIにおける各種呼び出しは
JNIEnv
を通じて行うが、これはJNIのエントリーポイントの実装などで引数として渡されでもしない限りは、JavaVM::GetEnv()
なりJavaVM::AttachCurrentThread()
なりを使用して、スレッド別に取得(・生成)しなければならない存在だ。 AttachCurrentThread()
呼び出し時にそのネイティブスレッドに対応するjava.lang.Thread
が存在しない場合は生成される。この時点で生成されるjava.lang.Thread
で名前から参照解決できるクラスは限られている。java.lang.*
などのランタイム型は解決できそうだが、少なくともapk内に複数あるclasses*.dex
の全てをロードして解決してはくれない。ARTなのでClassLoader.getSystemClassLoader()
で返されるClassLoaderが複数のclasses*.dex
に対応している、というわけではなさそうだ。- system ClassLoaderは素朴なPathClassLoaderで(parentはBootClassLoader)。mainスレッドで返されるClassLoaderも非mainスレッドで返されるClassLoaderも違いはない(同一インスタンスかどうかは未確認)。
3/14追記: 「mainスレッドで返されるClassLoaderも非mainスレッドで返されるClassLoaderも違いはない」と書いたが、そもそもgetSystemClassLoader()
で返されるClassLoaderはdex分割に対応した完全体のClassLoaderではないようだ。試しにJava.initialiseJUCE()
を呼び出す部分(Projucerが生成するJuceApp
でもいいし、自作のコードでもいい)で次のようなコードを実行してみよう(Kotlin):
javaClass.classLoader.loadClass("com.rmsl.juce.Java") ClassLoader.getSystemClassLoader().loadClass("com.rmsl.juce.Java") com.rmsl.juce.Java.initialiseJUCE(context.applicationContext)
実行してみると、2行目でClassNotFoundExceptionになることだろう。これは、system ClassLoaderでは不十分だということを示している。
JUCE編
JNIClassBaseの概要: Java Interopの必要性
JUCEでは内部的にAndroid APIと相互運用しなければならない部分が少なくない。たとえばjuce_audio_devices
のmidi_io
のAPIを実装するにはandroid.media.midi
を使う必要がある(Native MIDI APIはminSdkVersion 29になるのでJUCEのようなフレームワークが採用できるものではない)。そもそもjuce_gui_basics
が利用するjuce_events
のアプリケーションループもandroid.app.Activity
やandroid.app.Application.ActivityLifecycleCallbacks
のAPIを使わないと操作できない。
JNIを利用すれば、少なくともmainスレッドから有効なClassLoaderが提供されている範囲では、これらを実現することは可能だが、毎回JNIのboilerplate codeを書くのはしんどいので、JUCEでは内部実装用にJava APIに対応するJNIクラスをマクロで短いコードで定義・利用できるような仕組みが作り込まれている。これはJNIClassBase
というC++のコードを用いて実装されている。この仕組みを使うと、AndroidフレームワークのJava APIの仕組みの上でJNI経由で動作するC++コードを、比較的シームレスに作成できるようになっている。
これはJUCEの外側でも利用できないわけではないが、外部向けに作られたものではないので(少なくともAPIリファレンスには出現しない)、利用するなら将来的な破壊的変更もあり得るという認識が前提になる。
もうひとつ、JUCEではC++でJavaのインターフェースやクラスのメソッドのオーバーライドを実装しなければならない場面がある。この仕組みはそれなりに高度で、やっていることはAndroidと他言語のバインディングとあまり変わらない。Xamarin.AndroidのJava Binding、React Nativeのbridge、FlutterのPlatform Channel、そういった機構の内部を知っていれば理解しやすいだろう。バインディング自動化機構は無いし、様々な部分を手書きで実装する必要があるので、最小限のランタイムという感じだ。
ランタイムの中心となる実装はjuce_core/native/juce_android_JNIHelpers.h
および.cpp
に定義されている。
JNIClassBaseを使用したJavaクラス(のproxy)の定義
JUCEのC++コードで操作されるJavaオブジェクト(のうち、このバインディング機構で制御されるもの)は、そのクラス定義をJNIClassBase
で宣言されている。DECLARE_JNI_CLASS
、DECLARE_JNI_CLASS_WITH_MIN_SDK
、DECLARE_JNI_CLASS_WITH_BYTECODE
といったマクロを利用して宣言することになる(これらの違いは後述するが、どれも全て最後のやつに行き着く)。
juce_android_JNIHelpers.h
で定義されているJavaClassLoader
クラスを例に、いくつかのポイントを箇条書きで説明しよう:
#define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD, CALLBACK) \ METHOD (findClass, "findClass", "(Ljava/lang/String;)Ljava/lang/Class;") \ STATICMETHOD (getSystemClassLoader, "getSystemClassLoader", "()Ljava/lang/ClassLoader;") DECLARE_JNI_CLASS (JavaClassLoader, "java/lang/ClassLoader") #undef JNI_CLASS_MEMBERS
DECLARE_JNI_CLASS
マクロの最初の引数は、定義されるクラスとその名前で定義されるstaticなグローバル変数の名前になる。- メンバーの定義には
JNI_CLASS_MEMBERS
という名前のマクロが使われる。このマクロはその内容自体が現在定義しようとしているクラスのメンバーを列挙するかたちで「定義」し、DECLARE_JNI_CLASS_*
マクロからメンバー定義するときに呼び出されるもので、このマクロによる定義・宣言が済み次第#undef
で消される(消すべきものだ)。 - メンバー定義は、フィールドとメソッド、それからコールバック関数の類がstaticとnon-staticで別々に定義される。
JNI_CLASS_MEMBERS
を実際に呼び出すのはDECLARE_JNI_CLASS_WITH_BYTECODE
マクロであり、METHOD
やSTATICMETHOD
といったマクロ引数の実際の内容はこのマクロから与えられるので、このマクロを利用する側が気にする必要はあまりない。マクロなので内容のチェックは期待できない。 - これは
java.lang.ClassLoader
クラスのインスタンスを操作するときに、JavaClassLoader.getSystemClassLoader
をJNIEnv::CallStaticObjectMethod()
の引数に指定するときに有用だ。
JNIClassBaseのBLOB
JNIClassBase
ではバイトコードをuint8[]
定数のBLOBで保持していることがある。
static const uint8 invocationHandleByteCode[] = {31,139,8,8,215,115,161,94,...,12,5,0,0,0,0}; (..) DECLARE_JNI_CLASS_WITH_BYTECODE (JuceInvocationHandler, "com/roli/juce/JuceInvocationHandler", 10, invocationHandleByteCode, sizeof (invocationHandleByteCode))
これは実はdexバイトコードを抜き出してgzip圧縮したもので、これが指定されている場合、JNIClassBase
はInMemoryDexClassLoader
あるいはDexClassLoader
を使ってこの配列をClass
としてロードする(InMemoryDexClassLoader
を使うか使わないかは、実行時のSDK versionで決まり、in memoryでない場合は一時ファイルに出力する)。どのBLOBにも、対応するjavaソースがJUCEのソースツリーに含まれているはずで、ソースコードの隠蔽などを目的としているわけではない(はずだ)。
(非Android開発者向けに多少入門的な話を書くと、Androidは*.java
のソースをコンパイルすることはあっても、*.class
のJavaバイトコードはapk/aabのビルド時にclasses*.dex
というDalvikバイトコードに変換してあり、実行時にClassLoader
でロードできるのも*.class
ファイルではなく*.dex
だけだ。)
独自のdexバイトコードは、JUCEで何らかのJavaクラスを提供しなければならない場面で、*.java
のソースコードをAndroid Studio / Gradleでビルドする代わりに、C++のコンパイルだけで完結できるように作られているようだ。その技術選択にはかなり議論の余地があるが、現状ではそうなっている。
今回の調査の成果のひとつとして、BLOBを全て削り落として、代わりにbuild.gradle(.kts)
でJUCEの*.java
ソースから必要なコードをコンパイルする方式のパッチを作った。 https://gist.github.com/atsushieno/1ab9f9d4a1f9119db1d2ac88b4257bcb
BLOBを使用する代わりにJavaのソースコードをビルドするアプローチにした場合、build.gradle(.kts)
上でそれぞれのBLOBに相当するJavaソースをコンパイル対象として明示的に指定する必要がある。どのモジュールを選択しているときにどのディレクトリを追加する、といった処理をProjucerやCMakeモジュールに追加するか、あるいは全てのモジュールのJavaコードを全てコンパイルする必要がある。後者のほうがずっとシンプルだが、ソースコードによっては追加の依存モジュールが必要になる。たとえばjuce_product_unlocking
ではfirebaseのモジュールをdependencies
に追加する必要がある。これは割とニッチなモジュールで、通常は必要ない。必要ないものをdependencies
に常に指定するのはあまり健全ではない。
いや、そもそもJUCEは今でもCMakeをAndroidでサポートしていないんだった。CMakeを使えるようにするには、自前でAndroidプロジェクト全体を用意するアプローチになる。それであれば、使用するJUCEモジュールに合わせてbuild.gradle(.kts)
を調整するのは全く難しくない。それすらやりたくないということであれば、Projucerのbuild.gradle
生成をちょっと工夫させるだけでも良いだろう。
もちろん、BLOBを利用するのはビルドパフォーマンスの向上のためとは言い難い。これらBLOBがあろうと無かろうとJuceApp.java
などが存在する以上、javaコードのコンパイルは発生するし、ほとんどの場合そのコンパイルは一度だけしか起こらない(何しろコードが書き換わらないので)。
JNIClassBaseの実行時利用
JNIClassBaseはcom.rmsl.juce.JUCE.initialiseJUCE()
あるいはThread::initialiseJUCE()
が呼び出されたときに(前者は後者を呼び出すので実質的に同じ)、JNIClassBase::getClasses()
関数のstatic storageにあるArray<JNIClassBase> classes
に含まれている全てのクラスをそれぞれのinitialise()
でロードする(DECLARE_JNI_CLASS_*
マクロを使うと自動的にこのArrayに登録される)。この時点でもしバイトコードが定義されていたらロードされ、メソッドの引数型など依存クラスがあれば全て芋づる式にロードされる(はず)。
JNIClassBase::initialise()
でjava.lang.Class
をロードする手順はいくつか条件分岐があり多少コードが長いが、ポイントだけ押さえると、dex BLOBを含むJNIClassBase
のロードには、InMemoryDexClassLoader
などが使用される。実際に行われているのはJNIでClassLoaderによるクラスのロード(の試行)程度の内容だ。
JUCEで宣言されていないクラスをJNIClassBaseで宣言する
#include <juce_core/juce_android_JNIHelpers.h>
を書けばどこでも宣言できるはずだ。正しく記述できなければ実行時エラーになるところまではできた。
今回の調査の一環で、pthreadの代わりにjava.lang.Thread
を使ってjuce::Threadを書き直したパッチを作成したが、この中でJavaLangThread
というクラスを独自に定義している。これは最終的にjuce_android_JNIHelpers.hに直接追加したが(Threadの実装はjuce_coreにあるため)、自分のアプリケーションのソースに置いても、シンボルが解決できる限りビルドできるだろう。
AndroidInterfaceImplementer
さて、この節の冒頭で言及したもうひとつの課題として、JUCEのJNI interopではJavaのインターフェースやクラスのメソッドのオーバーライドをC++で実装しなければならない状況がある。たとえばjava.lang.Thread
のコンストラクタにはjava.lang.Runnable
を渡す必要があるが、自分でこのインターフェースを実装してrun()
でJUCE/C++のコードを実行したい場合は、Runnable
の実装が必要になる。
3/15追記: 初出時「インターフェースやクラスのメソッド」と書いたが、抽象クラスの実装として使えるようには見えないので取り消し線を追加した。Xamarin.AndroidでもXxxImplementerとXxxInvokerは別物だ。
このような場合に、足りないJava実装をどうやって提供するか、いくつか方法が考えられるが、JUCEではjava.lang.reflect.Proxy
とjava.lang.reflect.InvocationHandler
という仕組みを活用して、Javaコードのビルド時生成を一切行わないかたちで実現している。
Proxyは、Javaオブジェクトとして振る舞うが、名前と引数型からメソッドを呼び出すときに、Proxy生成時に渡されたInvocationHandler
の実装のinvoke()
メソッドを使う:
abstract Object invoke(Object proxy, Method method, Array<Object> args);
このInvocationHandler
は、java.lang.reflect.Proxy.newProxyInstance()
に引数として渡される。
public static Object newProxyInstance ( ClassLoader loader, Class[]<?> interfaces, InvocationHandler h);
これで生成されたObjectは、メソッド呼び出しをInvocationHandler
によって解決するので、Javaインターフェースの実装として機能するというわけだ。
JUCEの具体的な実装としては、JUCEInvocationHandler
というInvocationHandler
の実装が、JavaインターフェースをC++で実装したクラスで用いられている。javaコードで実装されていて、例によってdexバイトコードがjuce_android_JNIHelpers.cpp
に埋め込まれている。JUCEInvocationHandler
は実装の詳細だが、invoke()
の中でネイティブコードにコールバックするだけなので、われわれこの機構のユーザーがこのクラスを意識する必要は、通常はない(ただクラッシュ時にstacktraceに出現するので気になるだろう)。
このソースファイルの中には、CreateJavaInterface()
という関数が定義されている。この関数がjava.lang.reflect.Proxy.newProxyInstance()
を(JNI経由で)呼び出してProxyオブジェクトを返す。返されたjobjectのProxyはそのままJNIで利用できるというわけだ。CreateJavaInterface()
にはいくつかオーバーロードがあるが、ひとつだけ引用しよう:
LocalRef<jobject> CreateJavaInterface (AndroidInterfaceImplementer* implementer, const StringArray& interfaceNames, LocalRef<jobject> subclass)
ここで初出のAndroidInterfaceImplementer
クラスは、JavaインターフェースのC++実装で使われる基底クラスになる。この仕組みを利用してJavaのabstractメソッドを実装するときは、このクラスのinvoke()
関数をオーバーライドする。たとえばjuce_android_Files.cpp
にあるMediaScannerConnectionClient
クラスがどう実装されているか引用しよう:
class MediaScannerConnectionClient : public AndroidInterfaceImplementer { (...) jobject invoke (jobject proxy, jobject method, jobjectArray args) override { auto* env = getEnv(); auto methodName = juceString ((jstring) env->CallObjectMethod (method, JavaMethod.getName)); if (methodName == "onMediaScannerConnected") { onMediaScannerConnected(); return nullptr; } else if (methodName == "onScanCompleted") { onScanCompleted(); return nullptr; } return AndroidInterfaceImplementer::invoke (proxy, method, args); } };
このinvoke()
がjava.lang.reflect.Proxy.invoke()
の実装とほぼ同じ役割を担っている。ここでメソッド呼び出しを自前で解決できるので、この機構のユーザーはC++コードを書くだけでJavaインターフェースを実装できるというわけだ。
まとめ: java.lang.ThreadでJUCE::Threadの実装を差し替える
さてそろそろまとめ…というか本題に戻ろう。今回の調査の最終的な目的は、JUCEのnon-mainスレッドからでもJNIを問題なく呼び出せるようにコードを作り変えることにある。
Android/Dalvik/ART編でざっと見てきた通り、C++コードでpthread_create()
を呼んでしまうと、それをjava.lang.Thread
に紐づけ「かつ」ClassLoaderを正しく設定する、ということは出来なそうだ。それであれば、pthread_create()
している部分で代わりにjava.lang.Thread
を生成し、そのRunnable
引数の中で先のAndroidInterfaceImplementer
によるProxyを使って、ネイティブコードの中でpthread_self()
を呼び出してpthread_id
を取得することで、正しいClassLoaderをもつスレッドが生成できるはずだ。
という考え方に基づいてJUCEのパッチを作成した: https://gist.github.com/atsushieno/5e28f5a0a319dc24fa8a7579f826b3aa
まだ未完成だが(setPriority()
などが実装されていない)、とりあえずはこれでClassNotFoundExceptionは発生しなくなった。
実際にはこれは2つのパッチの組み合わせになっている
- https://gist.github.com/atsushieno/8e176beca9d6fd4ea91a6953838195b6
- https://gist.github.com/atsushieno/1ab9f9d4a1f9119db1d2ac88b4257bcb
後者は単にBLOBローダーの仕組みを取っ払ったパッチなので、本当は前者だけで動いてほしいのだけど、まだ未調査の原因でSEGVになる。この仕組みを取っ払った以上、build.gradle
を書き換える作業も必要になるのだけど、そこまでは出来ていないし自分のAudioPluginHostの移植でも自動書き換え機構が必要になるかどうかわからないのでProjucerなどは何も変更しておらず、upstreamに取り込める状態ではない(JUCEチームはいずれにせよ基本的にパッチを受け付けないので、彼らが自分で実装する必要があるだろう)。
3/14追記: Android/Dalvik/ART編の追記に繋がる話だが、JUCEではgetSystemClassLoader()
を使っている部分がある。これは上の追記部分で説明した通りダメなやつなので、これを使っている部分で適切なClassLoaderを使うことで、BLOBローダーの仕組みを残したままバグを解消できた。
これと上の1つ目のパッチならば、プロジェクト生成で抜本的な変更を必要としないので、JUCEにも当てやすいパッチになるだろう。