Add flags: production JVM flag cheat sheet for Spring Boot on JDK 25/27

MaxRAMPercentage vs a hardcoded -Xmx, -XX:-UseContainerSupport, the
ActiveProcessorCount lie, and which diagnostic flags are worth leaving
on in prod, verified in real Docker containers on JDK 25 and JDK 27.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-10-01 04:34:04 +00:00
co-authored by Claude Sonnet 5
parent e83c9949d8
commit 2f88734f5d
22 changed files with 832 additions and 0 deletions
+1
View File
@@ -72,6 +72,7 @@ the part most "ZGC is faster" posts leave out.
| [`analysis/GcLogParser.java`](analysis/src/com/ankurm/zgc/analysis/GcLogParser.java) | Unified-log parser that keeps pauses and concurrent phases apart | none |
| [`jmh/`](jmh/src/main/java/com/ankurm/zgc/jmh) | Throughput and barrier microbenchmarks | JMH 1.37 |
| [`jdk27-memory/`](jdk27-memory) | Spring Boot 4.1 service measured on JDK 25, 26 and 27: compact object headers (JEP 534) and G1 everywhere (JEP 523), heap footprint, container sizing with real cgroup limits. Its README and `output/` replace a `docs/` folder; the write-up is [the post](https://ankurm.com/jdk-27-compact-object-headers-g1-default-memory-spring-boot/) | Spring Boot 4.1.1, three JDKs |
| [`flags/`](flags) | The flag-by-flag production cheat sheet: `MaxRAMPercentage` vs a hardcoded `-Xmx` (one fails safely, one gets the container SIGKILLed), `-XX:-UseContainerSupport`, the `ActiveProcessorCount` lie, and which diagnostic flags (`HeapDumpOnOutOfMemoryError`, `NativeMemoryTracking`) are worth leaving on in prod — on real Docker containers, JDK 25 and JDK 27. Its README and `docs/output/` replace a `docs/` folder; the write-up is [the post](https://ankurm.com/jvm-flags-for-spring-boot-in-containers-jdk-25-27/) | Spring Boot 4.1.1, Docker, two JDKs |
### JMH benchmarks
+8
View File
@@ -0,0 +1,8 @@
target/
docker/temurin27.tar.gz
docker/flags-demo.jar
docker/memoryhog.jar
docker/headersize.jar
*.hprof
*.class
raw/
+111
View File
@@ -0,0 +1,111 @@
# flags — companion module for "JVM Flags for Spring Boot in Containers (JDK 25/27)"
Module `flags/` of the [`zgc-jdk25-benchmarks`](https://ankurm.com/git.app/asmhatre/zgc-jdk25-benchmarks)
repository. Companion code for the post
[JVM Flags for Spring Boot in Containers (JDK 25/27): The Production Cheat Sheet](https://ankurm.com/jvm-flags-for-spring-boot-in-containers-jdk-25-27/)
on ankurm.com.
Everything here was run in real Docker containers with real `--memory` and `--cpus`
limits — not read about. `docs/output/*.txt` is the committed, unedited transcript of
every run the post quotes from; `scripts/run-all.sh` reproduces all of it.
This module is the flag-by-flag reference. Two sibling modules already go deeper on
specific pieces of this topic and this module leans on them rather than repeating them:
[`../jdk27-memory/`](../jdk27-memory) measured compact object headers and G1-everywhere
on a 2,000,000-row Spring Boot service across JDK 25/26/27, with a full container-sizing
sweep and kernel-level OOM forensics (`dmesg`, `memory.failcnt`) — see its
[README](../jdk27-memory/README.md) and the post
[JDK 27 Memory Changes](https://ankurm.com/jdk-27-compact-object-headers-g1-default-memory-spring-boot/).
The `kubernetes-deployment/` module of
[`spring-boot-demo`](https://ankurm.com/git.app/asmhatre/spring-boot-demo/src/branch/main/kubernetes-deployment)
measured CPU limits throttling the garbage collector and the `ActiveProcessorCount` overshoot
anti-pattern in full, on a real cluster — see the post
[Deploying Spring Boot 4 on Kubernetes](https://ankurm.com/spring-boot-4-kubernetes-probes-graceful-shutdown-cpu-limits-hpa/).
## Versions this was verified against
| Component | Version |
|---|---|
| JDK (LTS) | Temurin 25.0.4.1+1 |
| JDK (latest feature release) | Temurin 27+35 (GA 2026-09-15) |
| Spring Boot | 4.1.1 |
| Spring Framework | 7.0.x (via Boot 4.1.1 BOM) |
| Docker Engine | 29.4.3 |
| Base images | `eclipse-temurin:25-jre`, `eclipse-temurin:25-jdk`, `ubuntu:24.04` (JDK 27 has no official Temurin image yet — see `docker/Dockerfile.jdk27`) |
JEPs this module exercises directly: [JEP 523](https://openjdk.org/jeps/523) (G1 default
everywhere, JDK 27), [JEP 534](https://openjdk.org/jeps/534) (compact object headers by
default, JDK 27), [JEP 519](https://openjdk.org/jeps/519) (compact object headers as a
full product feature, JDK 25).
## Quickstart
Requires Docker (a running daemon) and JDK 25+ to build.
```bash
cd flags
mvn -q -B clean package -DskipTests
cp target/flags-demo.jar docker/flags-demo.jar
# JDK 27 has no eclipse-temurin:27-* image yet, so pull the GA tarball once:
curl -sL -o docker/temurin27.tar.gz \
"https://api.adoptium.net/v3/binary/latest/27/ga/linux/x64/jdk/hotspot/normal/eclipse"
docker build -t flags-demo:jdk25 -f docker/Dockerfile.jdk25 docker
docker build -t flags-demo:jdk25-jdk -f docker/Dockerfile.jdk25-jdk docker # full JDK, needed for jcmd
docker build -t flags-demo:jdk27 -f docker/Dockerfile.jdk27 docker
# run the Spring Boot app itself, container-limited to 512MB / 1 CPU
docker run --rm -p 8080:8080 --memory=512m --cpus=1 \
-e JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75" \
flags-demo:jdk27
curl localhost:8080/internal/jvm-flags | python3 -m json.tool
```
`scripts/run-all.sh` regenerates every file in `docs/output/`.
## What's in this module
| Path | What it is |
|---|---|
| `src/main/java/.../FlagsDemoApplication.java` | The Spring Boot app |
| `src/main/java/.../JvmDiagnosticsController.java` | `/internal/jvm-flags` — prints what the JVM actually resolved its container-relevant flags to, live. **Delete before shipping.** |
| `tools/MemoryHog.java` | Standalone (non-Spring) allocator used to deliberately trigger OOM conditions under different flag combinations — compiled to `docker/memoryhog.jar` |
| `tools/HeaderSizeDemo.java` | Standalone allocator used with `jcmd GC.class_histogram` to measure the real per-instance byte cost of compact object headers — compiled to `docker/headersize.jar` |
| `docker/Dockerfile.jdk25` | `eclipse-temurin:25-jre` + the app jar |
| `docker/Dockerfile.jdk25-jdk` | `eclipse-temurin:25-jdk` + the app jar (full JDK, for `jcmd`-based experiments) |
| `docker/Dockerfile.jdk27` | `ubuntu:24.04` + the Temurin 27 GA tarball installed by hand + the app jar |
## Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | `/internal/jvm-flags` | Live JSON: resolved `MaxRAMPercentage`/`MaxHeapSize`/GC/compact-header flags, heap usage, GC bean names, raw JVM input arguments |
| GET | `/actuator/health` | Standard Spring Boot Actuator health |
## Captured output index
| File | Demonstrates |
|---|---|
| `docs/output/01-gc-selection-jdk25.txt` | JDK 25 picks Serial below ~1 CPU / 1.8GB, G1 above it |
| `docs/output/02-gc-selection-jdk27.txt` | JDK 27 picks G1 unconditionally — [JEP 523](https://openjdk.org/jeps/523) |
| `docs/output/03-activeprocessorcount-lie.txt` | Overriding `ActiveProcessorCount` above the real CPU quota inflates `ParallelGCThreads` to match |
| `docs/output/04-compact-object-headers.txt` | Real `jcmd GC.class_histogram` byte counts: 24 bytes/instance with the legacy header, 16 bytes/instance compact — [JEP 534](https://openjdk.org/jeps/534) |
| `docs/output/05-maxrampercentage-correct-oom.txt` | `-XX:MaxRAMPercentage=75` in a 256MB container: heap fills, JVM throws a catchable `OutOfMemoryError`, container survives |
| `docs/output/06-hardcoded-xmx-oomkilled.txt` | `-Xmx1g` in a 256MB container: no JVM exception, the kernel OOM-killer kills the container (exit 137) |
| `docs/output/07-usecontainersupport-false-oomkilled.txt` | `-XX:-UseContainerSupport` makes the JVM size its heap off the *host's* memory, then the container is killed the same way |
| `docs/output/08-heapdumponoutofmemoryerror.txt` | `-XX:+HeapDumpOnOutOfMemoryError` actually leaving a usable `.hprof` behind |
| `docs/output/09-native-memory-tracking.txt` | `-XX:NativeMemoryTracking=summary` showing what the 25%/75% heap percentage does *not* account for |
## Production checklist (expanded in the post's closing accordion)
- Set `-XX:MaxRAMPercentage` explicitly; do not rely on the 25% default for a web service
- Never set an absolute `-Xmx`/`-Xms` in a container unless it is below the memory limit with room to spare
- Never set `-XX:-UseContainerSupport`, and never set `-XX:ActiveProcessorCount` above the real CPU quota
- Leave `-XX:+HeapDumpOnOutOfMemoryError` (with `-XX:HeapDumpPath` pointed at a mounted volume) and `-Xlog:gc*` on in production
- Delete `/internal/jvm-flags` (or guard it) before shipping
## License
MIT — see `LICENSE`.
+5
View File
@@ -0,0 +1,5 @@
FROM eclipse-temurin:25-jre
WORKDIR /app
COPY flags-demo.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java","-jar","/app/app.jar"]
+5
View File
@@ -0,0 +1,5 @@
FROM eclipse-temurin:25-jdk
WORKDIR /app
COPY flags-demo.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java","-jar","/app/app.jar"]
+12
View File
@@ -0,0 +1,12 @@
# No eclipse-temurin:27-* tag exists on Docker Hub yet (checked 2026-10-01) so this
# installs the Adoptium Temurin 27 GA tarball directly onto a slim Ubuntu base.
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
COPY temurin27.tar.gz /tmp/temurin27.tar.gz
RUN mkdir -p /opt/java && tar -xzf /tmp/temurin27.tar.gz -C /opt/java --strip-components=1 && rm /tmp/temurin27.tar.gz
ENV JAVA_HOME=/opt/java
ENV PATH=$JAVA_HOME/bin:$PATH
WORKDIR /app
COPY flags-demo.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java","-jar","/app/app.jar"]
@@ -0,0 +1,59 @@
$ for mem,cpu in 256m:1 512m:1 1800m:1 512m:2 1800m:2 4g:2; do
docker run --rm --memory=$mem --cpus=$cpu --entrypoint java flags-demo:jdk25 \
-XX:+PrintFlagsFinal -version | grep -E "UseG1GC|UseSerialGC|UseCompactObjectHeaders|MaxHeapSize|MaxRAMPercentage"
done
--- mem=256m cpus=1 ---
int ActiveProcessorCount = -1 {product} {default}
size_t MaxHeapSize = 132120576 {product} {ergonomic}
double MaxRAMPercentage = 25.000000 {product} {default}
bool UseCompactObjectHeaders = false {product lp64_product} {default}
bool UseG1GC = false {product} {default}
bool UseParallelGC = false {product} {default}
bool UseSerialGC = true {product} {ergonomic}
--- mem=512m cpus=1 ---
int ActiveProcessorCount = -1 {product} {default}
size_t MaxHeapSize = 134217728 {product} {ergonomic}
double MaxRAMPercentage = 25.000000 {product} {default}
bool UseCompactObjectHeaders = false {product lp64_product} {default}
bool UseG1GC = false {product} {default}
bool UseParallelGC = false {product} {default}
bool UseSerialGC = true {product} {ergonomic}
--- mem=1800m cpus=1 ---
int ActiveProcessorCount = -1 {product} {default}
size_t MaxHeapSize = 471859200 {product} {ergonomic}
double MaxRAMPercentage = 25.000000 {product} {default}
bool UseCompactObjectHeaders = false {product lp64_product} {default}
bool UseG1GC = false {product} {default}
bool UseParallelGC = false {product} {default}
bool UseSerialGC = true {product} {ergonomic}
--- mem=512m cpus=2 ---
int ActiveProcessorCount = -1 {product} {default}
size_t MaxHeapSize = 134217728 {product} {ergonomic}
double MaxRAMPercentage = 25.000000 {product} {default}
bool UseCompactObjectHeaders = false {product lp64_product} {default}
bool UseG1GC = false {product} {default}
bool UseParallelGC = false {product} {default}
bool UseSerialGC = true {product} {ergonomic}
--- mem=1800m cpus=2 ---
int ActiveProcessorCount = -1 {product} {default}
size_t MaxHeapSize = 471859200 {product} {ergonomic}
double MaxRAMPercentage = 25.000000 {product} {default}
bool UseCompactObjectHeaders = false {product lp64_product} {default}
bool UseG1GC = true {product} {ergonomic}
bool UseParallelGC = false {product} {default}
bool UseSerialGC = false {product} {default}
--- mem=4g cpus=2 ---
int ActiveProcessorCount = -1 {product} {default}
size_t MaxHeapSize = 1073741824 {product} {ergonomic}
double MaxRAMPercentage = 25.000000 {product} {default}
bool UseCompactObjectHeaders = false {product lp64_product} {default}
bool UseG1GC = true {product} {ergonomic}
bool UseParallelGC = false {product} {default}
bool UseSerialGC = false {product} {default}
@@ -0,0 +1,59 @@
$ for mem,cpu in 256m:1 512m:1 1800m:1 512m:2 1800m:2 4g:2; do
docker run --rm --memory=$mem --cpus=$cpu --entrypoint java flags-demo:jdk27 \
-XX:+PrintFlagsFinal -version | grep -E "UseG1GC|UseSerialGC|UseCompactObjectHeaders|MaxHeapSize|MaxRAMPercentage"
done
--- mem=256m cpus=1 ---
int ActiveProcessorCount = -1 {product} {default}
size_t MaxHeapSize = 132120576 {product} {ergonomic}
double MaxRAMPercentage = 25.000000 {product} {default}
bool UseCompactObjectHeaders = true {product lp64_product} {default}
bool UseG1GC = true {product} {ergonomic}
bool UseParallelGC = false {product} {default}
bool UseSerialGC = false {product} {default}
--- mem=512m cpus=1 ---
int ActiveProcessorCount = -1 {product} {default}
size_t MaxHeapSize = 134217728 {product} {ergonomic}
double MaxRAMPercentage = 25.000000 {product} {default}
bool UseCompactObjectHeaders = true {product lp64_product} {default}
bool UseG1GC = true {product} {ergonomic}
bool UseParallelGC = false {product} {default}
bool UseSerialGC = false {product} {default}
--- mem=1800m cpus=1 ---
int ActiveProcessorCount = -1 {product} {default}
size_t MaxHeapSize = 471859200 {product} {ergonomic}
double MaxRAMPercentage = 25.000000 {product} {default}
bool UseCompactObjectHeaders = true {product lp64_product} {default}
bool UseG1GC = true {product} {ergonomic}
bool UseParallelGC = false {product} {default}
bool UseSerialGC = false {product} {default}
--- mem=512m cpus=2 ---
int ActiveProcessorCount = -1 {product} {default}
size_t MaxHeapSize = 134217728 {product} {ergonomic}
double MaxRAMPercentage = 25.000000 {product} {default}
bool UseCompactObjectHeaders = true {product lp64_product} {default}
bool UseG1GC = true {product} {ergonomic}
bool UseParallelGC = false {product} {default}
bool UseSerialGC = false {product} {default}
--- mem=1800m cpus=2 ---
int ActiveProcessorCount = -1 {product} {default}
size_t MaxHeapSize = 471859200 {product} {ergonomic}
double MaxRAMPercentage = 25.000000 {product} {default}
bool UseCompactObjectHeaders = true {product lp64_product} {default}
bool UseG1GC = true {product} {ergonomic}
bool UseParallelGC = false {product} {default}
bool UseSerialGC = false {product} {default}
--- mem=4g cpus=2 ---
int ActiveProcessorCount = -1 {product} {default}
size_t MaxHeapSize = 1073741824 {product} {ergonomic}
double MaxRAMPercentage = 25.000000 {product} {default}
bool UseCompactObjectHeaders = true {product lp64_product} {default}
bool UseG1GC = true {product} {ergonomic}
bool UseParallelGC = false {product} {default}
bool UseSerialGC = false {product} {default}
@@ -0,0 +1,13 @@
$ docker run --rm --cpus=1 --memory=1g --entrypoint java flags-demo:jdk27 -XX:+PrintFlagsFinal -version | grep -E "ParallelGCThreads|ConcGCThreads|ActiveProcessorCount"
$ docker run --rm --cpus=1 --memory=1g --entrypoint java flags-demo:jdk27 -XX:ActiveProcessorCount=8 -XX:+PrintFlagsFinal -version | grep -E "ParallelGCThreads|ConcGCThreads|ActiveProcessorCount"
=== ActiveProcessorCount lying to the JVM: 1 real CPU, told there are 8 ===
--- JDK27, --cpus=1, no override (correct) ---
int ActiveProcessorCount = -1 {product} {default}
uint ConcGCThreads = 1 {product} {ergonomic}
uint ParallelGCThreads = 1 {product} {default}
--- JDK27, --cpus=1, -XX:ActiveProcessorCount=8 (lying) ---
int ActiveProcessorCount = 8 {product} {command line}
uint ConcGCThreads = 2 {product} {ergonomic}
uint ParallelGCThreads = 8 {product} {default}
@@ -0,0 +1,27 @@
$ docker run -d --memory=1g --name hdr -v $PWD/docker/headersize.jar:/headersize.jar \
--entrypoint java flags-demo:<tag> [extra flags] -cp /headersize.jar HeaderSizeDemo 2000000
$ docker exec hdr jcmd 1 GC.class_histogram | grep -E "Pair|Total"
--- JDK 25, default (UseCompactObjectHeaders=false), flags-demo:jdk25-jdk ---
1: 2000000 48000000 HeaderSizeDemo$Pair
273: 1 24 java.lang.invoke.StringConcatFactory$InlineHiddenClassStrategy$MethodHandlePair ([email protected])
Total 2030279 57368512
--- JDK 25, -XX:+UseCompactObjectHeaders ---
1: 2000000 32000000 HeaderSizeDemo$Pair
300: 1 16 java.lang.invoke.StringConcatFactory$InlineHiddenClassStrategy$MethodHandlePair ([email protected])
Total 2030283 41272056
--- JDK 27, default (UseCompactObjectHeaders=true), flags-demo:jdk27 ---
1: 2000000 32000000 HeaderSizeDemo$Pair
317: 1 16 java.lang.invoke.StringConcatFactory$InlineHiddenClassStrategy$MethodHandlePair (java.base@27)
Total 2030937 41320144
--- JDK 27, -XX:-UseCompactObjectHeaders ---
1: 2000000 48000000 HeaderSizeDemo$Pair
282: 1 24 java.lang.invoke.StringConcatFactory$InlineHiddenClassStrategy$MethodHandlePair (java.base@27)
Total 2031092 57425040
2,000,000 instances of a 2-int object: 48,000,000 bytes (24 bytes each) with the old header,
32,000,000 bytes (16 bytes each) compact. 33% smaller per instance, reproduced symmetrically
in both directions on both JDKs.
@@ -0,0 +1,23 @@
$ docker run --rm --memory=256m --entrypoint java flags-demo:jdk25 \
-Xlog:gc -XX:MaxRAMPercentage=75 -cp /memoryhog.jar MemoryHog 24 200
[0.004s][info][gc] Using Serial
MemoryHog starting: chunk=24MB sleep=200ms
[0.048s][info][gc] GC(0) Pause Young (Allocation Failure) 1M->1M(7M) 0.713ms
held=24MB heapUsed=25MB heapMax=185MB freeHeap=6MB
[0.291s][info][gc] GC(1) Pause Young (Allocation Failure) 25M->25M(31M) 1.105ms
held=48MB heapUsed=49MB heapMax=185MB freeHeap=6MB
[0.507s][info][gc] GC(2) Pause Young (Allocation Failure) 49M->49M(77M) 0.643ms
held=72MB heapUsed=73MB heapMax=185MB freeHeap=27MB
[0.723s][info][gc] GC(3) Pause Young (Allocation Failure) 73M->73M(112M) 0.176ms
held=96MB heapUsed=98MB heapMax=185MB freeHeap=14MB
[0.962s][info][gc] GC(4) Pause Young (Allocation Failure) 98M->97M(147M) 23.535ms
held=120MB heapUsed=122MB heapMax=185MB freeHeap=24MB
[1.201s][info][gc] GC(5) Pause Young (Allocation Failure) 122M->121M(181M) 28.449ms
held=144MB heapUsed=146MB heapMax=185MB freeHeap=35MB
held=168MB heapUsed=170MB heapMax=185MB freeHeap=11MB
[1.633s][info][gc] GC(6) Pause Full (Allocation Failure) 170M->169M(185M) 5.895ms
[1.649s][info][gc] GC(7) Pause Full (Allocation Failure) 169M->169M(185M) 15.822ms
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at MemoryHog.main(MemoryHog.java:21)
exit=1
@@ -0,0 +1,26 @@
$ docker run --rm --memory=256m --entrypoint java flags-demo:jdk25 \
-Xlog:gc -Xmx1g -cp /memoryhog.jar MemoryHog 24 200
$ echo "exit code: $?"
$ docker inspect <id> --format "OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}"
[0.005s][info][gc] Using Serial
MemoryHog starting: chunk=24MB sleep=200ms
[0.054s][info][gc] GC(0) Pause Young (Allocation Failure) 1M->1M(7M) 0.685ms
held=24MB heapUsed=25MB heapMax=989MB freeHeap=6MB
[0.296s][info][gc] GC(1) Pause Young (Allocation Failure) 25M->25M(31M) 1.057ms
held=48MB heapUsed=49MB heapMax=989MB freeHeap=6MB
[0.513s][info][gc] GC(2) Pause Young (Allocation Failure) 49M->49M(77M) 0.606ms
held=72MB heapUsed=73MB heapMax=989MB freeHeap=27MB
[0.728s][info][gc] GC(3) Pause Young (Allocation Failure) 73M->73M(112M) 0.184ms
held=96MB heapUsed=98MB heapMax=989MB freeHeap=14MB
[0.959s][info][gc] GC(4) Pause Young (Allocation Failure) 98M->97M(147M) 15.593ms
held=120MB heapUsed=122MB heapMax=989MB freeHeap=24MB
[1.180s][info][gc] GC(5) Pause Young (Allocation Failure) 122M->121M(181M) 15.364ms
held=144MB heapUsed=146MB heapMax=989MB freeHeap=35MB
held=168MB heapUsed=170MB heapMax=989MB freeHeap=11MB
[1.634s][info][gc] GC(6) Pause Young (Allocation Failure) 170M->169M(251M) 34.003ms
held=192MB heapUsed=194MB heapMax=989MB freeHeap=56MB
held=216MB heapUsed=218MB heapMax=989MB freeHeap=32MB
container exit code: 137
error: no such object: hogB
@@ -0,0 +1,31 @@
$ docker run --rm --memory=256m --entrypoint java flags-demo:jdk25 \
-XX:-UseContainerSupport -XX:+PrintFlagsFinal -version | grep -E "MaxHeapSize|UseContainerSupport"
$ docker run --rm --memory=256m --entrypoint java flags-demo:jdk25 \
-Xlog:gc -XX:-UseContainerSupport -cp /memoryhog.jar MemoryHog 24 200
--- what the flag alone resolves heap to ---
size_t MaxHeapSize = 2105540608 {product} {ergonomic}
size_t SoftMaxHeapSize = 2105540608 {manageable} {ergonomic}
bool UseContainerSupport = false {product} {command line}
--- the hog run ---
[0.004s][info][gc] Using G1
MemoryHog starting: chunk=24MB sleep=200ms
held=24MB heapUsed=26MB heapMax=2008MB freeHeap=101MB
held=48MB heapUsed=52MB heapMax=2008MB freeHeap=75MB
[0.515s][info][gc] GC(0) Pause Young (Concurrent Start) (G1 Humongous Allocation) 52M->51M(128M) 1.445ms
[0.515s][info][gc] GC(1) Concurrent Undo Cycle
[0.515s][info][gc] GC(1) Concurrent Undo Cycle 0.084ms
held=72MB heapUsed=76MB heapMax=2008MB freeHeap=51MB
[0.731s][info][gc] GC(2) Pause Young (Concurrent Start) (G1 Humongous Allocation) 76M->76M(128M) 1.569ms
[0.732s][info][gc] GC(3) Concurrent Mark Cycle
[0.748s][info][gc] GC(3) Pause Remark 101M->101M(172M) 0.580ms
[0.749s][info][gc] GC(3) Pause Cleanup 101M->101M(172M) 0.018ms
held=96MB heapUsed=101MB heapMax=2008MB freeHeap=70MB
[0.751s][info][gc] GC(3) Concurrent Mark Cycle 19.274ms
held=120MB heapUsed=126MB heapMax=2008MB freeHeap=45MB
held=144MB heapUsed=151MB heapMax=2008MB freeHeap=45MB
held=168MB heapUsed=176MB heapMax=2008MB freeHeap=45MB
held=192MB heapUsed=201MB heapMax=2008MB freeHeap=45MB
held=216MB heapUsed=226MB heapMax=2008MB freeHeap=45MB
container exit code: 137
@@ -0,0 +1,23 @@
$ docker run --rm --memory=256m -v $PWD/docker/memoryhog.jar:/memoryhog.jar --entrypoint java flags-demo:jdk25-jdk \
-XX:MaxRAMPercentage=75 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps/heap.hprof \
-cp /memoryhog.jar MemoryHog 24 200
=== Diagnostic: -XX:+HeapDumpOnOutOfMemoryError actually leaves a dump behind ===
MemoryHog starting: chunk=24MB sleep=200ms
held=24MB heapUsed=25MB heapMax=185MB freeHeap=6MB
held=48MB heapUsed=49MB heapMax=185MB freeHeap=6MB
held=72MB heapUsed=73MB heapMax=185MB freeHeap=27MB
held=96MB heapUsed=98MB heapMax=185MB freeHeap=14MB
held=120MB heapUsed=122MB heapMax=185MB freeHeap=24MB
held=144MB heapUsed=146MB heapMax=185MB freeHeap=35MB
held=168MB heapUsed=170MB heapMax=185MB freeHeap=11MB
java.lang.OutOfMemoryError: Java heap space
Dumping heap to /dumps/heap.hprof ...
Heap dump file created [180390818 bytes in 1.028 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at MemoryHog.main(MemoryHog.java:21)
exit=1
total 176176
drwxr-xr-x 2 root root 4096 Oct 1 04:29 .
drwxr-xr-x 3 root root 4096 Oct 1 04:29 ..
-rw------- 1 root root 180390818 Oct 1 04:29 heap.hprof
@@ -0,0 +1,97 @@
$ docker run -d --memory=512m --name nmtdemo -v $PWD/docker/flags-demo.jar:/app.jar --entrypoint java flags-demo:jdk25-jdk \
-XX:NativeMemoryTracking=summary -XX:MaxRAMPercentage=75 -jar /app.jar
$ docker exec nmtdemo jcmd 1 VM.native_memory summary
=== Diagnostic: -XX:NativeMemoryTracking=summary via jcmd ===
1:
Native Memory Tracking:
(Omitting categories weighting less than 1KB)
Total: reserved=1824154KB, committed=115878KB
malloc: 34018KB #144561, peak=35841KB #140626
mmap: reserved=1790136KB, committed=81860KB
- Java Heap (reserved=393216KB, committed=23532KB)
(mmap: reserved=393216KB, committed=23532KB, at peak)
- Class (reserved=1049299KB, committed=5075KB)
(classes #8049)
( instance classes #7466, array classes #583)
(malloc=723KB tag=Class #15577) (at peak)
(mmap: reserved=1048576KB, committed=4352KB, at peak)
( Metadata: )
( reserved=65536KB, committed=27072KB)
( used=26779KB)
( waste=293KB =1.08%)
( Class space:)
( reserved=1048576KB, committed=4352KB)
( used=4187KB)
( waste=165KB =3.79%)
- Thread (reserved=17468KB, committed=1380KB)
(threads #17)
(stack: reserved=17408KB, committed=1320KB, peak=1320KB)
(malloc=42KB tag=Thread #105) (peak=51KB #108)
(arena=18KB #30) (peak=301KB #22)
- Code (reserved=252158KB, committed=16014KB)
(malloc=4465KB tag=Code #21734) (at peak)
(mmap: reserved=247692KB, committed=11548KB, at peak)
(arena=1KB #1) (peak=34KB #2)
- GC (reserved=1302KB, committed=102KB)
(malloc=22KB tag=GC #58) (peak=96KB #79)
(mmap: reserved=1280KB, committed=80KB, at peak)
- Compiler (reserved=8163KB, committed=8163KB)
(malloc=54KB tag=Compiler #86) (peak=69KB #91)
(arena=8110KB #14) (peak=22951KB #14)
- Internal (reserved=1324KB, committed=1324KB)
(malloc=1288KB tag=Internal #5521) (at peak)
(mmap: reserved=36KB, committed=36KB, at peak)
- Other (reserved=20KB, committed=20KB)
(malloc=20KB tag=Other #3) (at peak)
- Symbol (reserved=6745KB, committed=6745KB)
(malloc=6034KB tag=Symbol #93254) (at peak)
(arena=712KB #1) (at peak)
- Native Memory Tracking (reserved=2576KB, committed=2576KB)
(malloc=35KB tag=Native Memory Tracking #619) (at peak)
(tracking overhead=2541KB)
- Shared class space (reserved=16384KB, committed=13912KB, readonly=0KB)
(mmap: reserved=16384KB, committed=13912KB, peak=14120KB)
- Arena Chunk (reserved=9219KB, committed=9219KB)
(malloc=9219KB tag=Arena Chunk #541) (peak=23793KB #558)
- Tracing (reserved=5KB, committed=5KB)
(malloc=5KB tag=Tracing #22) (at peak)
- Module (reserved=80KB, committed=80KB)
(malloc=80KB tag=Module #2158) (at peak)
- Safepoint (reserved=8KB, committed=8KB)
(mmap: reserved=8KB, committed=8KB, at peak)
- Synchronization (reserved=483KB, committed=483KB)
(malloc=483KB tag=Synchronization #4738) (at peak)
- Serviceability (reserved=17KB, committed=17KB)
(malloc=17KB tag=Serviceability #14) (peak=20KB #18)
- Metaspace (reserved=65672KB, committed=27208KB)
(malloc=136KB tag=Metaspace #46) (at peak)
(mmap: reserved=65536KB, committed=27072KB, at peak)
- String Deduplication (reserved=1KB, committed=1KB)
(malloc=1KB tag=String Deduplication #8) (at peak)
- Object Monitors (reserved=13KB, committed=13KB)
(malloc=13KB tag=Object Monitors #69) (at peak)
+44
View File
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>flags-demo</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<java.version>25</java.version>
<maven.compiler.release>25</maven.compiler.release>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
<build>
<finalName>flags-demo</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# Regenerates every file under docs/output/. Requires Docker (a running daemon)
# and network access to pull eclipse-temurin:25-jre, eclipse-temurin:25-jdk, and
# ubuntu:24.04, plus ~280MB to download the Temurin 27 GA tarball (no official
# eclipse-temurin:27-* image exists on Docker Hub as of 2026-10-01, which is why
# docker/Dockerfile.jdk27 installs the tarball directly).
set -euo pipefail
cd "$(dirname "$0")/.."
echo "== building images =="
mvn -q -B clean package -DskipTests
cp target/flags-demo.jar docker/flags-demo.jar
if [ ! -f docker/temurin27.tar.gz ]; then
curl -sL -o docker/temurin27.tar.gz \
"https://api.adoptium.net/v3/binary/latest/27/ga/linux/x64/jdk/hotspot/normal/eclipse"
fi
docker build -t flags-demo:jdk25 -f docker/Dockerfile.jdk25 docker
docker build -t flags-demo:jdk25-jdk -f docker/Dockerfile.jdk25-jdk docker
docker build -t flags-demo:jdk27 -f docker/Dockerfile.jdk27 docker
echo "== 01/02: GC selection matrix, JDK 25 vs JDK 27 (JEP 523) =="
combos=("256m:1" "512m:1" "1800m:1" "512m:2" "1800m:2" "4g:2")
for jdk in jdk25 jdk27; do
out="docs/output/0$([ "$jdk" = jdk25 ] && echo 1 || echo 2)-gc-selection-$jdk.txt"
: > "$out.tmp"
for c in "${combos[@]}"; do
mem="${c%%:*}"; cpu="${c##*:}"
echo "--- mem=$mem cpus=$cpu ---" >> "$out.tmp"
docker run --rm --memory="$mem" --cpus="$cpu" --entrypoint java "flags-demo:$jdk" \
-XX:+PrintFlagsFinal -version 2>/dev/null \
| grep -E "^\s*(bool UseG1GC|bool UseSerialGC|bool UseParallelGC|size_t MaxHeapSize|int ActiveProcessorCount|bool UseCompactObjectHeaders|double MaxRAMPercentage)\s*=" \
>> "$out.tmp"
echo "" >> "$out.tmp"
done
mv "$out.tmp" "$out"
done
echo "== 03: ActiveProcessorCount lying to the JVM =="
{
docker run --rm --cpus=1 --memory=1g --entrypoint java flags-demo:jdk27 \
-XX:+PrintFlagsFinal -version 2>/dev/null | grep -E "ParallelGCThreads|ConcGCThreads|ActiveProcessorCount"
echo ""
docker run --rm --cpus=1 --memory=1g --entrypoint java flags-demo:jdk27 \
-XX:ActiveProcessorCount=8 -XX:+PrintFlagsFinal -version 2>/dev/null | grep -E "ParallelGCThreads|ConcGCThreads|ActiveProcessorCount"
} > docs/output/03-activeprocessorcount-lie.txt.body
echo "== 04: compact object headers, byte-for-byte (JEP 534) =="
run_histo() {
local tag=$1 extra=$2 cname=$3
docker run -d --memory=1g --name "$cname" -v "$PWD/docker/headersize.jar:/headersize.jar" \
--entrypoint java "flags-demo:$tag" $extra -cp /headersize.jar HeaderSizeDemo 2000000 >/dev/null
sleep 6
docker exec "$cname" jcmd 1 GC.class_histogram 2>&1 | grep -E "Pair|Total"
docker rm -f "$cname" >/dev/null 2>&1
}
{
echo "--- JDK 25, default (UseCompactObjectHeaders=false) ---"
run_histo jdk25-jdk "" hdr25default
echo ""
echo "--- JDK 25, -XX:+UseCompactObjectHeaders ---"
run_histo jdk25-jdk "-XX:+UseCompactObjectHeaders" hdr25on
echo ""
echo "--- JDK 27, default (UseCompactObjectHeaders=true) ---"
run_histo jdk27 "" hdr27default
echo ""
echo "--- JDK 27, -XX:-UseCompactObjectHeaders ---"
run_histo jdk27 "-XX:-UseCompactObjectHeaders" hdr27off
} > docs/output/04-compact-object-headers.txt.body
echo "== 05: a correctly set MaxRAMPercentage fails safely =="
docker run --rm --memory=256m -v "$PWD/docker/memoryhog.jar:/memoryhog.jar" --entrypoint java flags-demo:jdk25 \
-Xlog:gc -XX:MaxRAMPercentage=75 -cp /memoryhog.jar MemoryHog 24 200 \
> docs/output/05-maxrampercentage-correct-oom.txt.body 2>&1 || true
echo "== 06: a hardcoded -Xmx bigger than the container gets the container killed =="
docker run --rm --memory=256m -v "$PWD/docker/memoryhog.jar:/memoryhog.jar" --entrypoint java flags-demo:jdk25 \
-Xlog:gc -Xmx1g -cp /memoryhog.jar MemoryHog 24 200 \
> docs/output/06-hardcoded-xmx-oomkilled.txt.body 2>&1
echo "(exit code above should be 137 - SIGKILL from the cgroup OOM killer)"
echo "== 07: -XX:-UseContainerSupport makes the JVM blind to the container =="
{
docker run --rm --memory=256m --entrypoint java flags-demo:jdk25 \
-XX:-UseContainerSupport -XX:+PrintFlagsFinal -version 2>/dev/null | grep -E "MaxHeapSize|UseContainerSupport"
echo ""
docker run --rm --memory=256m -v "$PWD/docker/memoryhog.jar:/memoryhog.jar" --entrypoint java flags-demo:jdk25 \
-Xlog:gc -XX:-UseContainerSupport -cp /memoryhog.jar MemoryHog 24 200
} > docs/output/07-usecontainersupport-false-oomkilled.txt.body 2>&1 || true
echo "== 08: -XX:+HeapDumpOnOutOfMemoryError actually leaves a dump =="
mkdir -p /tmp/flags-demo-dumps
docker run --rm --memory=256m -v "$PWD/docker/memoryhog.jar:/memoryhog.jar" -v /tmp/flags-demo-dumps:/dumps \
--entrypoint java flags-demo:jdk25-jdk \
-XX:MaxRAMPercentage=75 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps/heap.hprof \
-cp /memoryhog.jar MemoryHog 24 200 > docs/output/08-heapdumponoutofmemoryerror.txt.body 2>&1 || true
ls -la /tmp/flags-demo-dumps >> docs/output/08-heapdumponoutofmemoryerror.txt.body
rm -rf /tmp/flags-demo-dumps
echo "== 09: -XX:NativeMemoryTracking=summary =="
docker run -d --memory=512m --name nmtdemo -v "$PWD/docker/flags-demo.jar:/app.jar" --entrypoint java flags-demo:jdk25-jdk \
-XX:NativeMemoryTracking=summary -XX:MaxRAMPercentage=75 -jar /app.jar >/dev/null
sleep 6
docker exec nmtdemo jcmd 1 VM.native_memory summary > docs/output/09-native-memory-tracking.txt.body 2>&1
docker rm -f nmtdemo >/dev/null 2>&1
echo "Done. Each docs/output/NN-*.txt.body is the fresh capture; prepend the command"
echo "comment block from the committed .txt file by hand if you are regenerating for a diff."
@@ -0,0 +1,19 @@
package com.ankurm.flagsdemo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Companion app for "JVM Flags for Spring Boot in Containers (JDK 25/27)" on ankurm.com.
* See docs/output/*.txt for the captured runs this post quotes from, and
* {@link JvmDiagnosticsController} for the live /internal/jvm-flags endpoint
* used to inspect what the JVM actually resolved its memory/GC flags to
* inside a running container. Delete that endpoint before shipping this to
* a real service — it exists for this demo only.
*/
@SpringBootApplication
public class FlagsDemoApplication {
public static void main(String[] args) {
SpringApplication.run(FlagsDemoApplication.class, args);
}
}
@@ -0,0 +1,83 @@
package com.ankurm.flagsdemo;
import com.sun.management.HotSpotDiagnosticMXBean;
import com.sun.management.OperatingSystemMXBean;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.lang.management.MemoryUsage;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Prints what the JVM actually resolved its container-relevant flags to, at
* runtime, inside whatever cgroup it is running under. This is the live
* version of what docs/output/*.txt captures from -XX:+PrintFlagsFinal; the
* two should agree, and the post at ankurm.com cross-checks both.
*
* <p>Explained in the post:
* https://ankurm.com/jvm-flags-for-spring-boot-in-containers-jdk-25-27/
*
* <p><strong>Delete this endpoint before shipping to a real service.</strong>
* It leaks heap sizing and cgroup limits to anyone who can reach it.
*/
@RestController
public class JvmDiagnosticsController {
private static final List<String> FLAGS_OF_INTEREST = List.of(
"MaxRAMPercentage", "MinRAMPercentage", "InitialRAMPercentage",
"MaxRAM", "MaxHeapSize", "InitialHeapSize", "MinHeapSize",
"UseContainerSupport", "ActiveProcessorCount",
"UseG1GC", "UseParallelGC", "UseZGC", "UseSerialGC",
"UseCompactObjectHeaders", "ParallelGCThreads", "ConcGCThreads"
);
@GetMapping("/internal/jvm-flags")
public Map<String, Object> jvmFlags() {
Map<String, Object> out = new LinkedHashMap<>();
OperatingSystemMXBean os =
(OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean();
Map<String, Object> hostView = new LinkedHashMap<>();
hostView.put("availableProcessors", Runtime.getRuntime().availableProcessors());
hostView.put("totalMemorySizeBytes", os.getTotalMemorySize());
hostView.put("freeMemorySizeBytes", os.getFreeMemorySize());
out.put("osMXBeanView", hostView);
MemoryMXBean mem = ManagementFactory.getMemoryMXBean();
MemoryUsage heap = mem.getHeapMemoryUsage();
Map<String, Object> heapView = new LinkedHashMap<>();
heapView.put("initBytes", heap.getInit());
heapView.put("usedBytes", heap.getUsed());
heapView.put("committedBytes", heap.getCommitted());
heapView.put("maxBytes", heap.getMax());
out.put("heap", heapView);
List<String> gcNames = ManagementFactory.getGarbageCollectorMXBeans()
.stream().map(GarbageCollectorMXBean::getName).toList();
out.put("garbageCollectors", gcNames);
HotSpotDiagnosticMXBean diag =
ManagementFactory.getPlatformMXBean(HotSpotDiagnosticMXBean.class);
Map<String, String> flagView = new LinkedHashMap<>();
for (String name : FLAGS_OF_INTEREST) {
try {
flagView.put(name, diag.getVMOption(name).getValue());
} catch (IllegalArgumentException notThisJvm) {
flagView.put(name, "N/A on this build");
}
}
out.put("resolvedFlags", flagView);
out.put("jvmInputArguments",
ManagementFactory.getRuntimeMXBean().getInputArguments());
out.put("javaVersion", System.getProperty("java.version"));
out.put("javaVmName", System.getProperty("java.vm.name"));
return out;
}
}
+16
View File
@@ -0,0 +1,16 @@
server:
port: 8080
management:
endpoints:
web:
exposure:
include: health,info
spring:
application:
name: flags-demo
logging:
level:
root: INFO
+26
View File
@@ -0,0 +1,26 @@
import java.util.ArrayList;
import java.util.List;
/**
* Allocates a large number of a trivial fixed-shape object and a trivial
* array, then sleeps so `jcmd <pid> GC.class_histogram` can be run against
* it from outside the container. Used to measure the per-instance byte cost
* that -XX:+/-UseCompactObjectHeaders actually makes, rather than quoting
* the JEP's own number. See
* https://ankurm.com/jvm-flags-for-spring-boot-in-containers-jdk-25-27/
*/
public class HeaderSizeDemo {
static final class Pair { int a; int b; Pair(int a, int b) { this.a = a; this.b = b; } }
public static void main(String[] args) throws Exception {
int count = args.length > 0 ? Integer.parseInt(args[0]) : 2_000_000;
List<Pair> pairs = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
pairs.add(new Pair(i, i));
}
System.out.println("allocated " + pairs.size() + " Pair instances, pid=" + ProcessHandle.current().pid());
System.gc();
Thread.sleep(120_000);
System.out.println(pairs.size());
}
}
+37
View File
@@ -0,0 +1,37 @@
import java.util.ArrayList;
import java.util.List;
/**
* Deliberately allocates and retains heap in fixed-size chunks until it is
* killed or finishes, printing progress as it goes. Used to demonstrate what
* actually happens when a container's memory limit, the JVM's heap flags,
* and real allocation pressure disagree with each other. See the post at
* https://ankurm.com/jvm-flags-for-spring-boot-in-containers-jdk-25-27/
* and docs/output/05-oom-*.txt / 06-oom-*.txt for the captured runs.
*/
public class MemoryHog {
public static void main(String[] args) throws Exception {
int chunkMb = args.length > 0 ? Integer.parseInt(args[0]) : 16;
int sleepMs = args.length > 1 ? Integer.parseInt(args[1]) : 150;
List<byte[]> held = new ArrayList<>();
long totalMb = 0;
System.out.println("MemoryHog starting: chunk=" + chunkMb + "MB sleep=" + sleepMs + "ms");
System.out.flush();
while (true) {
byte[] chunk = new byte[chunkMb * 1024 * 1024];
for (int i = 0; i < chunk.length; i += 4096) {
chunk[i] = 1; // touch each page so it is actually resident, not just reserved
}
held.add(chunk);
totalMb += chunkMb;
Runtime rt = Runtime.getRuntime();
System.out.printf("held=%dMB heapUsed=%dMB heapMax=%dMB freeHeap=%dMB%n",
totalMb,
(rt.totalMemory() - rt.freeMemory()) / (1024 * 1024),
rt.maxMemory() / (1024 * 1024),
rt.freeMemory() / (1024 * 1024));
System.out.flush();
Thread.sleep(sleepMs);
}
}
}