CodeGym /コース /JAVA 25 SELF /大きなファイル: チャンク化パターン

大きなファイル: チャンク化パターン

JAVA 25 SELF
レベル 41 , レッスン 2
使用可能

1. はじめに

現代ではデータ量が爆発的に増えています。ときには数十GB、あるいは数百GBのファイル(ログ、データベースのダンプ、巨大なアーカイブなど)を扱うこともあります。こうしたファイルを一度にメモリへ読み込もうとすると、たいていはうまくいきません。プログラムがメモリを食い尽くしたり、動作が極端に遅くなったりします。

理由は明白です。メインメモリは無限ではなく、ファイルがメモリ容量を超えると OutOfMemoryError を招くリスクがあります。仮に容量に余裕があっても、巨大ファイルを単一スレッドで逐次処理すれば何時間もかかることがあります。さらにストレージ自体の帯域にも限界がありますが、特にSSDでは並列に複数スレッドで読み込むことで処理が高速化する場合があります。

結論はシンプルです。大きなファイルは部分(いわゆる「チャンク」)に分割して処理し、可能なら並列化する。これが大量データを無理なく扱うための基本方針です。

2. 解決策: Chunking パターン

Chunking とは、大きなファイルを独立して処理できるサイズの小さな塊(チャンク)に分割するパターンです。

たとえ:
大きなスイカを一気に食べるのではなく、スライスに切り分けて一切れずつ食べます。そのほうが簡単で速いですよね。

どう動くのか?

  1. ファイルサイズの取得。
    • File.length()Files.size(Path) を使ってバイト数を取得します。
  2. チャンクサイズ(chunk size)の計算。
    • 一般的には 10〜20 MB(タスクやハードウェアに応じて増減)。
    • サイズは chunkSize といった変数に保持し、ディスクのブロックサイズに合わせると性能が最大化しやすいです。
  3. タスクのリストを作る。
    • 各タスクは1チャンクの処理(読み取り、パース、暗号化、圧縮など)。
    • スレッドプールを使ってタスクを並列実行できます。

図示:

+-------------------+
|      File         |
+-------------------+
|  [chunk 1]        |
|  [chunk 2]        |
|  [chunk 3]        |
|  ...              |
|  [chunk N]        |
+-------------------+

3. 並列処理の実装

ExecutorService または ForkJoinPool の利用

チャンクを並列処理するには、Java の標準的なマルチスレッド機構を使います。

  • ExecutorService — 固定サイズのスレッドプール(Executors.newFixedThreadPool(n))。
  • ForkJoinPool — 再帰的なタスクや「分割統治」に適しています。

例:

ExecutorService pool = Executors.newFixedThreadPool(4); // 4スレッド

for (int i = 0; i < chunkCount; i++) {
    final int chunkIndex = i;
    pool.submit(() -> {
        processChunk(file, chunkIndex, chunkSize);
    });
}

pool.shutdown();
pool.awaitTermination(1, TimeUnit.HOURS);

各タスクは自分のチャンクを読み取り、独立して処理します。

4. 重要な仕組み: RandomAccessFile と FileChannel

RandomAccessFile

RandomAccessFile はファイル内を「移動」して任意位置から読み取ることができます。

try (RandomAccessFile raf = new RandomAccessFile(file, "r")) {
    raf.seek(chunkStart); // チャンクの先頭へ移動
    byte[] buffer = new byte[chunkSize];
    int bytesRead = raf.read(buffer);
    // buffer を処理する
}
  • seek(long pos) — 「カーソル」を指定位置へ移動します。
  • 必要なバイト範囲だけ読み取れます。

FileChannel

FileChannel は(特に大きなファイルで)よりモダンで高速な方法です。

try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) {
    ByteBuffer buffer = ByteBuffer.allocate(chunkSize);
    channel.position(chunkStart);
    int bytesRead = channel.read(buffer);
    // buffer を処理する
}
  • position(long newPosition) — 読み取り位置を設定します。
  • 必要な範囲だけを読み、他の部分に触れずに済みます。

5. チャンク化と transferTo/transferFrom の比較

transferTo/transferFrom

FileChannel.transferTo()transferFrom() は、いわゆる ゼロコピー(zero-copy)を利用できます。データを JVM のバッファを介さずにファイルやストリーム間で直接コピー(移動)できるため、とても高速です。ただし制約として、データを「その場で」加工することはできず、コピー専用です。とはいえ多くの用途で大容量処理を大幅に高速化できます。

例:

try (FileChannel src = FileChannel.open(srcPath, READ);
     FileChannel dst = FileChannel.open(dstPath, WRITE)) {
    src.transferTo(0, src.size(), dst);
}

チャンク化(Chunking)

チャンク化は大きなファイルを部分(チャンク)に分けて処理する方法です。単なるコピーだけでなく、パース、暗号化、圧縮、検索など、内容の処理に向いています。各チャンクは独立して処理でき、必要なら並列化も可能で、全体のスループットを大きく向上できます。

要点はこうです。単純なコピーで済むなら transferTotransferFrom を使うのが最速です(データは直接移動し、余分なコピーがありません)。一方、内容を調べたり変えたり分析する必要があるなら、チャンク化が不可欠なツールになります。

6. 制約と落とし穴

スレッドのオーバーヘッド

  • スレッドを作りすぎると性能が低下します(コンテキストスイッチやリソース競合)。
  • スレッド数は CPU コア数と同程度か、やや多めにするのが一般的です。

ディスクの制約

  • スレッドが100本あっても、ディスクの最大読み取り速度は超えられません。
  • SSD では並列読み出しが効くことがありますが、HDD では効果が小さいことが多いです。

同期の必要性

  • チャンクの処理が独立しているなら簡単です。
  • 集計(例: ファイル中の全数値の合計)など共通結果をまとめる場合は、共有変数へのアクセスを同期する必要があります(例: AtomicLong を使う、結果を別リストに集約する、など)。

チャンク境界

  • テキストファイルでは注意が必要です。行や文字を途中で分割しないようにします。
  • バイナリ(アーカイブ、画像など)は通常、好きな位置で分割して問題ありません。
  • テキストの場合はチャンクを少し重ねたり、最寄りの改行を探して境界を合わせたりします。

7. 例: 大きなファイル内の数値の合計を並列に計算

課題:
数百万の数値が1行に1つずつ入ったファイルがある。合計を高速に求めたい。

手順:

  1. ファイルサイズを取得。
  2. チャンクサイズを決める(例: 10 MB)。
  3. 各チャンクについて:
    • 最寄りの改行位置を見つけ(数値を途中で切らないようにする)。
    • チャンクを読み取り、数値をパースし、合計を計算する。
  4. すべてのチャンクの合計を集約する。

コードスケルトン:

ExecutorService pool = Executors.newFixedThreadPool(4);
List<Future<Long>> results = new ArrayList<>();

for (int i = 0; i < chunkCount; i++) {
    final int chunkIndex = i;
    results.add(pool.submit(() -> {
        // RandomAccessFile を開き、チャンクの境界を探す
        // 読み取り、数値をパースし、合計を計算
        long chunkSum = 0L;
        return chunkSum;
    }));
}

long total = 0;
for (Future<Long> f : results) {
    total += f.get();
}
pool.shutdown();
System.out.println("合計: " + total);

8. まとめとベストプラクティス

  • Chunking は大きなファイル処理の定石。チャンクに分割し、独立して処理し、結果を集約する。
  • 任意位置からの読み取りには RandomAccessFile または FileChannel を使う。
  • 並列処理には ExecutorServiceForkJoinPool を使う。
  • 加工なしのコピーには transferTo/transferFrom(ゼロコピー)を使う。
  • チャンクサイズ、スレッド数、ディスクの制約に注意する。
  • テキストファイルでは行境界を丁寧に扱う。
  • バイナリは形式の仕様がなければ任意に分割してよい。

9. チャンク化でよくあるミス

エラー №1: ファイルが大きすぎる。 ファイル全体をメモリに読み込もうとして OutOfMemoryError を引き起こす。

エラー №2: スレッドが多すぎる。 スレッドを作りすぎて、コンテキストスイッチでシステムが「重く」なる。

エラー №3: 行を分断してしまう。 テキストの行境界を考慮せず、途中で切れてパースエラーになる。

エラー №4: メソッドの誤用。 transferTo/transferFrom でデータを加工しようとするが、これらはコピー専用なので動作しない。

エラー №5: 同期を忘れる。 結果の集約を同期せず、合計値が不正になるなどのバグが生じる。

エラー №6: リソースリーク。 ファイルやチャネルを閉じ忘れてリソースリークを起こす。

コメント
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION