> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Virtual Thread Stack Traces in Java, Decoded Line by Line
- URL: https://www.ggorantala.dev/virtual-thread-stack-traces/
- Published: 2026-10-04T01:13:00.000Z
- Updated: 2026-11-01T02:14:26.000Z
- Description: A virtual thread stack trace looks normal, minus the carrier. Why the thread name is empty, what each frame means, and how StructuredTaskScope rethrows.
- Author: Gopi Gorantala
- Tags: stack-traces, java-concurrency

Look at the thread name on the first line.

```log
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](https://ggorantala.dev/reading-java-stack-traces/?ref=ggorantala.dev) 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.

```java
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:

```bash
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:

```bash
java --enable-preview Lab execute
java --enable-preview Lab executor

```

```log
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](https://ggorantala.dev/java-async-stack-traces/?ref=ggorantala.dev):

```log
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](https://ggorantala.dev/java-stack-trace-strange-frames/?ref=ggorantala.dev) explains.

Last scenario, the structured one:

```bash
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()`.

```log
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:

```log
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](https://ggorantala.dev/java-stack-trace-framework-noise/?ref=ggorantala.dev): `-XX:+UnlockDiagnosticVMOptions -XX:+ShowHiddenFrames`. Here's the `bare` run with it:

```log
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:

```log
#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:

```log
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](https://ggorantala.dev/java-stack-trace-lambdas-streams/?ref=ggorantala.dev).

**"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](https://ggorantala.dev/java-missing-stack-trace/?ref=ggorantala.dev) 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:

```log
  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.

```java
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:

```java
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](https://ggorantala.dev/java-async-stack-traces/?ref=ggorantala.dev): 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](https://ggorantala.dev/stack-trace-vs-thread-dump/?ref=ggorantala.dev) is the place, once it's up. And if your virtual threads stall rather than fail, that's pinning, which [has its own article](https://ggorantala.dev/java-virtual-thread-pinning-synchronized-jep-491/?ref=ggorantala.dev).

## 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](https://ggorantala.dev/reading-java-stack-traces/?ref=ggorantala.dev) 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.