Abstract: No major feature has been adopted as quickly into production systems as virtual threads. Not generics. Not streams. But there are some catches. Original Java threads have six states. Virtual threads have 20 states, which Java maps onto the original six states. However, sometimes it can be useful to know what the virtual threads state is. In this newsletter we use deep reflection to learn how that works.
A hearty welcome to our 331st edition of The Java(tm) Specialists' Newsletter. In 2018, I spoke at the Øredev conference in Malmö, Sweden, and it was a lifechanging experience. The closing keynote was by a biohacker, who was at least 50% cyborg. After he was done, I chatted to him a bit, and he showed me his Oura Ring, which primarily measured his sleep. I immediately bought one, and have been wearing mine since 2019. Thus when my doctor asks about my sleep, I can tell him that in 2025, I slept on average 6h24m, slightly worse than 2024 with 6h27m, but better than 2023 with 6h16m. 2019 was a whopping 6h47m on average, and I felt great for it.
About six weeks ago, I discovered that the ring now also keeps track of our "sleep debt", which accumulates when we consistently get less sleep than our body needs. I was shocked to discover that I had seven hours of sleep debt! It took me a week to get that down to 2h20m, and another week to get it down to zero. I cannot remember feeling this good, probably ever. My goal for 2026 is to keep my sleep debt at lower than 2h30m.
javaspecialists.teachable.com: Please visit our new self-study course catalog to see how you can upskill your Java knowledge.
I have never seen a new Java feature get adopted as quickly as virtual threads. Major milestones were generics, streams, but even these took years before programmers used them with enthusiasm. However, even before virtual threads were fully baked, I was receiving requests for consulting help to iron out some virtual thread issues in production.
Over the years, they have improved substantially, and the way that I like to show students of my Mastering Virtual Threads in Java Course what is going on, is to explore the state machine. It is well documented in the VirtualThread class, and you don't need to visit my course to understand it - just read the comments in the class. The platform thread states are still the old NEW, RUNNABLE, etc. that we've always had, but the virtual thread states also have states such as RUNNING, YIELDING, PARKING, BLOCKING, BLOCKED, etc. Java does not expose the virtual thread states though, so if we call getState(), we get a matching platform thread state.
In order to get this detailed information, I wrote a
ThreadInspector, which returns a String containing
the virtual thread state, followed by the platform thread
state. We use deep reflection to find all the
private static final int fields in
the VirtualThread class, plus their values and
field names. We then put them in a Map and use
that to return the internal virtual thread state name. For
example, if a virtual thread is busy executing on a carrier
thread, it's compound state would be RUNNING/RUNNABLE.
If it is busy yielding with Thread.yield(), it
might be YIELDING/RUNNABLE or YIELDED/RUNNABLE.
import java.lang.invoke.*;
import java.lang.reflect.*;
import java.util.*;
import java.util.function.*;
import java.util.stream.*;
// --add-opens java.base/java.lang=ALL-UNNAMED
public class ThreadInspector {
private static final Map<Integer, String> virtualThreadStates;
private static final VarHandle STATE;
private static final Predicate<Field> INT_TYPE =
field -> field.getType() == int.class;
private static final Set<AccessFlag> ACCESS_FLAGS =
Set.of(AccessFlag.PRIVATE, AccessFlag.STATIC,
AccessFlag.FINAL);
private static final Predicate<Field> PRIVATE_STATIC_FINAL =
field -> field.accessFlags().equals(
ACCESS_FLAGS);
// To support older versions of Java, such as Java 21 to 23
private static final Predicate<Field> NOT_TRACE_PINNING_MODE =
field -> !field.getName().equals("TRACE_PINNING_MODE");
static {
var vthreadClass = Thread.ofVirtual()
.unstarted(() -> {})
.getClass();
virtualThreadStates = Stream.of(
vthreadClass.getDeclaredFields())
.filter(INT_TYPE)
.filter(PRIVATE_STATIC_FINAL)
.filter(NOT_TRACE_PINNING_MODE)
.collect(Collectors.toMap(
ThreadInspector::getStateValue,
Field::getName
));
try {
STATE = MethodHandles.privateLookupIn(vthreadClass,
MethodHandles.lookup())
.findVarHandle(vthreadClass,
"state", int.class);
} catch (ReflectiveOperationException e) {
throw new Error(e);
}
}
private static int getStateValue(Field field) {
try {
field.setAccessible(true);
return (int) field.get(null);
} catch (IllegalAccessException e) {
throw new Error(e);
}
}
public static String getCompoundThreadStates(Thread thread) {
return (thread.isVirtual() ? virtualThreadStates.get(
STATE.get(thread)) + "/" : "") + thread.getState();
}
}
In the ThreadInspector class, we use deep
reflection to access the innards of the VirtualThread class.
You can learn how to do this in my Java
Reflection Course (Also on LinkedIn
and Coursera).
Here is an example of how we can use the
ThreadInspector to investigate the thread
states. I have tested it with Java 21 through to 26, but
since we are breaking encapsulation with deep reflection,
it might not work in future versions of Java.
import java.io.*;
import java.net.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
// Use the JVM args --add-opens java.base/java.lang=ALL-UNNAMED
public class ThreadInspectorDemo {
public static void main(String... args)
throws InterruptedException, IOException {
demo(Thread.ofVirtual());
demo(Thread.ofPlatform());
}
private static void demo(Thread.Builder builder)
throws InterruptedException, IOException {
System.out.println(builder.getClass().getSimpleName());
demoUnstarted(builder);
demoTerminated(builder);
demoRunning(builder);
demoWaiting(builder);
demoBlocked(builder);
demoSleeping(builder);
demoWaitingOnIO(builder);
System.out.println();
}
private static void demoUnstarted(Thread.Builder builder) {
demo("Unstarted", builder.unstarted(() -> {}));
}
private static void demoTerminated(Thread.Builder builder)
throws InterruptedException {
var terminatedThread = builder.start(() -> {});
terminatedThread.join();
demo("Terminated", terminatedThread);
}
private static void jdemoRunning(Thread.Builder builder)
throws InterruptedException {
var running = new AtomicBoolean(true);
var runningThread = builder.start(() -> {
while (running.get()) ;
});
Thread.sleep(10); // give thread a chance to really start
demo("Running", runningThread);
running.set(false);
runningThread.join();
}
private static void demoWaiting(Thread.Builder builder)
throws InterruptedException {
var monitor = new Object();
var waitingThread = builder.start(() -> {
synchronized (monitor) {
try {
monitor.wait();
} catch (InterruptedException e) {
throw new CancellationException();
}
}
});
Thread.sleep(10);
demo("Waiting", waitingThread);
synchronized (monitor) {
monitor.notify();
}
waitingThread.join();
}
private static void demoBlocked(Thread.Builder builder)
throws InterruptedException {
var monitor = new Object();
synchronized (monitor) {
var blockedThread = builder.start(() -> {
synchronized (monitor) {}
});
Thread.sleep(10);
demo("Blocked", blockedThread);
}
}
private static void demoSleeping(Thread.Builder builder)
throws InterruptedException {
var sleepingThread = builder.start(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new CancellationException();
}
});
Thread.sleep(10);
demo("Sleeping", sleepingThread);
}
private static void demoWaitingOnIO(Thread.Builder builder)
throws InterruptedException, IOException {
var waitingOnIOThread = builder.start(() -> {
try (var serverSocket = new ServerSocket(8080)) {
serverSocket.accept();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
Thread.sleep(100); // needs longer wait
demo("Waiting on IO", waitingOnIOThread);
new Socket("localhost", 8080);
waitingOnIOThread.join();
}
private static void demo(String description, Thread thread) {
System.out.printf("%s: %s%n", description,
ThreadInspector.getCompoundThreadStates(thread));
}
}
For Java 21, when a thread is BLOCKED waiting to acquire a synchronized monitor, it cannot be unmounted from its carrier thread. The virtual thread state thus remains RUNNING:
openjdk version "21.0.9" 2025-10-21 LTS VirtualThreadBuilder Unstarted: NEW/NEW Terminated: TERMINATED/TERMINATED Running: RUNNING/RUNNABLE Waiting: RUNNING/WAITING Blocked: RUNNING/BLOCKED Sleeping: TIMED_PARKED/TIMED_WAITING Waiting on IO: PARKED/WAITING PlatformThreadBuilder Unstarted: NEW Terminated: TERMINATED Running: RUNNABLE Waiting: WAITING Blocked: BLOCKED Sleeping: TIMED_WAITING Waiting on IO: RUNNABLE
From Java 24 onwards, threads that are BLOCKED on synchronized or calling Object.wait() are properly unmounted from their carrier threads:
openjdk version "24.0.2" 2025-07-15 VirtualThreadBuilder Unstarted: NEW/NEW Terminated: TERMINATED/TERMINATED Running: RUNNING/RUNNABLE Waiting: WAIT/WAITING Blocked: BLOCKED/BLOCKED Sleeping: TIMED_PARKED/TIMED_WAITING Waiting on IO: PARKED/WAITING PlatformThreadBuilder Unstarted: NEW Terminated: TERMINATED Running: RUNNABLE Waiting: WAITING Blocked: BLOCKED Sleeping: TIMED_WAITING Waiting on IO: RUNNABLE
Note also the difference in states between virtual and platform threads when waiting on IO. Platform threads remain in the RUNNABLE state, and we actually cannot stop or interrupt such threads. Virtual threads, on the other hand, go into the PARKED/WAITING state, and thus interruption works to exit with an IOException.
I hope you enjoyed this little tip and that it will help you understand virtual threads and their 20 states better :-) I also wish excellent health in every way for 2026, be it physical, emotional, mental and spiritual. All the best and "see you" next year :-)
Kind regards
Heinz
We are always happy to receive comments from our readers. Feel free to send me a comment via email or discuss the newsletter in our JavaSpecialists Slack Channel (Get an invite here)
We deliver relevant courses, by top Java developers to produce more resourceful and efficient programmers within their organisations.