JDK 6 の FileChannel は追記が遅い

Java でファイルに書き込みを行う方法は、大きく分けて 2 つある。ひとつは java.io.FileOutputStream を使うもの、もうひとつは java.nio.FileChannel を使うものだ。
このうち、FileChannel は後から追加されたクラスであり、僕はなんとなく「性能を上げたい場合は FileChannel を使うといいんだろうな」と思っていた。
けれども、いろいろな理由でFileChannelの方が遅いことがあるらしい。例えばファイルを追記モードで開いている場合がそうだ。

どう遅いか

僕が自分で計測したわけではないんだけど、先日スーパーで買い物をしていたら、3 歳くらいの女の子がお母さんに質問していたのです。

「ママ〜、NFS でマウントしたディレクトリに FileChannel 使って書き込むとすごく遅いのはなんで?」
「あらあら、そんなに遅いの?」
「うん。あのね〜、FileOutputStream を使って書くより数倍時間がかかるの」
「あらやだ。でもそれ、ストリームの方は BufferedOutputStream かぶせてたりしない?」
「しないよ〜。性能測るときは条件を合わせてやりなさいって幼稚園で習ったから、ミカ、ちゃんと外してるもん」
「NFSv4 のバグかしら。そうだったらお父さんに直してもらわないと」
「うーん。でもね〜、ローカルディスクに書いても少しは遅くなるんだよ−」
「他には何か気づいたことないの?」
「えーとねー、ケンちゃんが『追記モードで開いてると遅くなるけど、新規作成だとそこまで遅くない』って言ってた」
「難しいわね。じゃぁ、今日はお夕飯食べ終わったらママと一緒に障害調査しようか」
「わーい! ママありがとう! ちなみに使ってるのは JDK 6 だよー」

僕も会話を聞きながら原因が気になってしまったので、帰宅してから少し調べてみた。

なぜ遅いか

結論から言うと、FileChannelの中で余分な処理が行われているから、ということのようだ。

java.nio.FileChannel は抽象クラスで、(JDK での) 実装である sun.nio.ch.FileChannelImpl のソースは JDK には付属していない。そこで OpenJDK 6 のソースをダウンロードして中を覗いてみたところ、FileChannelImpl の write メソッド中に、以下のような記述が見つかった。

                if (appending)
                    position(size());

日本語で言うと、「追記の場合に限り、ファイルサイズを取得し直して、書き込み位置をファイル終端に合わせ直す」。LinuxJDK の場合、size メソッドと position メソッドはそれぞれ、システムコール fstat(2) と lseek(2) を呼び出している。ここだけ見ると、FileOutputStream の write メソッドでは write(2) を呼ぶだけだったのに比べて、システムコールの回数が 3 倍になっている。
実際に strace コマンドでシステムコールをファイルに出力させてみたところ、

  • FileChannel を使うと、write(2) の直前で fstat(2) と lseek(2) が呼ばれる
  • FileOutputStream ではそういうことはない

ということが確認できた。
NFSv4 では、write(2) に渡されたデータはいったんクライアント側のバッファに蓄えられ、バッファがいっぱいになったり close(2) が呼ばれたりすると一気にサーバへ送られ、サーバ側のディスクに書き込みが行われる仕組みになっている。ネットワークを介した通信を減らすことにより、書き込み時の性能を上げるためだ。それなのに、上記のように fstat(2) が呼ばれるとサーバへの問い合わせが発生してしまい、結果として「FileChannel で NFS 上のファイルに追記すると遅い」ということになるのでは、というのが今の僕の考えているところ。
ローカルディスクへの書き込みなら fstat(2) もそんなに時間を食わないので、NFS の場合ほどには速度低下が起きない。

回避するには

FileChannel を使って追記をする場合、速度低下を避けるには write メソッドの呼び出し回数をできるだけ少なくする必要がありそうだ。言い換えると、クライアント側の Java プログラムで、バッファリングを自前で行う必要がある、ということになる。
バッファリングをきちんと作りこむのはそれなりに大変なので、FileOutputStream に切り替えられるのであれば、おとなしく FileOutputStream (と BufferedOutputStream) を使っておくのが一番楽かもしれない。

ただし、NFS に限って言うと、FileOutputStream を使う場合でも、close() に注意が必要で……という話があるんだけど、長くなるので次の機会に書くことにする。

JDK 7 では?

OpenJDK 7 のソースをちらっと見てみたところ、sun.nio.ch.FileChannelImpl 周辺が結構変わっているようだ。
何がどう変わったのは把握できていないんだけど、もしかしたら JDK 7 では修正されていて、FileChannel で追記しても遅くならないかもしれない。