ByteBuffer.allocateDirect() はどの程度遅いのか


はじめに

Java NIO では、java.nio.ByteBuffer を介してチャネルとのデータの受け渡しを行います。

java.nio.ByteBuffer は、ヒープにバッファを確保する非ダイレクトバッファと、ヒープ外にバッファを確保するダイレクトバッファの2つがあります。

それぞれは、一般に以下の特徴があります。

  • 非ダイレクトバッファは確保が早いが、IO処理が低速
  • ダイレクトバッファは確保が遅いが、IO処理が高速

非ダイレクトバッファは、単にバイト配列として実装されているだけなので、ヒープ上にバイト配列を確保するだけです。

一方ダイレクトバッファは、ネイティブで新たにメモリ確保するため遅くなりますが、チャネルとのデータIO処理に余計なレイヤを介さないため高速な処理が可能です。

ここでは、確保が遅いと言われるダイレクトバッファは、実際どの程度の遅さなのかをベンチマーク計測します。


ベンチマーク環境

Java21 上で Java Microbenchmark Harness (JMH) で計測します。

  • JMH version: 1.36
  • VM version: JDK 21.0.2, OpenJDK 64-Bit Server VM, 21.0.2+13-jvmci-23.1-b30

プラットフォームは system_profiler SPHardwareDataType にて以下

  • Model Name: Mac mini
  • Model Identifier: Macmini8,1
  • Processor Name: 6-Core Intel Core i5
  • Processor Speed: 3 GHz
  • Number of Processors: 1
  • Total Number of Cores: 6
  • L2 Cache (per Core): 256 KB
  • L3 Cache: 9 MB
  • Memory: 8 GB


JMH Gradle プラグイン

以下のようにプラグイン導入します。

plugins {  
    application  
    id("me.champeau.jmh") version "0.7.2"  
}  
  ...
jmh {  
    fork = 3  
    warmupIterations = 3  
}

src/jmh/java/bench/MicroBench に以下を準備します。

package bench;  
  
import org.openjdk.jmh.annotations.Benchmark;  
import java.nio.ByteBuffer;  
  
public class MicroBench {  
    @Benchmark  
    public void allocate() {  
        ByteBuffer.allocate(1024 * 16);  
    }  
  
    @Benchmark  
    public void allocateDirect() {  
        ByteBuffer.allocateDirect(1024 * 16);  
    }  
}

ベンチマーク実行は以下となります。

$ ./gradlew clean jmh


ByteBuffer.allocateDirect のスループット

ベンチマーク結果は以下のようになりました。

Benchmark Mode Cnt Score Error Units
MicroBench.allocate thrpt 15 2,596,971,743.130 ± 54,657,550.710 ops/s
MicroBench.allocateDirect thrpt 15 217,003.095 ± 9,144.014 ops/s

ByteBuffer.allocate() と比較すると非常に遅いと言えます(バイト配列確保しているだけなのでallocate() が早いのは当然)。

ByteBuffer.allocateDirect() は遅いとは言え、1回で見れば、4.6マイクロ秒程度なので、そこまで遅いとも言えないでしょう(比較対象が早すぎる)。


アロケートサイズによるベンチマーク

アロケートサイズ別でも見てみましょう。

public class MicroBench {  
  
    @Benchmark  
    public void allocateDirect1____16k() {  
        ByteBuffer.allocateDirect(1024 * 16);  
    }  
  
    @Benchmark  
    public void allocateDirect2____64k() {  
        ByteBuffer.allocateDirect(1024 * 64);  
    }  
  
    @Benchmark  
    public void allocateDirect3___256k() {  
        ByteBuffer.allocateDirect(1024 * 256);  
    }  
  
    @Benchmark  
    public void allocateDirect4__1024k() {  
        ByteBuffer.allocateDirect(1024 * 1024);  
    }  
  
    @Benchmark  
    public void allocateDirect5__4096k() {  
        ByteBuffer.allocateDirect(1024 * 4096);  
    }  
  
    @Benchmark  
    public void allocateDirect6_16384k() {  
        ByteBuffer.allocateDirect(1024 * 16384);  
    }  
  
}

結果は以下のようになりました。

Benchmark Mode Cnt Score Error Units
MicroBench.allocateDirect1____16k thrpt 15 224358.731 ± 9492.368 ops/s
MicroBench.allocateDirect2____64k thrpt 15 73276.176 ± 1068.284 ops/s
MicroBench.allocateDirect3___256k thrpt 15 12704.191 ± 1590.702 ops/s
MicroBench.allocateDirect4__1024k thrpt 15 3924.119 ± 173.010 ops/s
MicroBench.allocateDirect5__4096k thrpt 15 1070.230 ± 10.747 ops/s
MicroBench.allocateDirect6_16384k thrpt 15 284.148 ± 12.926 ops/s

容量が大きいほどスループットが低下しています。

1回あたりの処理時間に換算すると以下のようになります。

確保バイト数 1回あたりの処理時間
16k 4.5マイクロ秒
64k 13.6マイクロ秒
256k 78.7マイクロ秒
1024k 254.8マイクロ秒
4096k 934.4マイクロ秒
16384k 3,519.3マイクロ秒

グラフにすれば以下のようになり、線形的に処理時間が増加していることがわかります。

嘘のようにきれいな直線になりました。

若干GCの処理時間を計測しているような気もしますが。なお、ダイレクトバッファで確保したメモリは、以前はなかなか開放されずにメモリ不足になることがあり、ハック的にメモリ解放する手法が出回りましたが、Java11からは適切にクリーナー処理が動作するようになったため、この点は改善されています。