Trino 慢节点
jdk#28966
2 views
実はSAにはvalue objectを含むヒープダンプを生成できないという解析する側にとっては困る問題(というか先送りタスク)があったのですが、直しておきました。これでjhsdb jmapを安心して使える。JDK 28のリリースに間に合ってよかった。 jdk#33395
1 like · 122 views
"retired" を "retierd" と書くというしょうもない間違いをそっと直してくれるOpenJDKのSAチームが私は大好きです(そもそもそんなスペルミスすんな!): jdk#33527
5 likes · 146 views
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.
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 にしている。 ↩︎
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.
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
OpenJDK randomly(?) removes all eMudhra root CAs
1 point · 3 comments
54 of My Apps Carry Intel Code; Two of Them Run It
"Is this app Intel?" has a ready answer in macOS: select the app in Finder, hit
Get Info, read the "Kind" line. It will say "Application (Universal)", or
"Application (Intel)", or "Application (Apple silicon)". A good enough answer
for a user. My question was a little different: how many bytes of Intel code
sit inside that bundle? The Get Info window doesn't say.
So I sat down to count. Across the 64 app bundles under /Applications I walked
601,358 files, 6,149 of which turned out to be Mach-O binaries, and I read the
header of every one of them byte by byte. Here is the tally: 54 app bundles
contain x86_64 code, 6.95 GiB of it in total. This machine is an Apple M4
Pro, so it cannot execute Intel code natively. With two exceptions.
I didn't start out to reclaim disk space, but by the end I had something more
interesting than a cleanup plan: the thing we call "the transition is done" is
not done inside the binaries. And the largest share of it isn't where I can
delete it.
Anatomy of a fat file
A universal binary is one file that holds the complete code of more than one
architecture. Apple's transition guide says so plainly, and it asks developers
to turn not only apps but "all of your compiled code" universal: plug-ins,
custom frameworks, dynamic libraries, command-line tools, daemons. That is why
2,286 of the 2,533 fat files on my disk hold both an Intel and an ARM slice.
(In that count ARM means arm64 and arm64e only; two old Xcode archives that
carry arm plus i386 plus x86_64 fall outside it.)
The format is defined in fat.h inside cctools. The file opens with the magic
number 0xcafebabe, followed by a count of slices, then one record per slice
with its architecture code, its offset in the file and its length. One note in
that header matters: these structures are always written big-endian, so if
you read them in your machine's own byte order you get nonsense.
The same header describes a second format: when a slice is larger than 4 MB
or a slice's offset within the file passes 4 MB, the 64-bit layout with
0xcafebabf takes over. My script recognizes both magic numbers. It found not a
single one of the second kind: all 2,533 fat files on this disk are
0xcafebabe.
Why that is, fat.h says in its own words — the 64-bit format is flagged as "a
work in progress and not yet fully supported in all the Apple Developer Tools".
The condition is written down and not applied. The counter-example is in this
article's own diagram:
!Diagram
Those numbers come from Google Chrome's framework file. Of 519,576,720 bytes,
271,164,912 — 52.2 percent — is Intel. The second slice starts at byte
271,187,968, so both the size and the offset are sixty times the threshold, and
the layout is still 32-bit. More than half of Chrome's framework is code that
will never run on this machine.
The inspection command in Apple's documentation is this one:
% lipo -archs /System/Applications/Mail.app/Contents/MacOS/Mail
x86_64 arm64
Running the same command on this machine today gives a slightly different
answer:
% lipo -archs /System/Applications/Mail.app/Contents/MacOS/Mail
x86_64 arm64e
arm64e is the ARM slice with pointer authentication enabled; Apple compiles
its own system binaries that way. The doc prints arm64, the machine says
arm64e. Same mechanism, mismatched label. I can't claim the document was once
right and went stale — no record of mine shows that; all I can say is that today
the example and the machine disagree. Which is a fair warning about every sample
output you copy and paste.
The tally
I added up the slices of all 6,149 Mach-O files by architecture. The slices come
to 23.78 GiB:
| Architecture | Uncompressed | Compressed | Total |
|---|---|---|---|
| arm64 | 12,242.8 MiB | 4,750.6 MiB | 16,993.4 MiB |
| x86_64 | 5,769.6 MiB | 1,349.3 MiB | 7,118.9 MiB |
| arm64e | 0.1 MiB | 175.5 MiB | 175.6 MiB |
| arm64_32 | 0.0 MiB | 62.5 MiB | 62.5 MiB |
| arm | 0.0 MiB | 2.2 MiB | 2.2 MiB |
| i386 | 0.0 MiB | 0.45 MiB | 0.45 MiB |
One row is missing from that table: a 0.9 MiB entry whose architecture number my
script did not recognize. It gets its own section below. The arm64_32 row was
in the same state — the script printed cpu33554444 and I named it by hand: the
watchOS architecture, and all 62.5 MiB of it comes from inside Xcode. I own no
watch, but I do own a watch development environment. arm is 32-bit ARM: 2.2 MiB
of an older world, still here.
The Intel share (x86_64 plus i386) is 6.95 GiB, 29.2 percent of all slice bytes.
Here is the denominator too: the whole /Applications folder is 54.29 GiB, so
Intel code is 12.8 percent of my applications. One byte in eight.
Split by main executable, the picture is this: of 64 apps, 24 are ARM-only, 38
carry both ARM and Intel, and 2 are Intel-only. Look at the whole bundle and the
number climbs to 54, because an app with a clean main binary can still ship a fat
helper library inside. Only 10 apps are entirely free of Intel code.
The eight heaviest Intel loads:
| App | Intel slices |
|---|---|
| Google Chrome | 965.9 MiB |
| OneDrive | 685.0 MiB |
| Microsoft Outlook | 510.7 MiB |
| Microsoft Teams | 499.7 MiB |
| Xcode | 448.9 MiB |
| Microsoft Word | 441.1 MiB |
| Microsoft PowerPoint | 427.9 MiB |
| Microsoft Excel | 392.4 MiB |
Chrome's figure is inflated by one more thing: three separate versions of its
framework sit on disk (154.0.8037.98, 155.0.8059.85, 155.0.8059.86), each around
495 MiB. It is reasonable for a browser update not to delete the old version
immediately — you may need to roll back. Still, three versions times half-Intel
gives you pause.
First correction: what the file says is not what the disk holds
At this point it was tempting to write "6.95 GiB wasted" and move on. I'm glad I
ran one more check: a file's logical size and the blocks actually allocated to it
are different numbers. The logical total of those 6,149 Mach-O files is 23.84
GiB; the allocated blocks add up to 19.93 GiB. (The 56 MiB gap against the slice
total is fat headers plus the alignment padding between slices; fat.h notes
that padding may be stored as holes where the filesystem supports them.) So
where did the missing 3.9 GiB go?
Into filesystem compression. 2,333 files have the UF_COMPRESSED flag set, and
their 6.21 GiB of logical content occupies 2.29 GiB of blocks on disk. So the
accurate sentence is this: 5.63 GiB of the Intel slices live in uncompressed
files, byte for byte on disk; the remaining 1.32 GiB sit inside compressed files
and take up less room there. Applying the compression ratio from above to that
1.32 GiB gives roughly 0.5 GiB — a derived figure, not a counted one, and I'm
flagging it as such.
I've fallen into this trap before: asking "how much free space is there" five
different ways on the same disk produced a
13.5 GiB spread.
When someone quotes me a single number about a machine now, my reflex is to ask
what that number is counting.
Second correction: cafebabe doesn't belong to Mach-O alone
The table also had a row labelled cputype 16777239 — 0.93 MiB, apparently
irrelevant. I chased it anyway, because writing off something your tool doesn't
understand means writing off the other places where the tool is wrong.
The files turned out to be .metallib bundles inside Xcode: GPU shader
libraries. And here is the interesting part — lipo refuses to call them
Mach-O, while file does not.
% xxd -l 16 .../CoreImage.metallib
00000000: cafe babe 0000 0002 0100 0017 0000 0008
% lipo -archs .../CoreImage.metallib
lipo: not a mach-o '.../CoreImage.metallib'
% file .../CoreImage.metallib # shortened
Mach-O universal binary with 2 architectures: [air64_v24:...] [air64:...]
Same file, same magic number, two tools with two answers. The fat container is
not reserved for CPU code; Apple uses it for GPU code too, and air64 slices
live under the same header. My byte-level script counted a file the tool had
rejected. The error is 0.93 MiB, so the table survives — but it taught me not to
see 0xcafebabe and conclude "Mach-O" on the next pass. That same magic number
is also the signature of Java class files; OpenJDK's class file parser
identifies a file by those exact four bytes. I found no .class file in these
bundles, so that wasn't what polluted my table this time. It could have been.
Apple's own share, and a surprise of scale
Everything so far might read like a choice third-party developers made. So I ran
the same count under /System/Applications, across Apple's own 46 apps. 11,221
files, 201 binaries, 0.63 GiB of slices:
- arm64e: 330.2 MiB (51.1 percent)
- x86_64: 315.4 MiB (48.9 percent)
45 of the 46 apps carry Intel code. Mail, Notes, Music, Preview, Terminal — all
of them x86_64 arm64e. The heaviest are Books (46.3 MiB of Intel), Freeform
(45.5 MiB) and Music (36.7 MiB). Which also means the 54 in the title is scoped
to /Applications: the number of apps on this machine carrying Intel code is
really 99.
But calling 315 MiB "Apple's share" got the scale badly wrong, because system
apps are thin shells: the code mass lives in the shared cache. So I looked
there:
| File set | Logical | Blocks |
|---|---|---|
| dyld_shared_cache_x86_64* (9 files) | 5.35 GiB | 2.08 GiB |
| dyld_shared_cache_arm64e* (15 files) | 5.42 GiB | 1.91 GiB |
| aot_shared_cache.0..6 | 5.73 GiB | 2.17 GiB |
That directory shows up under two paths, but stat reports the same device and
inode for both: one copy, two doors. And the root volume is sealed —
diskutil apfs list says Sealed: Yes, and mount reports `/dev/disk3s1s1 on
/ (apfs, sealed, local, read-only, journaled)`.
Add it up and the picture inverts. Intel code I could delete: 6.95 GiB. Intel
code I cannot: 315.4 MiB plus 5.35 GiB, or 5.66 GiB logical, around 2.4 GiB
in blocks. The two are nearly the same weight. I came into this with a thesis
about third-party developers bloating my disk; the count talked me out of it.
This isn't one developer being careless, it's the platform's distribution model.
Can it be removed? An experiment
Curiosity turned practical: what happens to a bundle's signature if I thin it?
Apple's ditto has a documented flag for exactly this, and the man page puts it
in one line: "Thin Universal binaries to the specified architecture."
I ran the experiment on a small notarized app (Shottr, 6,780 KiB):
% ditto --arch arm64 /Applications/Shottr.app ./Shottr-thin.app
% du -sk ./Shottr-thin.app
3852 ./Shottr-thin.app
% lipo -archs ./Shottr-thin.app/Contents/MacOS/Shottr
arm64
% codesign -v ./Shottr-thin.app ; echo $?
0
% spctl -a -vv ./Shottr-thin.app
./Shottr-thin.app: accepted
source=Notarized Developer ID
The bundle went from 6,780 KiB to 3,852 KiB, 43.2 percent smaller. The signature
verified, Gatekeeper accepted it, notarization held. So each architecture slice
carries its own signature, and dropping one does not invalidate the other.
Let me state the limits of that experiment, because it is a narrow one: I never
launched the thinned copy, I ran codesign without --deep --strict, and I
never tried a large bundle with frameworks of its own inside. What it proves is
that one notarized bundle's signature chain survived thinning — not that this
works everywhere.
Beyond that, I'm not putting it forward as a way to reclaim disk space, and I
haven't kept it in place. The first update brings the universal bundle right
back, so the space you won is rented. What an app's own updater does with a
thinned bundle is a separate question mark — a tool that ships delta updates may
not recognize it. And the thinnable part isn't actually 6.95 GiB: 6,673.1 MiB of
those Intel bytes live in fat files, but 446.2 MiB sit in 533 single-architecture
files, where dropping the code means dropping the app. The two biggest of those
are the two Intel-only apps themselves.
In short, on a 460 GiB disk with 125 GiB free, the prize doesn't earn a weekend.
Counting was instructive; cleaning wouldn't have been.
The date that actually matters
I said "with two exceptions" at the top. Those two — an old build of a media
player (VLC 3.0.23) and a serial-port tool — carry Intel code only, so when they
launch they run inside the Rosetta translation environment.
Let me be honest here: I never watched a running process. My evidence is a
property of the binaries — these two bundles have no ARM slice, so they cannot
run without translation. And "two" isn't a property of the machine, only the
count under the defaults: per Apple's documentation a user can force any
universal app to open under Rosetta from the Get Info window, and a developer can
reorder the preference with the LSArchitecturePriority key. The Intel slice in
those 38 universal apps isn't dead; it's asleep.
Rosetta is installed (/Library/Apple/usr/libexec/oah is in place) and pkgutil
dates the package's install time to 1 September 2026 — an OS update can refresh
that stamp too, so I'm not reading it as "the day I installed it by hand".
How the translation actually works is in Apple's platform security
documentation, and reading it made me correct my first draft. There are two
paths. At runtime, the kernel spots an x86_64 Mach object and hands control to a
Rosetta translation stub rather than to the dynamic linker, and pages are
translated inside the process. On the ahead-of-time path, every Mach object goes
through the AOT pipeline while macOS is being built — but what lands in the trust
cache is not translated code, it is a hash: three code directory hashes per
object (arm64, x86_64, and the AOT translation of x86_64). Apple states outright
that "the actual translated products don't ship with the operating system";
translation is deterministic, so the products are reconstituted on demand, signed
with a device-specific key managed by the Secure Enclave and kept in a vault only
the Rosetta service can reach.
The table above has an aot_shared_cache row: 5.73 GiB logical, 2.17 GiB in
blocks, with file dates matching the OS build. From the outside I can't tell
whether it arrived with the installer or was produced during installation; what I
can tell is that it is on my disk right now.
And this is where the calendar arrives. Apple announced on 1 September 2026:
> macOS 27: Final release to support Rosetta — Intel-only apps will no longer
> run on Mac computers with Apple silicon after this update.
The support page says the same thing in user language: Rosetta is available on
macOS 27 and earlier; from the next major release it remains only for certain
older, unmaintained games that rely on Intel-based frameworks. Note the timing:
macOS 27 Golden Gate shipped on 14 September 2026 and supports Apple silicon Macs
only. So the last release to carry Rosetta is already out; this machine is on
26.6.2, one major release behind. My two Intel-only apps don't have a distant
expiry date — it is one release away.
What strikes me is that the two numbers pull in opposite directions. The code
under calendar pressure amounts to 2 apps. The code taking up disk space
lives inside 54 apps and weighs 6.95 GiB. The second never runs, so Rosetta's
retirement won't touch it; it will simply stay there. The troublesome part is
small, and the heavy part travels quietly.
A natural question follows, and I don't know the answer: when Rosetta goes, do
the x86_64 slices go too? Will macOS 28 drop the x86_64 shared cache from the
sealed volume, will developers stop building universal, or will this 6.95 GiB be
dragged along for years? All Apple has said so far is that the translation ends;
nothing has been promised about what the binaries contain. The carve-out for
games even suggests some x86_64 plumbing stays in place.
If you want to count your own machine
The Get Info window is a fine start, but it says nothing about the helper
binaries inside a bundle. If you plan to go deeper, mind four things:
1. Don't stop at the main binary. The file under Contents/MacOS/ may be
clean; the weight sits in frameworks, plug-ins and helper processes. In my
count the gap was between 38 and 54.
2. Separate logical size from block count. stat hands you both numbers; if
you don't know which one you're reporting, a 16 percent error follows you
around.
3. Don't treat every 0xcafebabe file as Mach-O. Compare lipo -archs
against file; when one refuses, look at what the other says.
4. Don't stop at the applications folder. The real mass is in the shared
cache and that volume is sealed; a number without its scope points at the
wrong culprit.
Finally, ask this: is anything actually running this code? For me the answer was
two. What sits in the other fifty-four bundles isn't load, it's luggage.
What's left
On the user's side the Apple silicon transition started in 2020 and looks long
finished; apps open, they run fast, nobody asks about architecture. Look inside
the binaries, though, and the transition hasn't ended, it has only gone
invisible. Carrying two architectures in one file is a clever compatibility
answer; the bill for that answer is split across every user's disk, and nobody
itemizes it.
The thing that stayed with me isn't the 6.95 GiB. It's how many times I reached
that number from the wrong place: the first count gave what the file says, the
second what the disk holds, the third what the tool got wrong, and the fourth
showed the real mass was never where I was looking. Someone quoting a single
number about their own machine usually doesn't know which question that number
answers. When I counted
the root certificate store
I ended up in the same place: once you start measuring, the work stops being
about finding the number and becomes about finding out what the number counts.
Official Sources
- Apple — Building a universal macOS binary
- Apple Developer News — Upcoming changes to Rosetta support for Intel-based macOS apps
- Apple Support — Using Intel-based apps on a Mac with Apple silicon
- Apple Platform Security — Rosetta 2 on a Mac with Apple silicon
- Apple Open Source — cctools, mach-o/fat.h
- OpenJDK — classFileParser.cpp
8 reactions
JDK 28 перестает дегать MALLOC по каждой мелочи
Начиная с JDK 28, API Foreign Function and Memory (FFM) сможет выделять небольшие объёмы памяти из повторно используемых пулов нативной памяти, если приложение использует Arena.ofConfined().
Главное преимущество этого нововведения — отсутствие необходимости изменять исходный код при существенном сокращении накладных расходов в распространённых сценариях использования FFM.
Выделение небольших объёмов нативной памяти — распространённая операция
Арены, привязанные к одному потоку (confined arenas), часто используются для временного хранения данных при вызовах нативных функций. Типичный пример:
try (Arena arena = Arena.ofConfined()) {
MemorySegment result = arena.allocate(ValueLayout.JAVA_INT);
nativeFunction.invokeExact(result); // Writes a result in the provided segment
return result.get(ValueLayout.JAVA_INT, 0);
}sourcecraft.dev
Этот подход обеспечивает детерминированное управление временем жизни памяти и строгие гарантии её доступности только одному потоку. После закрытия арены связанные с ней сегменты памяти немедленно становятся недоступными. В частности, такой шаблон часто применяется при обработке errno в системных вызовах.
До JDK 28 даже для выделения крошечного участка памяти мог потребоваться вызов стандартного нативного аллокатора и регистрация соответствующей операции очистки. Если нативной функции нужен лишь указатель, значение типа long или int либо короткая строка, накладные расходы на управление памятью могут оказаться выше затрат на непосредственный доступ к выделенному участку.
Анализ исходного кода и поведения среды выполнения, проведённый в ходе разработки этой оптимизации, подтвердил распространённость подобных сценариев. В одном из запусков тестового набора с инструментированием часть изолированных арен вообще не выделяла нативную память, а более 99,99% арен, выполнявших выделение, использовали менее 64 байт. Хотя нагрузку тестового набора нельзя считать репрезентативной для всех приложений, полученные результаты убедительно свидетельствовали в пользу небольшого пула с ленивой инициализацией.
Как работает пулинг
Каждый платформенный поток может лениво создавать и поддерживать небольшой кеш пулов нативной памяти. По умолчанию такой кеш способен хранить четыре пула по 64 байта каждый.
При создании арена не получает пул автоматически. Его получение откладывается до первого выделения памяти, для которого пулинг может оказаться полезным. Это важно, поскольку арены, не выделяющие память, не приводят к созданию нативных пулов.
После получения пул закрепляется исключительно за соответствующей ареной. Подходящие участки памяти последовательно выделяются из этого пула. Схематично процесс выглядит так:
64-byte pool
0 8 16 20 63
+----------+----------+-----+-------------------- ... --+
| pointer | long | int | remaining capacity ... |
+----------+----------+-----+-------------------- ... --+
sourcecraft.dev
Рисунок 1. Выделение небольших участков памяти из пула.
Если запрошенный участок не помещается в пул или для него требуется выравнивание, которое пул не может гарантировать, используется стандартный механизм выделения памяти. Таким образом, пулинг представляет собой оптимизацию, применяемую при подходящих условиях: большие блоки и участки с нестандартными требованиями к выравниванию продолжают выделяться прежним способом.
При закрытии арены использованная часть её пула очищается, после чего пул возвращается в кеш потока. Если кеш уже заполнен, пул освобождается через нативный аллокатор.
Каждая арена, получившая пул, владеет им эксклюзивно. Поэтому вложенные изолированные арены — например, когда внутри блока try-with-resources одной арены создаётся ещё один блок try-with-resources со второй ареной — остаются независимыми друг от друга.
Если свободного пула в кеше нет, арена пытается выделить собственный локальный пул. При закрытии арены этот пул либо добавляется в кеш, либо освобождается.
Виртуальные потоки
Создание отдельного кеша нативной памяти для каждого виртуального потока свело бы на нет одно из их главных преимуществ — минимальное потребление ресурсов. Поэтому виртуальный поток временно получает пул из кеша платформенного потока-носителя (carrier thread), на котором он в данный момент выполняется.
Виртуальный поток закрепляется за потоком-носителем (pinned) лишь на короткое время передачи пула. Затем пул отсоединяется от кеша потока-носителя и переходит в исключительное владение арены.
Благодаря этому виртуальный поток может безопасно мигрировать между потоками-носителями, пока арена остаётся открытой. После закрытия арены пул возвращается в кеш того потока-носителя, на котором в этот момент выполняется виртуальный поток. Это может быть уже другой поток-носитель.
Такая архитектура позволяет избежать создания кешей нативной памяти для потенциально миллионов виртуальных потоков, одновременно сохраняя эксклюзивное владение каждым пулом.
Сохранение гарантий безопасности
Пулинг не меняет правила времени жизни и доступности памяти, установленные API Arena.
После закрытия арены по-прежнему выполняются следующие условия:
Область действия арены больше не активна.
Доступ к её сегментам памяти невозможен.
Прежде чем пул станет доступен для повторного использования, его часть, задействованная предыдущей ареной, заполняется нулями. Вновь выделяемые пулы также изначально заполнены нулями.
Это сохраняет требование API, согласно которому нативные сегменты памяти, возвращаемые ареной, должны быть инициализированы нулями, и предотвращает раскрытие данных, записанных одной ареной, через другую арену, получившую тот же пул позднее.
Стандартные операции очистки выполняются до обнуления и освобождения пула. Такой порядок важен, поскольку операция очистки может на законных основаниях обращаться к нативной области памяти через сегмент, используемый для очистки.
Кешированные пулы, доступные для повторного использования, гарантированно освобождаются при завершении соответствующего платформенного потока или потока-носителя. Пулы, принадлежащие открытым аренам, отсоединены от кеша, поэтому завершение потока-носителя или миграция виртуального потока не приведут к их случайному освобождению.
Изменения в наблюдаемом поведении
Хотя время жизни сегментов памяти не меняется, время существования лежащих в их основе нативных выделений может измениться.
При закрытии арены, использующей пулинг, её память может возвращаться в кеш JDK, а не немедленно освобождаться через нативный аллокатор.
Поэтому приложения и диагностические инструменты не должны рассчитывать на однозначное соответствие между выделениями памяти ареной и нативными вызовами malloc или free.
Производительность
Микробенчмарки, проведённые в ходе разработки, показали значительное сокращение задержек при выделении небольших участков памяти в изолированных аренах.
В следующей таблице представлено ускорение при выделении 5 и 20 байт:
Платформа
Выделение 5 байт
Выделение 20 байт
Linux AArch64
12,3×
8,6×
Linux x64
6,8×
7,8×
macOS AArch64
18,6×
16,8×
Windows x64
16,8×
18,9×
Таблица 1. Ускорение на протестированных платформах.
Каждое значение представляет собой отношение среднего времени выполнения операции в JMH без пулинга к времени выполнения с пулингом. Чем выше показатель, тем лучше. Конфигурация бенчмарков и исходные результаты доступны в запросе на включение изменений OpenJDK PR #31365.
Отдельный запуск бенчмарков на компьютере Apple M4 под управлением macOS дал следующие результаты:
Benchmark (size) Mode Cnt Score Error Units
AllocTest.alloc_confined 5 avgt 30 1.052 ± 0.027 ns/op
AllocTest.alloc_confined 20 avgt 30 1.138 ± 0.023 ns/op
AllocTest.alloc_confined 100 avgt 30 19.044 ± 0.322 ns/op
AllocTest.alloc_confined 500 avgt 30 26.383 ± 0.714 ns/op
AllocTest.alloc_confined 2000 avgt 30 31.292 ± 0.253 ns/op
AllocTest.alloc_confined 8000 avgt 30 81.119 ± 0.957 ns/op
AllocTest.alloc_confined_no_pool 5 avgt 30 15.862 ± 0.226 ns/op
AllocTest.alloc_confined_no_pool 20 avgt 30 18.139 ± 0.262 ns/op
AllocTest.alloc_confined_no_pool 100 avgt 30 18.856 ± 0.281 ns/op
AllocTest.alloc_confined_no_pool 500 avgt 30 26.168 ± 0.232 ns/op
AllocTest.alloc_confined_no_pool 2000 avgt 30 31.209 ± 0.314 ns/op
AllocTest.alloc_confined_no_pool 8000 avgt 30 80.840 ± 0.804 ns/op
sourcecraft.dev
Результаты бенчмарков на Mac с процессором M4.
При выделении участков памяти, превышающих стандартный размер пула в 64 байта, производительность в целом оставалась на прежнем уровне, поскольку для таких операций используется стандартный механизм выделения.
Виртуальные потоки несут дополнительные накладные расходы на взаимодействие с потоками-носителями, однако и в этом случае пулинг обеспечивает существенное ускорение по сравнению с выделением памяти без использования пулов:
Benchmark (size) Mode Cnt Score Error Units
AllocTest.OfVirtual.alloc_confined 5 avgt 30 3.222 ± 0.087 ns/op
AllocTest.OfVirtual.alloc_confined 20 avgt 30 2.737 ± 0.021 ns/op
AllocTest.OfVirtual.alloc_confined 100 avgt 30 18.868 ± 0.199 ns/op
AllocTest.OfVirtual.alloc_confined 500 avgt 30 26.117 ± 0.240 ns/op
AllocTest.OfVirtual.alloc_confined 2000 avgt 30 31.407 ± 0.508 ns/op
AllocTest.OfVirtual.alloc_confined 8000 avgt 30 80.474 ± 0.676 ns/op
AllocTest.OfVirtual.alloc_confined_no_pool 5 avgt 30 15.672 ± 0.090 ns/op
AllocTest.OfVirtual.alloc_confined_no_pool 20 avgt 30 18.109 ± 0.250 ns/op
AllocTest.OfVirtual.alloc_confined_no_pool 100 avgt 30 18.791 ± 0.163 ns/op
AllocTest.OfVirtual.alloc_confined_no_pool 500 avgt 30 25.947 ± 0.174 ns/op
AllocTest.OfVirtual.alloc_confined_no_pool 2000 avgt 30 31.206 ± 0.440 ns/op
AllocTest.OfVirtual.alloc_confined_no_pool 8000 avgt 30 79.711 ± 0.576 ns/op
sourcecraft.dev
Результаты бенчмарков виртуальных потоков на Mac с процессором M4.
Как всегда, ускорение, продемонстрированное микробенчмарками, не означает, что производительность приложения в целом вырастет в той же пропорции.
Наибольший выигрыш ожидается в сценариях, где создание арены, выделение нативной памяти и её очистка составляют значительную часть затрат на выполнение короткой нативной операции.
Кто получит преимущества?
Эта оптимизация особенно актуальна для следующих сценариев:
Привязки FFM, создающие изолированную арену при каждом вызове нативной функции.
Автоматически генерируемые обёртки, подобные создаваемым с помощью jextract.
Небольшие значения и структуры языка C.
Нативные дескрипторы и выходные параметры, передаваемые через указатели.
Короткие строки и массивы байтов, передаваемые в нативный код.
Чувствительный к задержкам код, часто выполняющий недорогие нативные вызовы.
Следует учитывать, что преимущества распространяются только на арены, привязанные к одному потоку, и не затрагивают другие типы арен.
Прозрачная оптимизация
Наиболее полезные улучшения производительности нередко оказываются теми, ради которых разработчикам не приходится осваивать новый API или переписывать уже работающий код.
Изолированные арены с поддержкой пулинга сохраняют свойства Arena.ofConfined(): детерминированное управление временем жизни памяти, инициализацию нулями и ограничение доступа одним потоком.
Изменения происходят на более низком уровне: теперь при множестве небольших кратковременных выделений памяти можно избежать повторных обращений к нативному аллокатору.
Для FFM-приложений, использующих изолированные арены в качестве временного рабочего пространства с ограниченным временем жизни, это означает, что существующий код сохраняет все гарантии безопасности, но работает значительно быстрее.
Дополнительные материалы
Если вы хотите узнать больше о пулинге памяти в изолированных аренах, ознакомьтесь со следующими материалами:
JDK-8385697: Add a Pooled Confined Arena
OpenJDK pull request #31365
CSR JDK-8390770
Документация Foreign Function and Memory API
Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.
Mentions
Trino 慢节点 jdk#28966
実はSAにはvalue objectを含むヒープダンプを生成できないという解析する側にとっては困る問題(というか先送りタスク)があったのですが、直しておきました。これでjhsdb jmapを安心して使える。JDK 28のリリースに間に合ってよかった。 jdk#33395
"retired" を "retierd" と書くというしょうもない間違いをそっと直してくれるOpenJDKのSAチームが私は大好きです(そもそもそんなスペルミスすんな!): jdk#33527
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.
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 にしている。 ↩︎
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.
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活用や開発効率化について発信しています。フォローお気軽にどうぞ!
OpenJDK randomly(?) removes all eMudhra root CAs
54 of My Apps Carry Intel Code; Two of Them Run It "Is this app Intel?" has a ready answer in macOS: select the app in Finder, hit Get Info, read the "Kind" line. It will say "Application (Universal)", or "Application (Intel)", or "Application (Apple silicon)". A good enough answer for a user. My question was a little different: how many bytes of Intel code sit inside that bundle? The Get Info window doesn't say. So I sat down to count. Across the 64 app bundles under /Applications I walked 601,358 files, 6,149 of which turned out to be Mach-O binaries, and I read the header of every one of them byte by byte. Here is the tally: 54 app bundles contain x86_64 code, 6.95 GiB of it in total. This machine is an Apple M4 Pro, so it cannot execute Intel code natively. With two exceptions. I didn't start out to reclaim disk space, but by the end I had something more interesting than a cleanup plan: the thing we call "the transition is done" is not done inside the binaries. And the largest share of it isn't where I can delete it. Anatomy of a fat file A universal binary is one file that holds the complete code of more than one architecture. Apple's transition guide says so plainly, and it asks developers to turn not only apps but "all of your compiled code" universal: plug-ins, custom frameworks, dynamic libraries, command-line tools, daemons. That is why 2,286 of the 2,533 fat files on my disk hold both an Intel and an ARM slice. (In that count ARM means arm64 and arm64e only; two old Xcode archives that carry arm plus i386 plus x86_64 fall outside it.) The format is defined in fat.h inside cctools. The file opens with the magic number 0xcafebabe, followed by a count of slices, then one record per slice with its architecture code, its offset in the file and its length. One note in that header matters: these structures are always written big-endian, so if you read them in your machine's own byte order you get nonsense. The same header describes a second format: when a slice is larger than 4 MB or a slice's offset within the file passes 4 MB, the 64-bit layout with 0xcafebabf takes over. My script recognizes both magic numbers. It found not a single one of the second kind: all 2,533 fat files on this disk are 0xcafebabe. Why that is, fat.h says in its own words — the 64-bit format is flagged as "a work in progress and not yet fully supported in all the Apple Developer Tools". The condition is written down and not applied. The counter-example is in this article's own diagram: !Diagram Those numbers come from Google Chrome's framework file. Of 519,576,720 bytes, 271,164,912 — 52.2 percent — is Intel. The second slice starts at byte 271,187,968, so both the size and the offset are sixty times the threshold, and the layout is still 32-bit. More than half of Chrome's framework is code that will never run on this machine. The inspection command in Apple's documentation is this one: % lipo -archs /System/Applications/Mail.app/Contents/MacOS/Mail x86_64 arm64 Running the same command on this machine today gives a slightly different answer: % lipo -archs /System/Applications/Mail.app/Contents/MacOS/Mail x86_64 arm64e arm64e is the ARM slice with pointer authentication enabled; Apple compiles its own system binaries that way. The doc prints arm64, the machine says arm64e. Same mechanism, mismatched label. I can't claim the document was once right and went stale — no record of mine shows that; all I can say is that today the example and the machine disagree. Which is a fair warning about every sample output you copy and paste. The tally I added up the slices of all 6,149 Mach-O files by architecture. The slices come to 23.78 GiB: | Architecture | Uncompressed | Compressed | Total | |---|---|---|---| | arm64 | 12,242.8 MiB | 4,750.6 MiB | 16,993.4 MiB | | x86_64 | 5,769.6 MiB | 1,349.3 MiB | 7,118.9 MiB | | arm64e | 0.1 MiB | 175.5 MiB | 175.6 MiB | | arm64_32 | 0.0 MiB | 62.5 MiB | 62.5 MiB | | arm | 0.0 MiB | 2.2 MiB | 2.2 MiB | | i386 | 0.0 MiB | 0.45 MiB | 0.45 MiB | One row is missing from that table: a 0.9 MiB entry whose architecture number my script did not recognize. It gets its own section below. The arm64_32 row was in the same state — the script printed cpu33554444 and I named it by hand: the watchOS architecture, and all 62.5 MiB of it comes from inside Xcode. I own no watch, but I do own a watch development environment. arm is 32-bit ARM: 2.2 MiB of an older world, still here. The Intel share (x86_64 plus i386) is 6.95 GiB, 29.2 percent of all slice bytes. Here is the denominator too: the whole /Applications folder is 54.29 GiB, so Intel code is 12.8 percent of my applications. One byte in eight. Split by main executable, the picture is this: of 64 apps, 24 are ARM-only, 38 carry both ARM and Intel, and 2 are Intel-only. Look at the whole bundle and the number climbs to 54, because an app with a clean main binary can still ship a fat helper library inside. Only 10 apps are entirely free of Intel code. The eight heaviest Intel loads: | App | Intel slices | |---|---| | Google Chrome | 965.9 MiB | | OneDrive | 685.0 MiB | | Microsoft Outlook | 510.7 MiB | | Microsoft Teams | 499.7 MiB | | Xcode | 448.9 MiB | | Microsoft Word | 441.1 MiB | | Microsoft PowerPoint | 427.9 MiB | | Microsoft Excel | 392.4 MiB | Chrome's figure is inflated by one more thing: three separate versions of its framework sit on disk (154.0.8037.98, 155.0.8059.85, 155.0.8059.86), each around 495 MiB. It is reasonable for a browser update not to delete the old version immediately — you may need to roll back. Still, three versions times half-Intel gives you pause. First correction: what the file says is not what the disk holds At this point it was tempting to write "6.95 GiB wasted" and move on. I'm glad I ran one more check: a file's logical size and the blocks actually allocated to it are different numbers. The logical total of those 6,149 Mach-O files is 23.84 GiB; the allocated blocks add up to 19.93 GiB. (The 56 MiB gap against the slice total is fat headers plus the alignment padding between slices; fat.h notes that padding may be stored as holes where the filesystem supports them.) So where did the missing 3.9 GiB go? Into filesystem compression. 2,333 files have the UF_COMPRESSED flag set, and their 6.21 GiB of logical content occupies 2.29 GiB of blocks on disk. So the accurate sentence is this: 5.63 GiB of the Intel slices live in uncompressed files, byte for byte on disk; the remaining 1.32 GiB sit inside compressed files and take up less room there. Applying the compression ratio from above to that 1.32 GiB gives roughly 0.5 GiB — a derived figure, not a counted one, and I'm flagging it as such. I've fallen into this trap before: asking "how much free space is there" five different ways on the same disk produced a 13.5 GiB spread. When someone quotes me a single number about a machine now, my reflex is to ask what that number is counting. Second correction: cafebabe doesn't belong to Mach-O alone The table also had a row labelled cputype 16777239 — 0.93 MiB, apparently irrelevant. I chased it anyway, because writing off something your tool doesn't understand means writing off the other places where the tool is wrong. The files turned out to be .metallib bundles inside Xcode: GPU shader libraries. And here is the interesting part — lipo refuses to call them Mach-O, while file does not. % xxd -l 16 .../CoreImage.metallib 00000000: cafe babe 0000 0002 0100 0017 0000 0008 % lipo -archs .../CoreImage.metallib lipo: not a mach-o '.../CoreImage.metallib' % file .../CoreImage.metallib # shortened Mach-O universal binary with 2 architectures: [air64_v24:...] [air64:...] Same file, same magic number, two tools with two answers. The fat container is not reserved for CPU code; Apple uses it for GPU code too, and air64 slices live under the same header. My byte-level script counted a file the tool had rejected. The error is 0.93 MiB, so the table survives — but it taught me not to see 0xcafebabe and conclude "Mach-O" on the next pass. That same magic number is also the signature of Java class files; OpenJDK's class file parser identifies a file by those exact four bytes. I found no .class file in these bundles, so that wasn't what polluted my table this time. It could have been. Apple's own share, and a surprise of scale Everything so far might read like a choice third-party developers made. So I ran the same count under /System/Applications, across Apple's own 46 apps. 11,221 files, 201 binaries, 0.63 GiB of slices: - arm64e: 330.2 MiB (51.1 percent) - x86_64: 315.4 MiB (48.9 percent) 45 of the 46 apps carry Intel code. Mail, Notes, Music, Preview, Terminal — all of them x86_64 arm64e. The heaviest are Books (46.3 MiB of Intel), Freeform (45.5 MiB) and Music (36.7 MiB). Which also means the 54 in the title is scoped to /Applications: the number of apps on this machine carrying Intel code is really 99. But calling 315 MiB "Apple's share" got the scale badly wrong, because system apps are thin shells: the code mass lives in the shared cache. So I looked there: | File set | Logical | Blocks | |---|---|---| | dyld_shared_cache_x86_64* (9 files) | 5.35 GiB | 2.08 GiB | | dyld_shared_cache_arm64e* (15 files) | 5.42 GiB | 1.91 GiB | | aot_shared_cache.0..6 | 5.73 GiB | 2.17 GiB | That directory shows up under two paths, but stat reports the same device and inode for both: one copy, two doors. And the root volume is sealed — diskutil apfs list says Sealed: Yes, and mount reports `/dev/disk3s1s1 on / (apfs, sealed, local, read-only, journaled)`. Add it up and the picture inverts. Intel code I could delete: 6.95 GiB. Intel code I cannot: 315.4 MiB plus 5.35 GiB, or 5.66 GiB logical, around 2.4 GiB in blocks. The two are nearly the same weight. I came into this with a thesis about third-party developers bloating my disk; the count talked me out of it. This isn't one developer being careless, it's the platform's distribution model. Can it be removed? An experiment Curiosity turned practical: what happens to a bundle's signature if I thin it? Apple's ditto has a documented flag for exactly this, and the man page puts it in one line: "Thin Universal binaries to the specified architecture." I ran the experiment on a small notarized app (Shottr, 6,780 KiB): % ditto --arch arm64 /Applications/Shottr.app ./Shottr-thin.app % du -sk ./Shottr-thin.app 3852 ./Shottr-thin.app % lipo -archs ./Shottr-thin.app/Contents/MacOS/Shottr arm64 % codesign -v ./Shottr-thin.app ; echo $? 0 % spctl -a -vv ./Shottr-thin.app ./Shottr-thin.app: accepted source=Notarized Developer ID The bundle went from 6,780 KiB to 3,852 KiB, 43.2 percent smaller. The signature verified, Gatekeeper accepted it, notarization held. So each architecture slice carries its own signature, and dropping one does not invalidate the other. Let me state the limits of that experiment, because it is a narrow one: I never launched the thinned copy, I ran codesign without --deep --strict, and I never tried a large bundle with frameworks of its own inside. What it proves is that one notarized bundle's signature chain survived thinning — not that this works everywhere. Beyond that, I'm not putting it forward as a way to reclaim disk space, and I haven't kept it in place. The first update brings the universal bundle right back, so the space you won is rented. What an app's own updater does with a thinned bundle is a separate question mark — a tool that ships delta updates may not recognize it. And the thinnable part isn't actually 6.95 GiB: 6,673.1 MiB of those Intel bytes live in fat files, but 446.2 MiB sit in 533 single-architecture files, where dropping the code means dropping the app. The two biggest of those are the two Intel-only apps themselves. In short, on a 460 GiB disk with 125 GiB free, the prize doesn't earn a weekend. Counting was instructive; cleaning wouldn't have been. The date that actually matters I said "with two exceptions" at the top. Those two — an old build of a media player (VLC 3.0.23) and a serial-port tool — carry Intel code only, so when they launch they run inside the Rosetta translation environment. Let me be honest here: I never watched a running process. My evidence is a property of the binaries — these two bundles have no ARM slice, so they cannot run without translation. And "two" isn't a property of the machine, only the count under the defaults: per Apple's documentation a user can force any universal app to open under Rosetta from the Get Info window, and a developer can reorder the preference with the LSArchitecturePriority key. The Intel slice in those 38 universal apps isn't dead; it's asleep. Rosetta is installed (/Library/Apple/usr/libexec/oah is in place) and pkgutil dates the package's install time to 1 September 2026 — an OS update can refresh that stamp too, so I'm not reading it as "the day I installed it by hand". How the translation actually works is in Apple's platform security documentation, and reading it made me correct my first draft. There are two paths. At runtime, the kernel spots an x86_64 Mach object and hands control to a Rosetta translation stub rather than to the dynamic linker, and pages are translated inside the process. On the ahead-of-time path, every Mach object goes through the AOT pipeline while macOS is being built — but what lands in the trust cache is not translated code, it is a hash: three code directory hashes per object (arm64, x86_64, and the AOT translation of x86_64). Apple states outright that "the actual translated products don't ship with the operating system"; translation is deterministic, so the products are reconstituted on demand, signed with a device-specific key managed by the Secure Enclave and kept in a vault only the Rosetta service can reach. The table above has an aot_shared_cache row: 5.73 GiB logical, 2.17 GiB in blocks, with file dates matching the OS build. From the outside I can't tell whether it arrived with the installer or was produced during installation; what I can tell is that it is on my disk right now. And this is where the calendar arrives. Apple announced on 1 September 2026: > macOS 27: Final release to support Rosetta — Intel-only apps will no longer > run on Mac computers with Apple silicon after this update. The support page says the same thing in user language: Rosetta is available on macOS 27 and earlier; from the next major release it remains only for certain older, unmaintained games that rely on Intel-based frameworks. Note the timing: macOS 27 Golden Gate shipped on 14 September 2026 and supports Apple silicon Macs only. So the last release to carry Rosetta is already out; this machine is on 26.6.2, one major release behind. My two Intel-only apps don't have a distant expiry date — it is one release away. What strikes me is that the two numbers pull in opposite directions. The code under calendar pressure amounts to 2 apps. The code taking up disk space lives inside 54 apps and weighs 6.95 GiB. The second never runs, so Rosetta's retirement won't touch it; it will simply stay there. The troublesome part is small, and the heavy part travels quietly. A natural question follows, and I don't know the answer: when Rosetta goes, do the x86_64 slices go too? Will macOS 28 drop the x86_64 shared cache from the sealed volume, will developers stop building universal, or will this 6.95 GiB be dragged along for years? All Apple has said so far is that the translation ends; nothing has been promised about what the binaries contain. The carve-out for games even suggests some x86_64 plumbing stays in place. If you want to count your own machine The Get Info window is a fine start, but it says nothing about the helper binaries inside a bundle. If you plan to go deeper, mind four things: 1. Don't stop at the main binary. The file under Contents/MacOS/ may be clean; the weight sits in frameworks, plug-ins and helper processes. In my count the gap was between 38 and 54. 2. Separate logical size from block count. stat hands you both numbers; if you don't know which one you're reporting, a 16 percent error follows you around. 3. Don't treat every 0xcafebabe file as Mach-O. Compare lipo -archs against file; when one refuses, look at what the other says. 4. Don't stop at the applications folder. The real mass is in the shared cache and that volume is sealed; a number without its scope points at the wrong culprit. Finally, ask this: is anything actually running this code? For me the answer was two. What sits in the other fifty-four bundles isn't load, it's luggage. What's left On the user's side the Apple silicon transition started in 2020 and looks long finished; apps open, they run fast, nobody asks about architecture. Look inside the binaries, though, and the transition hasn't ended, it has only gone invisible. Carrying two architectures in one file is a clever compatibility answer; the bill for that answer is split across every user's disk, and nobody itemizes it. The thing that stayed with me isn't the 6.95 GiB. It's how many times I reached that number from the wrong place: the first count gave what the file says, the second what the disk holds, the third what the tool got wrong, and the fourth showed the real mass was never where I was looking. Someone quoting a single number about their own machine usually doesn't know which question that number answers. When I counted the root certificate store I ended up in the same place: once you start measuring, the work stops being about finding the number and becomes about finding out what the number counts. Official Sources - Apple — Building a universal macOS binary - Apple Developer News — Upcoming changes to Rosetta support for Intel-based macOS apps - Apple Support — Using Intel-based apps on a Mac with Apple silicon - Apple Platform Security — Rosetta 2 on a Mac with Apple silicon - Apple Open Source — cctools, mach-o/fat.h - OpenJDK — classFileParser.cpp
JDK 28 перестает дегать MALLOC по каждой мелочи Начиная с JDK 28, API Foreign Function and Memory (FFM) сможет выделять небольшие объёмы памяти из повторно используемых пулов нативной памяти, если приложение использует Arena.ofConfined(). Главное преимущество этого нововведения — отсутствие необходимости изменять исходный код при существенном сокращении накладных расходов в распространённых сценариях использования FFM. Выделение небольших объёмов нативной памяти — распространённая операция Арены, привязанные к одному потоку (confined arenas), часто используются для временного хранения данных при вызовах нативных функций. Типичный пример: try (Arena arena = Arena.ofConfined()) { MemorySegment result = arena.allocate(ValueLayout.JAVA_INT); nativeFunction.invokeExact(result); // Writes a result in the provided segment return result.get(ValueLayout.JAVA_INT, 0); }sourcecraft.dev Этот подход обеспечивает детерминированное управление временем жизни памяти и строгие гарантии её доступности только одному потоку. После закрытия арены связанные с ней сегменты памяти немедленно становятся недоступными. В частности, такой шаблон часто применяется при обработке errno в системных вызовах. До JDK 28 даже для выделения крошечного участка памяти мог потребоваться вызов стандартного нативного аллокатора и регистрация соответствующей операции очистки. Если нативной функции нужен лишь указатель, значение типа long или int либо короткая строка, накладные расходы на управление памятью могут оказаться выше затрат на непосредственный доступ к выделенному участку. Анализ исходного кода и поведения среды выполнения, проведённый в ходе разработки этой оптимизации, подтвердил распространённость подобных сценариев. В одном из запусков тестового набора с инструментированием часть изолированных арен вообще не выделяла нативную память, а более 99,99% арен, выполнявших выделение, использовали менее 64 байт. Хотя нагрузку тестового набора нельзя считать репрезентативной для всех приложений, полученные результаты убедительно свидетельствовали в пользу небольшого пула с ленивой инициализацией. Как работает пулинг Каждый платформенный поток может лениво создавать и поддерживать небольшой кеш пулов нативной памяти. По умолчанию такой кеш способен хранить четыре пула по 64 байта каждый. При создании арена не получает пул автоматически. Его получение откладывается до первого выделения памяти, для которого пулинг может оказаться полезным. Это важно, поскольку арены, не выделяющие память, не приводят к созданию нативных пулов. После получения пул закрепляется исключительно за соответствующей ареной. Подходящие участки памяти последовательно выделяются из этого пула. Схематично процесс выглядит так: 64-byte pool 0 8 16 20 63 +----------+----------+-----+-------------------- ... --+ | pointer | long | int | remaining capacity ... | +----------+----------+-----+-------------------- ... --+ sourcecraft.dev Рисунок 1. Выделение небольших участков памяти из пула. Если запрошенный участок не помещается в пул или для него требуется выравнивание, которое пул не может гарантировать, используется стандартный механизм выделения памяти. Таким образом, пулинг представляет собой оптимизацию, применяемую при подходящих условиях: большие блоки и участки с нестандартными требованиями к выравниванию продолжают выделяться прежним способом. При закрытии арены использованная часть её пула очищается, после чего пул возвращается в кеш потока. Если кеш уже заполнен, пул освобождается через нативный аллокатор. Каждая арена, получившая пул, владеет им эксклюзивно. Поэтому вложенные изолированные арены — например, когда внутри блока try-with-resources одной арены создаётся ещё один блок try-with-resources со второй ареной — остаются независимыми друг от друга. Если свободного пула в кеше нет, арена пытается выделить собственный локальный пул. При закрытии арены этот пул либо добавляется в кеш, либо освобождается. Виртуальные потоки Создание отдельного кеша нативной памяти для каждого виртуального потока свело бы на нет одно из их главных преимуществ — минимальное потребление ресурсов. Поэтому виртуальный поток временно получает пул из кеша платформенного потока-носителя (carrier thread), на котором он в данный момент выполняется. Виртуальный поток закрепляется за потоком-носителем (pinned) лишь на короткое время передачи пула. Затем пул отсоединяется от кеша потока-носителя и переходит в исключительное владение арены. Благодаря этому виртуальный поток может безопасно мигрировать между потоками-носителями, пока арена остаётся открытой. После закрытия арены пул возвращается в кеш того потока-носителя, на котором в этот момент выполняется виртуальный поток. Это может быть уже другой поток-носитель. Такая архитектура позволяет избежать создания кешей нативной памяти для потенциально миллионов виртуальных потоков, одновременно сохраняя эксклюзивное владение каждым пулом. Сохранение гарантий безопасности Пулинг не меняет правила времени жизни и доступности памяти, установленные API Arena. После закрытия арены по-прежнему выполняются следующие условия: Область действия арены больше не активна. Доступ к её сегментам памяти невозможен. Прежде чем пул станет доступен для повторного использования, его часть, задействованная предыдущей ареной, заполняется нулями. Вновь выделяемые пулы также изначально заполнены нулями. Это сохраняет требование API, согласно которому нативные сегменты памяти, возвращаемые ареной, должны быть инициализированы нулями, и предотвращает раскрытие данных, записанных одной ареной, через другую арену, получившую тот же пул позднее. Стандартные операции очистки выполняются до обнуления и освобождения пула. Такой порядок важен, поскольку операция очистки может на законных основаниях обращаться к нативной области памяти через сегмент, используемый для очистки. Кешированные пулы, доступные для повторного использования, гарантированно освобождаются при завершении соответствующего платформенного потока или потока-носителя. Пулы, принадлежащие открытым аренам, отсоединены от кеша, поэтому завершение потока-носителя или миграция виртуального потока не приведут к их случайному освобождению. Изменения в наблюдаемом поведении Хотя время жизни сегментов памяти не меняется, время существования лежащих в их основе нативных выделений может измениться. При закрытии арены, использующей пулинг, её память может возвращаться в кеш JDK, а не немедленно освобождаться через нативный аллокатор. Поэтому приложения и диагностические инструменты не должны рассчитывать на однозначное соответствие между выделениями памяти ареной и нативными вызовами malloc или free. Производительность Микробенчмарки, проведённые в ходе разработки, показали значительное сокращение задержек при выделении небольших участков памяти в изолированных аренах. В следующей таблице представлено ускорение при выделении 5 и 20 байт: Платформа Выделение 5 байт Выделение 20 байт Linux AArch64 12,3× 8,6× Linux x64 6,8× 7,8× macOS AArch64 18,6× 16,8× Windows x64 16,8× 18,9× Таблица 1. Ускорение на протестированных платформах. Каждое значение представляет собой отношение среднего времени выполнения операции в JMH без пулинга к времени выполнения с пулингом. Чем выше показатель, тем лучше. Конфигурация бенчмарков и исходные результаты доступны в запросе на включение изменений OpenJDK PR #31365. Отдельный запуск бенчмарков на компьютере Apple M4 под управлением macOS дал следующие результаты: Benchmark (size) Mode Cnt Score Error Units AllocTest.alloc_confined 5 avgt 30 1.052 ± 0.027 ns/op AllocTest.alloc_confined 20 avgt 30 1.138 ± 0.023 ns/op AllocTest.alloc_confined 100 avgt 30 19.044 ± 0.322 ns/op AllocTest.alloc_confined 500 avgt 30 26.383 ± 0.714 ns/op AllocTest.alloc_confined 2000 avgt 30 31.292 ± 0.253 ns/op AllocTest.alloc_confined 8000 avgt 30 81.119 ± 0.957 ns/op AllocTest.alloc_confined_no_pool 5 avgt 30 15.862 ± 0.226 ns/op AllocTest.alloc_confined_no_pool 20 avgt 30 18.139 ± 0.262 ns/op AllocTest.alloc_confined_no_pool 100 avgt 30 18.856 ± 0.281 ns/op AllocTest.alloc_confined_no_pool 500 avgt 30 26.168 ± 0.232 ns/op AllocTest.alloc_confined_no_pool 2000 avgt 30 31.209 ± 0.314 ns/op AllocTest.alloc_confined_no_pool 8000 avgt 30 80.840 ± 0.804 ns/op sourcecraft.dev Результаты бенчмарков на Mac с процессором M4. При выделении участков памяти, превышающих стандартный размер пула в 64 байта, производительность в целом оставалась на прежнем уровне, поскольку для таких операций используется стандартный механизм выделения. Виртуальные потоки несут дополнительные накладные расходы на взаимодействие с потоками-носителями, однако и в этом случае пулинг обеспечивает существенное ускорение по сравнению с выделением памяти без использования пулов: Benchmark (size) Mode Cnt Score Error Units AllocTest.OfVirtual.alloc_confined 5 avgt 30 3.222 ± 0.087 ns/op AllocTest.OfVirtual.alloc_confined 20 avgt 30 2.737 ± 0.021 ns/op AllocTest.OfVirtual.alloc_confined 100 avgt 30 18.868 ± 0.199 ns/op AllocTest.OfVirtual.alloc_confined 500 avgt 30 26.117 ± 0.240 ns/op AllocTest.OfVirtual.alloc_confined 2000 avgt 30 31.407 ± 0.508 ns/op AllocTest.OfVirtual.alloc_confined 8000 avgt 30 80.474 ± 0.676 ns/op AllocTest.OfVirtual.alloc_confined_no_pool 5 avgt 30 15.672 ± 0.090 ns/op AllocTest.OfVirtual.alloc_confined_no_pool 20 avgt 30 18.109 ± 0.250 ns/op AllocTest.OfVirtual.alloc_confined_no_pool 100 avgt 30 18.791 ± 0.163 ns/op AllocTest.OfVirtual.alloc_confined_no_pool 500 avgt 30 25.947 ± 0.174 ns/op AllocTest.OfVirtual.alloc_confined_no_pool 2000 avgt 30 31.206 ± 0.440 ns/op AllocTest.OfVirtual.alloc_confined_no_pool 8000 avgt 30 79.711 ± 0.576 ns/op sourcecraft.dev Результаты бенчмарков виртуальных потоков на Mac с процессором M4. Как всегда, ускорение, продемонстрированное микробенчмарками, не означает, что производительность приложения в целом вырастет в той же пропорции. Наибольший выигрыш ожидается в сценариях, где создание арены, выделение нативной памяти и её очистка составляют значительную часть затрат на выполнение короткой нативной операции. Кто получит преимущества? Эта оптимизация особенно актуальна для следующих сценариев: Привязки FFM, создающие изолированную арену при каждом вызове нативной функции. Автоматически генерируемые обёртки, подобные создаваемым с помощью jextract. Небольшие значения и структуры языка C. Нативные дескрипторы и выходные параметры, передаваемые через указатели. Короткие строки и массивы байтов, передаваемые в нативный код. Чувствительный к задержкам код, часто выполняющий недорогие нативные вызовы. Следует учитывать, что преимущества распространяются только на арены, привязанные к одному потоку, и не затрагивают другие типы арен. Прозрачная оптимизация Наиболее полезные улучшения производительности нередко оказываются теми, ради которых разработчикам не приходится осваивать новый API или переписывать уже работающий код. Изолированные арены с поддержкой пулинга сохраняют свойства Arena.ofConfined(): детерминированное управление временем жизни памяти, инициализацию нулями и ограничение доступа одним потоком. Изменения происходят на более низком уровне: теперь при множестве небольших кратковременных выделений памяти можно избежать повторных обращений к нативному аллокатору. Для FFM-приложений, использующих изолированные арены в качестве временного рабочего пространства с ограниченным временем жизни, это означает, что существующий код сохраняет все гарантии безопасности, но работает значительно быстрее. Дополнительные материалы Если вы хотите узнать больше о пулинге памяти в изолированных аренах, ознакомьтесь со следующими материалами: JDK-8385697: Add a Pooled Confined Arena OpenJDK pull request #31365 CSR JDK-8390770 Документация Foreign Function and Memory API Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.