OpenJDKのJavaのコンパイラは標準ライブラリのシンボルデータを別途持っていて --release でビルドした時はそのデータが使われるようだ

Spotbugsのビルドを弄ろうとしていて気になったので調べてみた。

以下のように、--releaseと-source/-target を使った時で挙動が違う。

f:id:reteria:20201002050301p:plain

まずはじめにSpotbugsの話になるが、Spotbugsにはツールの特性上、テストケースとしてJavaファイルを中に溜め込んでいる。 また、その中には削除されてしまった標準ライブラリのAPIに関するテストケースがあり 古いJavaでビルドする必要のあるファイルもある。

その中で今回あったのが以下のようなクラスだ。

class RunFinalizersOnExit {

    int f() {
        System.runFinalizersOnExit(true);
        return 42;
    }

}

System#runFinalizersOnExit は Java11で削除されたメソッドだ。 そのため、このクラスはJava8の標準ライブラリが必要になるはずだ

しかし、--release を使った場合、そうはならない。 なぜならタイトルにも書いたとおり、OpenJDKのJavacコンパイラは、内部に古いバージョンのシンボルを溜め込んでいて 古いターゲットが指定された時は、そのシンボルファイルを使うようだ。

このディレクトリにシンボルが入っていてjava.base-8.sym.txtなどがあり、このあたりのファイルに書かれているシンボルが読まれているのだと思われる。

使っているJVMに付属する標準ライブラリのシンボルのみが解決できるものだと思っていたので非常に驚いた。 終わり。

追記:

この辺で、シンボルを保存したバイナリを生成していて $JAVA_HOME/jmods/jdk.compiler.jmod の中の lib/ct.sym というところに格納されているようだ。 (jmodファイルはただのzipです)

Gradle 6.7 で リリースされる Toolchain support for JVM projects を試してみる

Gradle 6.7 (現在 6.7 rc-3がリリースされています) から導入される Toolchain support for JVM projects という機能を試してみます。

この機能で何が出来るのかというと Gradle を動かすJVMコンパイルやテストで使うJVMを簡単に分離することが出来るようになります。 また、コンパイルやテストの間でも簡単にJVMのバージョン制御を行うことが出来ます。

追記: 今までは複雑なステップが必要でした。 https://docs.gradle.org/6.6.1/userguide/building_java_projects.html#compiling_and_testing_java_67

デモのgifを見ると分かりやすいので見てみましょう。

ホストに入っている AdoptOpenJDK 11 を使って Gradleを動かして ビルドに14を使うデモです。

デモを見てみると分かるのですが 自動でビルドに必要なJDKをダウンロードしています

これはワクワクしますね。

前前職でJavaのアップデートしてた時に欲しかった・・・

というわけで、今回は、この Toolchain support for JVM Projects を使ってみます。 以下の目次でブログを書いていきます。

  • Gradle 6.7 の Toolchain support for JVM projects を試してみる
  • gradle で Javaリポジトリを作る
  • Toolchain support の設定を追加する
  • ビルドに使われたJDKが設定で指定したものになっているか確認する
  • 実際に動かしてみる
  • どのようにして使うJVMが選ばれるのか
  • Test/Compile だけ違うバージョンのJVMで実行も出来る
  • まとめ
  • おわりに

今回は、gradle で Javaリポジトリを作るところからやっていきましょう。

gradle で Javaリポジトリを作る

gradle init で interactive な形で リポジトリを生成しました。 今回は Java アプリケーションの実行を Java 15 で動かすとして ライブラリを Java 8 でビルドするような形のプロジェクトの構成で試してみます。

ホストに入っている Java は AdoptOpenJDK の 11 です。

$ gradle init 

Welcome to Gradle 6.7-rc-3!

Here are the highlights of this release:
 - File system watching is ready for production use
 - Declare the version of Java your build requires
 - Java 15 support

For more details see https://docs.gradle.org/6.7-rc-3/release-notes.html

Starting a Gradle Daemon (subsequent builds will be faster)

Select type of project to generate:
  1: basic
  2: application
  3: library
  4: Gradle plugin
Enter selection (default: basic) [1..4] 2

Select implementation language:
  1: C++
  2: Groovy
  3: Java
  4: Kotlin
  5: Scala
  6: Swift
Enter selection (default: Java) [1..6] 3

Split functionality across multiple subprojects?:
  1: no - only one application project
  2: yes - application and library projects
Enter selection (default: no - only one application project) [1..2] 2 

Select build script DSL:
  1: Groovy
  2: Kotlin
Enter selection (default: Groovy) [1..2] 1

Project name (default: gradle-toolchain-examples): 
Source package (default: gradle.toolchain.examples): 

> Task :init
Get more help with your project: https://docs.gradle.org/6.7-rc-3/samples/sample_building_java_applications_multi_project.html

BUILD SUCCESSFUL in 1m 0s
2 actionable tasks: 2 executed

生成してみたところこんな感じのソースが生成されました

$ tree
locales-launch: Data of ja_JP locale not found, generating, please wait...
.
├── README.md
├── app
│   ├── build.gradle
│   └── src
├── buildSrc
│   ├── build.gradle
│   └── src
│       └── main
│           └── groovy
│               ├── gradle.toolchain.examples.java-application-conventions.gradle
│               ├── gradle.toolchain.examples.java-common-conventions.gradle
│               └── gradle.toolchain.examples.java-library-conventions.gradle
├── gradle
│   └── wrapper
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── list
│   ├── build.gradle
│   └── src
├── settings.gradle
└── utilities
    ├── build.gradle
    └── src

49 directories, 21 files

久しぶりに生成したせいなのか、めっちゃファイルが生成されて驚きましたが無視していきましょう。

今回の記事で試すのに必要なのは設定を書くだけです。

Toolchain support の設定を追加する

サンプルプロジェクトの構成上、buildSrc配下のgroovyというかgradleファイルに書けば良さそうですね。 実際に変更したのはこの部分です。

gradle が Java 11で動いていることを確認しておきます。

./gradlew -v

------------------------------------------------------------
Gradle 6.7-rc-3
------------------------------------------------------------

Build time:   2020-09-30 19:16:51 UTC
Revision:     836e96a80625c9c48b612e662e3b13bd2e2f4c3b

Kotlin:       1.3.72
Groovy:       2.5.12
Ant:          Apache Ant(TM) version 1.10.8 compiled on May 10 2020
JVM:          11.0.7 (AdoptOpenJDK 11.0.7+10)
OS:           Linux 5.4.0-47-generic amd64

./gradlew compileJava と実行したところ AdoptOpenJDK をダウンロードしてコンパイルが実行されました。

clean してもう1回 ./gradlew -i compileJava で infoログを出しながらビルドしてみます。 その時のログが以下のログです。

$ ./gradlew -i compileJava
Initialized native services in: /home/masaya/.gradle/native
The client will now receive all logging from the daemon (pid: 334377). The daemon log file: /home/masaya/.gradle/daemon/6.7-rc-3/daemon-334377.out.log
Starting 6th build in daemon [uptime: 17 mins 6.354 secs, performance: 100%, non-heap usage: 29% of 268.4 MB]
Using 8 worker leases.
Watching the file system is disabled
Starting Build
Settings evaluated using settings file '/home/masaya/repo/gradle-toolchain-examples/settings.gradle'.
Projects loaded. Root project using build file '/home/masaya/repo/gradle-toolchain-examples/build.gradle'.
Included projects: [root project 'gradle-toolchain-examples', project ':app', project ':list', project ':utilities']

===================== 一部省略 ================================

> Task :list:compileJava
Caching disabled for task ':list:compileJava' because:
  Build cache is disabled
Task ':list:compileJava' is not up-to-date because:
  Output property 'destinationDirectory' file /home/masaya/repo/gradle-toolchain-examples/list/build/classes/java/main has been removed.
  Output property 'destinationDirectory' file /home/masaya/repo/gradle-toolchain-examples/list/build/classes/java/main/gradle has been removed.
  Output property 'destinationDirectory' file /home/masaya/repo/gradle-toolchain-examples/list/build/classes/java/main/gradle/toolchain has been removed.
The input changes require a full rebuild for incremental task ':list:compileJava'.
Full recompilation is required because no incremental change information is available. This is usually caused by clean builds or changing compiler arguments.
#
# 使っているツールチェインの場所が出力されている!!
#
Compiling with toolchain '/home/masaya/.gradle/jdks/jdk8u265-b01'.
Starting process 'Gradle Worker Daemon 3'. Working directory: /home/masaya/.gradle/workers Command: /home/masaya/.gradle/jdks/jdk8u265-b01/bin/java -Djava.security.manager=worker.org.gradle.process.internal.worker.child.BootstrapSecurityManager -Xmx512m -Dfile.encoding=UTF-8 -Duser.country=JP -Duser.language=ja -Duser.variant -cp /home/masaya/.gradle/caches/6.7-rc-3/workerMain/gradle-worker.jar worker.org.gradle.process.internal.worker.GradleWorkerMain 'Gradle Worker Daemon 3'
Successfully started process 'Gradle Worker Daemon 3'
Started Gradle worker daemon (0.338 secs) with fork options DaemonForkOptions{executable=/home/masaya/.gradle/jdks/jdk8u265-b01/bin/java, minHeapSize=null, maxHeapSize=null, jvmArgs=[], keepAliveMode=SESSION}.
Compiling with JDK Java compiler API.
Created classpath snapshot for incremental compilation in 0.0 secs.
:list:compileJava (Thread[Execution worker for ':',5,main]) completed. Took 1.048 secs.
:utilities:compileJava (Thread[Execution worker for ':',5,main]) started.

> Task :utilities:compileJava
Caching disabled for task ':utilities:compileJava' because:
  Build cache is disabled
Task ':utilities:compileJava' is not up-to-date because:
  Output property 'destinationDirectory' file /home/masaya/repo/gradle-toolchain-examples/utilities/build/classes/java/main has been removed.
  Output property 'destinationDirectory' file /home/masaya/repo/gradle-toolchain-examples/utilities/build/classes/java/main/gradle has been removed.
  Output property 'destinationDirectory' file /home/masaya/repo/gradle-toolchain-examples/utilities/build/classes/java/main/gradle/toolchain has been removed.
The input changes require a full rebuild for incremental task ':utilities:compileJava'.
Full recompilation is required because no incremental change information is available. This is usually caused by clean builds or changing compiler arguments.
#
# 使っているツールチェインの場所が出力されている!!
#
Compiling with toolchain '/home/masaya/.gradle/jdks/jdk8u265-b01'.
Compiling with JDK Java compiler API.
Created classpath snapshot for incremental compilation in 0.001 secs.
:utilities:compileJava (Thread[Execution worker for ':',5,main]) completed. Took 0.087 secs.
:app:compileJava (Thread[Execution worker for ':',5,main]) started.
This JVM does not support getting OS memory, so no OS memory status updates will be broadcast

> Task :app:compileJava
Caching disabled for task ':app:compileJava' because:
  Build cache is disabled
Task ':app:compileJava' is not up-to-date because:
  Output property 'destinationDirectory' file /home/masaya/repo/gradle-toolchain-examples/app/build/classes/java/main has been removed.
  Output property 'destinationDirectory' file /home/masaya/repo/gradle-toolchain-examples/app/build/classes/java/main/gradle has been removed.
  Output property 'destinationDirectory' file /home/masaya/repo/gradle-toolchain-examples/app/build/classes/java/main/gradle/toolchain has been removed.
The input changes require a full rebuild for incremental task ':app:compileJava'.
Full recompilation is required because no incremental change information is available. This is usually caused by clean builds or changing compiler arguments.
#
# 使っているツールチェインの場所が出力されている!!
#
Compiling with toolchain '/home/masaya/.gradle/jdks/jdk-15+36'.
Starting process 'Gradle Worker Daemon 4'. Working directory: /home/masaya/.gradle/workers Command: /home/masaya/.gradle/jdks/jdk-15+36/bin/java @/tmp/gradle-worker-classpath4424283455172721113txt -Xmx512m -Dfile.encoding=UTF-8 -Duser.country=JP -Duser.language=ja -Duser.variant worker.org.gradle.process.internal.worker.GradleWorkerMain 'Gradle Worker Daemon 4'
Successfully started process 'Gradle Worker Daemon 4'
Started Gradle worker daemon (0.304 secs) with fork options DaemonForkOptions{executable=/home/masaya/.gradle/jdks/jdk-15+36/bin/java, minHeapSize=null, maxHeapSize=null, jvmArgs=[], keepAliveMode=SESSION}.
Compiling with JDK Java compiler API.
Created classpath snapshot for incremental compilation in 0.0 secs.
:app:compileJava (Thread[Execution worker for ':',5,main]) completed. Took 1.073 secs.

BUILD SUCCESSFUL in 3s
3 actionable tasks: 3 executed
Stopped 2 worker daemon(s).

ログを見たところ、設定したとおりに順番に、8, 8, 15でビルドされているように見えます。 また、1回目ダウンロードした後、2回目のダウンロードは走りませんでした。 使う toolchain の JDK はキャッシュされています。 CI環境ならtoolchain のキャッシュが必要そうですが、普段使っていても特に困ることはなさそうですね。

ビルドに使われたJDKが設定で指定したものになっているか確認する

確認する方法は、javap を使って確認しましょう。 ところで、バイトコードのバージョンを見るの、-vしかないんだっけ・・・

$ javap -v list/build/classes/java/main/gradle/toolchain/examples/list/LinkedList.class
Classfile /home/masaya/repo/gradle-toolchain-examples/list/build/classes/java/main/gradle/toolchain/examples/list/LinkedList.class
  Last modified 2020/10/01; size 2020 bytes
  MD5 checksum dd80c79fa7cd4fbf29bac6bdc3032e9c
  Compiled from "LinkedList.java"
public class gradle.toolchain.examples.list.LinkedList
  minor version: 0
  major version: 52

〜〜〜〜〜〜〜〜〜 省略 〜〜〜〜〜〜〜〜〜〜〜

なにはともあれ、major version: 52 と書かれています。 これを Wikipedia にある java class fileを参照すると Java 8 のバイトコードであることが分かります。

utilities 配下に生成されたクラスファイルを見ても同様に Java 8のバイトコードが生成されています。

今度は、Java 15 でビルドしたプロジェクトの方のクラスファイルも見てみましょう。

$ javap -v app/build/classes/java/main/gradle/toolchain/examples/app/App.class 
Classfile /home/masaya/repo/gradle-toolchain-examples/app/build/classes/java/main/gradle/toolchain/examples/app/App.class
  Last modified 2020/10/01; size 947 bytes
  MD5 checksum 2a5ff88a5b8301ebf63b0ff8d0dd5b91
  Compiled from "App.java"
public class gradle.toolchain.examples.app.App
  minor version: 0
  major version: 59

〜〜〜〜〜〜〜〜〜 省略 〜〜〜〜〜〜〜〜〜〜〜

major verison:59 と書かれており、Java15のバイトコードが生成されたことがわかります。

というわけでここまでで toolchain support によって設定した toolchainを使って コンパイルがちゃんとされていることは確認できました。

実際に動かしてみる

今度は、このアプリケーションを実際に動かしてみましょう。 このアプリケーションはすぐに終了してしまうので debug オプションを追加してサスペンドさせて JVMのバージョンを確かめてみます。

$ ./gradlew run --debug-jvm

> Task :app:run
Listening for transport dt_socket at address: 5005
<===========--> 91% EXECUTING [42s]
> :app:run

というわけでサスペンドした状態で起動しました。

VMのバージョンはjcmdで確認しましょう。

$ jps
858300 Jps
847529 GradleWrapperMain
847799 Unknown
334377 GradleDaemon

$ jcmd 847799 VM.version
847799:
OpenJDK 64-Bit Server VM version 15+36
JDK 15.0.0

$ jcmd 847799 VM.system_properties
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
java.home=/home/masaya/.gradle/jdks/jdk-15+36
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜

VM.version の結果として、JDK 15.0.0 と表示されているのが分かります。 また、VM.system_properties から Javaのあるディレクトリを確認したところ Gradleのコンパイルで使っていたJDKが使われていることが分かります。

ここからは Toolchain support の 細かい挙動 をドキュメントから調べてみます。

どのようにして使うJVMが選ばれるのか

toolchain supportのドキュメント から挙動が書いてあるところ抜き出してきました。

* Setup all compile, test and javadoc tasks to use the defined toolchain which may be different than the one Gradle itself uses

* Gradle detects locally installed JVMs

* Gradle chooses a JRE/JDK matching the requirements of the build (in this case a JVM supporting Java 14)

* If no matching JVM is found, it will automatically download a matching JDK from AdoptOpenJDK

これによると、インストールされているJVMを自動で検知して、マッチするJVMが無い場合 AdoptOpenJDKから自動でダウンロードしてきてくれます。 また、今回は試しませんでしたが、javadocも指定したランタイムのものを使ってくれます。

インストールされているJVMの自動検知方法ですが、OS固有のインストール先に加えて、以下のパッケージマネージャもサポートされています。 普段SDKMANを使っているので、これは嬉しいですね。 AdoptOpenJDKを使っていない場合でも 自分でSDKMANなどを使ってインストールすれば良いですね。

Test/Compile だけ違うバージョンのJVMで実行も出来る

また、ドキュメントによると複雑なテストやコンパイルにおけるバージョン制御も出来ます。 ドキュメントに以下のサンプルがありました。

tasks.withType(JavaCompile).configureEach {
    javaCompiler = javaToolchains.compilerFor {
        languageVersion = JavaLanguageVersion.of(8)
    }
}
test {
    javaLauncher = javaToolchains.launcherFor {
        languageVersion = JavaLanguageVersion.of(14)
    }
}

これはコンパイルは 8 だけど、14でテストする、みたいなやつでしょうか? これは便利ですね。

まとめ

詳しい挙動を調べてみた結果、めちゃくちゃパワフルな機能であることが分かります。 また、JDKのダウンロード先をミラーにする機能だったり JDKのインストール先を手動で指定できる機能があります。 この辺は困った時にあると workaround として使えて嬉しいですね。

なんでこんないたれりつくせりなんや・・・。 嬉しいですが、めちゃくちゃ作り込まれていてすごいですね・・・。

おわりに

今回、Gradle 6.7 でリリースされる、Toolchain support for JVM Projects を使って コンパイルと実行を共に試してみました。 大体期待通りの挙動になっていることが分かります。

本当にこの機能、前前職でJavaのアップデートしている時に欲しかった・・・

また、細かい挙動をドキュメントから見て調べてみましたが 非常に多くの機能があり、基本的に欲しいなぁと思った機能はすでに実装されています。

Gradle 6.7 では、このブログで紹介した、非常に便利で強力な機能がリリースされます。 rc-3 なのでもうすぐリリースされるんじゃないかなぁと思います。 皆さんGradleのアップデートをしましょう。

Windowless moving percentileをgoで実装してみる

Windowless moving percentileとは指数平滑移動平均(Exponential moving average)をベースにしたパーセンタイル値の予測方法で 名前の通り、windowlessということで、windowなしにパーセンタイルの予測が可能で、メモリ効率や計算効率が良い。 windowとは、固定サイズの直近の観測値を集めた配列で、その配列の長さは100より大きい値を選ぶことが多いそう。 それを必要としないということは、メモリに優しいことが分かる。

今回はこのWindowless moving percentileをgoで実装していく。

Windowless moving percentileはこの記事 にて説明が書かれている。 ここの記述をベースに実装する。

まずはexponential moving percentileを実装する

Windowless moving percentileの実装の前に、exponential moving percentile を実装する必要があるので まずはこれを実装する。

以下のようなコードになる。

type ExponentialMovingAverageState struct {
    r float64

    average float64

    count   int32
    count_min int32
}

func NewExpMovingAvg(r float64) *ExponentialMovingAverageState {
    count_min := int32(math.Trunc(math.Ceil(1 / r)))
    return &ExponentialMovingAverageState{
        r:       r,
        count_min: count_min,
    }
}

func (s *ExponentialMovingAverageState) Sample(x float64) {
    var alpha float64
    if s.count < s.count_min {
        s.count++
    }
    if s.count >= s.count_min {
        alpha = s.r
    } else {
        alpha = 1 / float64(s.count)
    }
    s.average = alpha * x + (1-alpha)*s.average
}

ユーザから渡された値 r (0 < r <= 1)を使って 指数平滑移動平均を計算していくことになる。 この値は新しい観測値をどの程度重み付けするか、という値になる。

この値は windowless moving percentileでも使われる。

次はMoving varianceを計算する構造体を実装する

次はmoving varianceを計算する構造体を実装する。といいつつ、サンプル実装だと標準偏差も計算している謎の型になっている・・・。

ほぼサンプルのままなので 説明は省くがソースコードだけは書いておく。

type ExponentialMovingVarianceState struct {
    average *ExponentialMovingAverageState
    variance *ExponentialMovingAverageState
 
    stdev float64
    normalized float64
}

func NewExpMovingVariance(alphaAvg, alphaVar float64) *ExponentialMovingVarianceState {
    return &ExponentialMovingVarianceState{
        average: NewExpMovingAvg(alphaAvg),
        variance: NewExpMovingAvg(alphaVar),
    }
}

func (s *ExponentialMovingVarianceState) Sample(x float64) {
    if s.average.count > 0 {
        s.variance.Sample(math.Pow(x - s.average.average, 2))
    }
    s.average.Sample(x)
 
    s.stdev = math.Sqrt(s.variance.average)
    if s.stdev != 0 {
        s.normalized = (x - s.average.average) / s.stdev
    }
}

Windowless moving percentileを実装してみる

ここで準備が整ったので 本命のWindowless moving percentile実装していく。

構造体の定義とその初期化関数としては以下のようになった。

type WindowlessMovingPercentileState struct {
    r float64
    p float64
    value float64
    delta float64
 
    deltaState *ExponentialMovingVarianceState
    count   int32
}

func NewWindowlessMovingPercentile(percentile float64, r, alphaAvg, alphaVar float64) *WindowlessMovingPercentileState {
    return &WindowlessMovingPercentileState{
        r: r,
        p: percentile,
        delta: r,
        deltaState: NewExpMovingVariance(alphaAvg, alphaVar),
    }
}

サンプル実装を参考に書いてみたところ、以下のような実装になった。

func (s *WindowlessMovingPercentileState) Sample(x float64) {
    if s.count < 2 {
        s.count++
    }
    s.deltaState.Sample(x)
    // s.count >= 2 の場合のみ stdevの値が利用可能になるので guardを入れている
    if s.count >= 2 {
        s.delta = s.deltaState.stdev * s.r
    }
    if s.count == 1 {
        s.value = x
    } else if x < s.value {
        s.value = s.value - s.delta / s.p
    } else if x > s.value {
        s.value = s.value + s.delta / (1 - s.p)
    }
}

特に難しいところはなさそうに見えますが コードを少し間違えていたりしてハマりました・・・。

というわけで単に写経してみたよって記事でした。

一応、Go playgroundに置いておきました。

play.golang.org

まとめ

今回はWindowless moving percentileを実装してみた。 このアルゴリズムNetflix/concurrency-limits の go実装である、go-concurrency-limits にリファレンスされているアルゴリズムである。 windowlessということで、windowなしにpercentileの計算が出来るので メモリに優しく並行性制御が出来るようになる。

漸化式の非対称性が気になるので もう少し遊んでみようかなと思っています。

終わり。