After upgrading from Java 8, G1 stopped returning memory
When you move from Java 8 to any of Java 11 through 25, the GC the JVM picks when you don't specify one (the default GC) changes from Parallel to G1. A GC is the mechanism that collects the memory of objects no longer in use. Parallel and G1 are names of two kinds of GC.
With this change, container memory (cgroup memory.current) no longer drops after a load peak passes.
I ran the same program under the same load on each version and compared how much container memory changed after the peak[^env].
| Version | Change after the peak |
|---|---|
| Java 8 | −234.6 to −227.6MB |
| Java 11 | 0.0 to +2.9MB |
| Java 25 | +2.3 to +2.7MB |
In this article, the committed heap is the amount of heap the JVM has taken from the OS, and shrinking means reducing the committed heap and returning that memory to the OS. Memory stays high not because of a leak, but because G1 does not shrink the committed heap while load continues.
You can tell whether memory that stays high after a peak is a leak or the default GC's behavior from the JVM metrics and GC logs you already collect, without touching production. If you still want it to drop, add a setting to G1 or switch the GC back to Parallel.
[^env]: Docker on a GitHub Actions runner, with a 2GB, 2-CPU container and -Xmx1500m. A 5-second peak (simulating a sudden surge in traffic by allocating 1000MB of objects per second) was followed by 45 seconds of normal load, allocating 200MB of objects per second. Ranges are over 3 runs per version. Scripts and results are in show-your-work and the GitHub Actions run.
How GC works
Whether memory drops after a peak depends not on whether the GC ran, but on whether it shrank the committed heap.
Collecting vs. shrinking
The GC collects objects that are no longer in use and creates free space inside the heap. Collecting reduces the used heap, meaning the part of the committed heap that objects occupy. Whether the freed space is returned to the OS to shrink the committed heap is decided separately from collecting.
When a GC happens
The JVM splits the heap into a young generation and an old generation. Most new objects are placed in the young generation, and most of them become unused there. When the young generation fills up, a GC that collects only the young generation (young GC) runs. While the program keeps allocating, the young generation fills up again and again, so young GCs keep coming.
Objects that survive several young GCs are moved to the old generation. When the old generation fills up, the whole heap has to be collected. A GC that stops the program and collects the whole heap is called a full GC[^generations].
Two ways to pause
While the GC collects, the program is stopped, and requests that arrive in the meantime wait. GC designs differ in how they pause.
| | Pause all at once | Pause in small pieces |
|---|---|---|
| How it collects | Stops the program and collects in one go | Splits collection into pieces and runs part of it concurrently with the program |
| Single pause | Long | Short |
| Work the program gets done (throughput) | High | Somewhat lower, since the GC uses CPU |
| When Oracle's guide recommends it | Pauses of 1 second or longer are acceptable | Response time matters more than throughput |
| GC | Parallel | G1 |
The name Parallel comes from using multiple threads in parallel to collect while the program is stopped. It does not mean Parallel runs concurrently with the program[^gc-kinds].
To keep pauses short, G1 has a phase that scans live objects across the whole heap without stopping the program (concurrent marking). Concurrent marking ends with a short pause (Remark). It starts at the end of a young GC when old generation occupancy exceeds a threshold[^marking-start].
After concurrent marking, G1 collects part of the old generation together with the young generation in the following young GCs (mixed GCs). G1 does a full GC only when this collection can't keep up and memory runs short[^g1-mixed].
[^generations]: Oracle's HotSpot Virtual Machine Garbage Collection Tuning Guide, Generations (Java 25).
[^gc-kinds]: The same Oracle guide (Java 25): The Parallel Collector, Garbage-First (G1) Garbage Collector, and Selecting a Collector in Available Collectors.
[^marking-start]: In Java 25, the code that decides at the end of a young GC whether to start concurrent marking.
[^g1-mixed]: The same Oracle guide (Java 25), Garbage-First (G1) Garbage Collector: the Space-Reclamation Phase and the description of Full GC that follows.
What changed
Upgrading from Java 8 changes two things: the default GC, and what triggers shrinking the committed heap.
| Version | Default GC | Trigger for shrinking the committed heap |
|---|---|---|
| Java 8 | Parallel | Young GC (young generation only) |
| Java 11 | G1 | Full GC |
| Java 12 to 25 | G1 | Full GC and the end of concurrent marking |
The default GC
When no GC is specified, Java 8 picks Parallel, and Java 9 and later pick G1[^default-gc]. OpenJDK's stated reason for switching to G1 was that "limiting GC pause times is, in general, more important than maximizing throughput" for many users[^jep248].
Shrink triggers
Java 8's Parallel resizes the young generation on every young GC, and shrinks it[^parallel-shrink]. Young GCs keep coming while load continues, so the trigger for shrinking keeps coming too.
Java 11's G1 shrinks the committed heap only after a full GC[^jdk11-shrink]. G1 does a full GC only when collection can't keep up, so this trigger almost never comes.
From Java 12, G1 also shrinks the committed heap at the end of concurrent marking[^remark-shrink]. But concurrent marking starts only when the old generation is filling up, so under a load where newly allocated objects quickly become unused, this trigger doesn't come either[^check-shrink].
Periodic GC
According to the Java 12 change (JEP 346), G1 avoids full GCs as much as possible and starts concurrent marking only based on heap occupancy and allocation rate, so it often doesn't return heap memory to the OS unless prompted from outside.
This is a particular disadvantage in container environments where you pay for what you use, so Java 12 added a mechanism in which G1 starts concurrent marking on its own and shrinks the committed heap when no GC has happened for a while[^jep346]. This is called periodic GC.
At a set interval, G1 checks the time since the last GC. If at least one interval has passed, it starts concurrent marking and shrinks at the Remark that ends it. If not, it skips[^periodic-check]. Despite the name, it does not necessarily run a GC every interval.
For example, with a 10-second interval, if objects keep being allocated and young GCs happen less than a second apart, 10 seconds never pass since the last GC. During that time, periodic GC never runs.
The interval is set in milliseconds with G1PeriodicGCInterval. Setting it to 0 disables periodic GC. The default is 0, so periodic GC doesn't run unless you set it[^interval].
[^default-gc]: When Java 8 is started with -XX:+PrintCommandLineFlags, the flags line the JVM prints includes -XX:+UseParallelGC. Java 11 and Java 25 print a Using G1 line at the top of the GC log (in Java 11, [0.004s][info ][gc ] Using G1).
[^jep248]: The Motivation section of JEP 248: Make G1 the Default Garbage Collector.
[^parallel-shrink]: Java 8's Parallel resizes the young generation on every young GC.
[^jdk11-shrink]: Java 11's G1 resizes the committed heap as part of a full GC.
[^remark-shrink]: The change that made Remark shrink the heap is JDK-6490394 (Java 12). In Java 25, the committed heap is resized inside Remark.
[^check-shrink]: With 200MB of objects allocated per second after the peak, concurrent marking did not start after the peak on any of Java 11, 17, 21, or 25.
[^jep346]: The Motivation section of JEP 346: Promptly Return Unused Committed Memory from G1.
[^periodic-check]: Java 25's periodic GC check and the flag that makes it start concurrent marking by default (G1PeriodicGCInvokesConcurrent).
[^interval]: The definition of G1PeriodicGCInterval. It is a MANAGEABLE flag, so it can be changed on a running JVM.
With periodic GC enabled
With periodic GC enabled, it is skipped while load continues, and runs after load stops to shrink the committed heap.
I simulated two situations after a traffic surge subsides and compared them[^periodic-env].
| | Peak (5 seconds) | After the peak |
|---|---|---|
| Load continues | Traffic surges. Allocates 1000MB of objects per second | Normal requests keep coming. Keeps allocating 200MB of objects per second |
| Load stops | Same | Requests stop. Allocates nothing |
Both ran Java 25 with -XX:G1PeriodicGCInterval=10000, a 10-second interval.
While load continues
When load continues after the peak, the check at 10 seconds after startup writes the following line to the GC log. This line appears when -Xlog:gc+periodic=debug is set.
[10.017s][debug][gc,periodic ] Last GC occurred 505ms before which is below threshold 10000ms. Skipping.
The last GC was 505ms earlier, short of the 10000ms interval, so periodic GC was skipped[^periodic-check].
In the 45 seconds after the peak, the check ran 5 times, was skipped all 5 times, and periodic GC never ran. The committed heap didn't change from the end of the peak to the end of the run.
After load stops
When nothing was allocated after the peak, periodic GC ran at the check 20 seconds after startup. At the 10-second check, 10 seconds had not yet passed since the GC at the end of the peak. First, a line for the young GC that starts concurrent marking appears.
[20.016s][info ][gc ] GC(46) Pause Young (Concurrent Start) (G1 Periodic Collection) 195M->37M(377M) 1.104ms
(G1 Periodic Collection) marks this as a GC started by periodic GC. 195M->37M is the used heap before and after, and the 377M in parentheses is the committed heap[^log-format]. This young GC does not change the committed heap.
On the Remark line that follows, the committed heap in parentheses is 27M[^remark-shrink].
[20.019s][info ][gc ] GC(47) Pause Remark 37M->7M(27M) 0.478ms
The committed heap seen in JVM metrics (the sum of committed across the heap memory pools, read via MemoryPoolMXBean) dropped by the same amount[^runs].
| Measured at | Committed heap |
|---|---|
| Just before periodic GC | 377.0 to 432.0MB |
| After periodic GC | 27.0 to 30.0MB |
The container memory compared at the start (memory.current, sampled every second) dropped too. It went from 393.9 to 421.4MB at the sample just before periodic GC to 53.8 to 59.5MB at the last sample.
[^periodic-env]: 2GB, 2-CPU container, -Xmx1500m, no GC specified. In the load-continues runs, objects were allocated for 45 seconds after the peak. GC logs were captured with -Xlog:gc* and -Xlog:gc+periodic=debug. The committed heap table shows the sample just before periodic GC and the first sample after it.
[^runs]: The same test was run 3 times. Values shown as "X to Y" are the range across the 3 runs.
[^log-format]: How to read GC log lines ("used before GC" -> "used after GC" ("heap size")) in Generations in Oracle's GC tuning guide (Java 25).
Diagnosing your own environment
Use the JVM metrics and GC logs you already collect. For GC logs, the lines printed with -Xlog:gc are enough.
Which GC was selected
In JVM metrics, the heap shows up as several areas, and their names depend on the GC. These areas are called memory pools[^pools].
The pool name is in the id tag of jvm.memory.committed in Micrometer, and in the pool label of jvm_memory_pool_committed_bytes in the Prometheus Java client[^pool-metrics]. The names tell you which GC was selected in production.
| GC | Heap memory pools |
|---|---|
| Parallel | PS Eden Space, PS Survivor Space, PS Old Gen |
| G1 | G1 Eden Space, G1 Survivor Space, G1 Old Gen |
If you see the Parallel pools, the behavior described here doesn't apply. If you see neither, see the "Scope" section.
Is it a leak?
When memory doesn't drop, a leak is the first suspect. With a leak, the amount of live objects keeps growing over time even under constant load[^leak]. With the behavior described here, live objects stay few and only the committed heap is high.
To tell them apart, compare the committed heap with the used heap after GC. Read the committed heap as the sum of committed across the heap pools. Read the used heap after GC from the after-GC value on each GC line in the GC log. A pool's used value is not suitable for this comparison, because the Eden part goes up and down with every GC.
On Java 11 with continued load, the first young GC line after the peak was[^young-line]:
[6.877s][info ][gc ] GC(27) Pause Young (Normal) (G1 Evacuation Pause) 679M->89M(989M) 1.773ms
On this line, the 89M after the arrow is the used heap after GC, and the 989M in parentheses is the committed heap. Most of the committed heap is free, and collection is keeping up. The freed space just isn't being returned to the OS.
Live objects can't exceed the used heap after GC, so if the used heap after GC isn't growing, you don't need to suspect a leak.
However, a young GC collects only the young generation, so garbage piled up in the old generation stays in the used heap after a young GC. This value can creep up even without a leak.
If it is growing, the behavior described here doesn't explain it on its own. If you have values after a GC that also collects the old generation (a full GC, or a mixed GC after concurrent marking), look at those. If they keep growing too, or if no such GC has happened, investigate it as a leak[^leak].
GCs that trigger shrinking
Next, look in the GC log after the peak for lines from GCs that trigger shrinking the committed heap. The line names are the same in Java 11 and Java 25[^log-names]. Java 11 also prints Pause Remark, but it doesn't shrink the committed heap there.
| Version | GC lines that trigger shrinking |
|---|---|
| Java 11 | Pause Full |
| Java 12 to 25 | Pause Full, Pause Remark |
When concurrent marking starts, a Pause Young (Concurrent Start) line also appears before Pause Remark. If none of these appear, no shrink trigger has come. On Java 11, that settles it. On Java 12 and later, the remaining trigger is periodic GC.
Is periodic GC running?
On Java 12 and later, if there is no Pause Remark, periodic GC isn't running either. Periodic GC starts concurrent marking, so when it runs, Pause Remark appears.
If it isn't running, either the interval isn't set, or it is set but the check is skipped every time because load continues (see "With periodic GC enabled"). Besides the startup option, the interval can also be set on a running JVM with jcmd[^interval].
If all of the following hold, memory isn't dropping because G1 isn't getting a trigger to shrink the committed heap, not because of a leak.
- The pools are G1's
- The used heap after GC isn't growing
- There is no Pause Full after the peak, and on Java 12 and later, no Pause Remark either
[^pools]: The pool names are defined in the source of G1 and Parallel.
[^pool-metrics]: Both read values from MemoryPoolMXBean. JvmMemoryMetrics in Micrometer 1.13.0, JvmMemoryMetrics in the Prometheus Java client 1.3.1.
[^leak]: Oracle's Troubleshooting Guide, Troubleshoot Memory Leaks (Java 25).
[^log-names]: Java 11's Pause Full, Pause Young (Concurrent Start), and Pause Remark. Java 25's Pause Full. The other two for Java 25 appear in the logs in "With periodic GC enabled".
[^young-line]: The first young GC line after the peak, under the condition where 200MB of objects per second kept being allocated after the peak.
Fixes
If it isn't a leak but you still want memory to drop, either keep G1 and add a trigger to shrink when load stops, or go back to a GC that shrinks on every young GC.
| | Enable periodic GC | Go back to Parallel |
|---|---|---|
| Option | -XX:G1PeriodicGCInterval=<milliseconds> | -XX:+UseParallelGC |
| Shrink trigger | When no GC happens for longer than the interval | Every young GC |
| On a server whose load never stops | Doesn't shrink | Shrinks[^parallel-shrink-11] |
| When load stops right after the peak | Shrinks | Doesn't shrink[^scope-parallel] |
| On Java 11 | Not available[^unrecognized] | Available |
| Single pause | Stays short, as with G1 | Gets longer |
Which to choose depends on what you found in "Diagnosing your own environment" and on whether there are periods when load stops.
| What you found | Load stops at times | Load never stops |
|---|---|---|
| Java 11, no Pause Full | Upgrade to Java 12 or later and enable periodic GC, or go back to Parallel | Go back to Parallel |
| Java 12 or later, periodic GC not set | Enable periodic GC | Go back to Parallel |
| Periodic GC set, but no Pause Remark | Shorten the interval | Go back to Parallel |
Check for periods when load stops using the timestamp at the start of each GC log line. If there are periods with long gaps between GCs, periodic GC can run there.
Periodic GC runs when, at an interval check, at least one interval has passed since the last GC. Because the check comes only once per interval, it can take up to twice the interval from when load stops until it runs. Set the interval to less than half the length of the periods when load stops[^periodic-check].
Going back to Parallel makes each pause longer. How much longer depends on heap size and the amount of live objects. Before production, run the same load in a test environment with -XX:+UseParallelGC and compare the pause times at the end of each GC log line with those under G1.
[^unrecognized]: On Java 11, setting it makes the JVM print Unrecognized VM option 'G1PeriodicGCInterval=10000' and fail to start. Periodic GC is available from Java 12.
[^parallel-shrink-11]: Java 11's Parallel also resizes the young generation on every young GC. With -XX:+UseParallelGC on Java 11 and 200MB of objects per second allocated after the peak, the committed heap went from 369.0 to 380.0MB at the end of the peak to 103.5 to 120.5MB at the last sample, and container memory (memory.current) dropped by 141.4 to 145.5MB.
Scope
The tests in this article ran in a 2GB, 2-CPU container with no GC specified. The committed heap's upper limit is set by -Xmx, and its value at startup by -Xms. The tests did not set -Xms. The load was one where newly allocated objects quickly became unused. The conclusions don't apply in the following cases.
| Case | How it differs from the conclusions |
|---|---|
| Small containers (1 CPU, or less than 1792MB of memory) | Java 8 to 26 select Serial[^serial-select]. The Parallel and G1 discussion doesn't apply |
| -Xms is set | -Xms is also the lower bound of the committed heap, and G1 won't shrink below it. If it equals -Xmx, memory won't drop even with periodic GC[^scope-xms] |
| Parallel, with no GC after the peak | Parallel shrinks only during GC, so memory doesn't drop[^scope-parallel] |
| Load where objects survive and the old generation fills up | Concurrent marking starts, and G1 on Java 12 and later shrinks at its Remark. Memory may drop without periodic GC[^scope-old] |
| Java 26 | Both G1 and Parallel shrink after young GCs. Memory may drop without periodic GC[^scope-26] |
| Java 27 | G1 is selected even in small containers[^scope-27] |
You can tell Serial was selected if the memory pool names are Eden Space, Survivor Space, and Tenured Gen[^serial-pools], or if the following line appears in the GC log at startup[^serial-log].
[0.003s][info][gc ] Using Serial
Java 15 and later, 8u372 and later, and 11.0.16 and later can read cgroup v2 container limits. Earlier versions pick the GC based on the host's CPU and memory[^cgroup].
[^serial-select]: Only when there are 2 or more CPUs and memory is at least 2GB minus 256MB does the JVM select G1 on Java 9 and later or Parallel on Java 8. Java 8's check is the same.
[^scope-xms]: -Xms sets both the committed heap at startup and its lower bound (MinHeapSize). G1 does not shrink below MinHeapSize.
[^scope-parallel]: Parallel resizes during young GC on both Java 8 and Java 11.
[^scope-old]: G1 checks old generation occupancy at the end of a young GC to start concurrent marking, and shrinks at Remark.
[^scope-26]: Java 26's G1 (JDK-8238687) and Parallel (JDK-8338977).
[^scope-27]: JEP 523.
[^serial-pools]: Serial's memory pools.
[^serial-log]: On Java 25, this line appeared both when only the container memory was set to 1GB and when only the CPU count was set to 1.
[^cgroup]: The change to read cgroup v2 limits landed in Java 15 and was backported to 11.0.16 and 8u372.
SIGILL on Startup: How the JDK AOT Cache Crashed Axelix in Docker
Author: Artemiy Degtyarev
Imagine you are deploying an open source Java application in Docker. You pull the image, run it, and before the first
log line appears, the JVM dies with this:
# A fatal error has been detected by the Java Runtime Environment:
#
# SIGILL (0x4) at pc=..., pid=1, tid=...
#
# Problematic frame:
# v ~AdapterBlob
That is exactly what happened to some users of Axelix when they started Axelix
Master. SIGILL means illegal instruction: the CPU was asked to execute an instruction it does not support. That is
not something you expect from a Java application. Java is supposed to be "write once, run anywhere", and bytecode does not
contain CPU instructions.
We investigated it, fixed it, and wrote this article about it. It is a good story about a new JVM feature, the AOT
cache, and a small detail of its design that turned out to matter a lot for anyone who ships a pre-built Docker image.
What we saw
The bug was reported as #1744. The crash logs users sent us all
pointed to the same frame, ~AdapterBlob. Only the illegal instruction differed:
- On an ARM64 machine, the JVM crashed on ptrue p7.b. This is an SVE (Scalable Vector Extension) instruction,
and SVE was missing from the CPU features listed in the crash log.
- We also reproduced it ourselves on x86-64. We started Axelix 1.1.1 on an emulated CPU without AVX, and the JVM
crashed on vzeroupper, which is an AVX instruction.
So two different architectures crashed in the same place, both on a vector instruction the CPU did not have. As an
immediate mitigation, we removed the AOT cache from the Master image. Axelix 1.2.0 shipped without it. Then we went to
find out what was actually going on.
To understand that, we first need to look at how the AOT cache works.
What is the AOT cache?
Every time a JVM starts, it does a lot of the same work again. It reads classes from JARs, parses and verifies
bytecode, links classes, and runs static initializers. Then it runs the code in the interpreter for a while, collects
profiles, and only after that can the JIT compiler produce well-optimized machine code. For a Spring Boot application
with thousands of classes, this work is a large part of the startup time.
Project Leyden aims to reuse that work between runs. Its first result is the
AOT cache:
- JEP 483 (JDK 24) — classes are stored in the cache already loaded and linked.
- JEP 514 (JDK 25) — simpler command-line ergonomics.
- JEP 515 (JDK 25) — method execution profiles are stored too, so the JIT can start
optimizing earlier.
Training run → collect data → create app.aot
↓
Next runs ← use the data from app.aot
The three phases
Working with the AOT cache has three phases:
1. Training. You run the application with a representative workload. The JVM records which classes were loaded and
linked and how methods were executed.
2. Assembly. A separate JVM takes the recorded configuration and creates the cache file.
3. Production. Later runs start with -XX:AOTCache=... and use the prepared data instead of doing the work again.
In a Dockerfile this is a natural multi-stage build. A simplified version of the Axelix Master Dockerfile looks like
this:
FROM eclipse-temurin:25-jre-alpine AS training
WORKDIR /application
COPY build/libs/master.jar master.jar
# 1. Training: start the application up to the context refresh and record what it does
RUN java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf \
-Dspring.context.exit=onRefresh \
-jar master.jar
# 2. Assembly: create the cache from the recorded configuration
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. Production
ENTRYPOINT exec java -XX:AOTCache=app.aot -cp master.jar org.springframework.boot.loader.launch.JarLauncher
-Dspring.context.exit=onRefresh is a Spring Framework feature made for this purpose: the application starts, refreshes
the ApplicationContext, and exits. That is enough to warm up everything that matters for startup, and it does not need
a database or the network.
What is inside the cache?
In JDK 25, one AOT cache file contains several kinds of data:
- class metadata — classes that are already parsed, verified, and linked;
- method profiles — execution statistics collected during training;
- heap objects — some pre-built Java objects, for example the module graph;
- the AOT code cache — real machine code.
The first three are CPU-independent data structures. The last one is the source of our problem.
AOTAdapterCaching
To see what machine code ends up in the cache, we need to look a little deeper into HotSpot.
When a method is called for the first time, the interpreter executes its bytecode. When the method becomes hot, the
JIT compiles it into machine code. Interpreted and compiled code use different calling conventions. The interpreter
passes arguments on its own stack, while compiled code passes them in registers, as defined by the platform ABI. So the
JVM needs small pieces of glue code between them, called adapters:
- i2c (interpreted-to-compiled) — used when an interpreted method calls a compiled one;
- c2i (compiled-to-interpreted) — used when a compiled method calls a method that is still interpreted.
Adapters are generated per method signature, and a large application needs thousands of them. Generating them at
startup takes time, so JDK 25 added the AOTAdapterCaching option, which is on by default: the adapters are
generated during the assembly phase and stored in the AOT cache as ready-to-run machine code.
And here is the catch. Like any code that HotSpot generates, adapters are generated for the CPU the JVM is currently
running on. If that CPU supports AVX-512, the adapter may use AVX-512 registers. If it supports SVE, the adapter may
use SVE instructions. That code is correct on the build machine. On a machine without those features, the first call
through such an adapter executes an instruction the CPU does not know, and the kernel sends SIGILL.
Why the JVM didn't protect us
The JVM does validate the AOT cache. When the production run starts, it checks the JDK build, the operating system, the
CPU architecture (x64 vs aarch64), the classpath, the GC, compressed oops, and some other settings. If something does
not match, the JVM, in the default -XX:AOTMode=auto, prints a warning and continues without the cache. The startup
is slower, but nothing breaks.
In JDK 25, however, the JVM did not check one thing: CPU features within the same architecture. "x64" covers both a
modern server CPU with AVX-512 and an old laptop CPU without it. The cache was accepted, and the adapters inside it were
used as they were.
Now look at how our image was built. The release job runs on a GitHub-hosted runner and builds a multi-platform image:
docker build --platform linux/amd64,linux/arm64 --file master-oss/Dockerfile ...
- The amd64 variant is trained on whatever CPU the runner happens to have. GitHub runners are cloud VMs, and some of
them have AVX-512.
- The arm64 variant is trained under QEMU emulation (docker/setup-qemu-action), because the runner itself is
x86. QEMU user-mode emulation emulates its max CPU model by default, which includes SVE. However, many real ARM64
CPUs that people run containers on have no SVE: Apple Silicon Macs, AWS Graviton2, and Ampere Altra.
A multi-platform build runs the whole Dockerfile once per platform, so each variant of the image gets its own cache, and
docker pull picks the variant that matches the host. The architecture always matched. The CPU features within it did
not: our build machines had more of them than many of our users' machines. That is exactly the pattern that crashed.
The upstream bug
During the investigation we found
adoptium/adoptium-support#1507, "Launching with AOT cache
fatally crashes with SIGILL (0x4) depending on available CPU instructions". It was the same problem: a cache created on a
Zen 5 CPU with AVX-512 crashed in ~AdapterBlob on a Zen 2 CPU without AVX-512. The reporter found it the same way we
did: GitHub Actions runners sometimes have AVX-512 and sometimes do not.
The issue was moved to OpenJDK as JDK-8388703. The response from the
HotSpot developers was pragmatic. Adapters are the only AOT code in JDK 25 (stub caching is already off), and they do
not improve performance much. So instead of backporting CPU checks, they decided to simply turn adapter caching off by
default in JDK 25. That is the fix in 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);
The proper fix lives in JDK 27:
- JDK-8377507 — the CPU features of the assembly machine are stored in
the AOT code cache;
- JDK-8381975 — at startup the stored features must exactly match
the current CPU.
If they do not match, the JVM logs AOT Code Cache disabled: cpu features are incompatible, ignores only the machine
code, and keeps using everything else in the AOT cache. That is how it should work: the worst case is a little less
speed-up, never a crash.
Our fix
The fix is #1767. We brought the AOT cache back, but we create it
without adapters:
# 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
AOTAdapterCaching is a diagnostic flag, which is why -XX:+UnlockDiagnosticVMOptions is needed.
With this flag, the cache contains no machine code at all, and the JVM confirms it on startup:
[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)
What remains is class metadata, profiles, and heap objects, which are the same on any CPU of a given architecture. The
adapters are generated at runtime, on the user's machine, for the user's CPU. And removing them does not remove the
benefit. In a local check of the image built from the fixed Dockerfile, about 72% of the classes loaded during startup
(10,933 of 15,201) came from the AOT cache, and the time to context refresh dropped from about 7.3 s to about 5.4 s on
a developer laptop.
We discussed the alternatives in the PR. The first idea was to wait for JDK 25.0.5 and keep adapter caching on. But
25.0.5 does not make adapters safe. It simply turns them off, so the result is the same as our flag. The second idea
was to move to JDK 27, where the CPU check exists. Most people run Axelix Master in Docker or Kubernetes, so a new Java
baseline would not affect them. But Spring Boot 4.1 officially supports Java up to 26, and Java 27 support is expected
in Spring Boot 4.2. So we merged the explicit flag. It works on today's JDK 25 builds, and it stays correct after
25.0.5, where it simply matches the new default.
What about JDK 27?
Once Spring Boot 4.2 is out, we plan to move Axelix to JDK 27. There, turning AOTAdapterCaching back on is safe:
on a CPU that does not match, the JVM drops the cached code instead of crashing.
But "safe" is not the same as "useful". JDK 27 requires an exact match of CPU features. For an image built on a
GitHub runner, or trained under QEMU, the chance that a user's CPU has exactly the same feature set is small. For most
users the cached adapters would simply be thrown away. For a pre-built image to really benefit from AOT code, the JVM
needs to generate it for a generic, portable CPU baseline. There is an open enhancement for that,
JDK-8377630 "Restrict AOT code to a generic set of CPU features". Until
it lands, the AOT cache in a distributed image is best seen as a cache of data, not of code.
Takeaways
- The JDK AOT cache is a real and easy startup win for Spring Boot applications in Docker. A multi-stage Dockerfile with
a training stage is enough.
- On JDK 25 before 25.0.5, if you build the cache on one machine and run it on another, create it with
-XX:+UnlockDiagnosticVMOptions -XX:-AOTAdapterCaching. Otherwise your users' CPUs must have every feature your build
machine had. CI runners and QEMU usually have more.
- Multi-arch images get a separate cache per architecture automatically, because each platform runs its own training
and assembly stages.
- The JVM validates the AOT cache and falls back safely when something does not match. Machine code stored in the cache
is the one exception before JDK 27.
The fix will ship in the next Axelix release. If you are curious about the details, the whole story is in
#1744 and #1767.
You heard of "Code doesn't work on Tuesday", get ready for "Code doesn't work the morning after a daylight savings time change"
jdk#33760
2 likes · 104 views
Java 8 から上げたら G1 がメモリを解放しなくなった
Java 8 から Java 11〜25 に上げると、GC を指定しないときに選ばれる GC(既定の GC)が Parallel から G1 に変わる。GC は使われなくなったオブジェクトのメモリを片付ける仕組みで、Parallel と G1 はその種類の名前だ。
この変化で、負荷のピークが過ぎてもコンテナのメモリ(cgroup の memory.current)が下がらなくなる。
同じプログラムに同じ負荷をかけ、ピークのあとにコンテナのメモリがどれだけ変わったかをバージョンごとに比べた1。
バージョン
ピークのあとの変化
Java 8
−234.6〜−227.6MB
Java 11
0.0〜+2.9MB
Java 25
+2.3〜+2.7MB
ここでは、JVM が OS から取ってあるヒープの量を確保量、それを減らして OS に返すことを縮めると呼ぶ。下がらない原因はメモリリークではなく、G1 が負荷の続くあいだ確保量を縮めないことにある。
ピークのあとも下がらないメモリがリークか既定の GC の振る舞いかは、すでに集めている JVM のメトリクスと GC ログで、本番に触らずに見分けられる。それでも下げたいときは、G1 に設定を足すか、GC を Parallel に戻す。
GC の仕組み
ピークのあとにメモリが下がるかどうかは、GC が動いたかではなく、確保量を縮めたかで決まる。
片付けることと縮めること
GC は使われなくなったオブジェクトを片付けて、ヒープの中に空きを作る。片付けで減るのは使用量、つまり確保量の中でオブジェクトが占めている量だ。空いた分を OS に返して確保量を縮めるかどうかは、片付けとは別に決まる。
GC が来るとき
JVM はヒープを若い世代と古い世代に分けて使う。作られたオブジェクトの多くは若い世代に置かれ、その多くはそこで使われなくなる。若い世代が埋まると、若い世代だけを片付ける GC(young GC)が動く。オブジェクトを作り続けるあいだは若い世代が繰り返し埋まるので、young GC も繰り返し来る。
young GC を何度か生き延びたオブジェクトは、古い世代に移される。古い世代が埋まると、ヒープ全体を片付ける必要が出る。プログラムを止めてヒープ全体を片付ける GC を full GC と呼ぶ2。
2つの止め方
GC が片付けるあいだはプログラムが止まり、そのあいだに届いたリクエストは待たされる。GC の作りはこの止め方で分かれる。
まとめて止める
小分けに止める
片付け方
プログラムを止めて一度に片付ける
片付けを小分けにし、一部をプログラムと並行して進める
1回の停止
長い
短い
プログラムが進められる処理の量(スループット)
大きい
GC が CPU を使う分、少し下がる
Oracle のガイドが勧める場面
1秒以上の停止を許せる
スループットより応答時間が大事
GC
Parallel
G1
Parallel の名前は止めているあいだの片付けを複数のスレッドで並列に進めることから来ていて、Parallel がプログラムと並行して動くわけではない3。
停止を短く保つため、G1 はプログラムを止めずにヒープ全体の生きているオブジェクトを調べる処理(並行マーキング)を持つ。並行マーキングは最後に短い停止(Remark)を挟んで終わる。始まるのは、young GC の終わりに、古い世代の占有量がしきい値を超えたときだ4。
並行マーキングのあと、G1 は続く young GC で、若い世代と一緒に古い世代の一部も片付ける(mixed GC)。G1 が full GC をするのは、この片付けが追いつかずメモリが足りなくなったときに限られる5。
何が変わったか
Java 8 からバージョンを上げたときに変わるのは、既定の GC と、確保量を縮めるきっかけだ。
バージョン
既定の GC
確保量を縮めるきっかけ
Java 8
Parallel
young GC(若い世代の分)
Java 11
G1
full GC
Java 12〜25
G1
full GC と、並行マーキングの終わり
既定の GC
GC を指定しないとき、Java 8 は Parallel を、Java 9 からは G1 を選ぶ6。OpenJDK は G1 に替えた理由として、「多くの利用者には、スループットを最大にするより、GC の停止時間を抑えるほうが大事だ」を挙げた7。
縮めるきっかけ
Java 8 の Parallel は young GC のたびに若い世代の大きさを決め直し、縮める8。負荷が続くあいだも young GC は来るので、縮めるきっかけも来る。
Java 11 の G1 が確保量を縮めるのは、full GC のあとだけだ9。G1 の full GC は片付けが追いつかないときに限られるので、このきっかけはほとんど来ない。
Java 12 からは、並行マーキングの終わりでも確保量を縮める10。ただし並行マーキングが始まるのは古い世代が埋まってきたときなので、作ったオブジェクトがすぐ使われなくなる負荷では、このきっかけも来ない11。
周期 GC
Java 12 の変更(JEP 346)によると、G1 は full GC を極力避け、並行マーキングもヒープの占有量と、オブジェクトを作る速さからしか始めないので、外から促されない限りヒープを OS に返さないことが多い。
これは使った分だけ払うコンテナ環境で特に不利なので、Java 12 で、GC がしばらく起きていないときに G1 が自分から並行マーキングを始めて確保量を縮める仕組みが加わった12。これを周期 GC と呼ぶ。
G1 は決めた間隔ごとに直前の GC からの時間を見る。間隔以上たっていれば並行マーキングを始め、その終わりの Remark で縮める。たっていなければ飛ばす13。名前とは違い、間隔ごとに必ず GC するのではない。
たとえば間隔を 10 秒にしても、オブジェクトが作られ続けて young GC が 1 秒もたたずに次々起きていれば、直前の GC から 10 秒たつことがない。そのあいだ、周期 GC は一度も動かない。
間隔は G1PeriodicGCInterval にミリ秒で指定し、0 にすると周期 GC は動かない。既定値は 0 なので、指定しなければ動かない14。
周期 GC を付けたとき
周期 GC を付けると、負荷が続くあいだは飛ばされ、負荷が止んだあとに動いて確保量を縮める。
アクセスが急に増えて引いたあとの2つの状況を模して比べた15。
ピーク(5 秒)
ピークのあと
負荷が続く
アクセスが急に増える。毎秒 1000MB のオブジェクトを作る
普段のリクエストが来続ける。毎秒 200MB のオブジェクトを作り続ける
負荷が止む
同じ
リクエストが来なくなる。何も作らない
どちらも Java 25 に -XX:G1PeriodicGCInterval=10000 を付け、間隔を 10 秒にした。
負荷が続くあいだ
ピークのあとも負荷が続くと、起動から 10 秒の判定で、GC ログに次の行が出る。この行は -Xlog:gc+periodic=debug を付けたときに出る。
[10.017s][debug][gc,periodic ] Last GC occurred 505ms before which is below threshold 10000ms. Skipping.
直前の GC が 505ms 前で、間隔の 10000ms に届かないので、周期 GC を飛ばしたという行だ13:1。
ピークのあとの 45 秒間に、判定は 5 回あり、5 回とも飛ばされて、周期 GC は一度も動かなかった。確保量もピークの終わりから最後まで変わらなかった。
負荷が止んだあと
ピークのあとオブジェクトを作らないと、起動から 20 秒の判定で周期 GC が動いた。10 秒の判定の時点では、ピークの終わりの GC から 10 秒たっていなかった。まず、並行マーキングを始める young GC の行が出る。
[20.016s][info ][gc ] GC(46) Pause Young (Concurrent Start) (G1 Periodic Collection) 195M->37M(377M) 1.104ms
(G1 Periodic Collection) が、周期 GC が始めた GC であることを示す。195M->37M は使用量の前と後で、括弧の 377M が確保量だ16。この young GC では、確保量は変わらない。
続く Remark の行では、括弧の確保量が 27M になる10:1。
[20.019s][info ][gc ] GC(47) Pause Remark 37M->7M(27M) 0.478ms
JVM のメトリクスで見た確保量(MemoryPoolMXBean で取ったヒープのメモリプールの committed の合計)も、同じだけ下がった17。
計測した時点
確保量
周期 GC の直前
377.0〜432.0MB
周期 GC のあと
27.0〜30.0MB
冒頭で比べたコンテナのメモリ(memory.current、1 秒ごとに計測)も下がった。周期 GC の直前の計測で 393.9〜421.4MB だったのが、最後の計測では 53.8〜59.5MB になった。
自分の環境で見分ける
すでに集めている JVM のメトリクスと GC ログを使う。GC ログは -Xlog:gc を付けて出る行で足りる。
選ばれた GC
JVM のメトリクスでは、ヒープがいくつかの区画に分かれて見え、区画の名前は GC で決まる。この区画をメモリプールと呼ぶ18。
Micrometer なら jvm.memory.committed の id タグ、Prometheus の Java クライアントなら jvm_memory_pool_committed_bytes の pool ラベルに、プールの名前が入る19。名前を見れば、本番で選ばれた GC が分かる。
GC
ヒープのメモリプール
Parallel
PS Eden Space、PS Survivor Space、PS Old Gen
G1
G1 Eden Space、G1 Survivor Space、G1 Old Gen
Parallel のプールなら、この記事の現象ではない。どちらでもなければ、「適用範囲」の節を見る。
リークかどうか
下がらないメモリを見ると、まずリークを疑う。リークなら、負荷が一定でも、生きているオブジェクトの量が時間とともに増え続ける20。この記事の現象なら、生きているオブジェクトは少ないまま、確保量だけが高い。
見分けるには、確保量と、GC のあとの使用量を比べる。確保量はヒープのプールの committed の合計で見る。使用量は GC ログの各 GC の行にある GC のあとの値で見る。プールの used は、Eden の分が GC のたびに上下するので、比べるのに向かない。
Java 11 で負荷が続いたとき、ピークのあと最初の young GC の行は次のとおりだった21。
[6.877s][info ][gc ] GC(27) Pause Young (Normal) (G1 Evacuation Pause) 679M->89M(989M) 1.773ms
この行では、矢印のあとの 89M が GC のあとの使用量で、括弧の 989M が確保量だ。確保量の大半が空いていて、片付けは進んでいる。空いた分を OS に返していないだけだ。
生きているオブジェクトは GC のあとの使用量を超えないので、GC のあとの使用量が増えていなければ、リークは疑わなくてよい。
ただし young GC は若い世代しか片付けないので、古い世代にたまったゴミは、young GC のあとの使用量に残る。この値は、リークでなくても少しずつ増えることがある。
増えていたら、この記事の現象だけでは説明がつかない。古い世代まで片付ける GC(full GC か、並行マーキングのあとの mixed GC)のあとの値があればそれを見て、そこでも増え続けるか、そうした GC が来ていなければ、リークとして調べる20:1。
縮めるきっかけになる GC
次に、ピークのあとの GC ログで、確保量を縮めるきっかけになる GC の行を探す。行の名前は Java 11 と Java 25 で同じだ22。Java 11 でも Pause Remark は出るが、確保量は縮めない。
バージョン
縮めるきっかけになる GC の行
Java 11
Pause Full
Java 12〜25
Pause Full、Pause Remark
並行マーキングが始まると、Pause Remark の前に Pause Young (Concurrent Start) の行も出る。どれも無ければ、縮めるきっかけは来ていない。Java 11 なら、ここで判断がつく。Java 12 以降なら、残るきっかけは周期 GC だ。
周期 GC が動いているか
Java 12 以降で Pause Remark が無ければ、周期 GC も動いていない。周期 GC は並行マーキングを始めるので、動けば Pause Remark が出る。
動いていないのは、間隔を指定していないか、指定していても負荷が続いて判定のたびに飛ばされているかだ(「周期 GC を付けたとき」の節)。間隔は起動オプションのほか、動いている JVM にも jcmd で指定できる14:1。
次のすべてに当てはまれば、メモリが下がらないのはリークではなく、G1 が確保量を縮めるきっかけを得ていないためだ。
プールが G1 のもの
GC のあとの使用量が増えていない
ピークのあとに Pause Full が無く、Java 12 以降なら Pause Remark も無い
対策
リークではないと分かっても、メモリを下げたいときは、G1 のまま負荷が止んだときに縮めるきっかけを足すか、young GC のたびに縮める GC に戻す。
周期 GC を付ける
Parallel に戻す
オプション
-XX:G1PeriodicGCInterval=<ミリ秒>
-XX:+UseParallelGC
縮めるきっかけ
GC が間隔より長く起きないとき
young GC のたび
負荷が絶えないサーバーで
縮めない
縮める23
ピークのすぐあとに負荷が止むとき
縮める
縮めない24
Java 11 で
使えない25
使える
1回の停止
G1 のまま短い
長くなる
どちらを取るかは「自分の環境で見分ける」で当てはまったことと、負荷が止む時間帯があるかで決まる。
当てはまったこと
負荷が止む時間帯がある
負荷が止む時間帯がない
Java 11 で、Pause Full が出ていない
Java 12 以降に上げて周期 GC を付けるか、Parallel に戻す
Parallel に戻す
Java 12 以降で、周期 GC を指定していない
周期 GC を付ける
Parallel に戻す
周期 GC を指定しているが、Pause Remark が出ない
間隔を短くする
Parallel に戻す
負荷が止む時間帯は、GC ログの各行の先頭の時刻で見る。GC と GC のあいだが長く空く時間帯があれば、そこで周期 GC が動ける。
周期 GC は間隔ごとの判定で、直前の GC から間隔以上たっていれば動く。判定は間隔ごとにしか来ないので、止んでから動くまでに最大で間隔の2倍かかる。間隔は止む時間帯の長さの半分より短くする13:2。
Parallel に戻すと、1回の停止は長くなる。どれだけ長くなるかはヒープの大きさと生きているオブジェクトの量で変わる。本番の前に、検証環境で -XX:+UseParallelGC を付けて同じ負荷をかけ、GC ログの各行の最後にある停止時間を G1 のときと比べる。
適用範囲
この記事の検証は 2GB・2 CPU のコンテナで、GC を指定せずに動かした。ヒープの確保量は上限を -Xmx で、起動したときの値を -Xms で決める。検証では -Xms を付けていない。負荷は、作ったオブジェクトがすぐ使われなくなるものだった。次の場合は、結論が当てはまらない。
場合
結論との違い
小さいコンテナ(CPU が1つ、またはメモリが 1792MB 未満)
Java 8〜26 では Serial が選ばれる26。Parallel と G1 の話は当てはまらない
-Xms を付けている
-Xms は確保量の下限にもなり、G1 はこれより下に縮めない。-Xmx と同じ値なら、周期 GC を付けても下がらない27
Parallel で、ピークのあと GC が起きない
Parallel は GC のときにしか縮めないので、下がらない24:1
オブジェクトが生き残り、古い世代が埋まっていく負荷
並行マーキングが始まり、Java 12 以降の G1 はその Remark で縮める。周期 GC を付けなくても下がる場合がある28
Java 26
G1 も Parallel も young GC のあとに縮める。周期 GC を付けなくても下がる場合がある29
Java 27
小さいコンテナでも G1 が選ばれる30
Serial が選ばれたかは、メモリプールの名前が Eden Space、Survivor Space、Tenured Gen になるか31、起動時の GC ログに次の行が出るかで分かる32。
[0.003s][info][gc ] Using Serial
cgroup v2 のコンテナの上限を読めるのは、Java 15 以降と、8u372 以降、11.0.16 以降だ。それより前は、ホストの CPU とメモリで GC を選ぶ33。
脚注
GitHub Actions のランナーの上の Docker で、コンテナは 2GB・2 CPU、-Xmx1500m。5 秒のピーク(アクセスが急に増えたときを模して、毎秒 1000MB のオブジェクトを作った)のあと、45 秒、普段の負荷として毎秒 200MB のオブジェクトを作り続けた。各バージョン 3 回の範囲。スクリプトと結果は show-your-work と GitHub Actions の実行結果 にある。 ↩︎
Oracle の HotSpot Virtual Machine Garbage Collection Tuning Guide の Generations(Java 25)。 ↩︎
Oracle の同じガイド(Java 25)の The Parallel Collector、Garbage-First (G1) Garbage Collector、Available Collectors の Selecting a Collector。 ↩︎
Java 25 で、young GC の終わりに並行マーキングを始めるかを判定する箇所。 ↩︎
Oracle の同じガイド(Java 25)の Garbage-First (G1) Garbage Collector の Space-Reclamation Phase と、その後の Full GC の記述。 ↩︎
Java 8 を -XX:+PrintCommandLineFlags を付けて起動すると、JVM が出すフラグの行に -XX:+UseParallelGC が入る。Java 11 と Java 25 は GC ログの先頭に Using G1 の行(Java 11 では [0.004s][info ][gc ] Using G1)を出す。 ↩︎
JEP 248: Make G1 the Default Garbage Collector の Motivation。 ↩︎
Java 8 の Parallel はyoung GC のたびに若い世代の大きさを決め直す。 ↩︎
Java 11 の G1 は full GC の処理の中で確保量を見直す。 ↩︎
Remark で縮めるようにした変更は JDK-6490394(Java 12)。Java 25 では Remark の処理の中で確保量を見直す。 ↩︎ ↩︎
ピークのあとも毎秒 200MB のオブジェクトを作り続けたとき、Java 11、17、21、25 のどれでも、ピークのあとに並行マーキングは始まらなかった。 ↩︎
JEP 346: Promptly Return Unused Committed Memory from G1 の Motivation。 ↩︎
Java 25 の周期 GC の判定と、既定で並行マーキングを始める設定(G1PeriodicGCInvokesConcurrent)。 ↩︎ ↩︎ ↩︎
G1PeriodicGCInterval の定義。MANAGEABLE のフラグなので、動いている JVM でも変えられる。 ↩︎ ↩︎
2GB・2 CPU のコンテナ、-Xmx1500m、GC は指定しない。負荷が続く回は、ピークのあと 45 秒、オブジェクトを作り続けた。GC ログは -Xlog:gc*、-Xlog:gc+periodic=debug で採った。確保量の表は周期 GC の直前の計測と、周期 GC のあとの最初の計測。 ↩︎
Oracle の GC チューニングガイド(Java 25)の Generations にある GC ログの読み方("used before GC" -> "used after GC" ("heap size"))。 ↩︎
同じ検証を 3 回流した。「〜」で示した値は 3 回の範囲。 ↩︎
プールの名前は G1 と Parallel のソースで決まっている。 ↩︎
どちらも MemoryPoolMXBean から値を取っている。Micrometer 1.13.0 の JvmMemoryMetrics、Prometheus の Java クライアント 1.3.1 の JvmMemoryMetrics。 ↩︎
Oracle の Troubleshooting Guide の Troubleshoot Memory Leaks(Java 25)。 ↩︎ ↩︎
ピークのあとも毎秒 200MB のオブジェクトを作り続けた条件で、ピークのあと最初の young GC の行。 ↩︎
Java 11 の Pause Full、Pause Young (Concurrent Start)、Pause Remark。Java 25 の Pause Full。Java 25 の残りの2つは、「周期 GC を付けたとき」の節のログに出ている。 ↩︎
Java 11 の Parallel もyoung GC のたびに若い世代の大きさを決め直す。Java 11 に -XX:+UseParallelGC を付けてピークのあとも毎秒 200MB のオブジェクトを作り続けると、確保量はピークの終わりの 369.0〜380.0MB から最後の計測で 103.5〜120.5MB に減り、コンテナのメモリ(memory.current)も 141.4〜145.5MB 下がった。 ↩︎
Parallel は Java 8 でも Java 11 でも、young GC の中で大きさを決め直す。 ↩︎ ↩︎
Java 11 で付けると、Unrecognized VM option 'G1PeriodicGCInterval=10000' を出して JVM が起動しない。周期 GC は Java 12 から。 ↩︎
CPU が2つ以上で、メモリが 2GB から 256MB を引いた量以上のときだけ、Java 9 以降は G1 を、Java 8 は Parallel を選ぶ。Java 8 の判定も同じ。 ↩︎
-Xms は、起動したときの確保量と下限(MinHeapSize)の両方を決める。G1 は MinHeapSize より下に縮めない。 ↩︎
G1 はyoung GC の終わりに古い世代の占有量を見て並行マーキングを始め、Remark で縮める。 ↩︎
Java 26 の G1(JDK-8238687)と Parallel(JDK-8338977)。 ↩︎
JEP 523。 ↩︎
Serial のメモリプール。 ↩︎
Java 25 で、コンテナのメモリだけを 1GB にしても、CPU だけを1つにしても、この行が出た。 ↩︎
cgroup v2 の上限を読む変更は Java 15 で入り、11.0.16 と 8u372 に移された。 ↩︎
@ethauvin Are you interested into testing it with an alternative loom scheduler?
See pderop/looma using loom#227
5 likes · 139 views
@forked_franz @_karm @victorgao466305 confirmed. a fastdebug build hits this assert: github.com/openjdk/jdk8u/blob/b661ee6ff840d6922aa…
2 likes · 36 views
@forked_franz @_karm @victorgao466305 Clankers claim the problem is this narrow-casting and choose to believe it (in this very case): github.com/openjdk/jdk8u/blob/b661ee6ff840d6922aa…
but the ~Rulers~ Lawyers of OpenJDK think clankers stink so...
2 likes · 1 repost · 108 views
"retired" を "retierd" と書くというしょうもない間違いをそっと直してくれるOpenJDKのSAチームが私は大好きです(そもそもそんなスペルミスすんな!): jdk#33527
5 likes · 146 views
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.
Mentions
After upgrading from Java 8, G1 stopped returning memory When you move from Java 8 to any of Java 11 through 25, the GC the JVM picks when you don't specify one (the default GC) changes from Parallel to G1. A GC is the mechanism that collects the memory of objects no longer in use. Parallel and G1 are names of two kinds of GC. With this change, container memory (cgroup memory.current) no longer drops after a load peak passes. I ran the same program under the same load on each version and compared how much container memory changed after the peak[^env]. | Version | Change after the peak | |---|---| | Java 8 | −234.6 to −227.6MB | | Java 11 | 0.0 to +2.9MB | | Java 25 | +2.3 to +2.7MB | In this article, the committed heap is the amount of heap the JVM has taken from the OS, and shrinking means reducing the committed heap and returning that memory to the OS. Memory stays high not because of a leak, but because G1 does not shrink the committed heap while load continues. You can tell whether memory that stays high after a peak is a leak or the default GC's behavior from the JVM metrics and GC logs you already collect, without touching production. If you still want it to drop, add a setting to G1 or switch the GC back to Parallel. [^env]: Docker on a GitHub Actions runner, with a 2GB, 2-CPU container and -Xmx1500m. A 5-second peak (simulating a sudden surge in traffic by allocating 1000MB of objects per second) was followed by 45 seconds of normal load, allocating 200MB of objects per second. Ranges are over 3 runs per version. Scripts and results are in show-your-work and the GitHub Actions run. How GC works Whether memory drops after a peak depends not on whether the GC ran, but on whether it shrank the committed heap. Collecting vs. shrinking The GC collects objects that are no longer in use and creates free space inside the heap. Collecting reduces the used heap, meaning the part of the committed heap that objects occupy. Whether the freed space is returned to the OS to shrink the committed heap is decided separately from collecting. When a GC happens The JVM splits the heap into a young generation and an old generation. Most new objects are placed in the young generation, and most of them become unused there. When the young generation fills up, a GC that collects only the young generation (young GC) runs. While the program keeps allocating, the young generation fills up again and again, so young GCs keep coming. Objects that survive several young GCs are moved to the old generation. When the old generation fills up, the whole heap has to be collected. A GC that stops the program and collects the whole heap is called a full GC[^generations]. Two ways to pause While the GC collects, the program is stopped, and requests that arrive in the meantime wait. GC designs differ in how they pause. | | Pause all at once | Pause in small pieces | |---|---|---| | How it collects | Stops the program and collects in one go | Splits collection into pieces and runs part of it concurrently with the program | | Single pause | Long | Short | | Work the program gets done (throughput) | High | Somewhat lower, since the GC uses CPU | | When Oracle's guide recommends it | Pauses of 1 second or longer are acceptable | Response time matters more than throughput | | GC | Parallel | G1 | The name Parallel comes from using multiple threads in parallel to collect while the program is stopped. It does not mean Parallel runs concurrently with the program[^gc-kinds]. To keep pauses short, G1 has a phase that scans live objects across the whole heap without stopping the program (concurrent marking). Concurrent marking ends with a short pause (Remark). It starts at the end of a young GC when old generation occupancy exceeds a threshold[^marking-start]. After concurrent marking, G1 collects part of the old generation together with the young generation in the following young GCs (mixed GCs). G1 does a full GC only when this collection can't keep up and memory runs short[^g1-mixed]. [^generations]: Oracle's HotSpot Virtual Machine Garbage Collection Tuning Guide, Generations (Java 25). [^gc-kinds]: The same Oracle guide (Java 25): The Parallel Collector, Garbage-First (G1) Garbage Collector, and Selecting a Collector in Available Collectors. [^marking-start]: In Java 25, the code that decides at the end of a young GC whether to start concurrent marking. [^g1-mixed]: The same Oracle guide (Java 25), Garbage-First (G1) Garbage Collector: the Space-Reclamation Phase and the description of Full GC that follows. What changed Upgrading from Java 8 changes two things: the default GC, and what triggers shrinking the committed heap. | Version | Default GC | Trigger for shrinking the committed heap | |---|---|---| | Java 8 | Parallel | Young GC (young generation only) | | Java 11 | G1 | Full GC | | Java 12 to 25 | G1 | Full GC and the end of concurrent marking | The default GC When no GC is specified, Java 8 picks Parallel, and Java 9 and later pick G1[^default-gc]. OpenJDK's stated reason for switching to G1 was that "limiting GC pause times is, in general, more important than maximizing throughput" for many users[^jep248]. Shrink triggers Java 8's Parallel resizes the young generation on every young GC, and shrinks it[^parallel-shrink]. Young GCs keep coming while load continues, so the trigger for shrinking keeps coming too. Java 11's G1 shrinks the committed heap only after a full GC[^jdk11-shrink]. G1 does a full GC only when collection can't keep up, so this trigger almost never comes. From Java 12, G1 also shrinks the committed heap at the end of concurrent marking[^remark-shrink]. But concurrent marking starts only when the old generation is filling up, so under a load where newly allocated objects quickly become unused, this trigger doesn't come either[^check-shrink]. Periodic GC According to the Java 12 change (JEP 346), G1 avoids full GCs as much as possible and starts concurrent marking only based on heap occupancy and allocation rate, so it often doesn't return heap memory to the OS unless prompted from outside. This is a particular disadvantage in container environments where you pay for what you use, so Java 12 added a mechanism in which G1 starts concurrent marking on its own and shrinks the committed heap when no GC has happened for a while[^jep346]. This is called periodic GC. At a set interval, G1 checks the time since the last GC. If at least one interval has passed, it starts concurrent marking and shrinks at the Remark that ends it. If not, it skips[^periodic-check]. Despite the name, it does not necessarily run a GC every interval. For example, with a 10-second interval, if objects keep being allocated and young GCs happen less than a second apart, 10 seconds never pass since the last GC. During that time, periodic GC never runs. The interval is set in milliseconds with G1PeriodicGCInterval. Setting it to 0 disables periodic GC. The default is 0, so periodic GC doesn't run unless you set it[^interval]. [^default-gc]: When Java 8 is started with -XX:+PrintCommandLineFlags, the flags line the JVM prints includes -XX:+UseParallelGC. Java 11 and Java 25 print a Using G1 line at the top of the GC log (in Java 11, [0.004s][info ][gc ] Using G1). [^jep248]: The Motivation section of JEP 248: Make G1 the Default Garbage Collector. [^parallel-shrink]: Java 8's Parallel resizes the young generation on every young GC. [^jdk11-shrink]: Java 11's G1 resizes the committed heap as part of a full GC. [^remark-shrink]: The change that made Remark shrink the heap is JDK-6490394 (Java 12). In Java 25, the committed heap is resized inside Remark. [^check-shrink]: With 200MB of objects allocated per second after the peak, concurrent marking did not start after the peak on any of Java 11, 17, 21, or 25. [^jep346]: The Motivation section of JEP 346: Promptly Return Unused Committed Memory from G1. [^periodic-check]: Java 25's periodic GC check and the flag that makes it start concurrent marking by default (G1PeriodicGCInvokesConcurrent). [^interval]: The definition of G1PeriodicGCInterval. It is a MANAGEABLE flag, so it can be changed on a running JVM. With periodic GC enabled With periodic GC enabled, it is skipped while load continues, and runs after load stops to shrink the committed heap. I simulated two situations after a traffic surge subsides and compared them[^periodic-env]. | | Peak (5 seconds) | After the peak | |---|---|---| | Load continues | Traffic surges. Allocates 1000MB of objects per second | Normal requests keep coming. Keeps allocating 200MB of objects per second | | Load stops | Same | Requests stop. Allocates nothing | Both ran Java 25 with -XX:G1PeriodicGCInterval=10000, a 10-second interval. While load continues When load continues after the peak, the check at 10 seconds after startup writes the following line to the GC log. This line appears when -Xlog:gc+periodic=debug is set. [10.017s][debug][gc,periodic ] Last GC occurred 505ms before which is below threshold 10000ms. Skipping. The last GC was 505ms earlier, short of the 10000ms interval, so periodic GC was skipped[^periodic-check]. In the 45 seconds after the peak, the check ran 5 times, was skipped all 5 times, and periodic GC never ran. The committed heap didn't change from the end of the peak to the end of the run. After load stops When nothing was allocated after the peak, periodic GC ran at the check 20 seconds after startup. At the 10-second check, 10 seconds had not yet passed since the GC at the end of the peak. First, a line for the young GC that starts concurrent marking appears. [20.016s][info ][gc ] GC(46) Pause Young (Concurrent Start) (G1 Periodic Collection) 195M->37M(377M) 1.104ms (G1 Periodic Collection) marks this as a GC started by periodic GC. 195M->37M is the used heap before and after, and the 377M in parentheses is the committed heap[^log-format]. This young GC does not change the committed heap. On the Remark line that follows, the committed heap in parentheses is 27M[^remark-shrink]. [20.019s][info ][gc ] GC(47) Pause Remark 37M->7M(27M) 0.478ms The committed heap seen in JVM metrics (the sum of committed across the heap memory pools, read via MemoryPoolMXBean) dropped by the same amount[^runs]. | Measured at | Committed heap | |---|---| | Just before periodic GC | 377.0 to 432.0MB | | After periodic GC | 27.0 to 30.0MB | The container memory compared at the start (memory.current, sampled every second) dropped too. It went from 393.9 to 421.4MB at the sample just before periodic GC to 53.8 to 59.5MB at the last sample. [^periodic-env]: 2GB, 2-CPU container, -Xmx1500m, no GC specified. In the load-continues runs, objects were allocated for 45 seconds after the peak. GC logs were captured with -Xlog:gc* and -Xlog:gc+periodic=debug. The committed heap table shows the sample just before periodic GC and the first sample after it. [^runs]: The same test was run 3 times. Values shown as "X to Y" are the range across the 3 runs. [^log-format]: How to read GC log lines ("used before GC" -> "used after GC" ("heap size")) in Generations in Oracle's GC tuning guide (Java 25). Diagnosing your own environment Use the JVM metrics and GC logs you already collect. For GC logs, the lines printed with -Xlog:gc are enough. Which GC was selected In JVM metrics, the heap shows up as several areas, and their names depend on the GC. These areas are called memory pools[^pools]. The pool name is in the id tag of jvm.memory.committed in Micrometer, and in the pool label of jvm_memory_pool_committed_bytes in the Prometheus Java client[^pool-metrics]. The names tell you which GC was selected in production. | GC | Heap memory pools | |---|---| | Parallel | PS Eden Space, PS Survivor Space, PS Old Gen | | G1 | G1 Eden Space, G1 Survivor Space, G1 Old Gen | If you see the Parallel pools, the behavior described here doesn't apply. If you see neither, see the "Scope" section. Is it a leak? When memory doesn't drop, a leak is the first suspect. With a leak, the amount of live objects keeps growing over time even under constant load[^leak]. With the behavior described here, live objects stay few and only the committed heap is high. To tell them apart, compare the committed heap with the used heap after GC. Read the committed heap as the sum of committed across the heap pools. Read the used heap after GC from the after-GC value on each GC line in the GC log. A pool's used value is not suitable for this comparison, because the Eden part goes up and down with every GC. On Java 11 with continued load, the first young GC line after the peak was[^young-line]: [6.877s][info ][gc ] GC(27) Pause Young (Normal) (G1 Evacuation Pause) 679M->89M(989M) 1.773ms On this line, the 89M after the arrow is the used heap after GC, and the 989M in parentheses is the committed heap. Most of the committed heap is free, and collection is keeping up. The freed space just isn't being returned to the OS. Live objects can't exceed the used heap after GC, so if the used heap after GC isn't growing, you don't need to suspect a leak. However, a young GC collects only the young generation, so garbage piled up in the old generation stays in the used heap after a young GC. This value can creep up even without a leak. If it is growing, the behavior described here doesn't explain it on its own. If you have values after a GC that also collects the old generation (a full GC, or a mixed GC after concurrent marking), look at those. If they keep growing too, or if no such GC has happened, investigate it as a leak[^leak]. GCs that trigger shrinking Next, look in the GC log after the peak for lines from GCs that trigger shrinking the committed heap. The line names are the same in Java 11 and Java 25[^log-names]. Java 11 also prints Pause Remark, but it doesn't shrink the committed heap there. | Version | GC lines that trigger shrinking | |---|---| | Java 11 | Pause Full | | Java 12 to 25 | Pause Full, Pause Remark | When concurrent marking starts, a Pause Young (Concurrent Start) line also appears before Pause Remark. If none of these appear, no shrink trigger has come. On Java 11, that settles it. On Java 12 and later, the remaining trigger is periodic GC. Is periodic GC running? On Java 12 and later, if there is no Pause Remark, periodic GC isn't running either. Periodic GC starts concurrent marking, so when it runs, Pause Remark appears. If it isn't running, either the interval isn't set, or it is set but the check is skipped every time because load continues (see "With periodic GC enabled"). Besides the startup option, the interval can also be set on a running JVM with jcmd[^interval]. If all of the following hold, memory isn't dropping because G1 isn't getting a trigger to shrink the committed heap, not because of a leak. - The pools are G1's - The used heap after GC isn't growing - There is no Pause Full after the peak, and on Java 12 and later, no Pause Remark either [^pools]: The pool names are defined in the source of G1 and Parallel. [^pool-metrics]: Both read values from MemoryPoolMXBean. JvmMemoryMetrics in Micrometer 1.13.0, JvmMemoryMetrics in the Prometheus Java client 1.3.1. [^leak]: Oracle's Troubleshooting Guide, Troubleshoot Memory Leaks (Java 25). [^log-names]: Java 11's Pause Full, Pause Young (Concurrent Start), and Pause Remark. Java 25's Pause Full. The other two for Java 25 appear in the logs in "With periodic GC enabled". [^young-line]: The first young GC line after the peak, under the condition where 200MB of objects per second kept being allocated after the peak. Fixes If it isn't a leak but you still want memory to drop, either keep G1 and add a trigger to shrink when load stops, or go back to a GC that shrinks on every young GC. | | Enable periodic GC | Go back to Parallel | |---|---|---| | Option | -XX:G1PeriodicGCInterval=<milliseconds> | -XX:+UseParallelGC | | Shrink trigger | When no GC happens for longer than the interval | Every young GC | | On a server whose load never stops | Doesn't shrink | Shrinks[^parallel-shrink-11] | | When load stops right after the peak | Shrinks | Doesn't shrink[^scope-parallel] | | On Java 11 | Not available[^unrecognized] | Available | | Single pause | Stays short, as with G1 | Gets longer | Which to choose depends on what you found in "Diagnosing your own environment" and on whether there are periods when load stops. | What you found | Load stops at times | Load never stops | |---|---|---| | Java 11, no Pause Full | Upgrade to Java 12 or later and enable periodic GC, or go back to Parallel | Go back to Parallel | | Java 12 or later, periodic GC not set | Enable periodic GC | Go back to Parallel | | Periodic GC set, but no Pause Remark | Shorten the interval | Go back to Parallel | Check for periods when load stops using the timestamp at the start of each GC log line. If there are periods with long gaps between GCs, periodic GC can run there. Periodic GC runs when, at an interval check, at least one interval has passed since the last GC. Because the check comes only once per interval, it can take up to twice the interval from when load stops until it runs. Set the interval to less than half the length of the periods when load stops[^periodic-check]. Going back to Parallel makes each pause longer. How much longer depends on heap size and the amount of live objects. Before production, run the same load in a test environment with -XX:+UseParallelGC and compare the pause times at the end of each GC log line with those under G1. [^unrecognized]: On Java 11, setting it makes the JVM print Unrecognized VM option 'G1PeriodicGCInterval=10000' and fail to start. Periodic GC is available from Java 12. [^parallel-shrink-11]: Java 11's Parallel also resizes the young generation on every young GC. With -XX:+UseParallelGC on Java 11 and 200MB of objects per second allocated after the peak, the committed heap went from 369.0 to 380.0MB at the end of the peak to 103.5 to 120.5MB at the last sample, and container memory (memory.current) dropped by 141.4 to 145.5MB. Scope The tests in this article ran in a 2GB, 2-CPU container with no GC specified. The committed heap's upper limit is set by -Xmx, and its value at startup by -Xms. The tests did not set -Xms. The load was one where newly allocated objects quickly became unused. The conclusions don't apply in the following cases. | Case | How it differs from the conclusions | |---|---| | Small containers (1 CPU, or less than 1792MB of memory) | Java 8 to 26 select Serial[^serial-select]. The Parallel and G1 discussion doesn't apply | | -Xms is set | -Xms is also the lower bound of the committed heap, and G1 won't shrink below it. If it equals -Xmx, memory won't drop even with periodic GC[^scope-xms] | | Parallel, with no GC after the peak | Parallel shrinks only during GC, so memory doesn't drop[^scope-parallel] | | Load where objects survive and the old generation fills up | Concurrent marking starts, and G1 on Java 12 and later shrinks at its Remark. Memory may drop without periodic GC[^scope-old] | | Java 26 | Both G1 and Parallel shrink after young GCs. Memory may drop without periodic GC[^scope-26] | | Java 27 | G1 is selected even in small containers[^scope-27] | You can tell Serial was selected if the memory pool names are Eden Space, Survivor Space, and Tenured Gen[^serial-pools], or if the following line appears in the GC log at startup[^serial-log]. [0.003s][info][gc ] Using Serial Java 15 and later, 8u372 and later, and 11.0.16 and later can read cgroup v2 container limits. Earlier versions pick the GC based on the host's CPU and memory[^cgroup]. [^serial-select]: Only when there are 2 or more CPUs and memory is at least 2GB minus 256MB does the JVM select G1 on Java 9 and later or Parallel on Java 8. Java 8's check is the same. [^scope-xms]: -Xms sets both the committed heap at startup and its lower bound (MinHeapSize). G1 does not shrink below MinHeapSize. [^scope-parallel]: Parallel resizes during young GC on both Java 8 and Java 11. [^scope-old]: G1 checks old generation occupancy at the end of a young GC to start concurrent marking, and shrinks at Remark. [^scope-26]: Java 26's G1 (JDK-8238687) and Parallel (JDK-8338977). [^scope-27]: JEP 523. [^serial-pools]: Serial's memory pools. [^serial-log]: On Java 25, this line appeared both when only the container memory was set to 1GB and when only the CPU count was set to 1. [^cgroup]: The change to read cgroup v2 limits landed in Java 15 and was backported to 11.0.16 and 8u372.
SIGILL on Startup: How the JDK AOT Cache Crashed Axelix in Docker Author: Artemiy Degtyarev Imagine you are deploying an open source Java application in Docker. You pull the image, run it, and before the first log line appears, the JVM dies with this: # A fatal error has been detected by the Java Runtime Environment: # # SIGILL (0x4) at pc=..., pid=1, tid=... # # Problematic frame: # v ~AdapterBlob That is exactly what happened to some users of Axelix when they started Axelix Master. SIGILL means illegal instruction: the CPU was asked to execute an instruction it does not support. That is not something you expect from a Java application. Java is supposed to be "write once, run anywhere", and bytecode does not contain CPU instructions. We investigated it, fixed it, and wrote this article about it. It is a good story about a new JVM feature, the AOT cache, and a small detail of its design that turned out to matter a lot for anyone who ships a pre-built Docker image. What we saw The bug was reported as #1744. The crash logs users sent us all pointed to the same frame, ~AdapterBlob. Only the illegal instruction differed: - On an ARM64 machine, the JVM crashed on ptrue p7.b. This is an SVE (Scalable Vector Extension) instruction, and SVE was missing from the CPU features listed in the crash log. - We also reproduced it ourselves on x86-64. We started Axelix 1.1.1 on an emulated CPU without AVX, and the JVM crashed on vzeroupper, which is an AVX instruction. So two different architectures crashed in the same place, both on a vector instruction the CPU did not have. As an immediate mitigation, we removed the AOT cache from the Master image. Axelix 1.2.0 shipped without it. Then we went to find out what was actually going on. To understand that, we first need to look at how the AOT cache works. What is the AOT cache? Every time a JVM starts, it does a lot of the same work again. It reads classes from JARs, parses and verifies bytecode, links classes, and runs static initializers. Then it runs the code in the interpreter for a while, collects profiles, and only after that can the JIT compiler produce well-optimized machine code. For a Spring Boot application with thousands of classes, this work is a large part of the startup time. Project Leyden aims to reuse that work between runs. Its first result is the AOT cache: - JEP 483 (JDK 24) — classes are stored in the cache already loaded and linked. - JEP 514 (JDK 25) — simpler command-line ergonomics. - JEP 515 (JDK 25) — method execution profiles are stored too, so the JIT can start optimizing earlier. Training run → collect data → create app.aot ↓ Next runs ← use the data from app.aot The three phases Working with the AOT cache has three phases: 1. Training. You run the application with a representative workload. The JVM records which classes were loaded and linked and how methods were executed. 2. Assembly. A separate JVM takes the recorded configuration and creates the cache file. 3. Production. Later runs start with -XX:AOTCache=... and use the prepared data instead of doing the work again. In a Dockerfile this is a natural multi-stage build. A simplified version of the Axelix Master Dockerfile looks like this: FROM eclipse-temurin:25-jre-alpine AS training WORKDIR /application COPY build/libs/master.jar master.jar # 1. Training: start the application up to the context refresh and record what it does RUN java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf \ -Dspring.context.exit=onRefresh \ -jar master.jar # 2. Assembly: create the cache from the recorded configuration 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. Production ENTRYPOINT exec java -XX:AOTCache=app.aot -cp master.jar org.springframework.boot.loader.launch.JarLauncher -Dspring.context.exit=onRefresh is a Spring Framework feature made for this purpose: the application starts, refreshes the ApplicationContext, and exits. That is enough to warm up everything that matters for startup, and it does not need a database or the network. What is inside the cache? In JDK 25, one AOT cache file contains several kinds of data: - class metadata — classes that are already parsed, verified, and linked; - method profiles — execution statistics collected during training; - heap objects — some pre-built Java objects, for example the module graph; - the AOT code cache — real machine code. The first three are CPU-independent data structures. The last one is the source of our problem. AOTAdapterCaching To see what machine code ends up in the cache, we need to look a little deeper into HotSpot. When a method is called for the first time, the interpreter executes its bytecode. When the method becomes hot, the JIT compiles it into machine code. Interpreted and compiled code use different calling conventions. The interpreter passes arguments on its own stack, while compiled code passes them in registers, as defined by the platform ABI. So the JVM needs small pieces of glue code between them, called adapters: - i2c (interpreted-to-compiled) — used when an interpreted method calls a compiled one; - c2i (compiled-to-interpreted) — used when a compiled method calls a method that is still interpreted. Adapters are generated per method signature, and a large application needs thousands of them. Generating them at startup takes time, so JDK 25 added the AOTAdapterCaching option, which is on by default: the adapters are generated during the assembly phase and stored in the AOT cache as ready-to-run machine code. And here is the catch. Like any code that HotSpot generates, adapters are generated for the CPU the JVM is currently running on. If that CPU supports AVX-512, the adapter may use AVX-512 registers. If it supports SVE, the adapter may use SVE instructions. That code is correct on the build machine. On a machine without those features, the first call through such an adapter executes an instruction the CPU does not know, and the kernel sends SIGILL. Why the JVM didn't protect us The JVM does validate the AOT cache. When the production run starts, it checks the JDK build, the operating system, the CPU architecture (x64 vs aarch64), the classpath, the GC, compressed oops, and some other settings. If something does not match, the JVM, in the default -XX:AOTMode=auto, prints a warning and continues without the cache. The startup is slower, but nothing breaks. In JDK 25, however, the JVM did not check one thing: CPU features within the same architecture. "x64" covers both a modern server CPU with AVX-512 and an old laptop CPU without it. The cache was accepted, and the adapters inside it were used as they were. Now look at how our image was built. The release job runs on a GitHub-hosted runner and builds a multi-platform image: docker build --platform linux/amd64,linux/arm64 --file master-oss/Dockerfile ... - The amd64 variant is trained on whatever CPU the runner happens to have. GitHub runners are cloud VMs, and some of them have AVX-512. - The arm64 variant is trained under QEMU emulation (docker/setup-qemu-action), because the runner itself is x86. QEMU user-mode emulation emulates its max CPU model by default, which includes SVE. However, many real ARM64 CPUs that people run containers on have no SVE: Apple Silicon Macs, AWS Graviton2, and Ampere Altra. A multi-platform build runs the whole Dockerfile once per platform, so each variant of the image gets its own cache, and docker pull picks the variant that matches the host. The architecture always matched. The CPU features within it did not: our build machines had more of them than many of our users' machines. That is exactly the pattern that crashed. The upstream bug During the investigation we found adoptium/adoptium-support#1507, "Launching with AOT cache fatally crashes with SIGILL (0x4) depending on available CPU instructions". It was the same problem: a cache created on a Zen 5 CPU with AVX-512 crashed in ~AdapterBlob on a Zen 2 CPU without AVX-512. The reporter found it the same way we did: GitHub Actions runners sometimes have AVX-512 and sometimes do not. The issue was moved to OpenJDK as JDK-8388703. The response from the HotSpot developers was pragmatic. Adapters are the only AOT code in JDK 25 (stub caching is already off), and they do not improve performance much. So instead of backporting CPU checks, they decided to simply turn adapter caching off by default in JDK 25. That is the fix in 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); The proper fix lives in JDK 27: - JDK-8377507 — the CPU features of the assembly machine are stored in the AOT code cache; - JDK-8381975 — at startup the stored features must exactly match the current CPU. If they do not match, the JVM logs AOT Code Cache disabled: cpu features are incompatible, ignores only the machine code, and keeps using everything else in the AOT cache. That is how it should work: the worst case is a little less speed-up, never a crash. Our fix The fix is #1767. We brought the AOT cache back, but we create it without adapters: # 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 AOTAdapterCaching is a diagnostic flag, which is why -XX:+UnlockDiagnosticVMOptions is needed. With this flag, the cache contains no machine code at all, and the JVM confirms it on startup: [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) What remains is class metadata, profiles, and heap objects, which are the same on any CPU of a given architecture. The adapters are generated at runtime, on the user's machine, for the user's CPU. And removing them does not remove the benefit. In a local check of the image built from the fixed Dockerfile, about 72% of the classes loaded during startup (10,933 of 15,201) came from the AOT cache, and the time to context refresh dropped from about 7.3 s to about 5.4 s on a developer laptop. We discussed the alternatives in the PR. The first idea was to wait for JDK 25.0.5 and keep adapter caching on. But 25.0.5 does not make adapters safe. It simply turns them off, so the result is the same as our flag. The second idea was to move to JDK 27, where the CPU check exists. Most people run Axelix Master in Docker or Kubernetes, so a new Java baseline would not affect them. But Spring Boot 4.1 officially supports Java up to 26, and Java 27 support is expected in Spring Boot 4.2. So we merged the explicit flag. It works on today's JDK 25 builds, and it stays correct after 25.0.5, where it simply matches the new default. What about JDK 27? Once Spring Boot 4.2 is out, we plan to move Axelix to JDK 27. There, turning AOTAdapterCaching back on is safe: on a CPU that does not match, the JVM drops the cached code instead of crashing. But "safe" is not the same as "useful". JDK 27 requires an exact match of CPU features. For an image built on a GitHub runner, or trained under QEMU, the chance that a user's CPU has exactly the same feature set is small. For most users the cached adapters would simply be thrown away. For a pre-built image to really benefit from AOT code, the JVM needs to generate it for a generic, portable CPU baseline. There is an open enhancement for that, JDK-8377630 "Restrict AOT code to a generic set of CPU features". Until it lands, the AOT cache in a distributed image is best seen as a cache of data, not of code. Takeaways - The JDK AOT cache is a real and easy startup win for Spring Boot applications in Docker. A multi-stage Dockerfile with a training stage is enough. - On JDK 25 before 25.0.5, if you build the cache on one machine and run it on another, create it with -XX:+UnlockDiagnosticVMOptions -XX:-AOTAdapterCaching. Otherwise your users' CPUs must have every feature your build machine had. CI runners and QEMU usually have more. - Multi-arch images get a separate cache per architecture automatically, because each platform runs its own training and assembly stages. - The JVM validates the AOT cache and falls back safely when something does not match. Machine code stored in the cache is the one exception before JDK 27. The fix will ship in the next Axelix release. If you are curious about the details, the whole story is in #1744 and #1767.
You heard of "Code doesn't work on Tuesday", get ready for "Code doesn't work the morning after a daylight savings time change" jdk#33760
Java 8 から上げたら G1 がメモリを解放しなくなった Java 8 から Java 11〜25 に上げると、GC を指定しないときに選ばれる GC(既定の GC)が Parallel から G1 に変わる。GC は使われなくなったオブジェクトのメモリを片付ける仕組みで、Parallel と G1 はその種類の名前だ。 この変化で、負荷のピークが過ぎてもコンテナのメモリ(cgroup の memory.current)が下がらなくなる。 同じプログラムに同じ負荷をかけ、ピークのあとにコンテナのメモリがどれだけ変わったかをバージョンごとに比べた1。 バージョン ピークのあとの変化 Java 8 −234.6〜−227.6MB Java 11 0.0〜+2.9MB Java 25 +2.3〜+2.7MB ここでは、JVM が OS から取ってあるヒープの量を確保量、それを減らして OS に返すことを縮めると呼ぶ。下がらない原因はメモリリークではなく、G1 が負荷の続くあいだ確保量を縮めないことにある。 ピークのあとも下がらないメモリがリークか既定の GC の振る舞いかは、すでに集めている JVM のメトリクスと GC ログで、本番に触らずに見分けられる。それでも下げたいときは、G1 に設定を足すか、GC を Parallel に戻す。 GC の仕組み ピークのあとにメモリが下がるかどうかは、GC が動いたかではなく、確保量を縮めたかで決まる。 片付けることと縮めること GC は使われなくなったオブジェクトを片付けて、ヒープの中に空きを作る。片付けで減るのは使用量、つまり確保量の中でオブジェクトが占めている量だ。空いた分を OS に返して確保量を縮めるかどうかは、片付けとは別に決まる。 GC が来るとき JVM はヒープを若い世代と古い世代に分けて使う。作られたオブジェクトの多くは若い世代に置かれ、その多くはそこで使われなくなる。若い世代が埋まると、若い世代だけを片付ける GC(young GC)が動く。オブジェクトを作り続けるあいだは若い世代が繰り返し埋まるので、young GC も繰り返し来る。 young GC を何度か生き延びたオブジェクトは、古い世代に移される。古い世代が埋まると、ヒープ全体を片付ける必要が出る。プログラムを止めてヒープ全体を片付ける GC を full GC と呼ぶ2。 2つの止め方 GC が片付けるあいだはプログラムが止まり、そのあいだに届いたリクエストは待たされる。GC の作りはこの止め方で分かれる。 まとめて止める 小分けに止める 片付け方 プログラムを止めて一度に片付ける 片付けを小分けにし、一部をプログラムと並行して進める 1回の停止 長い 短い プログラムが進められる処理の量(スループット) 大きい GC が CPU を使う分、少し下がる Oracle のガイドが勧める場面 1秒以上の停止を許せる スループットより応答時間が大事 GC Parallel G1 Parallel の名前は止めているあいだの片付けを複数のスレッドで並列に進めることから来ていて、Parallel がプログラムと並行して動くわけではない3。 停止を短く保つため、G1 はプログラムを止めずにヒープ全体の生きているオブジェクトを調べる処理(並行マーキング)を持つ。並行マーキングは最後に短い停止(Remark)を挟んで終わる。始まるのは、young GC の終わりに、古い世代の占有量がしきい値を超えたときだ4。 並行マーキングのあと、G1 は続く young GC で、若い世代と一緒に古い世代の一部も片付ける(mixed GC)。G1 が full GC をするのは、この片付けが追いつかずメモリが足りなくなったときに限られる5。 何が変わったか Java 8 からバージョンを上げたときに変わるのは、既定の GC と、確保量を縮めるきっかけだ。 バージョン 既定の GC 確保量を縮めるきっかけ Java 8 Parallel young GC(若い世代の分) Java 11 G1 full GC Java 12〜25 G1 full GC と、並行マーキングの終わり 既定の GC GC を指定しないとき、Java 8 は Parallel を、Java 9 からは G1 を選ぶ6。OpenJDK は G1 に替えた理由として、「多くの利用者には、スループットを最大にするより、GC の停止時間を抑えるほうが大事だ」を挙げた7。 縮めるきっかけ Java 8 の Parallel は young GC のたびに若い世代の大きさを決め直し、縮める8。負荷が続くあいだも young GC は来るので、縮めるきっかけも来る。 Java 11 の G1 が確保量を縮めるのは、full GC のあとだけだ9。G1 の full GC は片付けが追いつかないときに限られるので、このきっかけはほとんど来ない。 Java 12 からは、並行マーキングの終わりでも確保量を縮める10。ただし並行マーキングが始まるのは古い世代が埋まってきたときなので、作ったオブジェクトがすぐ使われなくなる負荷では、このきっかけも来ない11。 周期 GC Java 12 の変更(JEP 346)によると、G1 は full GC を極力避け、並行マーキングもヒープの占有量と、オブジェクトを作る速さからしか始めないので、外から促されない限りヒープを OS に返さないことが多い。 これは使った分だけ払うコンテナ環境で特に不利なので、Java 12 で、GC がしばらく起きていないときに G1 が自分から並行マーキングを始めて確保量を縮める仕組みが加わった12。これを周期 GC と呼ぶ。 G1 は決めた間隔ごとに直前の GC からの時間を見る。間隔以上たっていれば並行マーキングを始め、その終わりの Remark で縮める。たっていなければ飛ばす13。名前とは違い、間隔ごとに必ず GC するのではない。 たとえば間隔を 10 秒にしても、オブジェクトが作られ続けて young GC が 1 秒もたたずに次々起きていれば、直前の GC から 10 秒たつことがない。そのあいだ、周期 GC は一度も動かない。 間隔は G1PeriodicGCInterval にミリ秒で指定し、0 にすると周期 GC は動かない。既定値は 0 なので、指定しなければ動かない14。 周期 GC を付けたとき 周期 GC を付けると、負荷が続くあいだは飛ばされ、負荷が止んだあとに動いて確保量を縮める。 アクセスが急に増えて引いたあとの2つの状況を模して比べた15。 ピーク(5 秒) ピークのあと 負荷が続く アクセスが急に増える。毎秒 1000MB のオブジェクトを作る 普段のリクエストが来続ける。毎秒 200MB のオブジェクトを作り続ける 負荷が止む 同じ リクエストが来なくなる。何も作らない どちらも Java 25 に -XX:G1PeriodicGCInterval=10000 を付け、間隔を 10 秒にした。 負荷が続くあいだ ピークのあとも負荷が続くと、起動から 10 秒の判定で、GC ログに次の行が出る。この行は -Xlog:gc+periodic=debug を付けたときに出る。 [10.017s][debug][gc,periodic ] Last GC occurred 505ms before which is below threshold 10000ms. Skipping. 直前の GC が 505ms 前で、間隔の 10000ms に届かないので、周期 GC を飛ばしたという行だ13:1。 ピークのあとの 45 秒間に、判定は 5 回あり、5 回とも飛ばされて、周期 GC は一度も動かなかった。確保量もピークの終わりから最後まで変わらなかった。 負荷が止んだあと ピークのあとオブジェクトを作らないと、起動から 20 秒の判定で周期 GC が動いた。10 秒の判定の時点では、ピークの終わりの GC から 10 秒たっていなかった。まず、並行マーキングを始める young GC の行が出る。 [20.016s][info ][gc ] GC(46) Pause Young (Concurrent Start) (G1 Periodic Collection) 195M->37M(377M) 1.104ms (G1 Periodic Collection) が、周期 GC が始めた GC であることを示す。195M->37M は使用量の前と後で、括弧の 377M が確保量だ16。この young GC では、確保量は変わらない。 続く Remark の行では、括弧の確保量が 27M になる10:1。 [20.019s][info ][gc ] GC(47) Pause Remark 37M->7M(27M) 0.478ms JVM のメトリクスで見た確保量(MemoryPoolMXBean で取ったヒープのメモリプールの committed の合計)も、同じだけ下がった17。 計測した時点 確保量 周期 GC の直前 377.0〜432.0MB 周期 GC のあと 27.0〜30.0MB 冒頭で比べたコンテナのメモリ(memory.current、1 秒ごとに計測)も下がった。周期 GC の直前の計測で 393.9〜421.4MB だったのが、最後の計測では 53.8〜59.5MB になった。 自分の環境で見分ける すでに集めている JVM のメトリクスと GC ログを使う。GC ログは -Xlog:gc を付けて出る行で足りる。 選ばれた GC JVM のメトリクスでは、ヒープがいくつかの区画に分かれて見え、区画の名前は GC で決まる。この区画をメモリプールと呼ぶ18。 Micrometer なら jvm.memory.committed の id タグ、Prometheus の Java クライアントなら jvm_memory_pool_committed_bytes の pool ラベルに、プールの名前が入る19。名前を見れば、本番で選ばれた GC が分かる。 GC ヒープのメモリプール Parallel PS Eden Space、PS Survivor Space、PS Old Gen G1 G1 Eden Space、G1 Survivor Space、G1 Old Gen Parallel のプールなら、この記事の現象ではない。どちらでもなければ、「適用範囲」の節を見る。 リークかどうか 下がらないメモリを見ると、まずリークを疑う。リークなら、負荷が一定でも、生きているオブジェクトの量が時間とともに増え続ける20。この記事の現象なら、生きているオブジェクトは少ないまま、確保量だけが高い。 見分けるには、確保量と、GC のあとの使用量を比べる。確保量はヒープのプールの committed の合計で見る。使用量は GC ログの各 GC の行にある GC のあとの値で見る。プールの used は、Eden の分が GC のたびに上下するので、比べるのに向かない。 Java 11 で負荷が続いたとき、ピークのあと最初の young GC の行は次のとおりだった21。 [6.877s][info ][gc ] GC(27) Pause Young (Normal) (G1 Evacuation Pause) 679M->89M(989M) 1.773ms この行では、矢印のあとの 89M が GC のあとの使用量で、括弧の 989M が確保量だ。確保量の大半が空いていて、片付けは進んでいる。空いた分を OS に返していないだけだ。 生きているオブジェクトは GC のあとの使用量を超えないので、GC のあとの使用量が増えていなければ、リークは疑わなくてよい。 ただし young GC は若い世代しか片付けないので、古い世代にたまったゴミは、young GC のあとの使用量に残る。この値は、リークでなくても少しずつ増えることがある。 増えていたら、この記事の現象だけでは説明がつかない。古い世代まで片付ける GC(full GC か、並行マーキングのあとの mixed GC)のあとの値があればそれを見て、そこでも増え続けるか、そうした GC が来ていなければ、リークとして調べる20:1。 縮めるきっかけになる GC 次に、ピークのあとの GC ログで、確保量を縮めるきっかけになる GC の行を探す。行の名前は Java 11 と Java 25 で同じだ22。Java 11 でも Pause Remark は出るが、確保量は縮めない。 バージョン 縮めるきっかけになる GC の行 Java 11 Pause Full Java 12〜25 Pause Full、Pause Remark 並行マーキングが始まると、Pause Remark の前に Pause Young (Concurrent Start) の行も出る。どれも無ければ、縮めるきっかけは来ていない。Java 11 なら、ここで判断がつく。Java 12 以降なら、残るきっかけは周期 GC だ。 周期 GC が動いているか Java 12 以降で Pause Remark が無ければ、周期 GC も動いていない。周期 GC は並行マーキングを始めるので、動けば Pause Remark が出る。 動いていないのは、間隔を指定していないか、指定していても負荷が続いて判定のたびに飛ばされているかだ(「周期 GC を付けたとき」の節)。間隔は起動オプションのほか、動いている JVM にも jcmd で指定できる14:1。 次のすべてに当てはまれば、メモリが下がらないのはリークではなく、G1 が確保量を縮めるきっかけを得ていないためだ。 プールが G1 のもの GC のあとの使用量が増えていない ピークのあとに Pause Full が無く、Java 12 以降なら Pause Remark も無い 対策 リークではないと分かっても、メモリを下げたいときは、G1 のまま負荷が止んだときに縮めるきっかけを足すか、young GC のたびに縮める GC に戻す。 周期 GC を付ける Parallel に戻す オプション -XX:G1PeriodicGCInterval=<ミリ秒> -XX:+UseParallelGC 縮めるきっかけ GC が間隔より長く起きないとき young GC のたび 負荷が絶えないサーバーで 縮めない 縮める23 ピークのすぐあとに負荷が止むとき 縮める 縮めない24 Java 11 で 使えない25 使える 1回の停止 G1 のまま短い 長くなる どちらを取るかは「自分の環境で見分ける」で当てはまったことと、負荷が止む時間帯があるかで決まる。 当てはまったこと 負荷が止む時間帯がある 負荷が止む時間帯がない Java 11 で、Pause Full が出ていない Java 12 以降に上げて周期 GC を付けるか、Parallel に戻す Parallel に戻す Java 12 以降で、周期 GC を指定していない 周期 GC を付ける Parallel に戻す 周期 GC を指定しているが、Pause Remark が出ない 間隔を短くする Parallel に戻す 負荷が止む時間帯は、GC ログの各行の先頭の時刻で見る。GC と GC のあいだが長く空く時間帯があれば、そこで周期 GC が動ける。 周期 GC は間隔ごとの判定で、直前の GC から間隔以上たっていれば動く。判定は間隔ごとにしか来ないので、止んでから動くまでに最大で間隔の2倍かかる。間隔は止む時間帯の長さの半分より短くする13:2。 Parallel に戻すと、1回の停止は長くなる。どれだけ長くなるかはヒープの大きさと生きているオブジェクトの量で変わる。本番の前に、検証環境で -XX:+UseParallelGC を付けて同じ負荷をかけ、GC ログの各行の最後にある停止時間を G1 のときと比べる。 適用範囲 この記事の検証は 2GB・2 CPU のコンテナで、GC を指定せずに動かした。ヒープの確保量は上限を -Xmx で、起動したときの値を -Xms で決める。検証では -Xms を付けていない。負荷は、作ったオブジェクトがすぐ使われなくなるものだった。次の場合は、結論が当てはまらない。 場合 結論との違い 小さいコンテナ(CPU が1つ、またはメモリが 1792MB 未満) Java 8〜26 では Serial が選ばれる26。Parallel と G1 の話は当てはまらない -Xms を付けている -Xms は確保量の下限にもなり、G1 はこれより下に縮めない。-Xmx と同じ値なら、周期 GC を付けても下がらない27 Parallel で、ピークのあと GC が起きない Parallel は GC のときにしか縮めないので、下がらない24:1 オブジェクトが生き残り、古い世代が埋まっていく負荷 並行マーキングが始まり、Java 12 以降の G1 はその Remark で縮める。周期 GC を付けなくても下がる場合がある28 Java 26 G1 も Parallel も young GC のあとに縮める。周期 GC を付けなくても下がる場合がある29 Java 27 小さいコンテナでも G1 が選ばれる30 Serial が選ばれたかは、メモリプールの名前が Eden Space、Survivor Space、Tenured Gen になるか31、起動時の GC ログに次の行が出るかで分かる32。 [0.003s][info][gc ] Using Serial cgroup v2 のコンテナの上限を読めるのは、Java 15 以降と、8u372 以降、11.0.16 以降だ。それより前は、ホストの CPU とメモリで GC を選ぶ33。 脚注 GitHub Actions のランナーの上の Docker で、コンテナは 2GB・2 CPU、-Xmx1500m。5 秒のピーク(アクセスが急に増えたときを模して、毎秒 1000MB のオブジェクトを作った)のあと、45 秒、普段の負荷として毎秒 200MB のオブジェクトを作り続けた。各バージョン 3 回の範囲。スクリプトと結果は show-your-work と GitHub Actions の実行結果 にある。 ↩︎ Oracle の HotSpot Virtual Machine Garbage Collection Tuning Guide の Generations(Java 25)。 ↩︎ Oracle の同じガイド(Java 25)の The Parallel Collector、Garbage-First (G1) Garbage Collector、Available Collectors の Selecting a Collector。 ↩︎ Java 25 で、young GC の終わりに並行マーキングを始めるかを判定する箇所。 ↩︎ Oracle の同じガイド(Java 25)の Garbage-First (G1) Garbage Collector の Space-Reclamation Phase と、その後の Full GC の記述。 ↩︎ Java 8 を -XX:+PrintCommandLineFlags を付けて起動すると、JVM が出すフラグの行に -XX:+UseParallelGC が入る。Java 11 と Java 25 は GC ログの先頭に Using G1 の行(Java 11 では [0.004s][info ][gc ] Using G1)を出す。 ↩︎ JEP 248: Make G1 the Default Garbage Collector の Motivation。 ↩︎ Java 8 の Parallel はyoung GC のたびに若い世代の大きさを決め直す。 ↩︎ Java 11 の G1 は full GC の処理の中で確保量を見直す。 ↩︎ Remark で縮めるようにした変更は JDK-6490394(Java 12)。Java 25 では Remark の処理の中で確保量を見直す。 ↩︎ ↩︎ ピークのあとも毎秒 200MB のオブジェクトを作り続けたとき、Java 11、17、21、25 のどれでも、ピークのあとに並行マーキングは始まらなかった。 ↩︎ JEP 346: Promptly Return Unused Committed Memory from G1 の Motivation。 ↩︎ Java 25 の周期 GC の判定と、既定で並行マーキングを始める設定(G1PeriodicGCInvokesConcurrent)。 ↩︎ ↩︎ ↩︎ G1PeriodicGCInterval の定義。MANAGEABLE のフラグなので、動いている JVM でも変えられる。 ↩︎ ↩︎ 2GB・2 CPU のコンテナ、-Xmx1500m、GC は指定しない。負荷が続く回は、ピークのあと 45 秒、オブジェクトを作り続けた。GC ログは -Xlog:gc*、-Xlog:gc+periodic=debug で採った。確保量の表は周期 GC の直前の計測と、周期 GC のあとの最初の計測。 ↩︎ Oracle の GC チューニングガイド(Java 25)の Generations にある GC ログの読み方("used before GC" -> "used after GC" ("heap size"))。 ↩︎ 同じ検証を 3 回流した。「〜」で示した値は 3 回の範囲。 ↩︎ プールの名前は G1 と Parallel のソースで決まっている。 ↩︎ どちらも MemoryPoolMXBean から値を取っている。Micrometer 1.13.0 の JvmMemoryMetrics、Prometheus の Java クライアント 1.3.1 の JvmMemoryMetrics。 ↩︎ Oracle の Troubleshooting Guide の Troubleshoot Memory Leaks(Java 25)。 ↩︎ ↩︎ ピークのあとも毎秒 200MB のオブジェクトを作り続けた条件で、ピークのあと最初の young GC の行。 ↩︎ Java 11 の Pause Full、Pause Young (Concurrent Start)、Pause Remark。Java 25 の Pause Full。Java 25 の残りの2つは、「周期 GC を付けたとき」の節のログに出ている。 ↩︎ Java 11 の Parallel もyoung GC のたびに若い世代の大きさを決め直す。Java 11 に -XX:+UseParallelGC を付けてピークのあとも毎秒 200MB のオブジェクトを作り続けると、確保量はピークの終わりの 369.0〜380.0MB から最後の計測で 103.5〜120.5MB に減り、コンテナのメモリ(memory.current)も 141.4〜145.5MB 下がった。 ↩︎ Parallel は Java 8 でも Java 11 でも、young GC の中で大きさを決め直す。 ↩︎ ↩︎ Java 11 で付けると、Unrecognized VM option 'G1PeriodicGCInterval=10000' を出して JVM が起動しない。周期 GC は Java 12 から。 ↩︎ CPU が2つ以上で、メモリが 2GB から 256MB を引いた量以上のときだけ、Java 9 以降は G1 を、Java 8 は Parallel を選ぶ。Java 8 の判定も同じ。 ↩︎ -Xms は、起動したときの確保量と下限(MinHeapSize)の両方を決める。G1 は MinHeapSize より下に縮めない。 ↩︎ G1 はyoung GC の終わりに古い世代の占有量を見て並行マーキングを始め、Remark で縮める。 ↩︎ Java 26 の G1(JDK-8238687)と Parallel(JDK-8338977)。 ↩︎ JEP 523。 ↩︎ Serial のメモリプール。 ↩︎ Java 25 で、コンテナのメモリだけを 1GB にしても、CPU だけを1つにしても、この行が出た。 ↩︎ cgroup v2 の上限を読む変更は Java 15 で入り、11.0.16 と 8u372 に移された。 ↩︎
@ethauvin Are you interested into testing it with an alternative loom scheduler? See pderop/looma using loom#227
@forked_franz @_karm @victorgao466305 confirmed. a fastdebug build hits this assert: github.com/openjdk/jdk8u/blob/b661ee6ff840d6922aa…
@forked_franz @_karm @victorgao466305 Clankers claim the problem is this narrow-casting and choose to believe it (in this very case): github.com/openjdk/jdk8u/blob/b661ee6ff840d6922aa… but the ~Rulers~ Lawyers of OpenJDK think clankers stink so...
"retired" を "retierd" と書くというしょうもない間違いをそっと直してくれるOpenJDKのSAチームが私は大好きです(そもそもそんなスペルミスすんな!): jdk#33527
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.