Skip to content

Virtual Thread Stack Traces in Java, Decoded Line by Line

A virtual thread stack trace looks normal, minus the carrier. Why the thread name is empty, what each frame means, and how StructuredTaskScope rethrows.

stack-traces java-concurrency
Gopi Gorantala
Reading Progress

On This Page

Look at the thread name on the first line.

Exception in thread "" java.lang.IllegalStateException: no config for t-404
	at Lab.loadTenant(Lab.java:8)
	at Lab.run(Lab.java:15)
	at Lab.lambda$main$0(Lab.java:25)
	at java.base/java.lang.VirtualThread.run(VirtualThread.java:329)

That's a virtual thread stack trace, straight from the console of OpenJDK 21.0.12.1 on Linux, printed by the JVM's default uncaught-exception handler. Two quotes with nothing between them. No Thread-0, no pool-1-thread-3, no ForkJoinPool-1-worker-1. If you've just moved a service onto virtual threads and your logs suddenly have blank thread names and a bottom frame you don't recognise, this is that.

The rest of the trace is boring, which is the good news. Everything from loadTenant down to the lambda is your code. The last line is the new part.

I'll use the same trace-reading rules as the hub article and only talk about what changes when the thread is virtual.

Nothing is missing, and that's the odd part

I expected something else. My first assumption was that a virtual thread's trace would have the carrier thread's plumbing at the bottom: ForkJoinPool, ForkJoinWorkerThread.run, some scheduler frames. Virtual threads run on a pool of platform threads called carriers, so the carrier has to be in there somewhere. Right?

It isn't. The trace ends at VirtualThread.run. The carrier's frames live on the carrier's stack, and the virtual thread has its own, separate stack that gets copied in and out as it mounts and unmounts. A trace only walks the stack of the thread that built the exception. So you get your frames plus one entry frame, and that's it.

Where you'd open the file first: Lab.loadTenant, line 8. Same as any other trace. The one practical difference is the thread name, and I'll get to why it's blank and what to do about it in a bit.

Reproduce it, preview flag included

Pin the JDK: eclipse-temurin:21 or any 21.0.x. Virtual threads are final in 21 (JEP 444). StructuredTaskScope is still a preview API in 21, so the lab needs --enable-preview. Keep the blank lines, because the line numbers in the traces below depend on them.

import java.util.concurrent.*;
import java.util.*;

public class Lab {
    static String loadTenant(String id) throws InterruptedException {
        Thread.sleep(20);
        if (id.equals("t-404")) {
            throw new IllegalStateException("no config for " + id);
        }
        return "cfg-" + id;
    }

    static void run(String id) {
        try {
            System.out.println(loadTenant(id));
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    public static void main(String[] args) throws Exception {
        String mode = args.length > 0 ? args[0] : "bare";
        switch (mode) {
            case "bare" -> {
                Thread t = Thread.startVirtualThread(() -> run("t-404"));
                t.join();
                System.out.println("toString after: " + t);
            }
            case "named" -> {
                Thread t = Thread.ofVirtual().name("loader-", 1).start(() -> run("t-404"));
                t.join();
            }
            case "platform" -> {
                Thread t = new Thread(() -> run("t-404"));
                t.start();
                t.join();
            }
            case "stack" -> {
                Thread t = Thread.ofVirtual().name("probe").start(() -> {
                    new Throwable("probe").printStackTrace(System.out);
                    System.out.println("toString inside: " + Thread.currentThread());
                });
                t.join();
            }
            case "executor" -> {
                try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
                    Future<String> f = ex.submit(() -> loadTenant("t-404"));
                    f.get();
                }
            }
            case "execute" -> {
                try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
                    ex.execute(() -> run("t-404"));
                }
                Thread.sleep(200);
            }
            case "scope" -> {
                try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
                    var a = scope.fork(() -> loadTenant("t-1"));
                    var b = scope.fork(() -> loadTenant("t-404"));
                    scope.join();
                    scope.throwIfFailed();
                    System.out.println(a.get() + b.get());
                }
            }
            case "scopeOverride" -> {
                try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
                    scope.fork(() -> loadTenant("t-404"));
                    scope.join();
                    scope.throwIfFailed(e -> new RuntimeException("tenant load failed", e));
                }
            }
            case "deep" -> {
                Thread t = Thread.ofVirtual().name("deep").start(() -> {
                    try { recurse(0); } catch (RuntimeException e) { e.printStackTrace(System.out); }
                });
                t.join();
            }
            case "dump" -> {
                for (int i = 0; i < 3; i++) {
                    Thread.ofVirtual().name("loader-" + i).start(() -> {
                        try { Thread.sleep(60_000); } catch (InterruptedException e) {}
                    });
                }
                System.out.println("pid=" + ProcessHandle.current().pid());
                Thread.sleep(30_000);
            }
            default -> throw new IllegalArgumentException(mode);
        }
    }

    static void recurse(int n) {
        if (n == 3) throw new RuntimeException("bottom");
        recurse(n + 1);
    }
}

Compile once, then run one scenario per invocation:

javac --enable-preview --release 21 Lab.java
java --enable-preview Lab bare
java --enable-preview Lab named
java --enable-preview Lab platform

named prints the same trace with Exception in thread "loader-1" on top. platform gives you a normal thread for comparison, and its last frame is java.base/java.lang.Thread.run(Thread.java:1583) instead of the VirtualThread.run line. That's the entire visible difference between the two for an uncaught exception.

Now the executor variants:

java --enable-preview Lab execute
java --enable-preview Lab executor
Exception in thread "" java.lang.IllegalStateException: no config for t-404
	at Lab.loadTenant(Lab.java:8)
	at Lab.run(Lab.java:15)
	at Lab.lambda$main$5(Lab.java:53)
	at java.base/java.util.concurrent.ThreadPerTaskExecutor$TaskRunner.run(ThreadPerTaskExecutor.java:314)
	at java.base/java.lang.VirtualThread.run(VirtualThread.java:329)

That's execute: one extra frame, ThreadPerTaskExecutor$TaskRunner.run, and the name is still empty. executor uses submit then get, and gives you the two-stack shape from the async article:

Exception in thread "main" java.util.concurrent.ExecutionException: java.lang.IllegalStateException: no config for t-404
	at java.base/java.util.concurrent.FutureTask.report(FutureTask.java:122)
	at java.base/java.util.concurrent.FutureTask.get(FutureTask.java:191)
	at Lab.main(Lab.java:48)
Caused by: java.lang.IllegalStateException: no config for t-404
	at Lab.loadTenant(Lab.java:8)
	at Lab.lambda$main$4(Lab.java:47)
	at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
	at java.base/java.lang.VirtualThread.run(VirtualThread.java:329)

Outer exception, main's frames. Cause, the virtual thread's frames. Nothing new in the mechanism; only the bottom frame of the cause changed.

If your lambda numbers differ (lambda$main$4 vs lambda$main$5), you changed the order of the cases. The counter is class-wide, which the strange-frames article explains.

Last scenario, the structured one:

java --enable-preview Lab scope

If you forget the flag you won't get a trace, you'll get a compile error: StructuredTaskScope is a preview API and is disabled by default.

Virtual thread stack traces, frame by frame

Here's the scope output, annotated. I forked two subtasks, one of which fails, joined, then called throwIfFailed().

Exception in thread "main" java.util.concurrent.ExecutionException: java.lang.IllegalStateException: no config for t-404   (1)
	at java.base/java.util.concurrent.StructuredTaskScope$ShutdownOnFailure.throwIfFailed(StructuredTaskScope.java:1317)    (2)
	at java.base/java.util.concurrent.StructuredTaskScope$ShutdownOnFailure.throwIfFailed(StructuredTaskScope.java:1294)    (3)
	at Lab.main(Lab.java:62)                                                                                               (4)
Caused by: java.lang.IllegalStateException: no config for t-404                                                              (5)
	at Lab.loadTenant(Lab.java:8)                                                                                          (6)
	at Lab.lambda$main$7(Lab.java:60)                                                                                      (7)
	at java.base/java.util.concurrent.StructuredTaskScope$SubtaskImpl.run(StructuredTaskScope.java:886)                    (8)
	at java.base/java.lang.VirtualThread.run(VirtualThread.java:329)                                                       (9)
  1. The thread is main, a platform thread. The ExecutionException was built on the thread that called throwIfFailed, not on the virtual thread that failed. That matters: this is a wrapper made after the fact.
  2. and 3. Two throwIfFailed frames, one on line 1317 and one on 1294. One is an overload delegating to the other. You'll see both, always, on 21.
  3. Your call site. This is the frame you can name: line 62, the throwIfFailed() call.
  4. The real exception, under Caused by.
  5. The throw. Line 8 of loadTenant. Open this one.
  6. The subtask lambda, lambda$main$7. That's the fork(() -> loadTenant("t-404")) on line 60.
  7. SubtaskImpl.run: the scope's own wrapper around your callable. It catches whatever your task throws and stores it.
  8. The entry frame of the virtual thread.

No ... N more here. The cause shares no frames with the outer exception (different thread, different stack), so there's nothing to elide.

Which frame would I open first? 6. Then 7 to see who started it. Frame 4 tells you where the join was, which is rarely where the trouble started.

The fork line (60) is only present as the lambda's definition site. The stack of whoever called fork() isn't in the cause. Same lesson as the thread pool case: whoever submitted the work has left the building by the time the failure is built.

There's a second form worth showing, throwIfFailed(Function) with your own exception type:

Exception in thread "main" java.lang.RuntimeException: tenant load failed
	at Lab.lambda$main$9(Lab.java:70)
	at java.base/java.util.concurrent.StructuredTaskScope$ShutdownOnFailure.throwIfFailed(StructuredTaskScope.java:1317)
	at Lab.main(Lab.java:70)
Caused by: java.lang.IllegalStateException: no config for t-404
	at Lab.loadTenant(Lab.java:8)
	at Lab.lambda$main$8(Lab.java:68)
	at java.base/java.util.concurrent.StructuredTaskScope$SubtaskImpl.run(StructuredTaskScope.java:886)
	at java.base/java.lang.VirtualThread.run(VirtualThread.java:329)

Notice the top frame is a lambda, lambda$main$9 on line 70: your own mapping function, because the exception object is constructed inside it. Created-versus-thrown again. The RuntimeException carries the frame that called new, and the top frame is where it was created, not where throwIfFailed threw it.

Why the carrier never shows up

Time for the research story. I spent a good chunk of an evening convinced that I was missing a flag, some way to print the carrier. There is one flag that shows more, and it's the one from the reflection article: -XX:+UnlockDiagnosticVMOptions -XX:+ShowHiddenFrames. Here's the bare run with it:

Exception in thread "" java.lang.IllegalStateException: no config for t-404
	at Lab.loadTenant(Lab.java:8)
	at Lab.run(Lab.java:15)
	at Lab.lambda$main$0(Lab.java:25)
	at Lab$$Lambda/0x00007f3c14000a08.run(Unknown Source)
	at java.base/java.lang.Thread.runWith(Thread.java:1596)
	at java.base/java.lang.VirtualThread.run(VirtualThread.java:329)
	at java.base/java.lang.VirtualThread$VThreadContinuation$1.run(VirtualThread.java:209)
	at java.base/jdk.internal.vm.Continuation.enter0(Continuation.java:320)
	at java.base/jdk.internal.vm.Continuation.enter(Continuation.java:312)

More frames, still no carrier. The extra ones are the hidden lambda class (Lab$$Lambda/0x..., the Unknown Source is the proxy-style class having no line info), Thread.runWith, and then the continuation machinery: VThreadContinuation$1.run, Continuation.enter0, Continuation.enter. A continuation is the JDK object that holds the virtual thread's frames while it's unmounted. The bottom of the trace is where that continuation begins. The carrier sits below it on a different stack.

You can see that other stack with a thread dump. Here's what I got from jcmd <pid> Thread.dump_to_file -format=text while a virtual thread called loader-0 slept:

#18 "loader-0" virtual
      java.base/java.lang.VirtualThread.parkNanos(VirtualThread.java:635)
      java.base/java.lang.VirtualThread.sleepNanos(VirtualThread.java:807)
      java.base/java.lang.Thread.sleep(Thread.java:507)
      Lab2.waitForConfig(Lab2.java:5)
      Lab2.lambda$main$0(Lab2.java:10)
      java.base/java.lang.VirtualThread.run(VirtualThread.java:329)

#19 "ForkJoinPool-1-worker-1"
      java.base/jdk.internal.misc.Unsafe.park(Native Method)
      java.base/java.util.concurrent.locks.LockSupport.parkUntil(LockSupport.java:449)
      java.base/java.util.concurrent.ForkJoinPool.awaitWork(ForkJoinPool.java:1891)
      java.base/java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1809)
      java.base/java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:188)

There it is. The sleeping virtual thread is #18, and its stack is yours plus VirtualThread.run. The carrier, #19, is parked idle in awaitWork, doing nothing, because the virtual thread isn't on it. A sleeping virtual thread doesn't hold a carrier. That's the whole point of them, and also why the carrier can't be in the trace: at the time of the dump, there was no carrier attached.

For the record, the code behind this is VirtualThread.run, which mounts the thread on a carrier, runs your task, and routes any Throwable into the uncaught-exception path. I read that via a summarising fetch of the jdk-21+35 file rather than the source text itself, so treat my description of its internals as reading notes. The traces are the evidence, not my paraphrase. I did not get Throwable.java itself this run, so everything I say about how the exception is printed is the same as in the earlier articles; nothing in a virtual thread changes the printer.

It took me the evening to stop looking for the carrier and some of the next morning to be sure it wasn't there. Writing it down so you don't have to.

The same applies to the empty name. Thread.startVirtualThread and a bare Thread.ofVirtual().start give the thread an empty-string name on purpose. Virtual threads are meant to be created in the millions; naming each one costs, and the JDK leaves it to you. toString() copes with that: it prints VirtualThread[#18]/terminated after the thread finished, and while it was running, with a name:

VirtualThread[#18,probe]/runnable@ForkJoinPool-1-worker-1

The carrier appears in toString(), after the @, and nowhere in the stack trace. Thread IDs like #18 vary between runs and JDKs; don't compare them across machines.

Three misreadings, one of them mine

"Where's the thread pool?" Mine, as above. A trace on a virtual thread has no pool frames. Whoever told you to look for ForkJoinWorkerThread.run on the bottom is describing the carrier, or the older parallelStream trace from the streams article.

"The trace is shorter, so frames got lost." There's nothing lost. A deep recursion in a virtual thread (the deep mode in the lab) prints the usual repeated frames, then your lambda, then VirtualThread.run. Same count you'd expect. If something is off about the depth, the platform-thread rules from the missing-trace article still apply.

"Thread.getAllStackTraces() will show me everything." It won't. In my run it did not contain the virtual thread (false), and jstack produced zero matches for loader-0. Both dump platform threads only. If you're diagnosing a hang on JDK 21, jcmd <pid> Thread.dump_to_file is the tool that lists them; the text format printed #18 "loader-0" virtual as shown above. Thread.getStackTrace() on the virtual thread object itself does work, including while it's unmounted:

  vt  java.base/java.lang.VirtualThread.parkNanos(VirtualThread.java:635)
  vt  java.base/java.lang.VirtualThread.sleepNanos(VirtualThread.java:807)
  vt  java.base/java.lang.Thread.sleep(Thread.java:507)
  vt  Lab2.waitForConfig(Lab2.java:5)
  vt  Lab2.lambda$main$0(Lab2.java:10)
  vt  java.base/java.lang.VirtualThread.run(VirtualThread.java:329)
  pt  java.base/java.lang.Thread.sleep0(Native Method)
  pt  java.base/java.lang.Thread.sleep(Thread.java:509)
  pt  Lab2.waitForConfig(Lab2.java:5)
  pt  Lab2.lambda$main$1(Lab2.java:13)
  pt  java.base/java.lang.Thread.run(Thread.java:1583)

Compare the vt and pt blocks. Same program, same sleep. The platform thread sits inside a Native Method frame, sleep0. The virtual thread is in parkNanos, which is Java all the way down: a virtual thread's sleep is a park, not a native call. The code for Lab2 is below.

import java.util.*;

public class Lab2 {
    static void waitForConfig() throws InterruptedException {
        Thread.sleep(60_000);
    }

    public static void main(String[] args) throws Exception {
        Thread v = Thread.ofVirtual().name("loader-0").start(() -> {
            try { waitForConfig(); } catch (InterruptedException e) { }
        });
        Thread p = new Thread(() -> {
            try { waitForConfig(); } catch (InterruptedException e) { }
        }, "platform-0");
        p.setDaemon(true);
        p.start();
        Thread.sleep(300);
        System.out.println("state=" + v.getState() + " " + v);
        for (StackTraceElement e : v.getStackTrace()) System.out.println("  vt  " + e);
        for (StackTraceElement e : p.getStackTrace()) System.out.println("  pt  " + e);
        boolean seen = Thread.getAllStackTraces().keySet().contains(v);
        System.out.println("getAllStackTraces contains virtual thread: " + seen);
        System.out.println("pid=" + ProcessHandle.current().pid());
        if (args.length > 0) Thread.sleep(20_000);
        System.exit(0);
    }
}

Getting names and context back

Fix one: name them. A thread factory does it with a counter:

ThreadFactory factory = Thread.ofVirtual().name("loader-", 0).factory();

Every thread it makes is loader-0, loader-1 and so on, and the Exception in thread "loader-1" line from the named scenario shows up in the console. Cost: nearly nothing, but you do have to decide on a convention. I only ran the single-thread builder path (named), not a factory plugged into an executor.

Fix two: put the submit-site context somewhere the trace can see it. Virtual threads don't change what you learned in the async article: the submitter's stack is gone when the failure is built. Capture a creation-site exception, add it as addSuppressed, or log a request ID before you fork. A structured scope helps a little because the failure surfaces in the parent, on the thread that was waiting, so the parent's call site is at least in the outer exception. Frame 4 earlier. It's the join site, not the fork site. Don't confuse them.

Fix three, the one I'd skip: rewriting frames. Don't strip VirtualThread.run from logs. It tells you at a glance that this was a virtual thread, and you'll want that on the day you're comparing two incidents.

Dumps, logs and JDK versions

What I ran: only OpenJDK 21.0.12.1. Everything above is that JDK. I did not run JDK 25, and JDK 17 has no final virtual threads to compare against.

What I know without having run it: virtual threads were previews in 19 and 20 and final in 21 (JEP 444). StructuredTaskScope was a preview in 21, and a search turned up JEP 505, "Structured Concurrency (Fifth Preview)", for JDK 25. I haven't read it, I haven't run it, and I'm not going to tell you what its exceptions look like. If you're on 25, the API changed enough that the new StructuredTaskScope.ShutdownOnFailure() in my lab won't be how you write it. Re-run your own failing scope and read the new trace the way I did above: find the outer exception, find the Caused by, find your own frame.

Line numbers like VirtualThread.java:329 are specific to 21.0.12.1. Other updates and other JDKs will show different numbers.

In logs: I didn't run Logback or Log4j2 for this article. The JVM's default handler prints Exception in thread "" as shown, and any log pattern that prints the thread name will get the same empty string unless you named the thread. Check one of your own error lines before you assume it's fine. If you want an alert on "new exception types", group on the exception class and the first of your own frames, not on the thread name.

For hangs, use jcmd <pid> Thread.dump_to_file -format=text <file> on 21. JSON is available too (-format=json), organized into thread containers. I looked at the container names (<root>, a ForkJoinPool-1 container, and a few executors) but didn't dig into where my virtual thread landed, so I'll stop there. For a wider look at how a dump relates to a stack trace, the comparison article is the place, once it's up. And if your virtual threads stall rather than fail, that's pinning, which has its own article.

Takeaways

  • A virtual thread's stack trace has your frames plus VirtualThread.run at the bottom. No carrier, no pool. On 21 the bottom frame was VirtualThread.run(VirtualThread.java:329).
  • An empty thread name is the default, not a bug. Name them with Thread.ofVirtual().name("prefix-", 0).factory() before they hit your logs.
  • -XX:+ShowHiddenFrames adds continuation frames (Continuation.enter) and the hidden lambda class, still without the carrier.
  • A structured scope's throwIfFailed() builds the ExecutionException on the joining thread. The real trace is the Caused by, and it won't contain the fork() call site.
  • jstack and Thread.getAllStackTraces() don't list virtual threads. Use jcmd Thread.dump_to_file.

Questions people type after this

Why is my virtual thread name empty in the stack trace?
Virtual threads have an empty name unless you set one. The default uncaught-exception handler prints it between quotes, hence Exception in thread "". Give them names with Thread.ofVirtual().name("loader-", 0), or use a factory. Checked on OpenJDK 21.0.12.1.

Does a virtual thread stack trace include the carrier thread?
No. The trace holds the virtual thread's own frames, ending at java.lang.VirtualThread.run. With -XX:+ShowHiddenFrames you also see Continuation.enter, but still not the carrier. The carrier shows up in Thread.toString() and in a thread dump as its own thread. See the hub article for the general reading rules.

How do I see all virtual threads in a thread dump?
On JDK 21, jcmd <pid> Thread.dump_to_file -format=text <file>. jstack didn't list the virtual thread in my run and Thread.getAllStackTraces() didn't contain it.

What does a StructuredTaskScope failure look like in a stack trace?
An ExecutionException from throwIfFailed() thrown on the joining thread, with the subtask's real trace under Caused by, ending in SubtaskImpl.run and VirtualThread.run. That's JDK 21 with --enable-preview. It's a preview API, so check your JDK.

stack-tracesjava-concurrency

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments