JUCE on Android : fixing ClassNotFoundException on non-main thread

調査の端緒

JUCE on Androidには、メインスレッド以外ではJNI経由でAndroid frameworkとJUCEのクラス以外でまともにJNI呼び出しが行えないという問題が存在する。自分が初めての発見者というわけではないようで、JUCE forumに同様の報告がある。

forum.juce.com

これは(詳しい分析はここではすっ飛ばして)juce::ThreadAndroid 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 correct ThreadGroup, and that you're using the same ClassLoader as your Java code. It's also easier to set the thread's name for debugging in Java than from native code (see pthread_setname_np() if you have a pthread_t or thread_t, and std::thread::native_handle() if you have a std::thread and want a pthread_t).

いったん処理を開始してしまうと、後からjava.lang.ThreadsetContextClassLoader()で適切な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にもそのような議論がある。

github.com

実際的な意味では、オーディオコールバックはリアルタイムに処理が行われることが期待されている。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という機能が便利だ。

pthread_create()を呼び出すcreateThread_l()のCall Hierarchy この検索結果はAAudioのソースを指している。OboeはOpenSLESとAAudioへのアクセスを共通化する、ある意味(非常に狭い)クロスプラットフォームAPIであり、その実体を追及する意義はあまり無い。AAudioだけ追及すれば概ね足りるので、OpenSLESのほうは追及していない。

https://cs.android.com/android/platform/superproject/+/master:frameworks/av/media/libaaudio/src/core/AudioStream.cpp;l=476;bpv=0;bpt=0

ちなみにほぼ余談だけど、このスクショのようにコールグラフを追ってみた感じ、AAudioの実装はAAudioServiceで、AIDLで規定されたプロトコルに沿って操作しているようだ。BinderはAndroid 8.0でリアルタイム処理も実現できるように改良されていて、オーディオ処理でも使えるようになっている(ただし/dev/binderには開放されていないようだ)。中にはframeworkのコードなのにOboeServiceなどという名前も使われていたりして(frameworkのほうはAAudioが適切なはず)、歴史が垣間見える。

https://cs.android.com/android/platform/superproject/+/master:frameworks/av/services/oboeservice/AAudioServiceStreamBase.cpp

解決策: 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.ThreadAPI経由で操作するほうが安全ではある(たとえば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.ThreadsetContextClassLoader()はいつでも呼び出し可能になっている。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_devicesmidi_ioAPIを実装するにはandroid.media.midiを使う必要がある(Native MIDI APIはminSdkVersion 29になるのでJUCEのようなフレームワークが採用できるものではない)。そもそもjuce_gui_basicsが利用するjuce_eventsのアプリケーションループもandroid.app.Activityandroid.app.Application.ActivityLifecycleCallbacksAPIを使わないと操作できない。

JNIを利用すれば、少なくともmainスレッドから有効なClassLoaderが提供されている範囲では、これらを実現することは可能だが、毎回JNIのboilerplate codeを書くのはしんどいので、JUCEでは内部実装用にJava APIに対応するJNIクラスをマクロで短いコードで定義・利用できるような仕組みが作り込まれている。これはJNIClassBaseというC++のコードを用いて実装されている。この仕組みを使うと、AndroidフレームワークJava APIの仕組みの上でJNI経由で動作するC++コードを、比較的シームレスに作成できるようになっている。

これはJUCEの外側でも利用できないわけではないが、外部向けに作られたものではないので(少なくともAPIリファレンスには出現しない)、利用するなら将来的な破壊的変更もあり得るという認識が前提になる。

もうひとつ、JUCEではC++Javaのインターフェースやクラスのメソッドのオーバーライドを実装しなければならない場面がある。この仕組みはそれなりに高度で、やっていることはAndroidと他言語のバインディングとあまり変わらない。Xamarin.AndroidJava Binding、React Nativeのbridge、FlutterのPlatform Channel、そういった機構の内部を知っていれば理解しやすいだろう。バインディング自動化機構は無いし、様々な部分を手書きで実装する必要があるので、最小限のランタイムという感じだ。

ランタイムの中心となる実装はjuce_core/native/juce_android_JNIHelpers.hおよび.cppに定義されている。

JNIClassBaseを使用したJavaクラス(のproxy)の定義

JUCEのC++コードで操作されるJavaオブジェクト(のうち、このバインディング機構で制御されるもの)は、そのクラス定義をJNIClassBaseで宣言されている。DECLARE_JNI_CLASSDECLARE_JNI_CLASS_WITH_MIN_SDKDECLARE_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マクロであり、METHODSTATICMETHODといったマクロ引数の実際の内容はこのマクロから与えられるので、このマクロを利用する側が気にする必要はあまりない。マクロなので内容のチェックは期待できない。
  • これはjava.lang.ClassLoaderクラスのインスタンスを操作するときに、JavaClassLoader.getSystemClassLoaderJNIEnv::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圧縮したもので、これが指定されている場合、JNIClassBaseInMemoryDexClassLoaderあるいはDexClassLoaderを使ってこの配列をClassとしてロードする(InMemoryDexClassLoaderを使うか使わないかは、実行時のSDK versionで決まり、in memoryでない場合は一時ファイルに出力する)。どのBLOBにも、対応するjavaソースがJUCEのソースツリーに含まれているはずで、ソースコードの隠蔽などを目的としているわけではない(はずだ)。

(非Android開発者向けに多少入門的な話を書くと、Android*.javaのソースをコンパイルすることはあっても、*.classJavaバイトコードは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.Proxyjava.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つのパッチの組み合わせになっている

後者は単にBLOBローダーの仕組みを取っ払ったパッチなので、本当は前者だけで動いてほしいのだけど、まだ未調査の原因でSEGVになる。この仕組みを取っ払った以上、build.gradleを書き換える作業も必要になるのだけど、そこまでは出来ていないし自分のAudioPluginHostの移植でも自動書き換え機構が必要になるかどうかわからないのでProjucerなどは何も変更しておらず、upstreamに取り込める状態ではない(JUCEチームはいずれにせよ基本的にパッチを受け付けないので、彼らが自分で実装する必要があるだろう)。

3/14追記: Android/Dalvik/ART編の追記に繋がる話だが、JUCEではgetSystemClassLoader()を使っている部分がある。これは上の追記部分で説明した通りダメなやつなので、これを使っている部分で適切なClassLoaderを使うことで、BLOBローダーの仕組みを残したままバグを解消できた。

これと上の1つ目のパッチならば、プロジェクト生成で抜本的な変更を必要としないので、JUCEにも当てやすいパッチになるだろう。