1. NIO チャネル入門
従来の Java IO (java.io) では「1 スレッド = 1 ファイルまたはリソース」という方式です。読み書きが始まると、そのスレッドは操作完了までブロックされて待機します。単純なケースでは便利ですが、高負荷なシステムではボトルネックになります。接続が数千あると、数千のスレッドが待ち状態のまま占有されてしまうからです。
NIO (New IO) ではアプローチが異なります。入出力はノンブロッキングにでき、スレッドを遊ばせる必要がありません。あるデータの入出力を待つ間に、同じスレッドは別の作業に切り替えられます。これにより、わずかなスレッドで非常に多くの接続を処理できます。
違いは細部にも現れます。旧来の IO は、バイトや文字を読み書きするストリーム中心で、操作中は常にブロックされます。NIO では中核概念がチャネル (Channels) とバッファ (Buffers) になります。これによりサーバーに重要なノンブロッキング I/O を実現でき、さらにゼロコピー手法でデータを直接転送し、JVM 内の不要なバッファコピーを避けられます。
比較: ストリーム (Streams) vs チャネル (Channels)
ストリーム (InputStream/OutputStream):
- バイトを 1 つずつ、または配列単位で読み書きする。
- ファイル位置を直接制御できない。
- 非常に大きなファイルの扱いが効率的でない。
チャネル (Channel):
- バッファ (Buffer) を介してデータを読み書きする。
- 位置を操作できる(ランダムアクセスを含む)。
- 非同期およびノンブロッキングモードをサポートする。
- ゼロコピーを用いた高速なコピーが可能。
2. FileChannel と SeekableByteChannel
バッファを使った読み書き
FileChannel はファイル操作の主要なチャネルです。FileInputStream、FileOutputStream から取得でき、NIO.2 の Files.newByteChannel(SeekableByteChannel を返す)でも開けます。
例: FileChannel と ByteBuffer でファイルを読む
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
public class FileChannelReadExample {
public static void main(String[] args) throws Exception {
try (RandomAccessFile file = new RandomAccessFile("data.txt", "r");
FileChannel channel = file.getChannel()) {
ByteBuffer buffer = ByteBuffer.allocate(1024); // 1 KB バッファ
int bytesRead = channel.read(buffer); // バッファへ読み込む
while (bytesRead != -1) {
buffer.flip(); // バッファを読み取りモードに切り替える
while (buffer.hasRemaining()) {
System.out.print((char) buffer.get());
}
buffer.clear(); // 次の読み込みに向けてバッファをクリア
bytesRead = channel.read(buffer);
}
}
}
}
ファイルへの書き込み:
try (RandomAccessFile file = new RandomAccessFile("output.txt", "rw");
FileChannel channel = file.getChannel()) {
ByteBuffer buffer = ByteBuffer.wrap("Hello, NIO!\n".getBytes());
channel.write(buffer);
}
NIO.2: Files.newByteChannel でチャネルを開く
import java.nio.file.*;
import java.nio.channels.SeekableByteChannel;
import static java.nio.file.StandardOpenOption.*;
Path path = Paths.get("data.txt");
try (SeekableByteChannel ch = Files.newByteChannel(path, READ)) {
ByteBuffer buf = ByteBuffer.allocate(256);
ch.read(buf);
}
位置指定 (position()) とサイズ変更 (truncate())
- position() — ファイルの現在位置を取得/設定できる(カーソルのようなもの)。
- truncate(long size) — 指定サイズまでファイルを切り詰める。
channel.position(100); // 100 バイト目へ移動
channel.truncate(1024); // ファイルを 1 KB に切り詰める
ダイレクトアクセスとポジショナルアクセス
- ダイレクトアクセス: 順次だけでなく、ファイル内の任意位置を読み書きできる。
- ポジショナルアクセス: チャネルの現在位置を変えずに、指定位置に対して読み書きできる。
ByteBuffer buffer = ByteBuffer.allocate(4);
channel.read(buffer, 128); // channel.position() を変えずに、位置 128 から 4 バイト読む
3. ByteBuffer: 仕組み
主要パラメータ: capacity, limit, position, mark
- capacity — バッファの最大サイズ(作成時に決まる)。
- limit — 読み書きできる上限(デフォルトは capacity)。
- position — 現在位置(書き込み先/読み出し元)。
- mark — 後で戻れる「しおり」の位置。
バッファのライフサイクル:
- データを書き込む(たとえばチャネルから read() で読み込む)。
- flip() — バッファを読み取りモードに切り替える(position = 0, limit = 現在の position)。
- バッファからデータを読む(get())。
- clear() — 次の書き込みに向けてバッファをクリアする(position = 0, limit = capacity)。
例:
ByteBuffer buffer = ByteBuffer.allocate(8);
buffer.put((byte) 42);
buffer.flip(); // これで読み取れる
byte value = buffer.get(); // 42
buffer.clear(); // 新たな書き込みの準備完了
バッファの作成: allocate() vs allocateDirect()
ByteBuffer には主に 2 つの作成方法があり、その違いは実運用で効いてきます。allocate() は JVM ヒープ上にバッファを確保します。作成が速く大半の用途に適しますが、ネイティブ入出力ではヒープと OS メモリの間で余分なコピーが発生することがあります。
allocateDirect() は JVM ヒープ外(ネイティブメモリ)に確保します。作成コストが高く管理も難しめですが、大きなファイルの読み書きやネットワーク操作では、余分なコピーがない分しばしば高速です。
考え方は単純です。大容量で性能が重要なら「ダイレクト」バッファを使いましょう。小さく頻繁な操作では、作成オーバーヘッドがメリットを打ち消す場合があります。
ByteBuffer directBuffer = ByteBuffer.allocateDirect(4096);
4. 高性能な操作: transferTo() と transferFrom()
メソッド transferTo() と transferFrom()
FileChannel にはゼロコピーで動作できる 2 つのメソッド — transferTo() と transferFrom() — があります。データをファイル間や、たとえばファイルとネットワークの間で直接転送できます。JVM はほとんど関与せず、操作は OS が行い、Java 側のバッファは触れられません。
その結果、大容量ファイルのコピーが明らかに高速になります。コピー回数が減り、ユーザー空間とカーネル空間の切り替えも少なく、CPU 負荷も低く抑えられます。
例: ゼロコピーでファイルをコピー
import java.nio.channels.FileChannel;
import java.nio.file.*;
public class ZeroCopyExample {
public static void main(String[] args) throws Exception {
try (FileChannel src = FileChannel.open(Paths.get("input.bin"), StandardOpenOption.READ);
FileChannel dst = FileChannel.open(Paths.get("output.bin"), StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
long size = src.size();
long transferred = src.transferTo(0, size, dst);
System.out.println("コピーしたバイト数: " + transferred);
}
}
}
ゼロコピーが実際に効く場面は?
- 同一ディスク上のファイル間コピー。
- ネットワーク送信(例: SocketChannel を介した送信)。
- OS がゼロコピーに対応している場合(Linux、macOS、Windows は対応)。
利点:
- コピー最小化: データが JVM のバッファを通過しない。
- 高速: コンテキストスイッチが少なく、CPU 負荷も低い。
- 省メモリ: 大きなユーザーバッファが不要。
例: 「ワンライナー」でファイルをコピー
Files.copy(Paths.get("input.bin"), Paths.get("output.bin"), StandardCopyOption.REPLACE_EXISTING);
// 可能であれば内部でゼロコピーを使用することがある
5. よくあるミス
エラー1: flip() を呼ばずにバッファから読もうとする。 バッファに書き込んだ後は必ず flip() を呼びます。そうしないと position/limit が「書き込みモード」のままで、期待どおりに読み出せません。
エラー2: 小規模な操作に allocateDirect() を使う。 ダイレクトバッファは大容量では効果的ですが、小さな要求では作成コストが割に合いません。基本は allocate() を選びましょう。
エラー3: チャネルを閉じない。 チャネルやストリームには必ず try-with-resources を使って、ディスクリプタのリークを防ぎましょう。
エラー4: position/limit/capacity を取り違える。 読み書きの前にバッファのモードを確認してください。書き込んだ後は flip()、読み取った後に再度書き込むなら clear() または compact()。
エラー5: ゼロコピーが「常に動く」と期待する。 一部の構成(異なるデバイス/別ファイルシステム/特殊なフラグなど)ではゼロコピーが使えず、通常のコピーにフォールバックします。その場合は性能が異なります。
GO TO FULL VERSION