« 「ナンプレ破り」Google Playで公開中 | トップページ | LTやります »

2013年10月31日 (木)

OpenCV Java APIをWindowsやLinuxで使うときのメモリ管理の注意

OpenCVのJava APIは、もともとAndroidでOpenCVを使うために開発されたもののようだが、WindowsやLinux(たぶんMacOSも)の普通のJava実行環境でも使える。OpenCV 2.4.4(?) 以降では、Windows用にOpenCVを普通にインストールすると、Java APIもインストールされる。

拙作「ナンプレ破り」は、内部でいろいろな画像処理をするのだが、そのパラメタを決めるために、たくさんの画像を処理して結果を評価するようなことをやっている。それは開発中にWindows上でプログラムを動かしているのだが、Android用の処理を評価するのが目的なので、WindowsでもOpenCVを使うJavaプログラムとして作ってある。

これで、少々ハマったので、何にどうハマったのか、どう解決したのか、書いておく。

OpenCVをJavaから使うときには、画像データはもっぱらMatクラスということになり、他の選択肢はない。JavaオブジェクトとしてのMatオブジェクトというのは、単なるネイティブオブジェクト(C++のcv::Matクラスのインスタンス)のラッパーで、メンバー変数はnativeObjというネイティブオブジェクトのアドレスを指すlong型の変数が一個あるだけ。

まぁ、それは、そんなものだろう。

問題は、nativeObjという変数が指すネイティブオブジェクト(が占有しているメモリ)を開放するメソッドが存在しない点だ。それじゃぁ、どんどんリークしちゃうのか、というと、もちろんそうではない。JavaのMatクラスにはfinalize()メソッドが実装されていて、そこで開放するようになっている。つまり、GCが走って、使わなくなったJavaのMatオブジェクトが開放されるときに、ネイティブオブジェクトも開放されるようになっている。逆に言うと、Matのネイティブオブジェクトは、アプリが必要としなくなっても、次回のGCまでは開放されずメモリを占有し続けることになる。

と、書くと、OpenCV Java APIを使ったことがある人は「Mat.release()というメソッドがあるじゃない。あれじゃだめなの?」って思うだろう。

だめなのだ。Mat.release() というのは、「JavaのMatオブジェクトが指すネイティブオブジェクト」を解放するのではなくて、「JavaのMatオブジェクトが指すネイティブオブジェクトであるC++のMatオブジェクトの、さらにその先にあるバルクメモリ」を解放するメソッドなのだ。

図にすると、こんな感じ。(クリックすると拡大。) Mat_2 ※ ここで「バルクメモリ」と呼んでいるものは、C++の意味でのクラスのインスタンスではない、malloc()で割り当てる単なるメモリです。実際の画像データが、ここに入ります。

Mat.release()を呼び出すと、右端のバルクメモリは解放されるが、C++のMatオブジェクト、OpenCVのJavaDocがしばしば「Mat header」と呼ぶモノは解放されない。まぁ、意図はわかる。Java側のMatを、完全なC++側Matのプロキシーにしたかったんだろう。それに、このC++のMatは、バルクメモリと比べると小さい。バルクメモリが画像データを格納するために、すぐにMBオーダーになっちゃうのと比べると、Matオブジェクトは一個100バイト弱だ。ネイティブヒープに放置しても、大した影響はないと考えたのかもしれない。とは言え、JavaのMatオブジェクトがヒープ上で占有するメモリ(たぶん16バイト?)と比べると大きい。

その結果、ある程度大きな画像データを保持した(つまり、ネイティブヒープの残りがそれなりに少なくなっている)状態で、JavaのMatオブジェクトを作っては捨て作っては捨て、ということを大量に繰り返すと、Javaのオブジェクトヒープよりも先にネイティブヒープが不足して、それ以上Matオブジェクトが割り当てられない、という状態になってしまう、らしいのだ。

GCが走れば、すでに未使用になっている古いJavaのMatオブジェクトが大量に回収されて、そのときに一緒にネイティブヒープも解放されるのだが、JavaのGCというのはJavaオブジェクトヒープが不足しない限り走らないので、いつまでもネイティブメモリが解放されずに、メモリ不足を起こしてしまう。

私がハマったのは、そういう状態だったらしい。

OpenCVのJava APIでは、メモリが不足すると、CvExceptionという例外を投げるようになっている。だから、この例外をキャッチして、System.gc()を呼んだ上で再度メモリの割り当てをすればいいはずだ。ただ、この例外は、Java側でオブジェクトをnewしたときに限らず、様々なOpenCVのAPIが内部で一時的に使うメモリを確保できなかったときなどにも発生するので、OpenCVのほとんど全てのAPIで発生する可能性がある。(CvExceptionはRuntimeExceptionになっている。この仕様を考えると当然だが。)だからと言って、全てのAPIの呼び出しをtry-catchで囲むわけにもいかないし、処理途中の半端なところでキャッチしても、途中だった処理の再実行ができない。

いくつか試した結果、私の解は、こんな感じに落ち着きましたよ。

    try {
        doSomething(file);
    } catch (CvException e) {
        System.gc();
        doSomething(file);
    }

doSomething(File)というメソッドで画像をファイルから読んで処理するようにしておき、CvExceptionが発生したらGCしてもう一回(ファイルの読み込みから)やり直すわけです。それでもまた例外が発生するとそのまま抜けちゃいますが、これはGC直後に処理してもまだメモリが不足するのはバグの可能性が高い(本当にメモリリークしているとか)ので、意図的にそうしています。

実際のプログラム全体は、上のコードの外側でfileを変えながらループ(ファイル100個くらい)するのですが、さらにその外側で画像処理のパラメタを変えるループがあって20~30通りくらいのパラメタを試すようになっており、doSomethingは都合2000~3000回くらい呼ばれます。そのため、ループの中で直接上のコードを実行するのではなく、ExecutorServiceを使って並列実行するようにしてあります。

ひょっとしたら、OpenCVのJava APIを使う人には常識かとも思ったのですが、解決前後にそれなりにウェブを検索しても、この件に言及している記事をみつけられなかったので。

ところで、普通Androidでは、こんなに大量の画像をまとめて処理するなんていうことはしないのですが、もしもやったらどうなるかと思って試してみたところ、一度もCvExceptionが発生せずに処理が終わります。処理の部分で消費するネイティブヒープとJavaオブジェクトヒープの比率が変わるようにコードを書き換えていろいろ試したのですが、(自動的な)GCの頻度が変わりはするものの、ネイティブヒープが不足して例外を投げるという状況にはなりませんでした。

不思議です。

ひょっとしたら、Androidのdalvikは、jniの先でネイティブコードが割り当てるネイティブヒープの使用量も見て、Javaオブジェクトヒープだけでなく、ネイティブヒープが不足したときにも自動GCが走るようになっているのでしょうか。考えてみると、.NETのCLRは、そういう仕組み(jniじゃなくてP/Invokeですけれども)を備えているわけで、Androidであれば、やればできるだろうとは思うわけですが。

というわけで、Androidのネイティブヒープ管理の仕組みについてもウェブを探してみましたが、そうなってるとも、なってないとも、この種の話題に言及した記事をみつけることができませんでした。今後の課題です。

« 「ナンプレ破り」Google Playで公開中 | トップページ | LTやります »

コメント

コメントを書く

(ウェブ上には掲載しません)

トラックバック

この記事のトラックバックURL:
http://app.f.cocolog-nifty.com/t/trackback/285638/53780465

この記事へのトラックバック一覧です: OpenCV Java APIをWindowsやLinuxで使うときのメモリ管理の注意:

« 「ナンプレ破り」Google Playで公開中 | トップページ | LTやります »