openjdk

Activity

Stars and mentions · Last 30 days, Oct 7 – Nov 5

GitHub stars125
Mentions23
036912Oct 7Oct 21Nov 5
UTC days · repositories openjdk owns5 repositories

Top repositories

openjdk’s repositories ranked by pull request authors, as on trending.

Loading

Contributes to

Repositories outside openjdk’s account where openjdk opened or reviewed pull requests in the past 365 days.

Who noticed openjdk

Everyone who starred or forked openjdk’s repositories since the archive began in 2011.

Notable accounts

Ordered by the stars their own repositories have received.

Biggest fans

Accounts that starred the most of these repositories.

Breakout weeks

Each repository’s best week of new stars, with the release published around it.

Mentions

22posts
Nov 60 posts
  1. Java 8 から上げたら G1 がメモリを解放しなくなった Java 8 から Java 11〜25 に上げると、GC を指定しないときに選ばれる GC(既定の GC)が Parallel から G1 に変わる。GC は使われなくなったオブジェクトのメモリを片付ける仕組みで、Parallel と G1 はその種類の名前だ。 この変化で、負荷のピークが過ぎてもコンテナのメモリ(cgroup の memory.current)が下がらなくなる。 同じプログラムに同じ負荷をかけ、ピークのあとにコンテナのメモリがどれだけ変わったかをバージョンごとに比べた1。 バージョン ピークのあとの変化 Java 8 −234.6〜−227.6MB Java 11 0.0〜+2.9MB Java 25 +2.3〜+2.7MB ここでは、JVM が OS から取ってあるヒープの量を確保量、それを減らして OS に返すことを縮めると呼ぶ。下がらない原因はメモリリークではなく、G1 が負荷の続くあいだ確保量を縮めないことにある。 ピークのあとも下がらないメモリがリークか既定の GC の振る舞いかは、すでに集めている JVM のメトリクスと GC ログで、本番に触らずに見分けられる。それでも下げたいときは、G1 に設定を足すか、GC を Parallel に戻す。 GC の仕組み ピークのあとにメモリが下がるかどうかは、GC が動いたかではなく、確保量を縮めたかで決まる。 片付けることと縮めること GC は使われなくなったオブジェクトを片付けて、ヒープの中に空きを作る。片付けで減るのは使用量、つまり確保量の中でオブジェクトが占めている量だ。空いた分を OS に返して確保量を縮めるかどうかは、片付けとは別に決まる。 GC が来るとき JVM はヒープを若い世代と古い世代に分けて使う。作られたオブジェクトの多くは若い世代に置かれ、その多くはそこで使われなくなる。若い世代が埋まると、若い世代だけを片付ける GC(young GC)が動く。オブジェクトを作り続けるあいだは若い世代が繰り返し埋まるので、young GC も繰り返し来る。 young GC を何度か生き延びたオブジェクトは、古い世代に移される。古い世代が埋まると、ヒープ全体を片付ける必要が出る。プログラムを止めてヒープ全体を片付ける GC を full GC と呼ぶ2。 2つの止め方 GC が片付けるあいだはプログラムが止まり、そのあいだに届いたリクエストは待たされる。GC の作りはこの止め方で分かれる。 まとめて止める 小分けに止める 片付け方 プログラムを止めて一度に片付ける 片付けを小分けにし、一部をプログラムと並行して進める 1回の停止 長い 短い プログラムが進められる処理の量(スループット) 大きい GC が CPU を使う分、少し下がる Oracle のガイドが勧める場面 1秒以上の停止を許せる スループットより応答時間が大事 GC Parallel G1 Parallel の名前は止めているあいだの片付けを複数のスレッドで並列に進めることから来ていて、Parallel がプログラムと並行して動くわけではない3。 停止を短く保つため、G1 はプログラムを止めずにヒープ全体の生きているオブジェクトを調べる処理(並行マーキング)を持つ。並行マーキングは最後に短い停止(Remark)を挟んで終わる。始まるのは、young GC の終わりに、古い世代の占有量がしきい値を超えたときだ4。 並行マーキングのあと、G1 は続く young GC で、若い世代と一緒に古い世代の一部も片付ける(mixed GC)。G1 が full GC をするのは、この片付けが追いつかずメモリが足りなくなったときに限られる5。 何が変わったか Java 8 からバージョンを上げたときに変わるのは、既定の GC と、確保量を縮めるきっかけだ。 バージョン 既定の GC 確保量を縮めるきっかけ Java 8 Parallel young GC(若い世代の分) Java 11 G1 full GC Java 12〜25 G1 full GC と、並行マーキングの終わり 既定の GC GC を指定しないとき、Java 8 は Parallel を、Java 9 からは G1 を選ぶ6。OpenJDK は G1 に替えた理由として、「多くの利用者には、スループットを最大にするより、GC の停止時間を抑えるほうが大事だ」を挙げた7。 縮めるきっかけ Java 8 の Parallel は young GC のたびに若い世代の大きさを決め直し、縮める8。負荷が続くあいだも young GC は来るので、縮めるきっかけも来る。 Java 11 の G1 が確保量を縮めるのは、full GC のあとだけだ9。G1 の full GC は片付けが追いつかないときに限られるので、このきっかけはほとんど来ない。 Java 12 からは、並行マーキングの終わりでも確保量を縮める10。ただし並行マーキングが始まるのは古い世代が埋まってきたときなので、作ったオブジェクトがすぐ使われなくなる負荷では、このきっかけも来ない11。 周期 GC Java 12 の変更(JEP 346)によると、G1 は full GC を極力避け、並行マーキングもヒープの占有量と、オブジェクトを作る速さからしか始めないので、外から促されない限りヒープを OS に返さないことが多い。 これは使った分だけ払うコンテナ環境で特に不利なので、Java 12 で、GC がしばらく起きていないときに G1 が自分から並行マーキングを始めて確保量を縮める仕組みが加わった12。これを周期 GC と呼ぶ。 G1 は決めた間隔ごとに直前の GC からの時間を見る。間隔以上たっていれば並行マーキングを始め、その終わりの Remark で縮める。たっていなければ飛ばす13。名前とは違い、間隔ごとに必ず GC するのではない。 たとえば間隔を 10 秒にしても、オブジェクトが作られ続けて young GC が 1 秒もたたずに次々起きていれば、直前の GC から 10 秒たつことがない。そのあいだ、周期 GC は一度も動かない。 間隔は G1PeriodicGCInterval にミリ秒で指定し、0 にすると周期 GC は動かない。既定値は 0 なので、指定しなければ動かない14。 周期 GC を付けたとき 周期 GC を付けると、負荷が続くあいだは飛ばされ、負荷が止んだあとに動いて確保量を縮める。 アクセスが急に増えて引いたあとの2つの状況を模して比べた15。 ピーク(5 秒) ピークのあと 負荷が続く アクセスが急に増える。毎秒 1000MB のオブジェクトを作る 普段のリクエストが来続ける。毎秒 200MB のオブジェクトを作り続ける 負荷が止む 同じ リクエストが来なくなる。何も作らない どちらも Java 25 に -XX:G1PeriodicGCInterval=10000 を付け、間隔を 10 秒にした。 負荷が続くあいだ ピークのあとも負荷が続くと、起動から 10 秒の判定で、GC ログに次の行が出る。この行は -Xlog:gc+periodic=debug を付けたときに出る。 [10.017s][debug][gc,periodic ] Last GC occurred 505ms before which is below threshold 10000ms. Skipping. 直前の GC が 505ms 前で、間隔の 10000ms に届かないので、周期 GC を飛ばしたという行だ13:1。 ピークのあとの 45 秒間に、判定は 5 回あり、5 回とも飛ばされて、周期 GC は一度も動かなかった。確保量もピークの終わりから最後まで変わらなかった。 負荷が止んだあと ピークのあとオブジェクトを作らないと、起動から 20 秒の判定で周期 GC が動いた。10 秒の判定の時点では、ピークの終わりの GC から 10 秒たっていなかった。まず、並行マーキングを始める young GC の行が出る。 [20.016s][info ][gc ] GC(46) Pause Young (Concurrent Start) (G1 Periodic Collection) 195M->37M(377M) 1.104ms (G1 Periodic Collection) が、周期 GC が始めた GC であることを示す。195M->37M は使用量の前と後で、括弧の 377M が確保量だ16。この young GC では、確保量は変わらない。 続く Remark の行では、括弧の確保量が 27M になる10:1。 [20.019s][info ][gc ] GC(47) Pause Remark 37M->7M(27M) 0.478ms JVM のメトリクスで見た確保量(MemoryPoolMXBean で取ったヒープのメモリプールの committed の合計)も、同じだけ下がった17。 計測した時点 確保量 周期 GC の直前 377.0〜432.0MB 周期 GC のあと 27.0〜30.0MB 冒頭で比べたコンテナのメモリ(memory.current、1 秒ごとに計測)も下がった。周期 GC の直前の計測で 393.9〜421.4MB だったのが、最後の計測では 53.8〜59.5MB になった。 自分の環境で見分ける すでに集めている JVM のメトリクスと GC ログを使う。GC ログは -Xlog:gc を付けて出る行で足りる。 選ばれた GC JVM のメトリクスでは、ヒープがいくつかの区画に分かれて見え、区画の名前は GC で決まる。この区画をメモリプールと呼ぶ18。 Micrometer なら jvm.memory.committed の id タグ、Prometheus の Java クライアントなら jvm_memory_pool_committed_bytes の pool ラベルに、プールの名前が入る19。名前を見れば、本番で選ばれた GC が分かる。 GC ヒープのメモリプール Parallel PS Eden Space、PS Survivor Space、PS Old Gen G1 G1 Eden Space、G1 Survivor Space、G1 Old Gen Parallel のプールなら、この記事の現象ではない。どちらでもなければ、「適用範囲」の節を見る。 リークかどうか 下がらないメモリを見ると、まずリークを疑う。リークなら、負荷が一定でも、生きているオブジェクトの量が時間とともに増え続ける20。この記事の現象なら、生きているオブジェクトは少ないまま、確保量だけが高い。 見分けるには、確保量と、GC のあとの使用量を比べる。確保量はヒープのプールの committed の合計で見る。使用量は GC ログの各 GC の行にある GC のあとの値で見る。プールの used は、Eden の分が GC のたびに上下するので、比べるのに向かない。 Java 11 で負荷が続いたとき、ピークのあと最初の young GC の行は次のとおりだった21。 [6.877s][info ][gc ] GC(27) Pause Young (Normal) (G1 Evacuation Pause) 679M->89M(989M) 1.773ms この行では、矢印のあとの 89M が GC のあとの使用量で、括弧の 989M が確保量だ。確保量の大半が空いていて、片付けは進んでいる。空いた分を OS に返していないだけだ。 生きているオブジェクトは GC のあとの使用量を超えないので、GC のあとの使用量が増えていなければ、リークは疑わなくてよい。 ただし young GC は若い世代しか片付けないので、古い世代にたまったゴミは、young GC のあとの使用量に残る。この値は、リークでなくても少しずつ増えることがある。 増えていたら、この記事の現象だけでは説明がつかない。古い世代まで片付ける GC(full GC か、並行マーキングのあとの mixed GC)のあとの値があればそれを見て、そこでも増え続けるか、そうした GC が来ていなければ、リークとして調べる20:1。 縮めるきっかけになる GC 次に、ピークのあとの GC ログで、確保量を縮めるきっかけになる GC の行を探す。行の名前は Java 11 と Java 25 で同じだ22。Java 11 でも Pause Remark は出るが、確保量は縮めない。 バージョン 縮めるきっかけになる GC の行 Java 11 Pause Full Java 12〜25 Pause Full、Pause Remark 並行マーキングが始まると、Pause Remark の前に Pause Young (Concurrent Start) の行も出る。どれも無ければ、縮めるきっかけは来ていない。Java 11 なら、ここで判断がつく。Java 12 以降なら、残るきっかけは周期 GC だ。 周期 GC が動いているか Java 12 以降で Pause Remark が無ければ、周期 GC も動いていない。周期 GC は並行マーキングを始めるので、動けば Pause Remark が出る。 動いていないのは、間隔を指定していないか、指定していても負荷が続いて判定のたびに飛ばされているかだ(「周期 GC を付けたとき」の節)。間隔は起動オプションのほか、動いている JVM にも jcmd で指定できる14:1。 次のすべてに当てはまれば、メモリが下がらないのはリークではなく、G1 が確保量を縮めるきっかけを得ていないためだ。 プールが G1 のもの GC のあとの使用量が増えていない ピークのあとに Pause Full が無く、Java 12 以降なら Pause Remark も無い 対策 リークではないと分かっても、メモリを下げたいときは、G1 のまま負荷が止んだときに縮めるきっかけを足すか、young GC のたびに縮める GC に戻す。 周期 GC を付ける Parallel に戻す オプション -XX:G1PeriodicGCInterval=<ミリ秒> -XX:+UseParallelGC 縮めるきっかけ GC が間隔より長く起きないとき young GC のたび 負荷が絶えないサーバーで 縮めない 縮める23 ピークのすぐあとに負荷が止むとき 縮める 縮めない24 Java 11 で 使えない25 使える 1回の停止 G1 のまま短い 長くなる どちらを取るかは「自分の環境で見分ける」で当てはまったことと、負荷が止む時間帯があるかで決まる。 当てはまったこと 負荷が止む時間帯がある 負荷が止む時間帯がない Java 11 で、Pause Full が出ていない Java 12 以降に上げて周期 GC を付けるか、Parallel に戻す Parallel に戻す Java 12 以降で、周期 GC を指定していない 周期 GC を付ける Parallel に戻す 周期 GC を指定しているが、Pause Remark が出ない 間隔を短くする Parallel に戻す 負荷が止む時間帯は、GC ログの各行の先頭の時刻で見る。GC と GC のあいだが長く空く時間帯があれば、そこで周期 GC が動ける。 周期 GC は間隔ごとの判定で、直前の GC から間隔以上たっていれば動く。判定は間隔ごとにしか来ないので、止んでから動くまでに最大で間隔の2倍かかる。間隔は止む時間帯の長さの半分より短くする13:2。 Parallel に戻すと、1回の停止は長くなる。どれだけ長くなるかはヒープの大きさと生きているオブジェクトの量で変わる。本番の前に、検証環境で -XX:+UseParallelGC を付けて同じ負荷をかけ、GC ログの各行の最後にある停止時間を G1 のときと比べる。 適用範囲 この記事の検証は 2GB・2 CPU のコンテナで、GC を指定せずに動かした。ヒープの確保量は上限を -Xmx で、起動したときの値を -Xms で決める。検証では -Xms を付けていない。負荷は、作ったオブジェクトがすぐ使われなくなるものだった。次の場合は、結論が当てはまらない。 場合 結論との違い 小さいコンテナ(CPU が1つ、またはメモリが 1792MB 未満) Java 8〜26 では Serial が選ばれる26。Parallel と G1 の話は当てはまらない -Xms を付けている -Xms は確保量の下限にもなり、G1 はこれより下に縮めない。-Xmx と同じ値なら、周期 GC を付けても下がらない27 Parallel で、ピークのあと GC が起きない Parallel は GC のときにしか縮めないので、下がらない24:1 オブジェクトが生き残り、古い世代が埋まっていく負荷 並行マーキングが始まり、Java 12 以降の G1 はその Remark で縮める。周期 GC を付けなくても下がる場合がある28 Java 26 G1 も Parallel も young GC のあとに縮める。周期 GC を付けなくても下がる場合がある29 Java 27 小さいコンテナでも G1 が選ばれる30 Serial が選ばれたかは、メモリプールの名前が Eden Space、Survivor Space、Tenured Gen になるか31、起動時の GC ログに次の行が出るかで分かる32。 [0.003s][info][gc ] Using Serial cgroup v2 のコンテナの上限を読めるのは、Java 15 以降と、8u372 以降、11.0.16 以降だ。それより前は、ホストの CPU とメモリで GC を選ぶ33。 脚注 GitHub Actions のランナーの上の Docker で、コンテナは 2GB・2 CPU、-Xmx1500m。5 秒のピーク(アクセスが急に増えたときを模して、毎秒 1000MB のオブジェクトを作った)のあと、45 秒、普段の負荷として毎秒 200MB のオブジェクトを作り続けた。各バージョン 3 回の範囲。スクリプトと結果は show-your-work と GitHub Actions の実行結果 にある。 ↩︎ Oracle の HotSpot Virtual Machine Garbage Collection Tuning Guide の Generations(Java 25)。 ↩︎ Oracle の同じガイド(Java 25)の The Parallel Collector、Garbage-First (G1) Garbage Collector、Available Collectors の Selecting a Collector。 ↩︎ Java 25 で、young GC の終わりに並行マーキングを始めるかを判定する箇所。 ↩︎ Oracle の同じガイド(Java 25)の Garbage-First (G1) Garbage Collector の Space-Reclamation Phase と、その後の Full GC の記述。 ↩︎ Java 8 を -XX:+PrintCommandLineFlags を付けて起動すると、JVM が出すフラグの行に -XX:+UseParallelGC が入る。Java 11 と Java 25 は GC ログの先頭に Using G1 の行(Java 11 では [0.004s][info ][gc ] Using G1)を出す。 ↩︎ JEP 248: Make G1 the Default Garbage Collector の Motivation。 ↩︎ Java 8 の Parallel はyoung GC のたびに若い世代の大きさを決め直す。 ↩︎ Java 11 の G1 は full GC の処理の中で確保量を見直す。 ↩︎ Remark で縮めるようにした変更は JDK-6490394(Java 12)。Java 25 では Remark の処理の中で確保量を見直す。 ↩︎ ↩︎ ピークのあとも毎秒 200MB のオブジェクトを作り続けたとき、Java 11、17、21、25 のどれでも、ピークのあとに並行マーキングは始まらなかった。 ↩︎ JEP 346: Promptly Return Unused Committed Memory from G1 の Motivation。 ↩︎ Java 25 の周期 GC の判定と、既定で並行マーキングを始める設定(G1PeriodicGCInvokesConcurrent)。 ↩︎ ↩︎ ↩︎ G1PeriodicGCInterval の定義。MANAGEABLE のフラグなので、動いている JVM でも変えられる。 ↩︎ ↩︎ 2GB・2 CPU のコンテナ、-Xmx1500m、GC は指定しない。負荷が続く回は、ピークのあと 45 秒、オブジェクトを作り続けた。GC ログは -Xlog:gc*、-Xlog:gc+periodic=debug で採った。確保量の表は周期 GC の直前の計測と、周期 GC のあとの最初の計測。 ↩︎ Oracle の GC チューニングガイド(Java 25)の Generations にある GC ログの読み方("used before GC" -> "used after GC" ("heap size"))。 ↩︎ 同じ検証を 3 回流した。「〜」で示した値は 3 回の範囲。 ↩︎ プールの名前は G1 と Parallel のソースで決まっている。 ↩︎ どちらも MemoryPoolMXBean から値を取っている。Micrometer 1.13.0 の JvmMemoryMetrics、Prometheus の Java クライアント 1.3.1 の JvmMemoryMetrics。 ↩︎ Oracle の Troubleshooting Guide の Troubleshoot Memory Leaks(Java 25)。 ↩︎ ↩︎ ピークのあとも毎秒 200MB のオブジェクトを作り続けた条件で、ピークのあと最初の young GC の行。 ↩︎ Java 11 の Pause Full、Pause Young (Concurrent Start)、Pause Remark。Java 25 の Pause Full。Java 25 の残りの2つは、「周期 GC を付けたとき」の節のログに出ている。 ↩︎ Java 11 の Parallel もyoung GC のたびに若い世代の大きさを決め直す。Java 11 に -XX:+UseParallelGC を付けてピークのあとも毎秒 200MB のオブジェクトを作り続けると、確保量はピークの終わりの 369.0〜380.0MB から最後の計測で 103.5〜120.5MB に減り、コンテナのメモリ(memory.current)も 141.4〜145.5MB 下がった。 ↩︎ Parallel は Java 8 でも Java 11 でも、young GC の中で大きさを決め直す。 ↩︎ ↩︎ Java 11 で付けると、Unrecognized VM option 'G1PeriodicGCInterval=10000' を出して JVM が起動しない。周期 GC は Java 12 から。 ↩︎ CPU が2つ以上で、メモリが 2GB から 256MB を引いた量以上のときだけ、Java 9 以降は G1 を、Java 8 は Parallel を選ぶ。Java 8 の判定も同じ。 ↩︎ -Xms は、起動したときの確保量と下限(MinHeapSize)の両方を決める。G1 は MinHeapSize より下に縮めない。 ↩︎ G1 はyoung GC の終わりに古い世代の占有量を見て並行マーキングを始め、Remark で縮める。 ↩︎ Java 26 の G1(JDK-8238687)と Parallel(JDK-8338977)。 ↩︎ JEP 523。 ↩︎ Serial のメモリプール。 ↩︎ Java 25 で、コンテナのメモリだけを 1GB にしても、CPU だけを1つにしても、この行が出た。 ↩︎ cgroup v2 の上限を読む変更は Java 15 で入り、11.0.16 と 8u372 に移された。 ↩︎

  2. @ethauvin Are you interested into testing it with an alternative loom scheduler? See pderop/looma using loom#227

    5 likes · 139 views
  3. @forked_franz @_karm @victorgao466305 confirmed. a fastdebug build hits this assert: github.com/openjdk/jdk8u/blob/b661ee6ff840d6922aa…

    2 likes · 36 views
  4. @forked_franz @_karm @victorgao466305 Clankers claim the problem is this narrow-casting and choose to believe it (in this very case): github.com/openjdk/jdk8u/blob/b661ee6ff840d6922aa… but the ~Rulers~ Lawyers of OpenJDK think clankers stink so...

    2 likes · 1 repost · 108 views
  5. 実はSAにはvalue objectを含むヒープダンプを生成できないという解析する側にとっては困る問題(というか先送りタスク)があったのですが、直しておきました。これでjhsdb jmapを安心して使える。JDK 28のリリースに間に合ってよかった。 jdk#33395

    1 like · 122 views
  6. "retired" を "retierd" と書くというしょうもない間違いをそっと直してくれるOpenJDKのSAチームが私は大好きです(そもそもそんなスペルミスすんな!): jdk#33527

    5 likes · 146 views
  7. nginx upstream_connect_time comes in 1-second steps An nginx latency alert fires, yet the logs and metrics of the application server behind nginx can show the same requests finishing quickly. This happens when the application server stops entirely for a few seconds, for example during a full GC[^stop], and connections that arrive in the meantime are kept waiting. [^stop]: Ordinary GC pauses are not this long. The JVM's G1 collector has a default pause-time goal of 200 ms (G1Arguments::initialize() in JDK 25). Pauses of several seconds come from things like a full GC under heap pressure, which this article does not verify. sequenceDiagram participant N as nginx participant K as Application server's kernel participant U as Application server Note over U: Stopped by a full GC or similar N->>K: SYN (open a connection) Note over K: The accept queue is full, so the SYN is dropped The dropped SYN is resent every second and connects after the application server resumes. From nginx, these look like 200s that took about 1, 2, or 3 seconds to connect. The three lines below are from the nginx access log, which records $upstream_connect_time (the seconds nginx took to finish connecting to the application server) as uct. The columns are time, request ID, status, uct, $upstream_header_time (uht), $upstream_response_time (urt), and $request_time (rt). uht and urt are the seconds from when nginx starts connecting to the application server until it receives the start of the response, and until it has received the whole response. rt is the seconds from when nginx receives the request until it finishes sending the response. 1792402370.463 487aefe88a1328206a8e93c7c242399a 200 uct=1.002 uht=1.002 urt=1.002 rt=1.002 1792402370.464 cfdd11ceca67677e3601df796850a712 200 uct=2.067 uht=2.067 urt=2.067 rt=2.068 1792402371.040 dd774240d10448d9dc41bfcaaa7d8dfe 200 uct=3.054 uht=3.055 urt=3.055 rt=3.054 The application server receives the request ID that nginx assigned, and its log records time, request ID, and processing time in milliseconds. These are its lines for the same request IDs: 1792402370.463 487aefe88a1328206a8e93c7c242399a 0.092 1792402370.463 cfdd11ceca67677e3601df796850a712 0.104 1792402371.040 dd774240d10448d9dc41bfcaaa7d8dfe 0.084 Nearly all of the time went to connecting, and processing on the application server took about 0.1 ms. The application server starts timing only after it takes the connection off the queue, so the wait to connect does not appear in its logs or metrics. The logs above were taken from an application server that receives 200 requests per second, stopped for 3 seconds[^run]. [^run]: The results are here, and the code is in show-your-work. It ran on GitHub Actions ubuntu-24.04 (x86_64, kernel 6.17) with Docker 28.0.4. What is happening When the application server stops, the accept queue fills up. SYNs that arrive after that are dropped, and connections are made through retransmissions by the nginx-side kernel. The accept queue The kernel carries out the exchange that sets up a TCP connection (the handshake). The connecting side sends a SYN to request a connection, the receiving side replies with a SYN-ACK to accept it, and the handshake completes when the connecting side sends an ACK. A completed connection waits in the kernel's accept queue until the server process takes it with the accept system call. Even while the application server is stopped, the kernel on its side keeps completing handshakes and queuing the connections. Nothing takes connections off the queue during the stop, so the queue fills up. sequenceDiagram participant N as nginx participant NK as nginx-side kernel participant UK as Application server's kernel participant U as Application server Note over U: Stopped N->>NK: Connect to the application server NK->>UK: SYN UK-->>NK: SYN-ACK NK->>UK: ACK NK-->>N: Connected Note over UK: Puts the connection in the accept queue Note over U: On resuming, takes connections off the queue in order The maximum number of connections the queue can hold is the backlog. The application server passes the backlog to the kernel when it starts listening for connections (when it calls listen). The value comes from the runtime's or framework's default, or from configuration. SYNs are dropped when the queue is full When the number of connections in the queue exceeds the backlog, the application server's kernel drops newly arriving SYNs without replying[^drop]. [^drop]: In the Linux 6.17 source, tcp_conn_request() drops the SYN when the queue is full. sk_acceptq_is_full() decides whether it is full, and treats the queue as full when the number of connections in it is greater than (>) the backlog. Retransmissions get through When a SYN gets no reply, the nginx-side kernel waits and sends it again. This is SYN retransmission. If the nginx-side kernel is Linux 6.5 or later, the first few retransmissions go out at 1-second intervals[^linear]. [^linear]: The number of retransmissions sent at 1-second intervals is set by net.ipv4.tcp_syn_linear_timeouts (default 4; see the Linux 6.17 documentation). Retransmitted SYNs are dropped too while the queue is full. Once the application server resumes and the queue has room, the next retransmission gets a SYN-ACK and the connection is made. Depending on which retransmission gets through, $upstream_connect_time is about 1, 2, or 3 seconds. The three log lines at the top are these cases. sequenceDiagram participant N as nginx participant NK as nginx-side kernel participant UK as Application server's kernel participant U as Application server Note over U: Stopped Note over UK: The accept queue is full N->>NK: Connect to the application server NK->>UK: SYN Note over UK: Drops it without replying loop Every second while the queue is full NK->>UK: Resend the SYN Note over UK: Drops it again end Note over U: Resumes and takes connections off the queue NK->>UK: Next retransmission UK-->>NK: SYN-ACK NK->>UK: ACK NK-->>N: Connected ($upstream_connect_time is about 1, 2, or 3 seconds) Verification I stopped the application server for 3 seconds to confirm this sequence. Requests that arrived during the stop split into those that connected immediately and those that connected after waiting about 1, 2, or 3 seconds. Setup nginx and the application server ran as containers on the same Docker network, and nginx was sent 200 requests per second for 15 seconds. Five seconds in, the application server process was stopped from outside with SIGSTOP, then resumed with SIGCONT 3 seconds later. This mimics a full GC stopping the whole process. - nginx is 1.30.6, with a single application server in the upstream block. proxy_connect_timeout (how long nginx waits to finish connecting to the application server) is left at its default of 60 seconds - The application server is an HTTP server that returns 200 immediately for every request, and listens with a backlog of 100 Time to connect In the 3 seconds between the stop and the resume, 600 requests reached nginx, and all of them returned 200. The counts by $upstream_connect_time are: | $upstream_connect_time | Requests | |---|---| | 0 seconds (connected immediately) | 102 | | About 1 second | 204 | | About 2 seconds | 205 | | About 3 seconds | 89 | This holds beyond the three lines at the top: for all 600 requests, the processing time in the application server's log was under 1 ms. Which row a request falls into depends on when it arrived. The queue was full about 0.5 seconds after the stop. Connections that arrived before that went into the queue and connected immediately. Connections that arrived later had their SYNs dropped, the nginx-side kernel resent them every second, and they connected on the first retransmission after the application server resumed. The earlier a connection arrived, the more times its SYN was resent and the longer it waited. Spotting it in your own logs To tell whether the accept queue overflowed, match the nginx access log against the application server's log. The access log shows that SYNs were dropped and the connections were made by retransmission. The application server's log shows that the application server was stopped. Access log In the access log, look at $upstream_connect_time on the 200 lines from when the alert fired. If the values cluster around 1, 2, and 3 seconds, SYNs were dropped and the connections were made by retransmission. $upstream_connect_time is not part of the default combined format, so add it with log_format[^log-format]. The verification used this format: log_format lab '$msec $request_id $status uct=$upstream_connect_time uht=$upstream_header_time urt=$upstream_response_time rt=$request_time'; [^log-format]: Adding variables with log_format is covered in nginx request_time is fast, but users say it's slow. Application server log In the application server's log, find the lines with the same IDs as the requests whose $upstream_connect_time came in 1-second steps, and look at how the lines are spaced around that time. If those requests finished quickly and the lines stop for a while around that time, the application server was stopped. The lines after the gap are bunched at the time it resumed. To match the two logs, pass the ID that nginx assigns to each request ($request_id) to the application server. On the nginx side, this takes one line: proxy_set_header X-Request-Id $request_id; On the application server side, its access log needs to be configured to include the received X-Request-Id header. Fix If the application server's backlog is set larger than the number of requests that arrive during a stop (requests per second × seconds stopped), the queue does not overflow[^somaxconn]. With a backlog of 4096, the 200s of about 1, 2, and 3 seconds no longer appear. The default backlogs of JVM servers, which can stop for a full GC, are: - Tomcat and Spring Boot: 100[^bl-tomcat] - Jetty: 50[^bl-jetty] [^somaxconn]: To go above 4096, also raise net.core.somaxconn in the application server's kernel. The kernel caps the backlog at somaxconn (__sys_listen_socket() in Linux 6.17), and its default is 4096 (preinit_net_sysctl(), documentation). [^bl-tomcat]: The Tomcat 11 documentation on HTTP connector configuration, and TomcatServerProperties in the Spring Boot 4.1.1 source. [^bl-jetty]: Jetty passes 0, and the JDK replaces it with 50. The default is 0 in ServerConnector in the Jetty 12.1.14 source, and ServerSocketChannelImpl in the JDK 25 source turns any value below 1 into 50. Even without an overflow, requests that arrive during a stop wait in the queue until the application server resumes. That wait shows up in $upstream_header_time, so 200s slowed by the length of the stop remain. How this varies by environment With a shortened connect timeout If proxy_connect_timeout is shorter than the stop, nginx gives up on connections whose retransmissions have not gotten through in time and returns 504. When the stop is not 3 seconds A stop of a different length should change the values $upstream_connect_time clusters around. This article tests a 3-second stop. Kernels older than 6.5 If the nginx-side kernel is older than 6.5, $upstream_connect_time clusters around 1 and 3 seconds, and about 2 seconds does not appear. This is because the retransmission interval doubles each time: after 1 second, then 2 seconds after that, then 4 seconds after that. The stock kernel of Ubuntu 24.04 (6.8) is 6.5 or later. Those of Ubuntu 22.04 (5.15), Debian 12 (6.1), and RHEL 9 (5.14) are older. Whether a distribution has backported this change into its own kernel has not been checked. With a proxy in front of the application server If a proxy that accepts connections first sits between nginx and the application server (Docker's userland-proxy, which handles published ports, or a sidecar proxy), that proxy answers the SYNs from nginx. Even if the application server's queue overflows, no retransmissions happen on the nginx side, so the method in this article should not apply. This article does not verify this case.

  8. nginxのupstream_connect_timeが1秒刻みになる nginx 側のレイテンシのアラートが鳴ったのに、nginx の後ろのアプリケーションサーバーのログやメトリクスでは、同じリクエストが速く終わっていることがある。アプリケーションサーバーが full GC などでプロセスごと数秒止まり1、その間に届いた接続が待たされると、こうなる。 捨てられた SYN は 1 秒ごとに送り直され、アプリケーションサーバーが動き出したあとにつながる。nginx からは、接続を張るのに約 1 秒、約 2 秒、約 3 秒かかった 200 に見える。下の3行は nginx のアクセスログで、$upstream_connect_time(nginx がアプリケーションサーバーへの接続を張り終えるまでにかかった秒数)を uct として出している。列は時刻、リクエスト ID、ステータス、uct、$upstream_header_time(uht)、$upstream_response_time(urt)、$request_time(rt)の順に並ぶ。uht と urt は、nginx がアプリケーションサーバーにつなぎ始めてから、応答の先頭を受け取るまでと、応答を受け取り終えるまでの秒数だ。rt は、nginx がリクエストを受けてから応答を返し終えるまでの秒数だ。 1792402370.463 487aefe88a1328206a8e93c7c242399a 200 uct=1.002 uht=1.002 urt=1.002 rt=1.002 1792402370.464 cfdd11ceca67677e3601df796850a712 200 uct=2.067 uht=2.067 urt=2.067 rt=2.068 1792402371.040 dd774240d10448d9dc41bfcaaa7d8dfe 200 uct=3.054 uht=3.055 urt=3.055 rt=3.054 アプリケーションサーバーには、nginx が付けたリクエスト ID を渡し、ログに時刻、リクエスト ID、処理時間(ミリ秒)の順に出している。上の3行と同じリクエスト ID の行は次のとおりだ。 1792402370.463 487aefe88a1328206a8e93c7c242399a 0.092 1792402370.463 cfdd11ceca67677e3601df796850a712 0.104 1792402371.040 dd774240d10448d9dc41bfcaaa7d8dfe 0.084 かかった時間のほとんどが接続を張るまでの時間で、アプリケーションサーバーでの処理は 0.1 ミリ秒ほどだ。アプリケーションサーバーが時間を測り始めるのは接続を取り出したあとなので、接続を張るまでの待ちは、そのログやメトリクスに出ない。 上のログは毎秒 200 件を受けるアプリケーションサーバーを 3 秒止めて取ったものだ2。 何が起きているか アプリケーションサーバーが止まると accept キューが埋まり、そのあと届いた SYN は捨てられ、nginx 側のカーネルの再送でつながる。 accept キュー TCP の接続を張るやりとり(ハンドシェイク)は、カーネルが行う。接続を張る側が接続を求める SYN を送り、受ける側が受け入れの返事の SYN-ACK を返し、張る側が ACK を返すと、ハンドシェイクが終わる。終わった接続は、サーバーのプロセスが accept というシステムコールで取り出すまで、カーネルの accept キューに並んで待つ。 アプリケーションサーバーが止まっていても、アプリケーションサーバー側のカーネルはハンドシェイクを進め、終わった接続をキューに積む。止まっている間は接続が取り出されないので、キューは埋まっていく。 キューに並べられる接続の数の上限が backlog で、アプリケーションサーバーが接続を待ち受けるとき(listen するとき)に backlog をカーネルに渡す。値はランタイムやフレームワークの既定値か設定で決まる。 満杯なら SYN を捨てる キューにある接続の数が backlog を超えると、アプリケーションサーバー側のカーネルは、新しく届いた SYN を返事をせずに捨てる3。 再送でつながる SYN に返事がないと、nginx 側のカーネルは時間をおいて SYN を送り直す。これを SYN の再送と呼ぶ。nginx 側のカーネルが Linux 6.5 以降なら、最初の数回を 1 秒間隔で送り直す4。 送り直した SYN も、キューが満杯のうちは捨てられる。アプリケーションサーバーが動き出してキューが空くと、その次の再送で SYN-ACK が返り、つながる。何回目の再送でつながったかで、$upstream_connect_time は約 1 秒、約 2 秒、約 3 秒になる。冒頭の3行がこれにあたる。 検証 アプリケーションサーバーを 3 秒止めて、この流れを確かめた。止まっている間に届いたリクエストは、すぐつながったものと、約 1 秒、約 2 秒、約 3 秒待ってつながったものに分かれた。 構成 nginx とアプリケーションサーバーを同じ Docker ネットワークのコンテナで動かし、nginx に毎秒 200 件のリクエストを 15 秒送った。送り始めて 5 秒後に、アプリケーションサーバーのプロセスに外から SIGSTOP を送って止め、3 秒後に SIGCONT で再開させた。full GC でプロセス全体が止まる状態を模している。 nginx は 1.30.6 で、upstream ブロックにアプリケーションサーバーを 1 台置いた。proxy_connect_timeout(nginx がアプリケーションサーバーへの接続を張り終えるのを待つ上限)は既定の 60 秒のまま アプリケーションサーバーは受けたリクエストにすぐ 200 を返す HTTP サーバーで、backlog 100 で listen する 接続にかかった時間 止めてから再開させるまでの 3 秒に nginx に届いたリクエストは 600 件で、すべて 200 だった。$upstream_connect_time ごとの件数は次のとおりだ。 $upstream_connect_time 件数 0 秒(すぐつながった) 102 件 約 1 秒 204 件 約 2 秒 205 件 約 3 秒 89 件 冒頭の3行に限らず、600 件とも、アプリケーションサーバーのログの処理時間は 1 ミリ秒未満だった。 表のどの値に入るかは、届いた時刻で決まる。止めてから 0.5 秒ほどでキューが満杯になり、それより前に届いた接続はキューに入って、すぐつながった。それより後に届いた接続は SYN を捨てられ、nginx 側のカーネルが 1 秒ごとに送り直して、アプリケーションサーバーが動き出したあとの次の再送でつながった。早く届いた接続ほど、送り直した回数が多く、待ちが長い。 自分のログで見分ける accept キューが溢れたかどうかは、nginx のアクセスログとアプリケーションサーバーのログを突き合わせて見分ける。アクセスログで SYN が捨てられて再送でつながったと分かり、アプリケーションサーバーのログで、アプリケーションサーバーが止まっていたと分かる。 アクセスログ アクセスログでは、アラートが鳴った時間帯の 200 の行の $upstream_connect_time を見る。値が約 1 秒、約 2 秒、約 3 秒に固まっていれば、SYN が捨てられ、再送でつながっている。 $upstream_connect_time は既定の combined 形式に入らないので、log_format に足す5。検証では次の形式を使った。 log_format lab '$msec $request_id $status uct=$upstream_connect_time uht=$upstream_header_time urt=$upstream_response_time rt=$request_time'; アプリケーションサーバーのログ アプリケーションサーバーのログでは、アクセスログで $upstream_connect_time が 1 秒刻みの値になったリクエストと同じ ID の行と、その時間帯の行の並びを見る。同じ ID のリクエストが速く終わり、その時間帯に行が途切れていれば、アプリケーションサーバーは止まっていた。途切れたあとの行は、動き出した時刻にまとまって並ぶ。 突き合わせるには、nginx がリクエストごとに付ける ID($request_id)をアプリケーションサーバーに渡す。nginx の側は次の1行で渡せる。 proxy_set_header X-Request-Id $request_id; アプリケーションサーバーの側では受け取った X-Request-Id ヘッダをアクセスログに出す設定が要る。 対策 アプリケーションサーバーの backlog の設定を、止まっている間に届く件数(毎秒の件数 × 止まる秒数)より大きくすれば、溢れは起きない6。backlog を 4096 にすると、約 1 秒、約 2 秒、約 3 秒の 200 は出なくなる。 full GC で止まることのある JVM のサーバーの既定の backlog は、次のとおりだ。 Tomcat と Spring Boot:1007 Jetty:508 止まっている間に届いたリクエストは、溢れなくても、アプリケーションサーバーが動き出すまでキューで待たされる。この待ちは $upstream_header_time に出て、止まった時間の分だけ遅い 200 は残る。 環境による違い 接続のタイムアウトを縮めているとき proxy_connect_timeout を止まる時間より短くしていると、再送を待ちきれなかった接続は、nginx が打ち切って 504 を返す。 止まる時間が 3 秒でないとき 止まる時間が違えば、$upstream_connect_time が固まる値も変わるはずだ。この記事では 3 秒止めた場合を確かめている。 6.5 より前のカーネル nginx 側のカーネルが 6.5 より前だと、$upstream_connect_time は約 1 秒と約 3 秒に固まり、約 2 秒は出ない。再送の間隔が 1 秒後、その 2 秒後、その 4 秒後と倍々に伸びるからだ。 Ubuntu 24.04 の標準のカーネル(6.8)は 6.5 以降、Ubuntu 22.04(5.15)、Debian 12(6.1)、RHEL 9(5.14)は 6.5 より前だ。ディストリビューションがこの変更を自分のカーネルに取り込んでいるかは確かめていない。 アプリケーションサーバーの前にプロキシがあるとき nginx とアプリケーションサーバーの間に、先に接続を受けるプロキシ(Docker の公開ポートを受ける userland-proxy、サイドカーのプロキシ)があると、nginx からの SYN にはそのプロキシが返事をする。アプリケーションサーバーのキューが溢れても nginx 側では再送が起きないので、この記事の見分け方は使えないはずだ。この記事では確かめていない。 脚注 普段の GC ではここまで止まらない。JVM の G1 は停止時間の目標が既定で 200ms だ(JDK 25 の G1Arguments::initialize())。秒単位で止まるのは、ヒープが逼迫したときの full GC などで、この記事では確かめていない。 ↩︎ 検証結果はこちら、コードは show-your-work にある。GitHub Actions の ubuntu-24.04(x86_64、カーネル 6.17)で、Docker 28.0.4 を使った。 ↩︎ Linux 6.17 のソースの tcp_conn_request() で、キューが満杯なら SYN を捨てている。満杯かどうかは sk_acceptq_is_full() が決め、キューにある接続の数が backlog より大きい(>)ときに満杯とする。 ↩︎ 1 秒間隔で送り直す回数は net.ipv4.tcp_syn_linear_timeouts(既定 4)で決まる(Linux 6.17 の文書)。 ↩︎ log_format の足し方は nginxのrequest_timeは速いのにユーザーは遅いと言うで扱った。 ↩︎ 4096 より大きくするときは、アプリケーションサーバー側のカーネルの net.core.somaxconn も上げる。カーネルは backlog を somaxconn までに切り詰め(Linux 6.17 の __sys_listen_socket())、その既定は 4096 だ(preinit_net_sysctl()、文書)。 ↩︎ Tomcat 11 の文書の HTTP コネクタの設定と、Spring Boot 4.1.1 のソースの TomcatServerProperties。 ↩︎ Jetty は 0 を渡し、JDK が 50 に置き換える。Jetty 12.1.14 のソースの ServerConnector で既定が 0、JDK 25 のソースの ServerSocketChannelImpl で 1 未満を 50 にしている。 ↩︎

  9. SIGILL при старте: как AOT-кэш JDK уронил Axelix в Docker Введение Представьте, что вы разворачиваете open source Java-приложение в Docker. Вы скачиваете образ, запускаете его, и ещё до первой строчки лога JVM падает вот с таким сообщением: A fatal error has been detected by the Java Runtime Environment: # SIGILL (0x4) at pc=..., pid=1, tid=... # Problematic frame: v ~AdapterBlob sourcecraft.dev Именно это произошло у некоторых пользователей Axelix при запуске Axelix Master. SIGILL означает illegal instruction (недопустимая инструкция): процессор получил команду выполнить инструкцию, которую он не поддерживает. От Java-приложения такого не ждёшь. Java должна следовать принципу «write once, run anywhere», а байткод не содержит процессорных инструкций. Мы разобрались в проблеме, исправили её и написали об этом статью. Это хорошая история о новой возможности JVM, AOT-кэше, и о небольшой детали его устройства, которая оказалась очень важной для всех, кто распространяет заранее собранный Docker-образ. Что мы увидели Баг был заведён как #1744. Все крэш-логи, которые нам прислали пользователи, указывали на один и тот же фрейм, ~AdapterBlob. Отличалась только недопустимая инструкция: На машине с ARM64 JVM падала на ptrue p7.b. Это инструкция SVE (Scalable Vector Extension), а SVE отсутствовал в списке возможностей процессора в крэш-логе. Мы также воспроизвели проблему сами на x86-64. Мы запустили Axelix 1.1.1 на эмулируемом процессоре без AVX, и JVM упала на vzeroupper, это инструкция AVX. Итак, две разные архитектуры падали в одном и том же месте, и оба раза на векторной инструкции, которой у процессора не было. В качестве срочной меры мы убрали AOT-кэш из образа Master. Axelix 1.2.0 вышел без него. А затем мы пошли выяснять, что на самом деле происходит. Чтобы в этом разобраться, сначала нужно посмотреть, как работает AOT-кэш. Что такое AOT-кэш? При каждом запуске JVM заново выполняет много одной и той же работы. Она читает классы из JAR-файлов, разбирает и верифицирует байткод, линкует классы и выполняет статические инициализаторы. Затем какое-то время исполняет код в интерпретаторе, собирает профили, и только после этого JIT-компилятор может выдать хорошо оптимизированный машинный код. Для Spring Boot-приложения с тысячами классов эта работа составляет значительную часть времени старта. Project Leyden ставит целью переиспользовать эту работу между запусками. Его первый результат: AOT-кэш: JEP 483 (JDK 24) — классы хранятся в кэше уже загруженными и слинкованными. JEP 514 (JDK 25) — упрощённые параметры командной строки. JEP 515 (JDK 25) — в кэше также хранятся профили исполнения методов, поэтому JIT может начать оптимизацию раньше. Тренировочный запуск → сбор данных → создание app.aot ↓ Следующие запуски ← используют данные из app.aot sourcecraft.dev Три фазы Работа с AOT-кэшем состоит из трёх фаз: Тренировка (training). Вы запускаете приложение с репрезентативной нагрузкой. JVM записывает, какие классы были загружены и слинкованы и как исполнялись методы. Сборка (assembly). Отдельная JVM берёт записанную конфигурацию и создаёт файл кэша. Продакшн (production). Последующие запуски стартуют с -XX:AOTCache=... и используют подготовленные данные вместо того, чтобы снова проделывать всю работу. В Dockerfile это естественным образом ложится на multi-stage сборку. Упрощённая версия Dockerfile для Axelix Master выглядит так: FROM eclipse-temurin:25-jre-alpine AS training WORKDIR /application COPY build/libs/master.jar master.jar 1. Тренировка: запускаем приложение до обновления контекста и записываем, что оно делает RUN java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf \ -Dspring.context.exit=onRefresh \ -jar master.jar 2. Сборка: создаём кэш из записанной конфигурации RUN java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf -XX:AOTCache=app.aot \ -jar master.jar FROM eclipse-temurin:25-jre-alpine AS final WORKDIR /application COPY --from=training /application/master.jar /application/app.aot ./ 3. Продакшн ENTRYPOINT exec java -XX:AOTCache=app.aot -cp master.jar org.springframework.boot.loader.launch.JarLauncher sourcecraft.dev -Dspring.context.exit=onRefresh это возможность Spring Framework, созданная как раз для этой цели: приложение стартует, обновляет ApplicationContext и завершается. Этого достаточно, чтобы «прогреть» всё, что важно для старта, и при этом не нужны ни база данных, ни сеть. Что лежит внутри кэша? В JDK 25 один файл AOT-кэша содержит несколько видов данных: метаданные классов — классы, которые уже разобраны, верифицированы и слинкованы; профили методов — статистика исполнения, собранная во время тренировки; объекты кучи — некоторые заранее построенные Java-объекты, например граф модулей; кэш AOT-кода — настоящий машинный код. Первые три являются структурами данных, не зависящими от процессора. Последний является источником нашей проблемы. AOTAdapterCaching Чтобы понять, какой машинный код попадает в кэш, нужно заглянуть в HotSpot чуть глубже. Когда метод вызывается впервые, интерпретатор исполняет его байткод. Когда метод становится «горячим», JIT компилирует его в машинный код. Интерпретируемый и скомпилированный код используют разные соглашения о вызовах. Интерпретатор передаёт аргументы через собственный стек, а скомпилированный код передаёт их через регистры, как того требует ABI платформы. Поэтому JVM нужны небольшие фрагменты связующего кода между ними, которые называются адаптерами: i2c (interpreted-to-compiled) — используется, когда интерпретируемый метод вызывает скомпилированный; c2i (compiled-to-interpreted) — используется, когда скомпилированный метод вызывает метод, который всё ещё интерпретируется. Адаптеры генерируются для каждой сигнатуры метода, и большому приложению их нужны тысячи. Их генерация при старте занимает время, поэтому в JDK 25 добавили опцию AOTAdapterCaching, которая включена по умолчанию: адаптеры генерируются на фазе сборки и сохраняются в AOT-кэше в виде готового к исполнению машинного кода. И вот в чём подвох. Как и любой код, который генерирует HotSpot, адаптеры генерируются под процессор, на котором JVM работает в данный момент. Если этот процессор поддерживает AVX-512, адаптер может использовать регистры AVX-512. Если он поддерживает SVE, адаптер может использовать инструкции SVE. На машине, где шла сборка, такой код корректен. На машине без этих возможностей первый же вызов через такой адаптер выполняет инструкцию, которую процессор не знает, и ядро посылает SIGILL. Почему JVM нас не защитила JVM действительно валидирует AOT-кэш. При продакшн-запуске она проверяет сборку JDK, операционную систему, архитектуру процессора (x64 или aarch64), classpath, GC, compressed oops и ещё ряд настроек. Если что-то не совпадает, JVM в режиме по умолчанию -XX:AOTMode=auto выводит предупреждение и продолжает работу без кэша. Старт получается медленнее, но ничего не ломается. Однако одну вещь JVM в JDK 25 не проверяла: возможности процессора в пределах одной архитектуры. «x64» означает и современный серверный процессор с AVX-512, и старый процессор ноутбука без него. Кэш принимался, а адаптеры внутри него использовались как есть. Теперь посмотрим, как собирался наш образ. Релизный job запускается на раннере, который предоставляет GitHub, и собирает multi-platform образ: docker build --platform linux/amd64,linux/arm64 --file master-oss/Dockerfile ... sourcecraft.dev Вариант amd64 тренируется на том процессоре, который случайно окажется у раннера. Раннеры GitHub являются облачными виртуальные машины, и у некоторых из них есть AVX-512. Вариант arm64 тренируется под эмуляцией QEMU (docker/setup-qemu-action), потому что сам раннер работает на x86. Эмуляция QEMU в user-mode по умолчанию эмулирует модель процессора max, в которой есть SVE. Однако у многих реальных ARM64-процессоров, на которых люди запускают контейнеры, SVE нет: у Mac на Apple Silicon, AWS Graviton2 и Ampere Altra. Multi-platform сборка прогоняет весь Dockerfile отдельно для каждой платформы, поэтому каждый вариант образа получает собственный кэш, а docker pull выбирает вариант, соответствующий хосту. Архитектура всегда совпадала. А вот возможности процессора в её пределах не совпадали: у наших сборочных машин их было больше, чем у многих машин наших пользователей. Ровно такая комбинация и приводила к падению. Баг в апстриме В ходе расследования мы нашли adoptium/adoptium-support#1507, «Launching with AOT cache fatally crashes with SIGILL (0x4) depending on available CPU instructions». Проблема была та же самая: кэш, созданный на процессоре Zen 5 с AVX-512, падал в ~AdapterBlob на процессоре Zen 2 без AVX-512. Автор обнаружил её так же, как и мы: у раннеров GitHub Actions AVX-512 то есть, то нет. Issue перенесли в OpenJDK как JDK-8388703. Реакция разработчиков HotSpot была прагматичной. Адаптеры являются единственным AOT-кодом в JDK 25 (кэширование стабов уже выключено), и производительность они улучшают не сильно. Поэтому вместо того, чтобы бэкпортировать проверки процессора, они решили просто выключить кэширование адаптеров по умолчанию в JDK 25. Именно это исправление вошло в JDK 25.0.5: // Disable adapters caching which requires CPU features matching checks // (implemented by JDK-8377507 and JDK-8381975) which we don't // have in this version of code. FLAG_SET_ERGO_IF_DEFAULT(AOTAdapterCaching, false); sourcecraft.dev Полноценное исправление находится в JDK 27: JDK-8377507 — возможности процессора машины, на которой шла сборка, сохраняются в кэше AOT-кода; JDK-8381975 — при старте сохранённые возможности должны точно совпадать с возможностями текущего процессора. Если они не совпадают, JVM пишет в лог AOT Code Cache disabled: cpu features are incompatible, игнорирует только машинный код и продолжает использовать всё остальное из AOT-кэша. Так и должно быть: в худшем случае ускорение будет чуть меньше, но падения не будет никогда. Наше исправление Исправление: #1767. Мы вернули AOT-кэш, но создаём его без адаптеров: Create AOT cache without CPU-specific adapters to avoid SIGILL on other CPUs RUN java \ -XX:+UnlockDiagnosticVMOptions \ -XX:-AOTAdapterCaching \ -XX:AOTMode=create \ -XX:AOTConfiguration=app.aotconf \ -XX:AOTCache=app.aot \ -jar /application/master.jar sourcecraft.dev AOTAdapterCaching является диагностическим флагом, поэтому нужен -XX:+UnlockDiagnosticVMOptions. С этим флагом в кэше вообще нет машинного кода, и JVM подтверждает это при старте: [info][aot] Opened AOT cache app.aot. [info][aot,codecache,init] AOT Code Cache is empty [info][aot] Using AOT-linked classes: true (static archive: has aot-linked classes) sourcecraft.dev Остаются метаданные классов, профили и объекты кучи, которые одинаковы на любом процессоре данной архитектуры. Адаптеры генерируются во время работы, на машине пользователя и под его процессор. И их удаление не лишает нас выигрыша. При локальной проверке образа, собранного из исправленного Dockerfile, около 72% классов, загруженных при старте (10 933 из 15 201), были взяты из AOT-кэша, а время до обновления контекста сократилось примерно с 7,3 с до 5,4 с на ноутбуке разработчика. Альтернативы мы обсуждали в PR. Первая идея была дождаться JDK 25.0.5 и оставить кэширование адаптеров включённым. Но 25.0.5 не делает адаптеры безопасными. Он просто выключает их, так что результат тот же, что и с нашим флагом. Вторая идея была перейти на JDK 27, где проверка процессора есть. Большинство запускает Axelix Master в Docker или Kubernetes, так что новая базовая версия Java их бы не затронула. Но Spring Boot 4.1 официально поддерживает Java до 26-й версии, а поддержка Java 27 ожидается в Spring Boot 4.2. Поэтому мы смёржили явный флаг. Он работает на сегодняшних сборках JDK 25 и остаётся корректным после 25.0.5, где просто совпадает с новым значением по умолчанию. А что с JDK 27? Как только выйдет Spring Boot 4.2, мы планируем перевести Axelix на JDK 27. Там снова включать AOTAdapterCaching безопасно: на несовпадающем процессоре JVM отбросит закэшированный код, а не упадёт. Но «безопасно» не значит «полезно». JDK 27 требует точного совпадения возможностей процессора. Для образа, собранного на раннере GitHub или натренированного под QEMU, шанс, что у процессора пользователя окажется ровно тот же набор возможностей, невелик. У большинства пользователей закэшированные адаптеры просто будут выброшены. Чтобы заранее собранный образ действительно выигрывал от AOT-кода, JVM должна генерировать его под общий, переносимый базовый набор возможностей процессора. Для этого есть открытое улучшение, JDK-8377630 «Restrict AOT code to a generic set of CPU features». Пока оно не появилось, AOT-кэш в распространяемом образе лучше воспринимать как кэш данных, а не кода. Выводы AOT-кэш JDK является реальным и простым способом ускорить старт Spring Boot-приложений в Docker. Достаточно multi-stage Dockerfile с этапом тренировки. На JDK 25 до версии 25.0.5, если вы собираете кэш на одной машине, а запускаете на другой, создавайте его с -XX:+UnlockDiagnosticVMOptions -XX:-AOTAdapterCaching. Иначе процессоры ваших пользователей должны поддерживать все возможности, которые были у вашей сборочной машины. А у CI-раннеров и QEMU их обычно больше. Multi-arch образы автоматически получают отдельный кэш для каждой архитектуры, потому что каждая платформа проходит собственные этапы тренировки и сборки. JVM валидирует AOT-кэш и безопасно откатывается, если что-то не совпадает. Единственное исключение до JDK 27: машинный код, хранящийся в кэше. Исправление войдёт в следующий релиз Axelix. Если вам интересны подробности, вся история описана в #1744 и #1767.

  10. IPA の Oracle Java 注意喚起(2026年10月)は、Oracle JDK を使っていなければ関係ないのか ― 手元の Mac の OpenJDK で確かめて更新した記録 株式会社Good Labでエンジニアをしている コータロー です。 日々、Java・SQL・Gitなどの技術情報や、新人エンジニア向けの学習ノウハウ、 AI活用についての情報を発信しています。 Good Labについて気になった方は、コーポレートサイトもぜひご覧ください。 ▶コーポレートサイト はじめに 2026年10月21日、IPA(情報処理推進機構)が「Oracle Java の脆弱性対策について(2026年10月)」を掲載しました。 タイトルが「Oracle Java」なので、最初に思ったのは「Oracle の JDK は入れていないから、自分には関係ないのでは?」でした。手元の Mac に入っているのは、Homebrew で入れた OpenJDK 21 です。 そこで、公式の情報を読み比べ、手元の Mac で実際に確かめて更新しました。先に結論を書きます。 - 関係ありました。 OpenJDK 側も同じ日に、同じ CVE 番号の修正を出しています。手元の OpenJDK は 21.0.12.1 で、IPA の対象一覧にある「Oracle Java SE 21.0.12.1」と同じ版番号でした。OpenJDK 側の advisory でも、影響を受ける版として 21.0.12.1 が挙がっています - brew upgrade openjdk@21 で 21.0.13 に上がりました。コマンドを打ってから更新後の確認まで、1分かかりませんでした - 確かめ方によっては「Java は入っていない」と見えてしまう落とし穴があります(/usr/libexec/java_home) - さらに、更新先の 21.0.13 も、10月21日に公表された別の脆弱性(CVE-2026-91403)の対象でした。3日後に Homebrew へ修正版 21.0.13.0.2 が来たので上げ直し、問題の中間 CA 証明書が Java の「信頼しない」一覧に入ったことを確かめました :::note warn この記事の情報は 2026年10月22日〜25日(日本時間)時点 のものです。特に CVE-2026-91403 の件は状況が動いている最中なので、読むときは各公式ページで最新の状況を確認してください。 ::: 確かめた環境 | 項目 | 内容 | |------|------| | マシン | MacBook Pro(Apple M5 Max) | | OS | macOS 27.0.1 | | パッケージ管理 | Homebrew 7.0.10 | | Java | Homebrew の openjdk@21(keg-only) | 1. IPA の注意喚起の要点 まず、IPA のページに書かれていることを整理します。 | 項目 | 内容 | |------|------| | タイトル | Oracle Java の脆弱性対策について(2026年10月) | | 掲載日 | 2026年10月21日 | | 区分 | 注意(「重要なセキュリティ情報」一覧での表示) | | 対象 | Oracle Java SE 27 / 26.0.2 / 25.0.4.1 / 21.0.12.1 / 17.0.20.1 / 11.0.32.1 / 8 Update 503 / 8 Update 503-perf | | 対策 | Oracle 社が提供する最新版に更新する(Java Downloads から) | 影響については、次のように書かれています。 > これらの脆弱性を悪用された場合、アプリケーションプログラムが異常終了する、攻撃者によってパソコンを制御される、といった様々な被害が発生するおそれがあります。 > 攻撃が行われた場合の影響が大きいため、早急にセキュリティ更新プログラムを適用してください。 ほかに、次の2点も案内されています。 - Oracle 社が 2019年4月16日以降のリリースについてライセンスの変更を案内しているので、特に商用利用する組織はライセンスを確認すること - 「MyJVN バージョンチェッカ for .NET」で、ソフトウェアが最新かどうかを簡単に確認できること IPA のページに書かれていないこと IPA のページには、CVE の件数・CVSS のスコア・修正版の版番号は載っていません。参考情報として挙げられている Oracle の「Critical Patch Update Advisory - October 2026」の Java SE Risk Matrix を読むと、次のとおりでした。 | 項目 | 内容 | |------|------| | 公開日 | 2026年10月20日(Rev 1. Initial Release) | | Oracle Java SE の新しいセキュリティパッチ | 55件。うち49件は認証なしでネットワーク経由で悪用されうる | | CVSS 3.1 ベーススコアの最大 | 7.5(2件。CVE-2026-91484〈Java plugin、8 系のみ〉と CVE-2026-43660〈JavaFX の WebKitGTK〉) | IPA の「2026年10月21日」は日本時間、Oracle の「10月20日」は米国の日付です。 2. Oracle JDK を使っていなければ関係ない? ここが本題です。結論は「Oracle 以外の配布の OpenJDK も、同じ修正の対象」でした。根拠を順に書きます。 Homebrew の openjdk@21 は Oracle JDK ではない Homebrew の openjdk@21 は、Oracle JDK とは別の配布です。Homebrew の公式のフォーミュラ情報(formulae.brew.sh)を見ると、次のようになっています。 - ソースの取得元: https://github.com/openjdk/jdk21u/archive/refs/tags/jdk-21.0.13-ga.tar.gz(OpenJDK の JDK 21 更新版リポジトリ) - ライセンス: GPL-2.0-only WITH Classpath-exception-2.0 つまり、OpenJDK のソースから Homebrew がビルドしたものです。java -version にも OpenJDK Runtime Environment Homebrew と出ます(後述)。 ライセンスも Oracle JDK とは違います。Oracle の JDK FAQ でも、OpenJDK のビルドは GPLv2 + Classpath Exception(GPLv2+CPE)で、Oracle JDK は別のライセンス(Oracle No-Fee Terms and Conditions License や OTN License Agreement for Java SE)だと整理されています。 OpenJDK 側も、同じ日に修正と advisory を出している OpenJDK には Vulnerability Group という組織があり、公式ページでは役割を次のように説明しています。 > The Vulnerability Group is a secure, private forum in which trusted members of the OpenJDK Community receive reports of vulnerabilities in OpenJDK code bases, review them, collaborate on fixing them, and coordinate the release of such fixes. (訳: OpenJDK のコードの脆弱性の報告を受け、レビューし、修正に協力し、修正のリリースを調整する、非公開の場) advisory の一覧ページには、公開の時期が次のように書かれています。 > We publish fixes to vulnerabilities in OpenJDK source code four times per year, in January, April, July, and October, on the third Tuesday of the month. (訳: OpenJDK のソースコードの脆弱性の修正は、年4回、1月・4月・7月・10月の第3火曜日に公開する) 今回の Oracle の Critical Patch Update と同じ 2026年10月20日付けで、OpenJDK の advisory も出ています。冒頭はこうです。 > The following vulnerabilities in OpenJDK source code were fixed in this release. The affected versions are 26.0.2.1, 25.0.4.1, 21.0.12.1, 17.0.20.1, 11.0.32.1, 8u504, and earlier. (訳: 以下の OpenJDK ソースコードの脆弱性は、このリリースで修正された。影響を受ける版は 26.0.2.1、25.0.4.1、21.0.12.1、17.0.20.1、11.0.32.1、8u504 と、それ以前) IPA(Oracle)の対象一覧と並べると、次のとおりです。 | 系列 | IPA の対象(Oracle Java SE) | OpenJDK advisory の影響を受ける版 | |------|------|------| | 25 | 25.0.4.1 | 25.0.4.1 以前 | | 21 | 21.0.12.1 | 21.0.12.1 以前 | | 17 | 17.0.20.1 | 17.0.20.1 以前 | | 11 | 11.0.32.1 | 11.0.32.1 以前 | | 8 | 8 Update 503 / 503-perf | 8u504 以前 | | 26 | 26.0.2 | 26.0.2.1 以前 | 25・21・17・11 は版番号まで一致しています。8 と 26 は番号の付け方が一致していないので、自分の版がどちらの一覧に当たるかは、使っている配布の情報で確かめてください。 CVE 番号もほぼ同じ 両方の表を突き合わせると、Oracle の Java SE Risk Matrix にある 55件の CVE のうち 53件 が、OpenJDK(と OpenJFX)の advisory にも同じ CVE 番号で載っていました(筆者が両表の CVE 番号を照合して数えたものです)。Oracle の表にだけあったのは、8 系だけが対象の Java plugin の CVE-2026-91484 と、CVE-2026-101516 の2件です。 OpenJDK の表で「21」の列に印が付いている CVE は 39件、CVSS 3.1 の最大は 7.0(CVE-2026-91415、client-libs/2d)でした。影響を受けるのが 21.0.12.1 以前なので、21 系で今回の修正が入った版は 21.0.13 です。 ただし、配布ごとに出るタイミングは違う 修正の公開日は同じでも、それをビルドして配る日は配布ごとに違います。執筆時点で見ると、次のとおりでした。 - Homebrew: 10月22日の朝の時点で openjdk@21 は 21.0.13 が配布されていた(後述の更新で確認) - Eclipse Temurin(Adoptium): Adoptium の API で 21 系の最新の GA を見ると、2026年10月22日 9時03分(日本時間)時点では jdk-21.0.12.1+1(2026年8月19日)のままだった 「OpenJDK なら全部同じ日に更新できる」わけではないので、使っている配布のリリース情報を確認する必要があります。 3. 自分の Java がどれかを確かめる ここからは手元の Mac で確かめた記録です。以下の出力は、更新前(2026年10月22日 8時59分)に実行したものです。 java -version $ java -version openjdk version "21.0.12.1" 2026-08-18 OpenJDK Runtime Environment Homebrew (build 21.0.12.1) OpenJDK 64-Bit Server VM Homebrew (build 21.0.12.1, mixed mode, sharing) 見るところは2つです。 - 版番号: 21.0.12.1。IPA の対象一覧の「21.0.12.1」、OpenJDK advisory の「21.0.12.1」と同じです - 2行目の Runtime Environment の後ろ: Homebrew とあるので、Homebrew がビルドした OpenJDK だと分かります which -a java java コマンドの実体がどこにあるかを確かめます。-a を付けると、PATH 上で見つかったものを全部出します。 $ which -a java /opt/homebrew/opt/openjdk@21/bin/java /usr/bin/java /opt/homebrew/opt/openjdk@21/bin/java 先頭の /opt/homebrew/opt/openjdk@21/bin/java が、実際に使われる Homebrew の Java です。同じパスが2回出ているのは、PATH に同じディレクトリが重複して入っているためです。~/.zshrc では次のように設定していました。 $ grep java ~/.zshrc 1:export JAVA_HOME="/opt/homebrew/opt/openjdk@21" 2:export PATH="$JAVA_HOME/bin:$PATH" 落とし穴: /usr/libexec/java_home では「無い」と出る macOS には、インストール済みの JDK を探す /usr/libexec/java_home というコマンドがあります。これで確かめると、こうなりました。 $ /usr/libexec/java_home -V The operation couldn’t be completed. Unable to locate a Java Runtime. Please visit http://www.java.com for information on installing Java. $ ls -la /Library/Java/JavaVirtualMachines/ total 0 drwxr-xr-x 2 root wheel 64 Sep 24 16:10 . drwxr-xr-x 4 root wheel 128 Sep 24 16:10 .. java -version では Java 21 が動いているのに、java_home は「Java Runtime が見つからない」と言います。JDK の登録場所である /Library/Java/JavaVirtualMachines/ も空です。 理由は、Homebrew の openjdk@21 が keg-only だからです。Homebrew の FAQ では keg-only を次のように説明しています。 > It means the formula is installed only into the Cellar and is not linked into the default prefix. This means most tools will not find it. (訳: Homebrew のインストール先(Cellar)にだけ入れて、標準の場所にはリンクしない。そのため、ほとんどのツールからは見つからない) brew upgrade の最後に出る案内(Caveats)にも、次の一文がありました(後述)。 For the system Java wrappers to find this JDK, symlink it with つまり、/usr/bin/java(macOS のラッパー)や java_home から見えるようにするには、別途シンボリックリンクを作る必要があります。手元の Mac では作っていなかったので、java_home からは見えていませんでした。 「java_home で何も出ないから、Java は入っていない=今回の注意喚起は関係ない」と判断すると、見落とします。 私の環境では、実際には PATH 経由で脆弱な版の Java が動いていました。確かめるときは、java -version と which -a java を合わせて見るのが確実です。 補足: 実行中の Java に版と配布元を聞く java -version のほかに、Java のプログラムから直接聞くこともできます。次のファイルを Hello.java として保存します。 public class Hello { public static void main(String[] args) { System.out.println(System.getProperty("java.version") + " " + System.getProperty("java.vendor")); } } Java 11 以降は、javac でコンパイルせずに java Hello.java で直接実行できます。更新後(後述)に実行した結果がこちらです。 $ java Hello.java 21.0.13 Homebrew インストール先まで知りたい場合は、-XshowSettings:properties でシステムプロパティを一覧できます(更新後の 9時05分に実行)。 $ java -XshowSettings:properties -version 2>&1 | grep -E '^\s*(java\.vendor|java\.home|java\.version|java\.runtime\.version) ' java.home = /opt/homebrew/Cellar/openjdk@21/21.0.13/libexec/openjdk.jdk/Contents/Home java.runtime.version = 21.0.13 java.vendor = Homebrew java.version = 21.0.13 4. brew upgrade で更新し、前後を比べる 更新できる版があるかを確かめる $ brew info openjdk@21 ==> openjdk@21: 21.0.12.1 → stable 21.0.13 (bottled) [keg-only] Development kit for the Java programming language https://openjdk.org/ (先頭の3行だけ抜粋しています) 21.0.12.1 → stable 21.0.13 と出ているので、21.0.13 に上げられます。 更新する $ brew upgrade openjdk@21 出力のうち、主なところを抜粋します。まず、依存しているパッケージ4つも一緒に更新されることが表示されます。 ==> Would upgrade 4 dependencies: freetype 2.14.3 -> 2.15.0 libunistring 1.4.2 -> 1.5 libx11 1.8.13 -> 1.8.14 libxrender 0.9.12 -> 0.9.13 ==> Would upgrade 1 requested outdated package openjdk@21 21.0.12.1 -> 21.0.13 (200.7MB) 最後に、古い版の削除と、案内(Caveats)が出ます。 ==> Cleanup Removing: /opt/homebrew/Cellar/openjdk@21/21.0.12.1... (599 files, 346.7MB) Removing: ~/Library/Caches/Homebrew/openjdk@21--21.0.12.1... (200.5MB) Removing: ~/Library/Caches/Homebrew/openjdk@21_bottle_manifest--21.0.12.1... (267.8KB) Disable this behaviour by setting `HOMEBREW_NO_INSTALL_CLEANUP=1`. Hide these hints with `HOMEBREW_NO_ENV_HINTS=1` (see `man brew`). ==> Caveats ==> openjdk@21 For the system Java wrappers to find this JDK, symlink it with sudo ln -sfn /opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk-21.jdk openjdk@21 is keg-only, which means it was not symlinked into /opt/homebrew, because this is an alternate version of another formula. If you need to have openjdk@21 first in your PATH, run: printf '%s\n' export\ PATH\=/opt/homebrew/opt/openjdk@21/bin:\$PATH >> ~/.zshrc For compilers to find openjdk@21 you may need to set: export CPPFLAGS=-I/opt/homebrew/opt/openjdk@21/include ==> Upgraded 1 requested outdated package openjdk@21 21.0.12.1 -> 21.0.13 気づいた点は3つです。 - 古い版は自動で消える: Cleanup で 21.0.12.1 の本体とキャッシュが削除されています。「何かあったら前の版に戻す」つもりなら、この動きは知っておいたほうがよいです(出力にあるとおり、HOMEBREW_NO_INSTALL_CLEANUP=1 で止められます) - sudo ln -sfn ... は java_home から見えるようにしたい場合の公式の案内: 3章の落とし穴を解消するためのものです。私は今回は実行していません。PATH と JAVA_HOME で使えているなら、必須ではありません - かかった時間: 8時59分38秒に始めて、8時59分59秒には更新後の確認まで終わっていました 更新後 $ java -version openjdk version "21.0.13" 2026-10-20 OpenJDK Runtime Environment Homebrew (build 21.0.13) OpenJDK 64-Bit Server VM Homebrew (build 21.0.13, mixed mode, sharing) | 項目 | 更新前 | 更新後 | |------|------|------| | 版番号 | 21.0.12.1 | 21.0.13 | | リリース日(java -version の表示) | 2026-08-18 | 2026-10-20 | | brew info | 21.0.12.1 → stable 21.0.13 | stable 21.0.13 | | /usr/libexec/java_home -V | Unable to locate a Java Runtime. | Unable to locate a Java Runtime.(変わらず) | java_home は、シンボリックリンクを作っていないので更新後も「見つからない」のままです。更新したかどうかの確認にも java_home は使えない、ということです。 なお、IDE やビルドツールのデーモンなど、更新前から起動しっぱなしの Java のプロセスは、起動したときの JVM で動き続けます。更新後は再起動しておくと確実です。 5. 21.0.13 にしても、まだ終わりではなかった(CVE-2026-91403) 更新を終えてから公式情報を読み直していて、もう1件あることに気づきました。 10月21日に追加で出た脆弱性 Oracle は 2026年10月21日に「Oracle Security Alert Advisory - CVE-2026-91403」(Rev 1)を出しています。 | 項目 | 内容 | |------|------| | CVE | CVE-2026-91403 | | コンポーネント | JSSE(Java の TLS の実装) | | CVSS 3.1 ベーススコア | 9.1(認証なしでネットワーク経由で悪用されうる) | | 影響を受ける Oracle Java SE | 8u511, 8u511-perf, 11.0.33, 17.0.21, 21.0.13, 25.0.5, 27.0.1 | OpenJDK 側も、同じ 2026/10/21 付けで advisory を出しています。 > The affected versions are 27.0.1, 25.0.5, 21.0.13, 17.0.21, 11.0.33, 8u512, and earlier. (訳: 影響を受ける版は 27.0.1、25.0.5、21.0.13、17.0.21、11.0.33、8u512 と、それ以前) さっき上げたばかりの 21.0.13 が、影響を受ける版に入っています。 何の脆弱性か OpenJDK の advisory によると、CVE-2026-91403 は、秘密鍵が漏えいした(compromised)8つの中間 CA 証明書に関するものです。advisory からリンクされている報告者側のページでは、eMudhra という認証局の中間 CA 証明書の秘密鍵が、証明書から復元できてしまう状態だったと説明されています。 この中間 CA を信頼したままだと、偽のサーバー証明書を信頼してしまうおそれがあります。ブラウザや OS 側ではすでに対応が進んでいますが、Java は Java 自身が持つ証明書の一覧(cacerts)と、ブロックする証明書の一覧(blocked.certs)で判断するため、Java の更新が必要になります。 OpenJDK の advisory には、Homebrew を名指しした一文があります。 > If you use an OpenJDK Java runtime provided by your operating system, then it most likely uses the operating system’s certificates. (中略) This is not true of the OpenJDK packages in FreeBSD and Homebrew, which must be updated directly. (訳: OS が提供する OpenJDK を使っているなら、OS の証明書を使っている可能性が高い。(中略:Debian や Ubuntu などの OpenJDK パッケージがこれに当たり、その場合は OS の証明書を更新すれば足りる、と続く)FreeBSD と Homebrew の OpenJDK パッケージはそうではないので、パッケージ自体を直接更新する必要がある) 手元の 21.0.13 で確かめたこと 更新後の Java の cacerts を keytool で見ると、報告者側のページが「この中間 CA の上位」として挙げている証明書のうち、「AAA Certificate Services」(別名 comodoaaaca)と「emSign ECC Root CA - G3」(別名 emsigneccrootcag3)が入っていました(9時06分に実行)。 $ keytool -list -cacerts -storepass changeit | grep -E 'comodoaaaca|emsigneccrootcag3' comodoaaaca [jdk],2026/10/22, trustedCertEntry, emsigneccrootcag3 [jdk],2026/10/22, trustedCertEntry, -storepass changeit は、JDK の cacerts の初期パスワードです(一覧を読むだけで、書き換えはしていません)。 また、報告者側のページが公開している中間 CA 証明書の SHA-256 の指紋を、手元の lib/security/blocked.certs と照合したところ、1件も載っていませんでした。 手元の 21.0.13 は、まだこの中間 CA をブロックしていない状態です。 10月22日の時点では、Homebrew に修正版がまだ無かった $ brew info openjdk@21 ==> openjdk@21: stable 21.0.13 (bottled) [keg-only] Development kit for the Java programming language (10月22日 9時02分に実行。先頭2行の抜粋) この時点では、Homebrew の openjdk@21 は 21.0.13 のままでした。修正版が出るまでの対処として、OpenJDK の advisory は次の2つを案内しています(私はどちらも試していません。手順の細部は advisory 本文を確認してください)。 - 配布されている blocked.certs をダウンロードして、$JDK_HOME/lib/security/blocked.certs を差し替える - 証明書の失効確認を有効にする(TLS を使うアプリなら、起動時に -Dcom.sun.net.ssl.checkRevocation=true -Dcom.sun.security.enableCRLDP=true を付ける) 10月25日、修正版 21.0.13.0.2 に上げて確かめた 3日後に見ると、Homebrew に修正版が来ていました。 $ brew info openjdk@21 ==> openjdk@21: 21.0.13 → stable 21.0.13.0.2 (bottled) [keg-only] 更新前に blocked.certs を控えてから、もう一度 brew upgrade openjdk@21 をしました(17時15分)。 $ brew upgrade openjdk@21 (抜粋) ==> Upgrading openjdk@21 21.0.13 -> 21.0.13.0.2 ==> Upgrading openjdk@21 dependency: brotli (1.2.0 -> 1.2.1) ==> Upgraded 1 requested outdated package openjdk@21 21.0.13 -> 21.0.13.0.2 $ java -version openjdk version "21.0.13.0.2" 2026-10-22 OpenJDK Runtime Environment Homebrew (build 21.0.13.0.2) OpenJDK 64-Bit Server VM Homebrew (build 21.0.13.0.2, mixed mode, sharing) 前の版と同じ Hello.java も、21.0.13.0.2 Homebrew と出力して動きました。 肝心の blocked.certs は、載っている指紋(Algorithm=SHA-256 の下の64桁の行)が 38件から86件に増えていました。報告者側のページが公開している中間 CA 証明書の一覧(impacted-intermediates.json。中間 CA 8つ分で16件、うち TLS 用が12件、コード署名用が4件)の SHA-256 の指紋と照合すると、16件すべてが新しい blocked.certs に載っていました。 | | 21.0.13 | 21.0.13.0.2 | |---|---:|---:| | blocked.certs の指紋の数 | 38 | 86 | | 報告者側の一覧(16件)のうち載っている数 | 0 | 16 | 増えた48件のうち、この16件以外が何の証明書かは確かめていません。照合はファイルの中身を比べただけで、偽の証明書を使った接続の試験まではしていません。 ちなみに、CVE-2026-91403 の悪用は、2026年10月25日時点で確認されていません。米国 CISA の「悪用が確認された脆弱性」の一覧(10月23日版)にも載っていません。 なお、2026年10月25日時点でも、IPA の「重要なセキュリティ情報」の一覧に CVE-2026-91403 の個別のページはありません。IPA の10月の注意喚起だけを見て「21.0.13 にしたから完了」とすると、この件を取りこぼします。 6. Windows や会社の端末で確かめるときの観点 ここは Mac 以外の環境の話で、私は検証していません。手順ではなく、確かめるときの観点として読んでください。 - まず版番号と配布元を見る: java -version の版番号と、2行目の Runtime Environment の後ろ(配布元)を見るのは、OS が違っても同じです。Windows では、PATH 上の java を全部出すのに where java が使えます - 「インストールされたアプリの一覧」だけで判断しない: Mac の java_home と同じで、登録の仕組みに乗っていない JDK は一覧に出ないことがあります。zip を展開しただけの JDK や、IDE(例: IntelliJ IDEA の JetBrains Runtime)・ビルドツールが自前で持っている JDK もありえます - MyJVN バージョンチェッカ for .NET: IPA が今回の注意喚起で案内しているツールです。ただし、何を検出の対象にしているかは確認できませんでした(執筆時点で配布ページがメンテナンス中でした)。Oracle 以外の配布まで見てくれるとは限らないので、java -version と合わせて確かめるのが安全です - ライセンスに注意(Oracle JDK を使っている場合): Oracle の JDK FAQ によると、Oracle JDK 21 は 2026年9月までのリリース(21.0.12 以前)が No-Fee Terms and Conditions License、それより後(21.0.13 以降)は OTN License Agreement for Java SE です。OTN ライセンスで無償なのは、個人利用・開発・テストなどに限られます。つまり、Oracle JDK 21 を使っている組織が「今回の注意喚起に従って更新」すると、ライセンスの条件が変わります。IPA も、商用利用する組織はライセンスを確認するよう書いています - 会社の端末は勝手に入れ替えない: どの配布・どの版を使うかを組織で決めていることがあります。自分で配布を乗り換える前に、管理している部門に確認するのが先です まとめ - IPA の注意喚起の対象は「Oracle Java SE」だが、OpenJDK 側も同じ日に、ほぼ同じ CVE の修正を出している。手元の Homebrew の OpenJDK 21.0.12.1 は、OpenJDK の advisory でも影響を受ける版だった - 自分の Java は java -version(版番号と配布元)と which -a java(実体の場所)で確かめる。/usr/libexec/java_home で「無い」と出ても、Java が無いとは限らない - Homebrew なら brew upgrade openjdk@21 で、1分かからずに 21.0.13 に上がった。古い版は自動で消える - ただし、10月21日に出た CVE-2026-91403(CVSS 9.1)は 21.0.13 も対象。Homebrew は OpenJDK の advisory で「パッケージ自体を更新する必要がある」と名指しされている。10月25日に来た 21.0.13.0.2 に上げると、blocked.certs に問題の中間 CA 証明書16件がすべて載った - 配布ごとに修正版が出るタイミングは違う。Oracle JDK を使っている組織は、更新でライセンスの条件が変わる点にも注意 「Oracle」とタイトルに付いた注意喚起でも、Java を使っているなら一度は自分の版を確かめる価値がありました。 参考 - Oracle Java の脆弱性対策について(2026年10月) | IPA - 重要なセキュリティ情報 2026年度 | IPA - Oracle Critical Patch Update Advisory - October 2026 - Oracle Security Alert Advisory - CVE-2026-91403 - OpenJDK Vulnerability Advisory: 2026/10/20 - OpenJDK Vulnerability Advisory: 2026/10/21 - OpenJDK Vulnerability Advisories(一覧) - OpenJDK Vulnerability Group - Oracle JDK FAQ(ライセンス) - GNU General Public License, version 2, with the Classpath Exception | OpenJDK - Eclipse Temurin のリリース | Adoptium - openjdk@21 | Homebrew Formulae - What does "keg-only" mean? | Homebrew Documentation - eMudhra Intermediate CA Compromise(OpenJDK の 2026/10/21 advisory からリンクされている報告者側のページ) - MyJVN バージョンチェッカ for .NET | JVN iPedia - Known Exploited Vulnerabilities Catalog | CISA --- @kotaro_ai_lab AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!

    1 like

Achievements

Awards earned by the repositories openjdk owns. Select an award to embed its badge.

No awards yet

Repositories earn awards by reaching the top three in a completed trending day or week.

A repository earns an award for finishing first, second or third in a completed trending day or week, ranked by new stars, new forks or pull request authors. Awards follow a repository through renames and transfers and count toward its current owner. Weeks run Monday to Sunday, UTC.