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:途中でなんかへんな処理が入ったかブレが大きかった

Java 8でのProcess#destroyとProcess#destroyForciblyの環境差異について

Java 8のJavaDocでProcess#destoryとProcess#destroyForciblyメソッドを調べてみるとすごい怪しい記述がされています。

https://docs.oracle.com/javase/jp/8/docs/api/java/lang/Process.html


destroy

サブプロセスを終了します。このProcessオブジェクトが表すサブプロセスが強制終了されるかどうかは、実装によって異なります。

destroyForcibly

サブプロセスを終了します。このProcessオブジェクトが表すサブプロセスは強制終了されます。
このメソッドのデフォルト実装では、destroy()を呼び出すため、プロセスが強制終了されない場合があります。このクラスの具象実装では、このメソッドを準拠した実装でオーバーライドすることを強くお薦めします。ProcessBuilder.start()およびRuntime.exec(java.lang.String)から返されたProcessオブジェクトに対してこのメソッドを呼び出すと、プロセスが強制終了されます。

注: サブプロセスがただちに終了しない場合があります。つまり、destroyForcibly()が呼び出された後ほんのしばらくの間、isAlive()がtrueを返すことがあります。このメソッドは、必要に応じてwaitFor()にチェーンされることがあります。


実装によって異なりますとか、デフォルト実装ではとか・・・・・・

ということで調べた。

調べたら以下の通りでした。

  • WindowsではdestroyでもdestroyForciblyでもSIGTERM(kill -15)が呼ばれ、必ず強制終了される。
  • LinuxUnix系OSではdestroyではSIGKILL(kill -9)が呼ばれ、destroyForciblyではSIGTERM(kill -15)が呼ばれる。

なお、WindoesでSIGKILLを呼び出したい場合は、自分でnative実装を作成してJNIで呼び出すか、Processのインスタンスからプライベートフィールドとして定義されてるhandleを取得し、別コマンドとしてkilltask /PID pidを呼び出すのが良さそうです。

しかし、JavaDocでうたってるデフォルト実装通りなのがWindowsのみってどういうことなの・・・・・・・





Windows参考資料
http://hg.openjdk.java.net/jdk8/jdk8/jdk/file/tip/src/windows/classes/java/lang/ProcessImpl.java#l475
http://hg.openjdk.java.net/jdk8/jdk8/jdk/file/687fd7c7986d/src/windows/native/java/lang/ProcessImpl_md.c#l435
http://hg.openjdk.java.net/jdk8/jdk8/jdk/file/tip/src/windows/classes/java/lang/ProcessImpl.java#l309

WindowsでもSIGKILLを送れないのかというとそんなことはあるわけがなくて、JavaではなぜかTerminateProcess関数が呼び出されているだけです。ExitProcess 関数というのも用意されています。
https://msdn.microsoft.com/ja-jp/library/cc429376.aspx

LinuxUnix系OS参考資料
http://hg.openjdk.java.net/jdk8/jdk8/jdk/file/687fd7c7986d/src/solaris/classes/java/lang/UNIXProcess.java.bsd#l293
http://hg.openjdk.java.net/jdk8/jdk8/jdk/file/687fd7c7986d/src/solaris/classes/java/lang/UNIXProcess.java.linux#l295
http://hg.openjdk.java.net/jdk8/jdk8/jdk/file/687fd7c7986d/src/solaris/classes/java/lang/UNIXProcess.java.solaris#l247
http://hg.openjdk.java.net/jdk8/jdk8/jdk/file/687fd7c7986d/src/solaris/native/java/lang/UNIXProcess_md.c#l715

log4jdbcで設定したはずなのに有効にならない場合

log4jdbcのjarを依存ライブラリに入れて、jdbc urlにlog4jdbcを入れたのにlog4jdbcが有効にならない場合がある。
DriverManagerがDBへのコネクションを作成するときにlog4jdbcで使用されているロガー類がすべて無効化されている場合にはlog4jdbcがラッパーを作成せずに、log4jdbcでのログ出力が行われない。
https://github.com/arthurblake/log4jdbc/blob/master/src-jdbc4/net/sf/log4jdbc/DriverSpy.java#L740-L760
https://github.com/arthurblake/log4jdbc/blob/master/src-jdbc4/net/sf/log4jdbc/Slf4jSpyLogDelegator.java#L90-L95

これは、起動時にログ設定を無効化していて、起動後に改めてログ設定を正しいものに変更するかつコネクションプールを使用して初期にまとめてコネクションが取得される場合に問題となることがある。というか、なった。

なお、この挙動はlog4jdbcではSlf4jSpyLogDelegatorクラスで定義されているが、外側から入れ替える手段が存在しないため、問題がある場合はlog4jdbc互換のほかのラッパーを使用する必要がある。

たとえば、log4jdbc-log4j2は入れ替える手段があるらしい。
http://qiita.com/ksby/items/7a2cb97215b252bf41b1

それにしても、log4jdbc系はいろいろ亜流があって何がなんだかわからないですな・・・・・・

Java EE Webアプリケーションをモジュール化して組み合わせる

これはJava EE Advent Calendarの9日目の記事です。

完全に市民権を得た感じのあるSpring Bootですが、機能は複数のjarに分けて固められており、jarを追加するだけで機能を追加することが出来ます。
それと同じ事をJava EEでもやってみようというお話です。

たとえば、以下のようなクラスを用意します。

@WebFilter(urlPatterns = "/*", filterName = "auth")
public class AuthenticationFilter implements Filter {
    @Override
    public void init(FilterConfig fc) throws ServletException {
    }

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain fc) throws IOException, ServletException {
        if (!authorize(authKey, req)) {
            sendError(res);
            return;
        }
        fc.doFilter(req, res);
    }

    private void sendError(ServletResponse res) throws IOException {
        HttpServletResponse response = (HttpServletResponse) res;
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }

    private boolean authorize(String authKey, ServletRequest req) throws ServletException {
        //実際の認証処理
    }

    @Override
    public void destroy() {
    }
}

これをauthrization.jarにまとめて、認証処理を通したい場合はクラスパスに通して、認証処理を通したくない場合はクラスパスからはずせば認証処理だけモジュール化することが出来ます。

これはテストのときに非常に便利です。
また、各モジュールの祖結合化が進み、可搬性もあがります。


これを応用すると、テストのときと本番のときとで使用するライブラリを入れ替えられることにも気が付くと思います。
aaa-production.jarとaaa-test.jarを用意して、それぞれで同じ名称のXXX.javaを作成します。
通常、CDIを利用しているとQualifierを定義してテストのときだけプライオリティを変更して云々とかやって、テストするだけでひどい苦労をするのですが、そういったことよりはもうクラス自体入れ替えてしまえばいいんじゃないかとか。

JAX-WSでエンドポイントから生成したJAX-WSクライアントライブラリについてもテスト環境と本番環境でどうせ違うのはURLだけだったりするので、テストのときだけフックしてURL書き換えてーとかするよりはこちらのほうが簡単になります。


という感じで。

最後にmavenでライブラリを切り替える方法だけ説明をしておくと、mavenではprofileの仕組みがあり、profileのところに依存関係を書くことで特定のprofileのみ依存するjarを増やすみたいなことが出来ます。

〜略〜
    <profiles>
        <profile>
            <id>release</id>
            <dependencies>
                <dependency>
                    <groupId>xxxxx</groupId>
                    <artifactId>auth</artifactId>
                    <version>1.0</version>
                </dependency>
            </dependencies>
        </profile>
        <profile>
            <id>development</id>
            <dependencies>
                <dependency>
                    <groupId>xxxxx</groupId>
                    <artifactId>auth-test</artifactId>
                    <version>1.0</version>
                </dependency>
            </dependencies>
        </profile>
    </profiles>
〜略〜


終わり。

どこからも使用されてないクラスを列挙する

これはJava Advent Calendarの2日目の記事です。

さて、Javaで開発をしているといつの間にかどこからも使用されていないクラスというものが出てきてしまいます。
リファクタリングや仕様変更の結果、呼び出されてなくなったクラスです。

それら、どこからも使用されてないクラスを一覧化してみます。

まず、使用されているクラスを一覧化します。
一覧化にはjdepsを使用します。

jdepsの概要についてはCLOVERの記事を参照してください。

CLOVERの記事ではclassもしくはjarを指定していますが、実はパッケージ(フォルダ)を指定することで、その下のクラスの依存先をすべて出力してくれます。

使用されているクラスの一覧が出来たら、全体のクラスの一覧から引き算してあげます。
package 配下のクラス一覧を取得する方法はいろいろあるようですが、今回はGuavaのClassPathを使用しました。

ということで、以下のような感じです。

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.Charset;
import java.nio.file.Paths;
import java.util.Set;
import java.util.stream.Collectors;

import com.google.common.reflect.ClassPath;

public class SelectNotUsedClass {

  public static void main(String[] args) throws IOException {

    // buildはコンパイルされたクラスの出力先
    Process process = new ProcessBuilder("jdeps", "-v", Paths.get("build").toAbsolutePath().toString()).start();

    //Charsetは各環境に合わせてください 
    Set<String> dependencies = new BufferedReader(new InputStreamReader(process.getInputStream(), Charset.forName("Windows-31J"))).lines().map(s -> s.trim())
        .filter(s -> s.contains("->")).map(s -> s.substring(s.indexOf("->"))).map(s -> s.replace("->", "").trim().replaceAll("\\s+.*$", ""))
        .collect(Collectors.toSet());

    ClassLoader loader = Thread.currentThread().getContextClassLoader();

    // jp.hoge.xxxxは依存関係を調べたいパッケージの名前
    ClassPath.from(loader).getTopLevelClassesRecursive("jp.hoge.xxxx").stream().map(info -> info.getName()).filter(c -> !dependencies.contains(c))
        .forEach(System.out::println);
  }
}

jdepsが出るまでは.javaファイルのimport行を解析して出してたりしましたが、ずいぶんと楽になりましたね。

      • -

ブコメ
> これ普通に便利そう。ちょっと気になったんだけどテストからは参照されてるけどプロダクトコードからは一切参照されてないのって検出できるのかかしら?

というのがありましたが、
テストクラスとプロダクトコードは別管理されているはずなので、プロダクトコードのみをコンパイルした上で(テストコードは取り除いて)実行すればテストからのみ参照されているものも取り除くことが出来ます。
その後、いくつかのテストコードがコンパイルエラーになるので、そちらは個別で削除してください。

Go言語を完全にマスターする

これはさすかめアドベントカレンダーの一日目です。

○○を完全にマスターしたという言葉はチョットデキルに対抗してさすかめ先生が提案した概念です。
各言語でhello worldが出力できた状態を示します。

ということで、Go言語を完全にマスターしました。

package main

import "fmt"

func main() {
	fmt.Printf("hello, world\n")
}

実行は

C:\gowork\src\hello>go run hello.go
hello, world

ということで、完全にマスターできました。