はじめに
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からは適切にクリーナー処理が動作するようになったため、この点は改善されています。