util-mmap でメモリマッピング

今回の記事では、オープンソースで利用可能な util-mmapを取り上げていく。 util-mmap は Java のメモリマッピング・ライブラリで、大きなファイルにアクセスするのに有効なメカニズムを提供する。 Indeed の分析用プラットフォーム Imhotep ( 2014年にリリース ) は、 これをデータアクセスの管理に使用している。

メモリマッピングを使用する理由

Indeed のバックエンドサービスはLSM trees や Lucene のインデックスのような大きなデータセットを扱っている。util-mmap のライブラリは、そういった大きなファイルへの安全なメモリマッピングを提供している。 また、これは JDK 内の MappedByteBuffer の既知の制約を克服している。メモリマッピングとは、ファイルの一部を、仮想メモリのセグメントに移すプロセスを指す。 メモリマッピングをすることで、アプリケーションはマップされた部分をメインメモリのように処理できるのだ。 Indeed では、大きなファイルを持つ、レイテンシに影響されやすい本番のアプリケーションでメモリマッピングを使用している。そうすることで、 I/O 操作にかかるコストをおさえられるからだ。

MappedByteBuffer の制約

JDK は、メモリマッピングをするために java.nio パッケージ内にある MappedByteBuffer を提供している。 このライブラリには三つ、主要な問題がある。

安全にアンマップできない MappedByteBuffer を使用したアンマッピングをリクエストする唯一の方法は、System.gc()を呼び出すことだ。 このアプローチはアンマッピングを保証するものではなく、 既知のバグである。 メモリにマップされたファイルは、削除する前にまずアンマップする必要がある。
このバグは、サイズが大きく、頻繁に更新されるファイルをマップすると、ディスクの容量に問題を発生させてしまう。

サイズが 2GB を超えるファイルをマップできない MappedByteBufferは全インデックスにint を使用している。つまり 2GB を超えるファイルを管理するには複数のバッファを使用する必要があるのだ。 複数のバッファを管理するのは煩雑で、エラーが発生しやすいコードにつながる。

スレッドセーフではない ByteBuffer は内部ステータスを保持して、位置をトラッキングおよび制限している。 get() などの関連したメソッド使用を読み込むのには、 duplicate() 経由でスレッドにつきユニークバッファを一つ必要とする。

例:

public class ByteBufferThreadLocal extends ThreadLocal<ByteBuffer>
{
    private ByteBuffer src;
    public ByteBufferThreadLocal(ByteBuffer src)
    {
        src = src;
    }

    @Override
    protected synchronized ByteBuffer initialValue()
    {
        return src.duplicate();
    }
}

util-mmapを使用したメモリマッピング

util-mmap はこれらの課題すべてに対応している。

  • アンマッピングを実装することで、使用していないファイルをすぐに削除可能。
  • long ポインタを使用するので 2GB より大きなファイルもメモリマッピングが可能。
  • AtomicSharedReference を使用して、複数のスレッドから安全でシンプルなアクセスが可能。

例: 大きなlong[] 配列のメモリマッピング

バイナリファイルの作成に Guava のリトルエンディアン・データ出力ストリーム を使用:

try (LittleEndianDataOutputStream out =
        new LittleEndianDataOutputStream(new FileOutputStream(filePath))) {
    for (long value : contents) {
        out.writeLong(value);
    }
}

このファイルをメモリマッピングするために  MMapBuffer を使用:

final MMapBuffer buffer = new MMapBuffer(
       filePath,
       FileChannel.MapMode.READ_ONLY,
       ByteOrder.LITTLE_ENDIAN);
final LongArray longArray =
    buffer.memory().longArray(0, buffer.memory().length() / 8);

Java のシリアライゼーションを使用しない理由とは? Java は、 ビッグエンディアン方式でデータを管理している。 Indeed の本番システムは、リトルエンディアンの Intel プロセッサで実行されている。 また、長い配列の実際のデータは、ファイル内でオブジェクットヘッダの後の 17 バイトから開始する。 ネイティブ Java のシリアライズされた配列をきちんとメモリマッピングするためには、上記に述べたオフセットを正しく管理するためのコードを書く必要が出てくる。 バイトをひっくり返さなくてはならなくなるし、その作業はコストがかかる。 リトルエンディアンでデータを書き込むと、よりシンプルなメモリマッピングのコードがもたらされる。

スレッドセーフ

複数のスレッドから安全なアクセスをするためには、 AtomicSharedReference を使用する。このクラスはメモリにマップされたファイルを使用する Java オブジェクトをラップしている。
例:

final AtomicSharedReference<LongArray> objRef =
    AtomicSharedReference.create(longArray);

変数 objRef は、内部にある SharedReference、つまり 参照カウントをもつオブジェクトに対する可変の参照である。
配列を使用する際には getCopy() を呼び出し、参照を閉じる必要がある。

try(final SharedReference<LongArray> myData = objRef.getCopy())  {
    LongArray obj = myData.get();
    // … do something …
}

SharedReference は参照のトラッキングをし、どのファイルも使用されていない時に、アンマップを行う。

リロード

setQuietly のメソッドを使用し、より新しいファイルのコピーと置換する。

final MyObject newMyObj = reloadMyObjectFromDisk();
objRef.setQuietly(newMyObj);

終了

アプリケーションのシャットダウンの際には closeQuietly を使用し、ファイルをアンマップする。

objRef.closeQuietly();

util-mmapを使ってみよう

Indeed では、複数のプロダクションサービスで util-mmap を使用している。
数分ごとに更新される最大 15GB のファイルにアクセスするためである。
読者の皆さんの中で、大きなファイルをメモリマッピングする必要がある方は、 GitHub のサイトから、util-mmap を是非試してみて欲しい。