Tracer Version(s)
1.63.0
Java Version(s)
Corretto-26.0.1.8.1
JVM Vendor
Amazon Corretto
Bug Report
On JDK 26 with an AOT cache (-XX:AOTMode=on, JEP 483/515), attaching dd-java-agent makes every virtual thread terminate without ever executing its Runnable. Thread.ofVirtual().start(r) returns, the thread goes to TERMINATED, and r is never invoked. No exception is thrown, nothing is logged, and platform threads are unaffected — the work is silently discarded.
Anything that awaits virtual-thread work therefore hangs forever. In our case a Spring Boot service never finished startup (its Kafka producer warmup runs on Executors.newVirtualThreadPerTaskExecutor()), so all pods crashlooped in production while the identical binary without the AOT cache was fine.
The trigger is narrow: the AOT cache must have been recorded by a run that (a) had --enable-final-field-mutation=ALL-UNNAMED (JEP 500) set and (b) actually created a virtual thread. The flag is irrelevant at run time — only what the cache was recorded with matters, which makes this very hard to attribute: the run that breaks does not have to mention the flag at all.
-Ddd.integration.virtual-thread.enabled=false avoids it, which points at datadog.trace.instrumentation.java.lang.jdk21.VirtualThreadInstrumentation.
Reproducer
VirtualThreadAotRepro.java + run.sh (attached). It reports whether the body of a platform thread, a virtual thread, and a task submitted to newVirtualThreadPerTaskExecutor() actually executed:
JAVA_HOME=/path/to/jdk26 DD_AGENT_JAR=/path/to/dd-java-agent.jar ./run.sh
Minimal manual version:
1. record an AOT cache with the JEP 500 flag, from a run that uses a virtual thread
java -XX:AOTMode=record -XX:AOTCacheOutput=app.aot \
--enable-final-field-mutation=ALL-UNNAMED \
-javaagent:dd-java-agent.jar -cp repro.jar VirtualThreadAotRepro
2. run with that cache and the agent — the virtual thread bodies never run
java -XX:AOTMode=on -XX:AOTCache=app.aot \
-javaagent:dd-java-agent.jar -cp repro.jar VirtualThreadAotRepro
Observed vs expected
Expected: three body ran=true lines. Observed in the broken combination:
platform thread : body ran=true alive=false state=TERMINATED
virtual thread : body ran=false alive=false state=TERMINATED <-- Runnable never invoked
vthread executor: body ran=false (latch released=false) <-- task never runs, await times out
Full matrix from run.sh
The agent is attached during recording in every row below, as it is in production (a cache recorded without -javaagent cannot be loaded by a run that adds one — module graph mismatch).
AOT cache recorded with run with result
(no cache) agent + flag PASS
(no cache) no agent, no flag PASS
flag, virtual threads used agent + flag FAIL
flag, virtual threads used agent, no flag FAIL
flag, virtual threads used no agent, flag PASS
flag, virtual threads used no agent, no flag PASS
no flag, virtual threads used agent PASS
flag, virtual threads NOT used agent + flag PASS
So all three of these are required: the JEP 500 flag at record time, a virtual thread created during the recording run, and the agent attached at run time.
Narrowing inside the agent
Run against the broken cache:
additional property result
-Ddd.trace.enabled=false FAIL
-Ddd.trace.runtime.context.field.injection=false FAIL
-Ddd.integration.java_concurrent.enabled=false FAIL
-Ddd.integrations.enabled=false PASS
-Ddd.integration.virtual-thread.enabled=false PASS
Disabling tracing is not enough — instrumentation is what matters, and specifically the virtual-thread integration (java/lang/jdk21/VirtualThreadInstrumentation, whose Construct, Mount, Unmount and AfterDone advice weaves java.lang.VirtualThread). Our reading, which we have not confirmed against the JVM internals, is that when VirtualThread is AOT-linked from a cache recorded under relaxed final-field-mutation semantics, that advice's effect on the thread's task/state field is lost, so the thread has nothing to run. Whatever the precise cause, retransforming an AOT-linked VirtualThread should not be able to silently drop the task.
Expected Behavior
Virtual threads should work :)
Reproduction Code
No response
Tracer Version(s)
1.63.0
Java Version(s)
Corretto-26.0.1.8.1
JVM Vendor
Amazon Corretto
Bug Report
On JDK 26 with an AOT cache (-XX:AOTMode=on, JEP 483/515), attaching dd-java-agent makes every virtual thread terminate without ever executing its Runnable. Thread.ofVirtual().start(r) returns, the thread goes to TERMINATED, and r is never invoked. No exception is thrown, nothing is logged, and platform threads are unaffected — the work is silently discarded.
Anything that awaits virtual-thread work therefore hangs forever. In our case a Spring Boot service never finished startup (its Kafka producer warmup runs on Executors.newVirtualThreadPerTaskExecutor()), so all pods crashlooped in production while the identical binary without the AOT cache was fine.
The trigger is narrow: the AOT cache must have been recorded by a run that (a) had --enable-final-field-mutation=ALL-UNNAMED (JEP 500) set and (b) actually created a virtual thread. The flag is irrelevant at run time — only what the cache was recorded with matters, which makes this very hard to attribute: the run that breaks does not have to mention the flag at all.
-Ddd.integration.virtual-thread.enabled=false avoids it, which points at datadog.trace.instrumentation.java.lang.jdk21.VirtualThreadInstrumentation.
Reproducer
VirtualThreadAotRepro.java + run.sh (attached). It reports whether the body of a platform thread, a virtual thread, and a task submitted to newVirtualThreadPerTaskExecutor() actually executed:
JAVA_HOME=/path/to/jdk26 DD_AGENT_JAR=/path/to/dd-java-agent.jar ./run.sh
Minimal manual version:
1. record an AOT cache with the JEP 500 flag, from a run that uses a virtual thread
2. run with that cache and the agent — the virtual thread bodies never run
platform thread : body ran=true alive=false state=TERMINATED
virtual thread : body ran=false alive=false state=TERMINATED <-- Runnable never invoked
vthread executor: body ran=false (latch released=false) <-- task never runs, await times out
Full matrix from run.sh
The agent is attached during recording in every row below, as it is in production (a cache recorded without -javaagent cannot be loaded by a run that adds one — module graph mismatch).
AOT cache recorded with run with result
(no cache) agent + flag PASS
(no cache) no agent, no flag PASS
flag, virtual threads used agent + flag FAIL
flag, virtual threads used agent, no flag FAIL
flag, virtual threads used no agent, flag PASS
flag, virtual threads used no agent, no flag PASS
no flag, virtual threads used agent PASS
flag, virtual threads NOT used agent + flag PASS
So all three of these are required: the JEP 500 flag at record time, a virtual thread created during the recording run, and the agent attached at run time.
Narrowing inside the agent
Run against the broken cache:
additional property result
-Ddd.trace.enabled=false FAIL
-Ddd.trace.runtime.context.field.injection=false FAIL
-Ddd.integration.java_concurrent.enabled=false FAIL
-Ddd.integrations.enabled=false PASS
-Ddd.integration.virtual-thread.enabled=false PASS
Disabling tracing is not enough — instrumentation is what matters, and specifically the virtual-thread integration (java/lang/jdk21/VirtualThreadInstrumentation, whose Construct, Mount, Unmount and AfterDone advice weaves java.lang.VirtualThread). Our reading, which we have not confirmed against the JVM internals, is that when VirtualThread is AOT-linked from a cache recorded under relaxed final-field-mutation semantics, that advice's effect on the thread's task/state field is lost, so the thread has nothing to run. Whatever the precise cause, retransforming an AOT-linked VirtualThread should not be able to silently drop the task.
Expected Behavior
Virtual threads should work :)
Reproduction Code
No response