Add shenandoah-jdk28: generational vs SATB Shenandoah, G1 and ZGC on a Spring Boot service (JEP 535 status probe included)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HmvoHH3ZnCEaPNXRpDxDZe
This commit is contained in:
Claude
2026-10-24 10:53:17 +00:00
parent df13a904d7
commit b25bd65d4b
14 changed files with 460 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Arrays;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.LockSupport;
/**
* Load generator, run with the source launcher: java LoadGen.java open|closed URL RATE_OR_0 CONCURRENCY WARMUP_S MEASURE_S
*
* open : requests are SCHEDULED at a fixed rate and latency is measured from the scheduled time, so a server
* that stalls is charged for the requests that queued behind the stall (no coordinated omission).
* closed : CONCURRENCY clients send back to back; the result is the peak throughput.
*
* Prints one line: mode=... achieved=... p50_ms=... p99_ms=... p999_ms=... max_ms=... errors=... window_start_ms=... window_end_ms=...
* (the window is the measured interval in epoch milliseconds, so GC pauses can be cut to exactly that interval).
*/
public class LoadGen {
public static void main(String[] a) throws Exception {
String mode = a[0];
URI uri = URI.create(a[1]);
double rate = Double.parseDouble(a[2]);
int concurrency = Integer.parseInt(a[3]);
int warmup = Integer.parseInt(a[4]);
int measure = Integer.parseInt(a[5]);
boolean open = mode.equals("open");
HttpClient client = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(5)).build();
HttpRequest request = HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(30)).GET().build();
long start = System.nanoTime();
long epochStartMs = System.currentTimeMillis();
long measureFrom = start + warmup * 1_000_000_000L;
long end = measureFrom + measure * 1_000_000_000L;
int capacity = (int) (open ? rate * measure * 2 + 1000 : 2_000_000);
long[] latencies = new long[capacity];
AtomicInteger count = new AtomicInteger();
AtomicLong errors = new AtomicLong();
AtomicLong next = new AtomicLong();
long intervalNanos = open ? (long) (1e9 / rate) : 0;
ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();
for (int t = 0; t < concurrency; t++) {
pool.submit(() -> {
while (true) {
long intended;
if (open) {
intended = start + next.getAndIncrement() * intervalNanos;
if (intended >= end) return;
long wait = intended - System.nanoTime();
if (wait > 0) LockSupport.parkNanos(wait);
} else {
intended = System.nanoTime();
if (intended >= end) return;
}
try {
HttpResponse<Void> r = client.send(request, HttpResponse.BodyHandlers.discarding());
long done = System.nanoTime();
if (r.statusCode() != 200) errors.incrementAndGet();
if (intended >= measureFrom) {
int i = count.getAndIncrement();
if (i < latencies.length) latencies[i] = done - intended;
}
} catch (Exception e) {
errors.incrementAndGet();
}
}
});
}
pool.shutdown();
pool.awaitTermination(measure + warmup + 120, TimeUnit.SECONDS);
int n = Math.min(count.get(), latencies.length);
long[] sorted = Arrays.copyOf(latencies, n);
Arrays.sort(sorted);
System.out.printf("mode=%s target=%.0f achieved=%.1f requests=%d p50_ms=%.2f p99_ms=%.2f p999_ms=%.2f max_ms=%.2f errors=%d window_start_ms=%d window_end_ms=%d%n",
mode, rate, n / (double) measure, n, ms(sorted, 0.50), ms(sorted, 0.99), ms(sorted, 0.999),
n == 0 ? 0 : sorted[n - 1] / 1e6, errors.get(),
epochStartMs + warmup * 1000L, epochStartMs + (warmup + measure) * 1000L);
}
static double ms(long[] sorted, double q) {
if (sorted.length == 0) return 0;
return sorted[Math.min(sorted.length - 1, (int) Math.ceil(q * sorted.length) - 1)] / 1e6;
}
}
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# One benchmark run: start the server with the given JVM flags, warm up, measure at a fixed request rate
# (latency), then measure peak throughput, and print ONE result line.
#
# bench-one.sh <java-home> <label> <rep> [jvm flags...]
#
# Env: RATE (default 3000 req/s), WARMUP (30 s), MEASURE (60 s), PORT (18090)
set -u
JH=$1; LABEL=$2; REP=$3; shift 3
HERE="$(cd "$(dirname "$0")" && pwd)"
JAR="$HERE/../target/shenandoah-jdk28-1.0.0.jar"
RATE=${RATE:-3000}; WARMUP=${WARMUP:-30}; MEASURE=${MEASURE:-60}; PORT=${PORT:-18090}
OUT="$HERE/../target/gclogs"; mkdir -p "$OUT"
LOG="$OUT/$LABEL-$REP.log"
env -u JAVA_TOOL_OPTIONS "$JH/bin/java" -Xms1g -Xmx1g "$@" \
-Xlog:gc*:file="$LOG":time,uptime \
-jar "$JAR" --server.port=$PORT --spring.main.banner-mode=off --logging.level.root=WARN >/dev/null 2>&1 &
PID=$!
for _ in $(seq 1 200); do
curl -s -o /dev/null -m 1 "localhost:$PORT/work" && break
kill -0 $PID 2>/dev/null || { echo "label=$LABEL rep=$REP FAILED: JVM exited"; exit 1; }
sleep 0.3
done
OPEN=$(env -u JAVA_TOOL_OPTIONS "$JH/bin/java" "$HERE/LoadGen.java" open "http://localhost:$PORT/work" "$RATE" 64 "$WARMUP" "$MEASURE")
CLOSED=$(env -u JAVA_TOOL_OPTIONS "$JH/bin/java" "$HERE/LoadGen.java" closed "http://localhost:$PORT/work" 0 16 10 20)
RSS_PEAK_MB=$(awk '/VmHWM/ {printf "%d", $2/1024}' /proc/$PID/status)
COMMITTED=$(curl -s "localhost:$PORT/info" | sed -n 's/.*"committedMiB":\([0-9]*\).*/\1/p')
kill $PID 2>/dev/null; wait $PID 2>/dev/null
W0=$(sed -n 's/.*window_start_ms=\([0-9]*\).*/\1/p' <<<"$OPEN"); W1=$(sed -n 's/.*window_end_ms=\([0-9]*\).*/\1/p' <<<"$OPEN")
PAUSES=$(python3 "$HERE/gc_pauses.py" "$LOG" "$W0" "$W1")
CLOSED_TPUT=$(sed -n 's/.*achieved=\([0-9.]*\).*/\1/p' <<<"$CLOSED")
echo "label=$LABEL rep=$REP $(sed 's/ window_start_ms=.*//; s/^mode=open //' <<<"$OPEN") peak_rps=$CLOSED_TPUT $PAUSES rss_peak_mb=$RSS_PEAK_MB heap_committed_mb=$COMMITTED"
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""Stop-the-world pauses from a -Xlog:gc*:...:time,uptime log, restricted to an epoch-ms window.
usage: gc_pauses.py LOG WINDOW_START_MS WINDOW_END_MS
A pause is a log line that contains "Pause" and ends in "<n>ms" (G1 "Pause Young ... 4.2ms", ZGC
"Y: Pause Mark Start 0.018ms", Shenandoah "Pause Init Mark 0.052ms"). The line without a duration that
G1 and Shenandoah print when a pause *starts* is ignored, so nothing is counted twice.
"""
import re
import sys
from datetime import datetime
LINE = re.compile(r"^\[(\d{4}-\d\d-\d\dT[\d:.]+[+-]\d{4})\].*?\bGC\(\d+\) .*Pause .* ([0-9.]+)ms$")
log, lo, hi = sys.argv[1], int(sys.argv[2]), int(sys.argv[3])
pauses = []
for line in open(log, errors="replace"):
m = LINE.match(line.rstrip("\n"))
if not m:
continue
ts = datetime.strptime(m.group(1), "%Y-%m-%dT%H:%M:%S.%f%z").timestamp() * 1000
if lo <= ts <= hi:
pauses.append(float(m.group(2)))
pauses.sort()
n = len(pauses)
def q(p):
return pauses[min(n - 1, int(p * n))] if n else 0.0
print(f"pauses={n} pause_p50_ms={q(0.50):.3f} pause_p99_ms={q(0.99):.3f} pause_max_ms={pauses[-1] if n else 0.0:.3f} pause_total_ms={sum(pauses):.1f}")
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Appends extra interleaved repetitions to output/02-runs.txt (same protocol as run-all.sh), then re-summarises.
# scripts/more-reps.sh FIRST_REP LAST_REP e.g. scripts/more-reps.sh 4 5
set -eu
cd "$(dirname "$0")/.."
: "${J28:?set J28}"
for rep in $(seq "$1" "$2"); do
./scripts/bench-one.sh "$J28" g1 "$rep" -XX:+UseG1GC | tee -a output/02-runs.txt
./scripts/bench-one.sh "$J28" zgc "$rep" -XX:+UseZGC | tee -a output/02-runs.txt
./scripts/bench-one.sh "$J28" shenandoah-satb "$rep" -XX:+UseShenandoahGC -XX:ShenandoahGCMode=satb | tee -a output/02-runs.txt
./scripts/bench-one.sh "$J28" shenandoah-generational "$rep" -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational | tee -a output/02-runs.txt
done
python3 scripts/summarize.py output/02-runs.txt | tee output/03-summary.txt
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# What does plain -XX:+UseShenandoahGC select on each JDK? Needs J25 and J28 pointing at JDKs that include Shenandoah
# (Temurin does; the Oracle builds from jdk.java.net do not).
set -u
for entry in "JDK 25:$J25" "JDK 28 EA:$J28"; do
name=${entry%%:*}; home=${entry#*:}
echo "== $name: $(env -u JAVA_TOOL_OPTIONS "$home/bin/java" -version 2>&1 | sed -n 3p)"
echo "-- java -XX:+UseShenandoahGC -Xlog:gc+init -version"
env -u JAVA_TOOL_OPTIONS "$home/bin/java" -XX:+UseShenandoahGC -Xlog:gc+init -Xmx256m -version 2>&1 | grep -E 'Mode:|warning|WARNING|deprecat'
echo "-- java -XX:+UseShenandoahGC -XX:+PrintFlagsFinal -version (ShenandoahGCMode)"
env -u JAVA_TOOL_OPTIONS "$home/bin/java" -XX:+UseShenandoahGC -XX:+PrintFlagsFinal -Xmx256m -version 2>&1 | grep -E 'ShenandoahGCMode'
echo "-- java -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -Xlog:gc+init -version"
env -u JAVA_TOOL_OPTIONS "$home/bin/java" -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -Xlog:gc+init -Xmx256m -version 2>&1 | grep -E 'Mode:|warning|WARNING|deprecat'
echo "-- java -XX:+UseShenandoahGC -XX:ShenandoahGCMode=satb -Xlog:gc+init -version"
env -u JAVA_TOOL_OPTIONS "$home/bin/java" -XX:+UseShenandoahGC -XX:ShenandoahGCMode=satb -Xlog:gc+init -Xmx256m -version 2>&1 | grep -E 'Mode:|warning|WARNING|deprecat'
echo
done
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Regenerates everything in output/.
# export J25=/path/to/temurin-25 J28=/path/to/temurin-28-ea (both must include Shenandoah: use Temurin, not the jdk.java.net builds)
# mvn -B -DskipTests package
# scripts/run-all.sh # 4 configurations x REPS runs, interleaved, about 2.5 minutes per run
#
# Env: REPS (default 3), RATE / WARMUP / MEASURE (see bench-one.sh)
set -eu
cd "$(dirname "$0")/.."
: "${J25:?set J25}" "${J28:?set J28}"
REPS=${REPS:-3}
./scripts/probe.sh > output/01-gc-mode-probe.txt 2>&1
: > output/02-runs.txt
for rep in $(seq 1 "$REPS"); do
# interleaved so slow drift on the machine hits every configuration alike
./scripts/bench-one.sh "$J28" g1 "$rep" -XX:+UseG1GC | tee -a output/02-runs.txt
./scripts/bench-one.sh "$J28" zgc "$rep" -XX:+UseZGC | tee -a output/02-runs.txt
./scripts/bench-one.sh "$J28" shenandoah-satb "$rep" -XX:+UseShenandoahGC -XX:ShenandoahGCMode=satb | tee -a output/02-runs.txt
./scripts/bench-one.sh "$J28" shenandoah-generational "$rep" -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational | tee -a output/02-runs.txt
done
python3 scripts/summarize.py output/02-runs.txt | tee output/03-summary.txt
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""Median across repetitions, one row per configuration. usage: summarize.py output/02-runs.txt"""
import re
import statistics
import sys
from collections import OrderedDict
rows = OrderedDict()
for line in open(sys.argv[1]):
if "FAILED" in line or not line.strip():
continue
kv = dict(re.findall(r"(\w+)=([^\s]+)", line))
rows.setdefault(kv["label"], []).append({k: float(v) for k, v in kv.items() if k not in ("label",)})
cols = [
("p50 ms", "p50_ms", "{:.2f}"),
("p99 ms", "p99_ms", "{:.1f}"),
("p99.9 ms", "p999_ms", "{:.1f}"),
("max ms", "max_ms", "{:.1f}"),
("peak req/s", "peak_rps", "{:.0f}"),
("pauses", "pauses", "{:.0f}"),
("pause p99 ms", "pause_p99_ms", "{:.3f}"),
("pause max ms", "pause_max_ms", "{:.3f}"),
("pause total ms", "pause_total_ms", "{:.0f}"),
("RSS MB", "rss_peak_mb", "{:.0f}"),
]
reps = max(len(v) for v in rows.values())
print(f"median of {reps} runs per configuration (latency from a fixed-rate open loop; throughput from a closed loop)")
print()
print(f"{'configuration':<26}" + "".join(f"{c[0]:>15}" for c in cols))
for label, runs in rows.items():
cells = [c[2].format(statistics.median(r[c[1]] for r in runs)) for c in cols]
print(f"{label:<26}" + "".join(f"{x:>15}" for x in cells))