Java 9でjava.nio.Files#linesがパラレル実行向けに最適化されたと聞いたので試してみた

さくらばさんから以下のような話を聞いたので。

なお、対応するチケットは以下となっています。

(fs) Files.lines needs a better splitting implementation for stream source

JavaDocは以下の通り。
java.nio.Files#lines

ドキュメント上は文字コードUTF-8, US-ASCII and ISO-8859-1の場合のみサポートとなっていますが、実装を調べたところ、さらにファイルサイズが2Gを超えてもいけないようです。
中身のファイルのFileChannelをByteBufferクラスにダイレクトにリンクをし扱うようにしているためで、Javaの配列が2Gまでしか取り扱えないことによる制限のようです。

なお、高速化の方法ですが、従来はファイルの中身のバイト文字列を文字として変換してから1行を取り出していたのを、バイト文字列のまま1行取り出してから文字に変換するようにしたという感じのようです。
中身をbyteのまま\rとか\nと比べてるので、\rと\nがバイト単位で比べた時にほかの文字としても使われている文字コード(例えばUTF-16)の場合に不具合になってしまうため、確実に問題がない文字コードのみをサポートしている模様。
JavaDocを読む限りFileChannel自体は非同期からのアクセスに最適化されていますが、それを文字列の行として取り扱うBufferdReaderクラスは非同期からのアクセスに最適化されていませんでした。それを取り除くことで、非同期処理の恩恵を受けやすくしたみたいです。

ということで試してみた。

2Gbyteを基準に従来のコードと新しい処理が分かれるため、2Gを基準にシングススレッド、マルチスレッドでどれくらい処理時間に差が出るのかを雑に試してみました。

PCとしては5年ぐらい前のCore i7だけどHDDなWindows 10のものを使用しています。
使用したJavaのバイナリはjdk-9-ea+167_windows-x64_bin.exeです。

使ったコードはこんな感じ。filterを入れてるのは、JITによって処理消えちゃわないよね?ってあたりが心配になっただけで、あまり意味はないです。

//マルチスレッド
Files.lines(Paths.get(fileName), UTF_8).parallel().filter(s -> true).forEach(s -> {return;});
//シングルスレッド
Files.lines(Paths.get(fileName), UTF_8).filter(s -> true).forEach(s -> {return;});

大体以下のような感じ。

  • マルチスレッドで最適化されている(2G以下) -> 2.6秒程度
  • マルチスレッドで最適化されていない(2G超) -> 9秒程度
  • シングルスレッドで最適化されている(2G以下) -> 6.6秒程度
  • シングルスレッドで最適化されていない(2G超) -> 7.1秒程度*1

ということで、ものすごく最適化されているという結果でした。

使ったコードはこちら。
https://gist.github.com/megascus/5bdf8d7d45b06cb639c40ce67d1533a3

コード解説(5/7追記)

もともとのlinesメソッドの中身はFiles#newBufferedReaderを呼び出してBufferedReaderを作成した後にBufferedReader#linesを呼び出しているだけでした。
そこからUTF-8かUS-ASCIIもしくはISO-8859-1であった場合に、新しい処理が呼び出されるようになりました。

(元のコード)
64行目と、110行目〜122行目まで
http://hg.openjdk.java.net/jdk9/jdk9/jdk/rev/4472aa4d4ae9#l2.64
http://hg.openjdk.java.net/jdk9/jdk9/jdk/rev/4472aa4d4ae9#l2.110

(新しい処理の条件、ファイルシステムがシステムのデフォルトである場合、かつ文字コードが上記の場合のみ)
http://hg.openjdk.java.net/jdk9/jdk9/jdk/rev/4472aa4d4ae9#l2.71
(使用できる文字コード自体は下記で定義)
http://hg.openjdk.java.net/jdk9/jdk9/jdk/rev/4472aa4d4ae9#l1.71
※つまりZipFileSystem等の場合はこの処理には入らないので、一度ローカルファイルに直す必要がある。

新しい処理の中で、FileChannelを開いた後にファイルサイズを確認し、2Gを超えていた場合は元の処理に戻しています。
http://hg.openjdk.java.net/jdk9/dev/jdk/rev/4472aa4d4ae9#l2.90

実際の処理はFileChannelLinesSpliteratorの中で行っていますが、
ByteBufferとして扱う場所は以下でやっています。
http://hg.openjdk.java.net/jdk9/dev/jdk/rev/4472aa4d4ae9#l1.203
http://hg.openjdk.java.net/jdk9/dev/jdk/rev/4472aa4d4ae9#l1.180

並列処理として扱うときに部分分割するメソッドとして、Spliterator#trySpritが用意されていますが、
そのなかでバイト文字列のまま分割するようになりました。(210行目〜)
http://hg.openjdk.java.net/jdk9/dev/jdk/rev/4472aa4d4ae9#l1.210

分割された後はByteBufferをソースにしたBufferedReaderを作成して処理をしているようです。
http://hg.openjdk.java.net/jdk9/dev/jdk/rev/4472aa4d4ae9#l1.121

BufferedReaderができてしまえばあとは変わりません。

trySpritの処理まではシングスルレッドで行うしかなかないので、そこでのバイトから文字列への変換をせずに、マルチスレッドで処理をする時に初めて文字列へ変換することで、処理を分散しているということでしょうか。

文字列変換にどれくらいのコストがかかっているかは次の記事で。
http://d.hatena.ne.jp/megascus/20170507/1494171105

*1:途中でなんかへんな処理が入ったかブレが大きかった