Java 25 Changes Your Telemetry in Strings, Not Code
Contents
A JDK upgrade gets tested like a runtime change. Does it compile, does it start, did the GC pauses get worse. Telemetry rides along as if it were part of the runtime. In practice it's a contract written in strings, like jvm.gc.name="ZGC Pauses" and jvm.memory.pool.name="ZHeap", and no test suite asserts on a name the JVM makes up at start-up. When those strings change, nothing errors. A panel goes flat, an alert stops firing, and both look exactly like a healthy service.
TL;DR — before moving a service with OpenTelemetry from Java 21 to 25:
- Agent 2.22.0 or later. 2.21.0 brought JDK 25 support without saying so; 2.22.0 takes the agent's own helper injection off
sun.misc.Unsafeon 23+. - AOT cache plus agent needs 2.32.0 and a recipe. A plain
-javaagentbreaks the cache's rules, and the JVM's default response is a warning. - On ZGC, rewrite GC and pool selectors. JDK 25 only has generational ZGC:
ZGC Cycles,ZGC PausesandZHeapgive way to Minor/Major and Young/Old names. Inferred from the JDK sources; I have no ZGC cell. - Pinned-thread series get quieter, on purpose (JEP 491; inferred, not measured), and
-Djdk.tracePinnedThreadsnow does nothing. - Going native as well? GC-timing and heap-limit panels lose their metrics (
jvm.gc.durationandjvm.memory.limitwith OTel's starter,jvm.gc.pauseand friends with Boot's), per-pool series come back under new pool names, and Boot'sjvm.memory.maxstays but reads −1. Heap used survives. Measured. - Read the agent changelog at the tag you deploy, not on main.
Every cell I ran is on JDK 25 (Temurin 25.0.4.1; GraalVM CE 25.4.4.1.1 on JDK 25.0.4.1.1 for native), on a Spring Boot 4.1.2 RabbitMQ → Mongo/Postgres pipeline. No Java 21 control, no ZGC cell. The native gaps are measured; the ZGC names come from the JDK sources; the pinning change comes from JEP 491.
Agent version floor for Java 25: 2.21.0, then 2.22.0#
JDK 25 support arrived in agent 2.21.0 (2025-10-17), and that release's changelog never mentions it. The CI matrix at the v2.21.0 tag lists 25, and four days before the release a maintainer wrote in issue #13375 that all tests now ran against Java 25 and the next release would support it officially. A user later asked there whether that meant 2.21.0. Nobody answered, so the version is my reading of the dates.
The same issue has what 2.20.0 printed at start-up on Corretto 25:
WARNING: sun.misc.Unsafe::objectFieldOffset has been called by net.bytebuddy.dynamic.loading.ClassInjector$UsingUnsafe$Dispatcher$CreationAction (file:/path/to/otel/agent.jar)
2.22.0 (2025-11-20) carries the line "Avoid Unsafe usage on Java 23+" (#14855, #15091). In HelperInjector it comes down to a version check:
// disable using unsafe for jdk 23+ where it prints a warning
private static final boolean canUseUnsafe =
Double.parseDouble(System.getProperty("java.specification.version")) < 23;
The injection path tests canUseUnsafe && ClassInjector.UsingUnsafe.isAvailable(), so on 23+ it never touches the ByteBuddy class named in that warning. I haven't run old and new agents side by side to watch the warning go, and it covers only the agent's own injection; a library that calls Unsafe can still warn.
-javaagent itself is outside JEP 451's warnings: the JEP lists warning about agents loaded at startup "via the -javaagent or -agentlib command-line options" as a non-goal, and only attaching to a running JVM warns. So the floor is 2.22.0 if you want no Unsafe warning from the agent, and 2.32.0 if you want the AOT cache. A start with the agent still isn't silent. All eight agent cells in the parity runs (2.33.0, Temurin 25.0.4.1, plain -javaagent, no AOT cache) print this as their second log line, and none of the eight starter cells on the JVM do:
OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
That is class-data sharing narrowing itself because the agent appended its jar to the bootstrap class path, the same append the AOT cache section is about. JDK 21's HotSpot carries the same warning, so it isn't a 25 regression.
AOT cache and -javaagent need the 2.32.0 recipe#
JDK 24 and 25 brought the AOT cache (JEPs 483, 514 and 515): train once, start faster after. JEP 483 lists what a cached run must not do, and one item is a JVMTI agent that calls AddToBootstrapClassLoaderSearch. The OTel agent does the Java-level version at start-up, appending its own jar with Instrumentation.appendToBootstrapClassLoaderSearch. The JEP's default for a violation is polite:
If any of these constraints are violated then HotSpot, by default, issues a warning and ignores the cache.
I haven't measured how much of a cache survives a plain -javaagent on 25; upstream says the plain append invalidates "JDK 25 AOT linkage". Either way the service starts, serves and reports healthy. It's the same trap as a broken AOT cache that still serves 200, with the same fix: -XX:AOTMode=on on the run you measure, so a violated constraint stops the start instead.
Agent 2.32.0 (2026-10-03) added the way around it (#20001): if its jar is already on the bootstrap class path, the agent skips the append, which "avoids invalidating JDK 25 AOT linkage". The recipe from the PR: launch with java -Xbootclasspath/a:opentelemetry-javaagent.jar -javaagent:opentelemetry-javaagent.jar ...; create the cache without an active -javaagent, but with --add-modules=java.instrument, the same bootstrap path in every phase and -XX:+DisableAttachMechanism; add -javaagent only in production. The price, same paragraph: "Virtual fields on cached classes loaded before premain fall back to weak maps".
Upstream measured it in #20003 (small Spring Boot 2.6.15 app, JDK 25.0.1, 2 CPUs, 1 GiB, median of 20 starts): 4.481 s → 3.554 s with the agent, 1.658 s → 1.022 s without, so 20.7 % and 38.4 % by my arithmetic. Their agent baseline is the recipe without a cache, not a plain -javaagent launch, so there's no upstream number for plain versus recipe. And the cache shaves 0.93 s off an agent that still adds 2.53 s to start-up (3.554 − 1.022).
Field note2026-10-24 · AE8 host, Spring Boot 4.1.2, OTel Spring Boot starter 2.33.0, Temurin 25.0.4.1 JRE, 256 MB tier (trained at 1 GB)
My own AOT-cache tier for the telemetry study never measured anything. The training warm-up ran 1,501 iterations, none interrupted, and then the proof-of-training check failed the cell: FAIL: AOT training: warm-up did not train (http_reqs=none failed=none). It's the check from the Leyden post, and it reads k6's default http_reqs summary line, which this load script's own summary doesn't print. The cache was never tested, so the cell says nothing about whether a cached JVM fits in 256 MB. I dropped the tier rather than fix the parser: the upstream recipe trains without the agent, so agent and starter cells would no longer differ only in the telemetry library. Part 4 has the rest.
ZGC names on JDK 25: six strings replace three#
On JDK 21, "The -XX:+UseZGC command-line option will select non-generational ZGC" (JEP 439). JDK 23 made generational the default (JEP 474) and JDK 24 removed the old mode (JEP 490). On 25 the same flag gets a different collector, which names its own MXBeans:
-XX:+UseZGC | JDK 21 (non-generational) | JDK 25 (generational only) |
|---|---|---|
| GC MXBeans | ZGC Cycles, ZGC Pauses | ZGC Minor Cycles, ZGC Major Cycles, ZGC Minor Pauses, ZGC Major Pauses |
| Heap memory pools | ZHeap | ZGC Young Generation, ZGC Old Generation |
| Source file | gc/x/xServiceability.cpp | gc/z/zServiceability.cpp |
Those names travel straight into metrics. Micrometer tags jvm.gc.pause with gc set to notificationInfo.getGcName(), the MXBean name, and its known-collectors map already lists ZGC Major Cycles: the library is ready, your queries aren't. OpenTelemetry's runtime-telemetry README uses the MXBean names verbatim for every collector, and all 16 of my JVM cells, agent and both starters, carry G1's that way (G1 Young Generation, G1 Concurrent GC). The README's ZGC row still documents ZGC Cycles, ZGC Pauses and ZHeap.
From there it's inference: no ZGC cell, and I haven't read the agent's mapping code. A selector on jvm.gc.name="ZGC Pauses" or jvm.memory.pool.name="ZHeap" matches nothing after the upgrade. It doesn't fail. It returns an empty result, and unless the alert treats no data as a problem, it stays quiet. A JDK 21 service already on -XX:+ZGenerational may emit the new names today; I haven't checked. G1 users have less to worry about, though I have no 21 run to diff my G1 names against.
Virtual-thread pinning metrics go quiet on purpose#
JEP 491 (JDK 24) lets a virtual thread that blocks inside synchronized release its carrier, which "will eliminate nearly all cases of virtual threads being pinned to platform threads". The jdk.VirtualThreadPinned JFR event stays for what remains, chiefly native code calling back into Java and blocking. jdk.tracePinnedThreads is gone: "setting it on the command line will have no effect".
Two metrics track pinning: OTel's experimental, opt-in, JFR-based jvm.thread.virtual.pinned (since agent 2.30.0) and Micrometer's jvm.threads.virtual.pinned, which reads jdk.VirtualThreadPinned directly. On 25 the same synchronized-heavy code should record far fewer pinned samples; the JEP predicts it; I didn't measure it. A pinning alert that goes silent after the upgrade is probably good news, and good news is the kind nobody opens a ticket for. If you used the metric to hunt synchronized hotspots, they vanish from it because they stopped pinning, not because anyone fixed them.
One binder got worse on 25 instead of quieter. Micrometer #8019, still open: on JDK 25, VirtualThreadMetrics starts a RecordingStream, and with many short-lived virtual threads calling Object.notifyAll(), about one RecordedThread per virtual thread stays reachable, even with no pinned event recorded. A commenter's heap dumps after 20,000 virtual threads: 34 on JDK 21.0.12, 20,043 on 25.0.4.1, the same 20,043 from a bare RecordingStream without Micrometer, and 42 after forcing a JFR chunk rotation. They read it as a JDK regression tied to JEP 491; no maintainer has replied. The posted workaround keeps the scheduler MXBean gauges and drops the JFR-backed pinned and submit-failed meters; the pinned one is what JEP 491 should have quieted anyway.
Native image: GC and heap-limit panels go flat or lie#
Part 1 of the series asks whether dashboards survive a switch to native. Native is a separate move from 21 → 25, but every cell here is on 25, so this is JVM against native on one JDK, measured. The agent can't run in a native image, so native also means a starter. On the same starter, JVM and native emit identical store metrics (name, unit, type, attributes, buckets). What native removes is runtime metrics:
- OpenTelemetry's Spring Boot starter:
jvm.gc.duration,jvm.memory.limit,jvm.buffer.count,jvm.buffer.memory.limit,jvm.buffer.memory.used. - Boot's starter (Micrometer to OTLP): 13 names, among them
jvm.gc.pause,jvm.gc.memory.allocated,jvm.gc.memory.promoted,jvm.compilation.timeandjvm.memory.usage.after.gc.
The pool names change alphabet too. In the OTel starter cells, the JVM's eight pools (G1 Eden Space, G1 Old Gen, Metaspace and five more) become native's five: eden space, old generation space, survivor space, runtime code cache (code and data), runtime code cache (native metadata). GC-timing and heap-limit panels, and the alerts built on them, go flat after a native switch, and per-pool panels need the new names. Heap used survives: the OTel starter still emits jvm.memory.used with jvm.memory.type=heap, and Boot's starter does with area=heap. Three of the survivors are traps. Boot's jvm.memory.max is still there and reads −1 for every pool in every native sample, so a used-over-max heap panel goes negative and its alert never fires. And jvm.memory.committed equals jvm.memory.used in 328 of 335 native heap-pool samples, so a committed-versus-used panel shows no headroom at all. Boot's jvm.gc.overhead is emitted as well and always reads 0. Store panels survive the runtime change; whether they survive agent-to-starter is a separate table in part 1.
Why, per a diagnosis in quarkus#48365 (Mandrel 25.0.4.1): native memory pools report a maximum of −1, so jvm.memory.limit is skipped; GC MXBeans accept notification listeners and never call them; and native emits only the generic jdk.GarbageCollection JFR event. PR #20599 adds a string problem: the JFR path picks GC handlers by MXBean name, and native's young generation scavenger and complete scavenger match none. Its fix, open and in no release, would record jvm.gc.duration with jvm.gc.name="serial" and no jvm.gc.action. Even the fix arrives as a new string.
Agent changelog: read it at the tag, not on main#
The agent's CHANGELOG.md on main opens with ## Unreleased and a breaking-changes list that reads like a telemetry diff: span thread details off by default, jvm.gc.cause always on JMX jvm.gc.duration, jvm.cpu.limit removed in favour of jvm.cpu.count, and runtime metrics always under the io.opentelemetry.runtime-telemetry scope, with an instruction to "Update views, dashboards, and filters" that match the old -java8 and -java17 scopes.
The next heading down is 2.32.0. Version 2.33.0, released 2026-10-23 and the newest on Maven Central, has no section on main at all; its notes live at the v2.33.0 tag, and they're the SDK 1.67.0 bump and three fixes. The changelog on the default branch describes a release nobody can download yet and skips the one everybody can.
That list has nothing to do with Java 25, which is the point: telemetry also changes when the agent version changes, whatever the JDK does. Pin the agent and read its notes at the tag you deploy. The JDK isn't the only thing that renames your strings.
Filed under: java, jvm, opentelemetry, observability, jdk25, native-image
Read next#
- Agent vs starter, store by store: part 1 of the Telemetry Bill series, the measured table of what each setup emits on the JVM and in native.
- A broken AOT cache still serves 200: why a cache the JVM can't use looks like a healthy run, and the flag that makes it fail loudly.
- Measuring telemetry overhead fairly: part 4, why the AOT tier was dropped and what "fair" ended up meaning.
- Upgrade errors that named the wrong layer: the framework side of a JDK 25 upgrade day.
Sources#
- JEP 439: Generational ZGC: on JDK 21,
-XX:+UseZGCselects non-generational ZGC. - JEP 474: ZGC: Generational Mode by Default (JDK 23) and JEP 490: ZGC: Remove the Non-Generational Mode (JDK 24).
xServiceability.cppat jdk-21-ga andzServiceability.cppat jdk-25-ga: ZGC MXBean and memory pool names.- JEP 483: Ahead-of-Time Class Loading & Linking: consistency rules for agents, the default warn-and-ignore,
AOTMode=on. - JEP 491: Synchronize Virtual Threads without Pinning:
synchronizedstops pinning,jdk.VirtualThreadPinnedkept,jdk.tracePinnedThreadsremoved. - JEP 451: Prepare to Disallow the Dynamic Loading of Agents:
-javaagentat start-up is not warned about. - OTel Java instrumentation CHANGELOG at v2.33.0 and on main: 2.21.0, 2.22.0, 2.30.0, 2.32.0 entries; the Unreleased breaking changes.
- CI matrix at v2.21.0: Java 25 in the test matrix.
- Issue #13375, "JDK 25 Support?": the maintainer's support comment and the older agents' warnings.
HelperInjector.javaat v2.32.0:canUseUnsafebelow Java 23 only.- PR #20001: agent jar already on the bootstrap class path, and the AOT cache recipe.
- PR #20003: upstream's JDK 25 AOT start-up benchmark and its baseline.
- PR #20599:
jvm.gc.durationin native images (open). - Runtime telemetry README: GC and pool names per collector, JFR-based virtual-thread metrics.
- Micrometer
JvmGcMetricsandVirtualThreadMetrics: thegctag and the pinned-thread timer. - Micrometer #8019:
RecordingStreamretention on JDK 25 (open). - quarkusio/quarkus#48365: why OTel's JVM metrics go missing in native.