seraphyの日記

日記というよりは過去を振り返るときのための単なる備忘録

Javaのダイレクトバッファの利用上の注意点と、各種バッファを使った読み込みパフォーマンスの比較

概要

前回では、ヒープ上に確保したByteBufferと、ダイレクトバッファとして作成したByteBufferでのデータの読み取りパフォーマンスを比較した結果、圧倒的にダイレクトバッファが速いことが分かった。


しかし上記ベンチマークは、あえてByteBuffer単体でのパフォーマンスの比較を行ったものであり、実際のファイル入出力処理を含んだ比較ではない。
いわば、純粋なByteBufferの性能比較である。


では、実際にファイルの読み取りを含んだ場合でも、はたしてダイレクトバッファを使ったほうが速いのであろうか?


今回は、実際の利用方法を意識して、このあたりを調べてみることとする。

ヒープバッファとダイレクトバッファの違い

ByteBufferには大きく分けて2種類がある。

  • ヒープ上の作成されるヒープバッファ
  • ネイティブ領域に作成されるダイレクトバッファ

ヒープバッファは以下のように作成される。

ByteBuffer buf = ByteBuffer.alloc(siz);
// または
ByteBuffer buf = ByteBuffer.wrap(new byte[siz]);

(allocは単純に内部でnew byte[]を実行しているだけである。)

これは、当然、Javaのヒープ領域からバッファが割り当てられる。
バッファとして確保した分、利用可能なヒープサイズは減ることになる。
その特性はJavaの一般的なオブジェクトと何ら変わることは無い。


これに対して、ダイレクトバッファは以下のように作成される。

    ByteBuffer buf = ByteBuffer.allocDirect(siz);

(そのほか、RandomAccessFileから直接MappedByteBufferを取得する方法もある。)


こちらはJavaのヒープではなく、ネイティブメモリ上から割り当てられる。


ダイレクトバッファはヒープ上にバッファは作らないので、ヒープはほとんど減らない。
そのかわり、ネイティブメモリ、つまりOS側からJavaVMが借りているコミットメモリが増える。

ネイティブメモリはガベージコレクタが管理しているメモリの範囲外であるため、ガベージコレクトなどのメカニズムが効かない領域である。

実験コード
public class BufferAllocExample {

    /**
     * バイトバッファをallocする方法
     */
    public enum ByteBufferAllocStrategy {

        /**
         * ヒープのByteBufferを構築する.
         */
        heap() {
            @Override
            public ByteBuffer alloc(int siz) {
                return ByteBuffer.allocate(siz);
            }
        },

        /**
         * ダイレクトのByteBufferを構築する.
         */
        direct() {
            @Override
            public ByteBuffer alloc(int siz) {
                return ByteBuffer.allocateDirect(siz);
            }
        };

        public abstract ByteBuffer alloc(int siz);
    }

    /**
     * エントリポイント.
     * 第一引数にheapかdirectを指定する.
     * @param args
     * @throws Exception
     */
    public static void main(String... args) throws Exception {
        ByteBufferAllocStrategy allocator = ByteBufferAllocStrategy
                .valueOf(args[0]);

        Runtime rt = Runtime.getRuntime();

        List<ByteBuffer> buffers = new ArrayList<>();

        // 10 MiBの割り当てを10回繰り返す
        for (int idx = 0; idx < 10; idx++) {
            System.gc();
            System.out.println(rt.freeMemory() + "/" + rt.totalMemory());

            ByteBuffer buf = allocator.alloc(1024 * 1024 * 10);
            buffers.add(buf);
        }

        // 100 MiB割り当て後のヒープの残量
        System.gc();
        System.out.println(rt.freeMemory() + "/" + rt.totalMemory());

        // OSのコミットサイズ、プライベートワーキングセットのサイズを
        // タスクマネージャで見られるように、アプリ終了前に一旦停止する。
        System.console().readLine();
    }
}

第一引数にallocかallocDirectを指定して実行すると、10 MiBごとのバッファを確保しながらヒープの残量を表示し、最後に入力待ちになって停止するアプリである。

ヒープバッファ確保の場合
>java -Xms128m -Xmx128m -cp classes jp.seraphyware.jmhexample.BufferAllocExample heap
127831384/128974848
118026720/128974848
107540896/128974848
97055072/128974848
86569248/128974848
76083424/128974848
65597600/128974848
55111776/128974848
44625952/128974848
34140128/128974848
23654304/128974848 

バッファを確保するたびにヒープがどんどん減ってゆく順当な動きである。


このときのタスクマネージャで取得したOSから予約されたコミットサイズは、

  • コミットサイズ = 184,444 k

となっていた。

ダイレクトバッファの場合
>java -Xms128m -Xmx128m -cp classes jp.seraphyware.jmhexample.BufferAllocExample direct
127831360/128974848
128512256/128974848
128512120/128974848
128511984/128974848
128511848/128974848
128511712/128974848
128511576/128974848
128511440/128974848
128511304/128974848
128511168/128974848
128511032/128974848

ヒープはぜんぜん減っていない。

このときのタスクマネージャで取得したOSから予約されたコミットサイズは、

  • コミットサイズ = 286,676 k

となっている。


ヒープバッファのときと比較して、コミットサイズ = OSからJavaVMが予約しているメモリとしては確実に100 MB(102,232 k)ほど増えている。

ダイレクトバッファの最大サイズの指定方法

ちなみに、ダイレクトバッファの最大値はJVMのオプション"-XX:MaxDirectMemorySize "のように明示的に設定することができる。


省略した場合は自動(≒ 制限なし)で、既定ではヒープの最大値と同じようになるため、通常は気にしなくてもよいらしい。

https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html


ただし、最大ヒープサイズを超えてダイレクトバッファサイズを指定することもできるらしく、以下のように最大ヒープサイズを64 MBにしても、それを超える100 MBのダイレクトバッファを確保することが可能であった。(Java8u60で確認。)

>java -Xms64m -Xmx64m -XX:MaxDirectMemorySize=512m -cp classes jp.seraphyware.jmhexample.BufferAllocExample direct
63689944/64487424
64020424/64487424
63684664/64487424
63684528/64487424
63684392/64487424
63684256/64487424
63684120/64487424
63683984/64487424
63683848/64487424
63683712/64487424
63683656/64487424

バッファ確保のパフォーマンス比較

ヒープ上のByteBufferは、単に普通にヒープ上でnew byte[…]するだけのオーバーヘッドと同等である。
ヒープメモリは事前にJavaVMがOSよりメモリをもらっており、そこでJavaVMが領域を割り当てるだけである。


これに対して、ダイレクトバッファはネイティブ領域に都度、OSよりバッファを確保してもらっているような動きになっている。
(実際は、もう少し効率は良いかもしれないが、前述の実験からすると、要求されてから都度、OSからメモリをもらっている感じである。)


両者はメモリの確保方法が異なるため、確保するまでにかかる処理時間も異なると予想される。

ベンチマーク

そこで、以下のようなベンチマークコードを書いて計測してみた。

public class BufferAllocBenchmark {

    @State(Scope.Thread)
    public static class BufferAllocContext {

        private ByteBuffer buf;

        @Setup(Level.Trial)
        public void setup() {
        }

        public void setByteBuffer(ByteBuffer buf) {
            this.buf = buf;
        }

        public ByteBuffer getButeBuffer() {
            return this.buf;
        }
    }

    @Benchmark
    public void testAllocDirect8(BufferAllocContext ctx) {
        ctx.setByteBuffer(ByteBuffer.allocateDirect(8 * 1024));
    }

    @Benchmark
    public void testAllocHeap8(BufferAllocContext ctx) {
        ctx.setByteBuffer(ByteBuffer.allocate(8 * 1024));
    }
  … (中略) …
}

ByteBuffer.alloc()と、ByteBuffer.allocDirect()をバッファサイズを変えて計測している。

計測するバッファサイズは、8, 16, 32, 64, 128, 256, 512, 1024 kibである。


(過度な最適化が起きないように、得られたByteBufferは状態インスタンスに1世代のみ保存している。)


Javaはjdk1.8u60で実行し、オプションとして-Xmx128m -Xmx128m -XX:MaxDirectMemorySize=256mを指定したものである。

計測結果
size Direct Heap Direct Error Heap Error
8 183090.216 2018841.709 320.863 49630.037
16 93116.245 1096969.848 191.754 8257.477
32 47854.178 547637.302 82.986 13006.356
64 22689.129 266632.138 224.02 5163.652
128 10988.465 69412.152 518.684 864.918
256 5536.215 33852.752 223.108 84.935
512 2897.219 28651.803 24.236 406.872
1024 1574.254 14013.506 51.023 104.658

数値は、1秒あたりのベンチマークメソッドの実行回数の平均である。

激しくガベージコレクタが動作することが予想されるので、gcによる揺らぎを考慮して測定回数(イテレーション)は50回まわすことにした。


これをチャート化すると、以下のようになる。

チャート化するにあたり、数値は実行回数ではなく確保したバイト数に換算している。
(= 実行した回数 x 確保したバイト数)


これにより、バッファ確保の効率を比較できるようにしている。

これを見ると、明らかにダイレクトバッファはヒープバッファよりも時間がかかっている。


差が10倍ほど開いてしまったので、縦軸は対数表示している。
ダイレクトバッファは、確保するまでの時間が、むちゃくちゃ遅い。


この点については、そもそもJavaDocに書かれている。

ダイレクト byte バッファーは、このクラスのファクトリメソッド allocateDirect を呼び出すと作成されます。通常は、こちらのバッファーのほうが、非ダイレクトバッファーよりも割り当ておよび解放コストがやや高くなります。ダイレクトバッファーの内容が標準のガベージコレクトされたヒープの外部にあるなら、アプリケーションのメモリーフットプリントに対する影響はわずかです。このことから、ダイレクトバッファーには、基本となるシステム固有の入出力操作に従属する、寿命が長く容量の大きいバッファーを指定することをお勧めします。一般に、ダイレクトバッファーの割り当ては、プログラムの性能を十分に改善できる見込みがある場合にのみ行うべきです。

http://docs.oracle.com/javase/jp/8/docs/api/java/nio/ByteBuffer.html


チャートを見ると揺らぎがあるが、だいたいメモリ確保のスピードは確保するバッファサイズに係わらず、ほぼ一定のように思われるので、バッファが小さければ、あるいは、大きければバッファ確保が速くなるとか、そうゆうことはなさそうである。


いずれにしても、ヒープとのダイレクトバッファの確保時のパフォーマンスの差はかなり大きい。

DirerctByteBufferの連続確保と破棄の問題点

ヒープ上のbyte[]配列であるヒープバッファは連続確保・破棄したとしても、その動きは通常のJavaオブジェクトと変わらないことが予想される。

また、OSから見た使用メモリには増減は発生しないはずである。
(すでに確保済みのヒープ内で処理されるため。)


これに対して、ダイレクトバッファはOSより都度メモリを借り、そのメモリを解放するためには、そのダイレクトバッファのハンドルをもつヒープ上のByteBufferオブジェクトがgcされるまで待たなければならないはずである。
(ByteBufferのメソッドにはI/O処理につきものの「close」のようなメソッドがないため、バッファが使用中であるかどうかを判断するにはgcによるしかない。)

また、OSから見た使用メモリは、ダイレクトバッファは確保されるたびにコミットサイズが増加し、gcが発生するたびに解放されるような動きを示すはずである。

実験

そこで、以下のようなコードを書いて試してみた。

public class BufferStressExample2 {

    /**
     * 最新のバイトバッファを保持するスレッドローカル
     */
    public static ThreadLocal<ByteBuffer> lastBufTLS = new ThreadLocal<>();

    /**
     * バイトバッファをallocする方法
     */
    public enum ByteBufferAllocStrategy {

        /**
         * ヒープのByteBufferを構築する.
         */
        heap() {
            @Override
            public ByteBuffer alloc(int siz) {
                return ByteBuffer.allocate(siz);
            }
        },

        /**
         * ダイレクトのByteBufferを構築する.
         */
        direct() {
            @Override
            public ByteBuffer alloc(int siz) {
                return ByteBuffer.allocateDirect(siz);
            }
        };

        public abstract ByteBuffer alloc(int siz);
    }

    /**
     * バイトバッファの破棄方法.
     */
    public enum ByteBufferDeallocStrategy {

        /**
         * 何もしない.システムにお任せ.
         */
        none() {
            @Override
            public void dealloc(ByteBuffer buf) {
            }
        },

        /**
         * 明示的にgcを呼び出す.
         */
        gc() {
            @Override
            public void dealloc(ByteBuffer buf) {
                System.gc();
            }
        },

        /**
         * 明示的にcleanerを呼び出す.
         */
        cleaner() {
            @Override
            public void dealloc(ByteBuffer buf) {
                if (buf != null && buf.isDirect()) {
                    destroyDirectByteBuffer(buf);
                }
            }
        };

        public abstract void dealloc(ByteBuffer buf);
    }

    /**
     * エントリポイント
     * @param args
     * @throws Exception
     */
    public static void main(String... args) throws Exception {

        ByteBufferAllocStrategy allocator =
                ByteBufferAllocStrategy.valueOf(args[0]);
        ByteBufferDeallocStrategy deallocator;
        if (args.length > 1) {
            deallocator = ByteBufferDeallocStrategy.valueOf(args[1]);
        } else {
            deallocator = ByteBufferDeallocStrategy.none;
        }

        int mxthread = (args.length > 2) ? Integer.parseInt(args[2]) : 2;

        // 10 MiBの割り当てを永久に繰り返すジョブ
        Runnable job = () -> {
            for (;;) {
                try {
                    // ByteBufferの構築
                    ByteBuffer buf = allocator.alloc(10 * 1024 * 1024); // 10 MiB

                    // ByteBufferの破棄
                    deallocator.dealloc(lastBufTLS.get());

                    // 最後に確保したByteBufferの保存
                    lastBufTLS.set(buf);

                } catch (Exception ex) {
                    throw new RuntimeException(ex);
                }
            }
        };

        IntFunction<Thread> makeThread = (idx) -> {
            Thread t = new Thread(job);
            t.setDaemon(false);
            t.setName("bufferStress:" + idx);
            return t;
        };

        IntStream.range(0, mxthread)
                .mapToObj(makeThread)
                .forEach(Thread::start);
    }
}

JDK1.8u60で実行した。

テストコードでは2スレッドの同時バッファ確保を行っている。

またコマンドは以下のオプションで実行した。

java -cp classes -Xms128m -Xmx128m -XX:MaxDirectMemorySize=256m jp.seraphyware.jmhexample.BufferStressExample2 heap
ヒープによるByteBufferの連続確保と破棄

以下はWindowsのパフォーマンスモニタによるPage File Bytes(≒コミットサイズ)と、ワーキングセットサイズの推移グラフである。
(縦軸の単位は10 MiBである。以下のパフォーマンスモニタのチャートはすべて同様である。)

new byte[]の破棄と解放はJavaVMの中の出来事であり、OSから見てメモリ消費の増減はないのが分かる。

ダイレクトバッファによるByteBufferの連続確保と破棄と、OutOfMemory問題

このテストコードでは、ダイレクトバッファは1回につき10 Mbytesを割り当て、直近の1つのByteBufferだけを保存し、それ以前のByteBufferは参照がなくなりガベージコレクト対象としている。

これが2スレッドで動いているので、理想的にはダイレクトバッファは1世代前の10 Mbytes x 2と、作成されたばかりの10 Mbytes x 2の、計40 Mbytesのバッファ以外は不要のはずである。


しかし、パフォーマンスモニタによると、瞬間的には450 Mbytesほどのコミットサイズになっている。(ヒープサイズが128MB、これに加えてMaxDirectMemorySizeが256 MBなので、384 MB+α ぐらいが最大値になっていると思われる。)

つまり、不要になったネイティブメモリは、すぐには消えていない。


また、このテストでは、MaxDirectMemorySizeで十分なサイズを指定しておかないと、「OutOfMemoryError: Direct buffer memory」とメモリ不足エラーが発生しやすくなる。
また、スレッド数を増やすと、如実にOutOfMemoryErrorが発生するようになる。

実際には10 Mbytesしか要求しておらず、最大バッファサイズが256 MBもあり、十分に空きがあると考えられるにも係わらず、である。

おそらく確保するスピードが解放するスピードを上回っているのだろう。

ダイレクトバッファでOutOfMemoryを避ける方法

原理的には、ByteBufferがgcされるまでネイティブに確保されたダイレクトバッファも解放されないであろうため、最大ダイレクトメモリサイズよりヒープが大きい場合は、場合によってはヒープのgcが発生する前にダイレクトメモリが枯渇する可能性があるように思われる。(まさか、そんなナイーブな実装ではないとは思うし、OutOfMemoryをあげる前にgcは試行されるはずだが…。)

現実に、Windows7/8.1上のjdk1.8u60では、MaxDirectMemorySizeが十分に大きくないと、このテストでは、すぐにOutOfMemoryが発生する状況である。

System.gc()によるOutOfMemoryの回避

ダイレクトバッファを確保する直前にSystem.gc()をしてあげると、この事象はでてこない。
ByteBufferがgcされ、ネイティブの解放も逐次行われて、パフォーマンスモニタの波形も比較的穏やかなものになる。

ただし、はたして実運用コードの中に「System.gc()」のようなコードを書いて良いものか?
(あるいは、-XX:+DisableExplicitGC オプションによりgcが無視される可能性もある。)
、というような課題はある。

DirectBuffer内部のcleanerを直接使用した早期解放を使う場合

実は、どうやらダイレクトバッファの破棄が遅延されてOutOfMemoryErrorが発生する問題は、かなりメジャーな問題のようで、Stackoverflowあたりを検索すると沢山出てくる。


System.gc()を使うのが、おそらくもっとも正式な方法だが、DirectByteBufferが内部で持っているcleanerメソッドを呼び出して、それに対してcleanを実行することで、ネイティブのメモリ破棄処理を強制的に早期に実行させる、という方法もあるようである。


以下に引用する、ApacheHadoopのソースにも、ダイレクトバッファを破棄するためのコードが書かれている。
(および、どうしてダイレクトバッファの破棄が遅延するのかの理由も。)

https://hbase.apache.org/0.94/apidocs/src-html/org/apache/hadoop/hbase/util/DirectMemoryUtils.html#line.80

    /**
      * DirectByteBuffers are garbage collected by using a phantom reference and a
      * reference queue. Every once a while, the JVM checks the reference queue and
      * cleans the DirectByteBuffers. However, as this doesn't happen
      * immediately after discarding all references to a DirectByteBuffer, it's
      * easy to OutOfMemoryError yourself using DirectByteBuffers. This function
      * explicitly calls the Cleaner method of a DirectByteBuffer.
      * 
      * @param toBeDestroyed
      *          The DirectByteBuffer that will be "cleaned". Utilizes reflection.
      *          
      */
    public static void destroyDirectByteBuffer(ByteBuffer toBeDestroyed)
        throws IllegalArgumentException, IllegalAccessException,
        InvocationTargetException, SecurityException, NoSuchMethodException {
    
      Preconditions.checkArgument(toBeDestroyed.isDirect(),
          "toBeDestroyed isn't direct!");
    
      Method cleanerMethod = toBeDestroyed.getClass().getMethod("cleaner");
      cleanerMethod.setAccessible(true);
      Object cleaner = cleanerMethod.invoke(toBeDestroyed);
      Method cleanMethod = cleaner.getClass().getMethod("clean");
      cleanMethod.setAccessible(true);
      cleanMethod.invoke(cleaner);
    
    }

このコメントによると、参照がすべて切れたからといって、ただちにネイティブメモリが解放されるわけではなく、ダイレクトバッファはファントムリファレンスによって保持され、そのリファレンスキューをJavaVMがチェックして、gcによりキューに入れられたバッファがあれば、そのネイティブメモリを解放しているようである。
このコメントから察するに、タイミングは不明だが、ともかくリファレンスキューを見に行く必要のあるパッシブな動きになっているのだろう。


ためしに、このcleanerを呼び出すコードを使ってみると、パフォーマンスモニタの波形は、大変穏やかなものとなり、スレッド数を増やしてもOutOfMemoryも発生しなくなる。


ただし、はたして実運用コードの中にJavaVMの特定の実装にべったり依存するかのようなコードを書いてよいものかどうか、そのあたりの課題は残る。

ヒープバッファとダイレクトバッファのファイルの読み込み速度の比較

以上の考察から、ダイレクトバッファを使うには2つ注意点があることが分かった。

  1. ダイレクトバッファは確保するのが遅い。
  2. ダイレクトバッファは破棄するのが難しい。

つまり、ファイルを開くたびに、ダイレクトバッファを毎回確保するような方法では、バッファ確保時のオーバーヘッドと、バッファ破棄のトラブルによって、あまり良いパフォーマンスは得られない可能性がある。


しかし、「単にファイルをシーケンシャルに読み込みたい」という、ありふれたニーズであれば、これを対策するのは比較的容易である。


単に、ダイレクトバッファは確保したらキャッシュし、破棄しなければ良いだけである。
(同時アクセスするファイルの最大数分だけダイレクトバッファをキャッシュすれば良い。)
*1


これを実装した上で、ダイレクトバッファとヒープバッファでのファイル読み取りのパフォーマンスを比較してみたいと思う。

実験内容
  • テストデータはテンポラリ上に作成される10 Mbytesのファイルである。
  • テストデータはLittle EndianとBig Endianの2種類作成する
  • データはIntとDoubleの2種類作成する。
  • バッファサイズは、8, 16, 32, 64, 128, 256, 512, 1024 KiB
  • 読み取り方式として、
    • ダイレクトバッファ (バッファは@Setup時に予め初期化しておき、各イテレーションでは確保しない)
    • ヒープバッファ (バッファは@Setup時に予め初期化しておき、各イテレーションでは確保しない)
    • BufferedInputStream
    • InputStream (バッファ忘れケース)
  • ウォームアップとして10回、計測として10回のイテレーションを行う

なお、ウォームアップを10回も繰り返すと、10 MBytes程度のファイルならOSのファイルキャッシュに確実に乗っかることになる。
しかし、今回はディスクの性能を比較するわけではないので、これで良いものと考える。
ある意味、理想的なディスクを使った場合の読み取り方式の違いによるパフォーマンスの違いを計測するようなものといえるだろう。

データ作成コード

(Intデータ部のみ。Doubleデータのソースコードの記載は省略)

データ共通部

/**
 * テスト中にスレッドごとベンチマークごとの状態を保持するインスタンス.
 *
 * @author seraphy
 */
@State(Scope.Thread)
public abstract class AbstractFileBenchContext {

    protected final ByteOrder byteOrder;

    protected final int bufsiz;

    private Path tempFile;

    private ByteBuffer bufHeap;

    private ByteBuffer bufDirect;

    protected AbstractFileBenchContext(ByteOrder byteOrder, int bufsiz) {
        this.byteOrder = byteOrder;
        this.bufsiz = bufsiz;
    }

    /**
     * データの初期化.
     * Trialの場合、スレッドごとベンチマークの開始ごとに一度呼び出される.<br>
     * (ウォームアップイテレーション、および計測イテレーション中には呼び出されない。)
     * (Trialがデフォルト、そのほかにIteration, Invocationが指定可能.)
     */
    @Setup(Level.Trial)
    public void setup() throws IOException {

        // バッファの確保 (ヒープ)
        byte[] data = new byte[bufsiz];
        bufHeap = ByteBuffer.wrap(data);
        bufHeap.order(byteOrder);

        // バッファの確保 (ダイレクト)
        bufDirect = ByteBuffer.allocateDirect(data.length);
        bufDirect.order(byteOrder);

        // テストデータファイルの作成
        tempFile = initTempFile();
    }

    protected abstract Path initTempFile() throws IOException;

    /**
     * スレッドごとベンチマーク終了ごとに呼び出される.
     */
    @TearDown
    public void teardown() {
        try {
            //System.out.println("★delete tempFile=" + tempFile);
            Files.deleteIfExists(tempFile);

        } catch (Exception ex) {
            ex.printStackTrace();
        }
    }

    public Path getTempFile() {
        return tempFile;
    }

    public abstract void verify(Object actual);

    public ByteBuffer getBufDirect() {
        return bufDirect;
    }

    public ByteBuffer getBufHeap() {
        return bufHeap;
    }
}

テストごとのデータ部(一部)

    public static class BenchContextBase extends AbstractFileBenchContext {

        public static final int reqSize = 1024 * 1024 * 10; // 10 MiBytes

        private long total;

        protected BenchContextBase(ByteOrder byteOrder, int bufsiz) {
            super(byteOrder, bufsiz);
        }

        /**
         * テストデータファイルの作成
         * @throws IOException
         */
        @Override
        protected Path initTempFile() throws IOException {
            Path tempFile = Files.createTempFile("filebufferbench", ".tmp");
            //System.out.println("★create tempFile=" + tempFile);

            ByteBuffer buf = ByteBuffer.allocate(bufsiz);
            buf.order(byteOrder);

            total = 0;
            int capacity = buf.capacity();
            long siz;
            try (FileChannel channel = (FileChannel) Files
                    .newByteChannel(tempFile, CREATE, TRUNCATE_EXISTING, WRITE)) {
                int idx = 0;
                int mx = reqSize / capacity;
                for (int loop = 0; loop < mx; loop++) {
                    buf.clear();
                    while (buf.position() < capacity) {
                        buf.putInt(idx);
                        total += idx;
                        idx += 1;
                    }
                    buf.flip();
                    channel.write(buf);
                }
                siz = channel.size();
            }

            if (reqSize != siz) {
                throw new RuntimeException();
            }
            //System.out.println("★total=" + total + "/size=" + siz);
            return tempFile;
        }

        @Override
        public void verify(Object actual) {
            if ((Long) actual != total) {
                throw new RuntimeException("actual=" + actual);
            }
         }
    }

    public static class BenchContextLE8k extends BenchContextBase {

        public BenchContextLE8k() {
            super(ByteOrder.LITTLE_ENDIAN, 1024 * 8);
        }
    }

    public static class BenchContextBE8k extends BenchContextBase {

        public BenchContextBE8k() {
            super(ByteOrder.BIG_ENDIAN, 1024 * 8);
        }
    }

※ このあと、バッファサイズ、LE/BEごとのコンテキストクラスが延々と続く。

テスト実行部

FileChannelを使ってByteBufferでファイルの中身を取り出しテストする部分。

    /**
     * バイトバッファからint値を読み出すテスト
     * @param buf
     */
    private static long runByteBuffer(FileChannel channel, ByteBuffer buf)
            throws IOException {
        long total = 0;
        for (;;) {
            buf.clear();
            int len = channel.read(buf);
            if (len < 0) {
                break;
            }
            buf.flip();
            int limit = buf.limit();
            while (buf.position() < limit) {
                total += buf.getInt();
            }
        }
        return total;
    }

データファイルを読み取り、ファイルチャネルを取り出して中身を走査する。

    private static void doBench(AbstractFileBenchContext ctx, ByteBuffer buf)
            throws IOException {
        try (FileChannel channel = (FileChannel) Files.newByteChannel(
                ctx.getTempFile(), READ)) {
            ctx.verify(runByteBuffer(channel, buf));
        }
    }

ヒープバッファ、ダイレクトバッファによるテスト

    @Benchmark
    public void testHeapBufferLE8k(BenchContextLE8k ctx) throws IOException {
        doBench(ctx, ctx.getBufHeap());
    }

    @Benchmark
    public void testDirectBufferLE8k(BenchContextLE8k ctx) throws IOException {
        doBench(ctx, ctx.getBufDirect());
    }
旧式のストリーム系APIによる読み取り

FileInputStream → BufferedInputStream → DataInputStream の連携でデータを読み取るテスト。

DataInputStreamはBigEndian専用なので、BigEndianのデータのみテストする。


DataInputStreamは一般的にいって使用頻度が高いとはいえないと思うが、byte[]からint値を取り出すための、昔からある、由緒正しい方法の1つなので、とりあえず、これで計測してみる。

    private static long readInts(InputStream is, int cnt) throws IOException {
        long total = 0;
        try (DataInputStream dis = new DataInputStream(is)) {
            for (int idx = 0; idx < cnt; idx++) {
                total += dis.readInt();
            }
        }
        return total;
    }

    @Benchmark
    public void testOldBufferedIO8k(BenchContextBE1m ctx) throws IOException {
        long total;
        try (BufferedInputStream bis = new BufferedInputStream(
                new FileInputStream(ctx.getTempFile().toFile()))) {
            total = readInts(bis, BenchContextBase.reqSize / 4);
        }
        ctx.verify(total);
    }

    @Benchmark
    public void testOldBufferedNIO8k(BenchContextBE1m ctx) throws IOException {
        long total;
        try (BufferedInputStream bis = new BufferedInputStream(
                Files.newInputStream(ctx.getTempFile()))) {
            total = readInts(bis, BenchContextBase.reqSize / 4);
        }
        ctx.verify(total);
    }

なお、Java7から、FilesクラスのnewInputStreamメソッドを使って、簡易にファイルを開くことができるようになったが、従来のFileInputStreamのコンテストラクタで開く場合と変わりないのか気になったので、そのテストも追加している。


また、FileInputStreamをBufferedInputStreamでラップしなかった場合、パフォーマンスの低下がどの程度なものなのか気になったので、こちらもテストを追加している。

計測結果

Intデータの読み取り
Benchmark                                                Mode  Cnt    Score    Error  Units
FileBufferAccessIntBenchmark.testDirectBufferBE128      thrpt   10  312.589 ±  4.157  ops/s
FileBufferAccessIntBenchmark.testDirectBufferBE16       thrpt   10  242.295 ±  3.186  ops/s
FileBufferAccessIntBenchmark.testDirectBufferBE1m       thrpt   10  317.480 ±  2.527  ops/s
FileBufferAccessIntBenchmark.testDirectBufferBE256      thrpt   10  313.295 ±  8.739  ops/s
FileBufferAccessIntBenchmark.testDirectBufferBE32       thrpt   10  294.236 ±  1.012  ops/s
FileBufferAccessIntBenchmark.testDirectBufferBE512      thrpt   10  312.779 ±  5.954  ops/s
FileBufferAccessIntBenchmark.testDirectBufferBE64       thrpt   10  307.159 ±  4.509  ops/s
FileBufferAccessIntBenchmark.testDirectBufferBE8k       thrpt   10  202.106 ±  0.781  ops/s
FileBufferAccessIntBenchmark.testDirectBufferLE128      thrpt   10  364.260 ± 10.193  ops/s
FileBufferAccessIntBenchmark.testDirectBufferLE16       thrpt   10  264.034 ±  0.895  ops/s
FileBufferAccessIntBenchmark.testDirectBufferLE1m       thrpt   10  360.495 ± 13.502  ops/s
FileBufferAccessIntBenchmark.testDirectBufferLE256      thrpt   10  362.586 ±  9.963  ops/s
FileBufferAccessIntBenchmark.testDirectBufferLE32       thrpt   10  327.563 ±  4.147  ops/s
FileBufferAccessIntBenchmark.testDirectBufferLE512      thrpt   10  359.784 ± 15.424  ops/s
FileBufferAccessIntBenchmark.testDirectBufferLE64       thrpt   10  355.960 ±  9.823  ops/s
FileBufferAccessIntBenchmark.testDirectBufferLE8k       thrpt   10  221.521 ±  0.949  ops/s
FileBufferAccessIntBenchmark.testHeapBufferBE128        thrpt   10  144.247 ±  0.341  ops/s
FileBufferAccessIntBenchmark.testHeapBufferBE16         thrpt   10  132.583 ±  0.299  ops/s
FileBufferAccessIntBenchmark.testHeapBufferBE1m         thrpt   10  162.469 ±  0.401  ops/s
FileBufferAccessIntBenchmark.testHeapBufferBE256        thrpt   10  162.569 ±  0.869  ops/s
FileBufferAccessIntBenchmark.testHeapBufferBE32         thrpt   10  135.383 ±  8.522  ops/s
FileBufferAccessIntBenchmark.testHeapBufferBE512        thrpt   10  158.935 ±  1.510  ops/s
FileBufferAccessIntBenchmark.testHeapBufferBE64         thrpt   10  136.175 ±  1.539  ops/s
FileBufferAccessIntBenchmark.testHeapBufferBE8k         thrpt   10  110.557 ±  1.032  ops/s
FileBufferAccessIntBenchmark.testHeapBufferLE128        thrpt   10  139.430 ±  0.641  ops/s
FileBufferAccessIntBenchmark.testHeapBufferLE16         thrpt   10  141.571 ±  0.264  ops/s
FileBufferAccessIntBenchmark.testHeapBufferLE1m         thrpt   10  161.376 ±  1.468  ops/s
FileBufferAccessIntBenchmark.testHeapBufferLE256        thrpt   10  164.129 ±  0.593  ops/s
FileBufferAccessIntBenchmark.testHeapBufferLE32         thrpt   10  139.513 ±  1.686  ops/s
FileBufferAccessIntBenchmark.testHeapBufferLE512        thrpt   10  164.563 ±  0.675  ops/s
FileBufferAccessIntBenchmark.testHeapBufferLE64         thrpt   10  142.430 ±  0.715  ops/s
FileBufferAccessIntBenchmark.testHeapBufferLE8k         thrpt   10  115.688 ±  0.349  ops/s
FileBufferAccessIntBenchmark.testOldBufferedIO128k      thrpt   10   21.981 ±  0.201  ops/s
FileBufferAccessIntBenchmark.testOldBufferedIO16k       thrpt   10   21.746 ±  0.176  ops/s
FileBufferAccessIntBenchmark.testOldBufferedIO32k       thrpt   10   21.734 ±  0.249  ops/s
FileBufferAccessIntBenchmark.testOldBufferedIO64k       thrpt   10   21.992 ±  0.109  ops/s
FileBufferAccessIntBenchmark.testOldBufferedIO8k        thrpt   10   21.298 ±  0.168  ops/s
FileBufferAccessIntBenchmark.testOldBufferedNIO128k     thrpt   10   22.111 ±  0.117  ops/s
FileBufferAccessIntBenchmark.testOldBufferedNIO16k      thrpt   10   21.814 ±  0.188  ops/s
FileBufferAccessIntBenchmark.testOldBufferedNIO32k      thrpt   10   22.020 ±  0.252  ops/s
FileBufferAccessIntBenchmark.testOldBufferedNIO64k      thrpt   10   22.072 ±  0.300  ops/s
FileBufferAccessIntBenchmark.testOldBufferedNIO8k       thrpt   10   21.420 ±  0.185  ops/s
FileBufferAccessIntBenchmark.testOldPlainIO             thrpt   10    0.077 ±  0.001  ops/s
FileBufferAccessIntBenchmark.testOldPlainNIO            thrpt   10    0.072 ±  0.001  ops/s
Doubleデータの読み取り
Benchmark                                                Mode  Cnt    Score    Error  Units
FileBufferAccessDoubleBenchmark.testDirectBufferBE128   thrpt   10  350.916 ±  2.222  ops/s
FileBufferAccessDoubleBenchmark.testDirectBufferBE16    thrpt   10  273.792 ±  2.641  ops/s
FileBufferAccessDoubleBenchmark.testDirectBufferBE1m    thrpt   10  350.331 ±  2.718  ops/s
FileBufferAccessDoubleBenchmark.testDirectBufferBE256   thrpt   10  351.351 ±  3.570  ops/s
FileBufferAccessDoubleBenchmark.testDirectBufferBE32    thrpt   10  322.476 ±  1.697  ops/s
FileBufferAccessDoubleBenchmark.testDirectBufferBE512   thrpt   10  347.777 ±  5.238  ops/s
FileBufferAccessDoubleBenchmark.testDirectBufferBE64    thrpt   10  347.016 ±  2.038  ops/s
FileBufferAccessDoubleBenchmark.testDirectBufferBE8k    thrpt   10  220.529 ±  0.839  ops/s
FileBufferAccessDoubleBenchmark.testDirectBufferLE128   thrpt   10  346.273 ±  7.606  ops/s
FileBufferAccessDoubleBenchmark.testDirectBufferLE16    thrpt   10  275.622 ±  1.436  ops/s
FileBufferAccessDoubleBenchmark.testDirectBufferLE1m    thrpt   10  345.511 ±  6.223  ops/s
FileBufferAccessDoubleBenchmark.testDirectBufferLE256   thrpt   10  348.193 ± 14.641  ops/s
FileBufferAccessDoubleBenchmark.testDirectBufferLE32    thrpt   10  321.897 ±  2.498  ops/s
FileBufferAccessDoubleBenchmark.testDirectBufferLE512   thrpt   10  354.947 ±  2.515  ops/s
FileBufferAccessDoubleBenchmark.testDirectBufferLE64    thrpt   10  339.376 ±  5.021  ops/s
FileBufferAccessDoubleBenchmark.testDirectBufferLE8k    thrpt   10  218.614 ±  2.389  ops/s
FileBufferAccessDoubleBenchmark.testHeapBufferBE128     thrpt   10  135.477 ±  0.477  ops/s
FileBufferAccessDoubleBenchmark.testHeapBufferBE16      thrpt   10  128.826 ±  0.493  ops/s
FileBufferAccessDoubleBenchmark.testHeapBufferBE1m      thrpt   10  133.019 ±  1.100  ops/s
FileBufferAccessDoubleBenchmark.testHeapBufferBE256     thrpt   10  135.249 ±  0.382  ops/s
FileBufferAccessDoubleBenchmark.testHeapBufferBE32      thrpt   10  138.792 ±  0.382  ops/s
FileBufferAccessDoubleBenchmark.testHeapBufferBE512     thrpt   10  133.881 ±  1.120  ops/s
FileBufferAccessDoubleBenchmark.testHeapBufferBE64      thrpt   10  135.730 ±  0.320  ops/s
FileBufferAccessDoubleBenchmark.testHeapBufferBE8k      thrpt   10  110.442 ±  0.632  ops/s
FileBufferAccessDoubleBenchmark.testHeapBufferLE128     thrpt   10  134.690 ±  0.560  ops/s
FileBufferAccessDoubleBenchmark.testHeapBufferLE16      thrpt   10  128.883 ±  0.583  ops/s
FileBufferAccessDoubleBenchmark.testHeapBufferLE1m      thrpt   10  132.606 ±  1.049  ops/s
FileBufferAccessDoubleBenchmark.testHeapBufferLE256     thrpt   10  134.812 ±  0.587  ops/s
FileBufferAccessDoubleBenchmark.testHeapBufferLE32      thrpt   10  138.150 ±  0.572  ops/s
FileBufferAccessDoubleBenchmark.testHeapBufferLE512     thrpt   10  134.951 ±  0.408  ops/s
FileBufferAccessDoubleBenchmark.testHeapBufferLE64      thrpt   10  135.497 ±  0.874  ops/s
FileBufferAccessDoubleBenchmark.testHeapBufferLE8k      thrpt   10  111.449 ±  0.286  ops/s
FileBufferAccessDoubleBenchmark.testOldBufferedIO128k   thrpt   10   46.183 ±  0.480  ops/s
FileBufferAccessDoubleBenchmark.testOldBufferedIO16     thrpt   10   42.791 ±  0.701  ops/s
FileBufferAccessDoubleBenchmark.testOldBufferedIO32     thrpt   10   45.116 ±  0.071  ops/s
FileBufferAccessDoubleBenchmark.testOldBufferedIO64     thrpt   10   45.764 ±  0.452  ops/s
FileBufferAccessDoubleBenchmark.testOldBufferedIO8k     thrpt   10   43.101 ±  0.208  ops/s
FileBufferAccessDoubleBenchmark.testOldBufferedNIO128k  thrpt   10   46.364 ±  0.373  ops/s
FileBufferAccessDoubleBenchmark.testOldBufferedNIO16    thrpt   10   44.852 ±  0.131  ops/s
FileBufferAccessDoubleBenchmark.testOldBufferedNIO32    thrpt   10   46.075 ±  0.125  ops/s
FileBufferAccessDoubleBenchmark.testOldBufferedNIO64    thrpt   10   46.895 ±  0.301  ops/s
FileBufferAccessDoubleBenchmark.testOldBufferedNIO8k    thrpt   10   43.116 ±  0.320  ops/s
FileBufferAccessDoubleBenchmark.testOldIO               thrpt   10    0.560 ±  0.006  ops/s
FileBufferAccessDoubleBenchmark.testOldNIO              thrpt   10    0.563 ±  0.008  ops/s
パフォーマンスの比較

10MbytesのファイルのInt値の連続読み取りパフォーマンスのチャート。
縦軸はテストメソッド(10 MBytesファイルの読み取り)の1秒間あたりの実行回数の平均を示す。


やはり、ダイレクトバッファはヒープバッファよりも2倍以上性能が高い。

またバッファサイズとしては256 KiB以上確保したほうが読み込み性能は良いように見える。しかし、256 KiB以上確保しても、それ以上は性能は変わらないようでもある。


10 MbytesのファイルのDouble値の連続読み取りパフォーマンスのチャート。
縦軸はテストメソッド(10 MBytesファイルの読み取り)の1秒間あたり実行回数の平均を示す。


やはり、ダイレクトバッファはヒープバッファよりも2倍以上性能が高い。

また、こちらもバッファサイズとしては256 KiB以上確保したほうが読み込み性能は良いように見える。しかし、256 KiB以上確保しても、やはり、それ以上は性能は変わらないようでもある。

OldBufferedIOの性能が思った以上に低い

今回のベンチマークで思い知ったことは、Javaの古典的なストリーム系のBufferedInputStreamとDataInputStreamの組み合わせが、相当にパフォーマンスが低い、ということだった。
本当に予想外に性能がかなり悪い。
ヒープ上のByteBufferと比較しても、ここまでパフォーマンスが悪いとは思わなかった。


また、うっかりBufferedInputStreamで包むのを忘れてしまったFileInputStreamの性能が大変悲惨であることをチャートで見るとよく分かる。
(これは想定されていたことだが、実際にチャートで比較してみると"悲惨"という言葉に尽きる性能である。)


ただ、Java7で追加されたFiles#newInputStream()はPathで示されるファイルをオープンしてInputStreamを返すコンビニエンスなメソッドだが、コンビニエンスメソッドとして用意されたのであれば、何らかのパフォーマンスのために最低限の構成ができたものが返ってきている可能性もあるのでは?と思ってみたのだが、ぜんぜんそうゆうことはなくて、ただのnew FileInputStream()の代わりにすぎなかったようだ。
(ドキュメントにかかれてないので、まあ、そうだろう、とは思っていたけれど、全然コンビニエンスじゃないところが違和感があるというか。)


また、BufferedInputStreamはデフォルトで8 KiBのバッファサイズをもっているが、これを増やしても、ほとんど性能は向上しなさそうだということも判明した。
(なので、たぶん、大多数の人が引数でバッファサイズを指定していないのだろう。)

結論

今回の実験により、以下のことが分かった。

  • ダイレクトバッファは確保と破棄に注意が必要である。
    • ダイレクトメモリサイズはヒープとは別のJavaVMオプションで設定できる。
    • 確保と破棄が連続する場合では解放が追い付かずOutOfMemoryErrorの発生の恐れがある。
      • System.gc()または内部クラスのCleanerを利用する対策がある。
    • ダイレクトバッファは逐次構築するのではなくキャッシュすると効率が良い。
  • ダイレクトバッファ > ヒープバッファ >> 古典的なストリームI/O という性能差のようである。


実のところ、ダイレクトバッファのベンチマークをとるのは、これで2〜3回目ぐらいである。
しかし、過去のテストでは、ダイレクトバッファは使いどころが難しい、うまくパフォーマンスを発揮させられない、という結論を出してしまっていた。
(今思えばベンチマークとして適切でない部分の時間も含んでいたような気もする。)
また、ヒープ上のByteBufferについても、同じくヒープ上で動作する古典的ストリームI/Oとパフォーマンスに大きな差はないだろう、という先入観のようなものもあったのかもしれない。


だが、今回のテスト結果をみると、どうも過去のテストは明らかに何かを間違えていたかのように思われる。
今回、確実に、かなり高速でデータの読み込みができることの確証を得た。


今後は、ByteBufferがうまくマッチする場合は、優先的にByteBufferによるアクセスに切り替えて行く方が良さそうだ、という教訓が得られたと思う。

今思うことは、どうして、もっと早く再評価してみようと思わなかったのか、ということだろうか。


※ 今回のベンチマーク等のソースコードは、https://gist.github.com/seraphy/c173e3d82afd1ad3f81c にあります。


以上、メモ終了。

*1:ただし、RandomAccessFileから取得されるMappedByteBufferについては、特定のファイルに結びついているので、これは不要になったら、すぐに破棄するべきである。また、ネイティブメモリを早期に解放させるためにgcをかけることが望ましいだろう。これはOSのネイティブなハンドルが閉じられていないと使用中のままになるため、ファイルの読み書き削除に支障がでるためである。