> ## 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.

# StackWalker in Java: Walk the Call Stack Without the Cost
- URL: https://www.ggorantala.dev/java-stackwalker-api/
- Published: 2026-10-27T02:09:00.000Z
- Updated: 2026-11-01T02:14:19.000Z
- Description: StackWalker Java (9+) walks the call stack lazily, so you pay for the frames you read. How it works, its three options, and how it compares to getStackTrace.
- Author: Gopi Gorantala
- Tags: stack-traces, jvm-internals

`Thread.currentThread().getStackTrace()` copies the entire stack to hand you one frame. `StackWalker` doesn't. That's the whole pitch, and on a 200-frame stack I measured it as roughly thirty times cheaper for the "who called me" question. The rest of this post is what you give up for that, what the three options do, and the places the API surprised me.

If you want the basics of what a frame is first, the hub is [How to Read a Java Stack Trace, Line by Line, Fast](https://ggorantala.dev/reading-java-stack-traces/?ref=ggorantala.dev). Here I'm assuming you already read traces and want to produce them on purpose.

## The same stack, asked three ways

Same call chain (`main` calls `layer1`, which calls `layer2`, then `layer3`, then `dump`). OpenJDK 21.0.12.1 (Ubuntu build), Linux, compiled with plain `javac`, run with `java -cp`:

```log
-- Thread.getStackTrace()
  java.base/java.lang.Thread.getStackTrace(Thread.java:2451)
  csvexport.Lab.dump(Lab.java:30)
  csvexport.Lab.layer3(Lab.java:24)
  csvexport.Lab.layer2(Lab.java:25)
  csvexport.Lab.layer1(Lab.java:26)
  csvexport.Lab.main(Lab.java:118)
-- new Throwable().getStackTrace()
  csvexport.Lab.dump(Lab.java:32)
  csvexport.Lab.layer3(Lab.java:24)
  csvexport.Lab.layer2(Lab.java:25)
  csvexport.Lab.layer1(Lab.java:26)
  csvexport.Lab.main(Lab.java:118)
-- StackWalker.forEach
  csvexport.Lab.dump(Lab.java:34)
  csvexport.Lab.layer3(Lab.java:24)
  csvexport.Lab.layer2(Lab.java:25)
  csvexport.Lab.layer1(Lab.java:26)
  csvexport.Lab.main(Lab.java:118)

```

Three APIs, one stack. Look at the first line of each. `getStackTrace()` includes itself (`Thread.getStackTrace`, line 2451 of `Thread.java`). The other two start at your own method. That off-by-one is why half the "get my caller" snippets on the internet say `[2]` and the other half say `[1]`.

## The short read

A `StackWalker` is a cursor over the live stack. You don't ask it for an array of everything; you hand it a function that receives a `Stream<StackFrame>`, and the JVM feeds frames into that stream in batches as you consume them. Stop after two frames and it never decodes the other 300.

My first assumption, when I started this, was that it would be a nicer wrapper around the same array. It isn't. The array-returning calls build a `StackTraceElement` for every frame, strings and all, before you've looked at any of them. The walker builds a lightweight `StackFrame` and only turns it into a `StackTraceElement` if you ask.

## Make it yourself

Pinned: OpenJDK 21.0.12.1\. No dependencies. The whole thing is one file; keep the blank lines, because the line numbers in every trace in this post depend on them. I used a CSV exporter as the excuse (`Exporter` calls `Audit.log`, and `Audit` wants to know who called it).

```java
package csvexport;

import java.lang.StackWalker.Option;
import java.lang.StackWalker.StackFrame;
import java.util.List;
import java.util.stream.Collectors;

public class Lab {

    static class Exporter {
        void export(String file) {
            Audit.log("exporting " + file);
        }
    }

    static class Audit {
        static void log(String msg) {
            StackWalker w = StackWalker.getInstance(Option.RETAIN_CLASS_REFERENCE);
            Class<?> caller = w.getCallerClass();
            System.out.println(caller.getName() + " -> " + msg);
        }
    }

    static void layer3() { dump(); }
    static void layer2() { layer3(); }
    static void layer1() { layer2(); }

    static void dump() {
        System.out.println("-- Thread.getStackTrace()");
        for (StackTraceElement e : Thread.currentThread().getStackTrace()) System.out.println("  " + e);
        System.out.println("-- new Throwable().getStackTrace()");
        for (StackTraceElement e : new Throwable().getStackTrace()) System.out.println("  " + e);
        System.out.println("-- StackWalker.forEach");
        StackWalker.getInstance().forEach(f -> System.out.println("  " + f));
    }

    static void frames() {
        StackWalker w = StackWalker.getInstance(Option.RETAIN_CLASS_REFERENCE);
        w.forEach(f -> System.out.println("  " + f.getClassName() + "#" + f.getMethodName()
            + " desc=" + f.getDescriptor() + " bci=" + f.getByteCodeIndex()
            + " line=" + f.getLineNumber() + " native=" + f.isNativeMethod()
            + " cls=" + f.getDeclaringClass().getSimpleName()
            + " type=" + f.getMethodType()));
        StackTraceElement ste = w.walk(s -> s.findFirst()).get().toStackTraceElement();
        System.out.println("  STE: " + ste + " module=" + ste.getModuleName() + " loader=" + ste.getClassLoaderName());
    }

    static int depth(int n, Runnable r) { if (n == 0) { r.run(); return 0; } return 1 + depth(n - 1, r); }

    static void options() throws Exception {
        Runnable r = () -> {
            for (Option o : new Option[]{null, Option.SHOW_REFLECT_FRAMES, Option.SHOW_HIDDEN_FRAMES}) {
                StackWalker w = o == null ? StackWalker.getInstance() : StackWalker.getInstance(o);
                System.out.println("-- option " + o);
                w.forEach(f -> System.out.println("  " + f));
            }
        };
        Lab.class.getDeclaredMethod("viaReflection", Runnable.class).invoke(null, r);
    }
    static void viaReflection(Runnable r) { r.run(); }

    static void lazy() {
        int[] seen = {0};
        StackWalker w = StackWalker.getInstance();
        List<String> top2 = depth(300, () -> {
            List<String> l = w.walk(s -> s.peek(f -> seen[0]++).limit(2).map(StackFrame::getMethodName).collect(Collectors.toList()));
            System.out.println("  top2=" + l + " frames visited=" + seen[0]);
            seen[0] = 0;
            long all = w.walk(s -> s.peek(f -> seen[0]++).count());
            System.out.println("  count=" + all + " visited=" + seen[0]);
            System.out.println("  getStackTrace length=" + Thread.currentThread().getStackTrace().length);
        }) == 300 ? null : null;
    }

    static void callerFromMain() {
        try {
            System.out.println(StackWalker.getInstance(Option.RETAIN_CLASS_REFERENCE).getCallerClass());
        } catch (Exception e) { System.out.println("  " + e); }
        try {
            System.out.println(StackWalker.getInstance().getCallerClass());
        } catch (Exception e) { System.out.println("  " + e); }
    }

    static void stale() {
        StackWalker w = StackWalker.getInstance();
        java.util.stream.Stream<StackFrame>[] holder = new java.util.stream.Stream[1];
        w.walk(s -> { holder[0] = s; return null; });
        try { holder[0].count(); } catch (Exception e) { System.out.println("  " + e); }
    }

    static void cost() {
        for (int depthN : new int[]{10, 200}) {
            depth(depthN, () -> {
                Object sink = null;
                StackWalker w = StackWalker.getInstance(Option.RETAIN_CLASS_REFERENCE);
                for (int round = 0; round < 6; round++) {
                    int N = 100_000;
                    long t0 = System.nanoTime();
                    for (int i = 0; i < N; i++) sink = Thread.currentThread().getStackTrace()[2];
                    long t1 = System.nanoTime();
                    for (int i = 0; i < N; i++) sink = new Throwable().getStackTrace()[1];
                    long t2 = System.nanoTime();
                    for (int i = 0; i < N; i++) sink = w.walk(s -> s.skip(1).findFirst().get());
                    long t3 = System.nanoTime();
                    for (int i = 0; i < N; i++) sink = w.getCallerClass();
                    long t4 = System.nanoTime();
                    if (round >= 4) System.out.printf("  depth=%d  getStackTrace=%d ns  Throwable=%d ns  walk(findFirst)=%d ns  getCallerClass=%d ns%n",
                        depthN, (t1 - t0) / N, (t2 - t1) / N, (t3 - t2) / N, (t4 - t3) / N);
                }
                if (sink == null) System.out.println();
            });
        }
    }

    public static void main(String[] args) throws Exception {
        switch (args[0]) {
            case "caller" -> new Exporter().export("orders.csv");
            case "three" -> layer1();
            case "frames" -> frames();
            case "options" -> options();
            case "lazy" -> lazy();
            case "main" -> callerFromMain();
            case "stale" -> stale();
            case "cost" -> cost();
        }
    }
}

```

Compile and run each mode:

```bash
mkdir -p out && javac -d out csvexport/Lab.java
java -cp out csvexport.Lab caller
java -cp out csvexport.Lab three
java -cp out csvexport.Lab frames
java -cp out csvexport.Lab options
java -cp out csvexport.Lab lazy
java -cp out csvexport.Lab main
java -cp out csvexport.Lab stale
java -cp out csvexport.Lab cost

```

`caller` prints `csvexport.Lab$Exporter -> exporting orders.csv`. That's `getCallerClass()` doing the one job it's best at: the audit logger knows who called it without anyone passing a name in.

The `javac` warning about unchecked operations comes from the generic array in `stale()`. Ignore it.

Don't use `java Lab.java` for this one. The source launcher loads your class through its own class loader, and the `loader=` value in the next section would be a javac internal class name instead of `app`.

## Every field a frame carries

`frames` mode prints what a `StackFrame` gives you directly:

```log
  csvexport.Lab#frames desc=()V bci=13 line=39 native=false cls=Lab type=()void
  csvexport.Lab#main desc=([Ljava/lang/String;)V bci=265 line=119 native=false cls=Lab type=(String[])void
  STE: csvexport.Lab.frames(Lab.java:44) module=null loader=app

```

Walk through it:

- `bci=13`: the bytecode index inside the method. `StackTraceElement` never gave you this. Line numbers can be ambiguous (several calls on one line); the bytecode index isn't.
- `desc=()V`: the JVM method descriptor, so overloads are distinguishable without guessing from the name.
- `cls=Lab`: `getDeclaringClass()` returned a real `Class` object, not a string. That only works because I built the walker with `RETAIN_CLASS_REFERENCE`; per the Javadoc, `getDeclaringClass()` throws without it (I only ran that failure for `getCallerClass()`).
- `line=39` for `frames` but `line=119` for `main`: the walker reports the line currently executing in each frame, same as a trace would.
- The last line shows `toStackTraceElement()`. It says line 44, not 39, because it came from a second, separate walk on a different statement of the same method (the `findFirst` line). I stared at that for a while before I noticed the two walks were two statements.
- `module=null loader=app`: my class is in the unnamed module, loaded by the application loader. For a JDK frame you'd see `java.base` and `null` for the loader, which is what produces the `java.base/` prefix in a printed trace. See the hub post for the format.

## Why it's lazy, and what that buys you

The `lazy` mode puts the walker 300 recursive calls deep and counts how many frames each approach touches:

```log
  top2=[lambda$lazy$9, depth] frames visited=2
  count=304 visited=304
  getStackTrace length=305

```

Two frames visited for `limit(2)`. All 304 for `count()`. And `getStackTrace()` returns 305 elements regardless of whether you want one. I put a `peek` in the stream just to be sure I wasn't fooling myself about that first number.

From what I could verify, the JVM walks the stack in batches and hands them to the stream as it needs more. I read the Javadoc and JEP 259 for this, not the HotSpot walking code, so I'm stating the observable behaviour (frames visited = frames you consumed) and not the batch size. If you need the batch size, go read `StackWalker.java` yourself.

That laziness has a catch. The stream is only valid inside the function you pass to `walk`:

```log
  java.lang.IllegalStateException: This stack stream is not valid for walking.

```

That's `stale` mode: I stashed the stream in an array, left the `walk` call, and tried to use it. The walker closes the stream when `walk` returns. If you want frames afterwards, collect them inside the function (`.collect(toList())`, or `.map(StackFrame::toStackTraceElement)`).

## The frames it hides unless you ask

By default the walker hides reflection frames and the JVM's internal glue. Three modes, same call, `options` mode (all of it goes through `Method.invoke` and a lambda on the way):

```log
-- option null
  csvexport.Lab.lambda$options$4(Lab.java:55)
  csvexport.Lab.viaReflection(Lab.java:60)
  csvexport.Lab.options(Lab.java:58)
  csvexport.Lab.main(Lab.java:120)
-- option SHOW_REFLECT_FRAMES
  csvexport.Lab.lambda$options$4(Lab.java:55)
  csvexport.Lab.viaReflection(Lab.java:60)
  java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
  java.base/java.lang.reflect.Method.invoke(Method.java:580)
  csvexport.Lab.options(Lab.java:58)
  csvexport.Lab.main(Lab.java:120)
-- option SHOW_HIDDEN_FRAMES
  csvexport.Lab.lambda$options$4(Lab.java:55)
  csvexport.Lab$$Lambda/0x00007f4eac000a08.run(Unknown Source)
  csvexport.Lab.viaReflection(Lab.java:60)
  java.base/java.lang.invoke.LambdaForm$DMH/0x00007f4eac001000.invokeStatic(LambdaForm$DMH)
  java.base/java.lang.invoke.LambdaForm$MH/0x00007f4eac002400.invoke(LambdaForm$MH)
  java.base/java.lang.invoke.Invokers$Holder.invokeExact_MT(Invokers$Holder)
  java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invokeImpl(DirectMethodHandleAccessor.java:154)
  java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
  java.base/java.lang.reflect.Method.invoke(Method.java:580)
  csvexport.Lab.options(Lab.java:58)
  csvexport.Lab.main(Lab.java:120)

```

The default is what you want for "which of my classes called this". `SHOW_REFLECT_FRAMES` adds the two frames you'd see in an ordinary `InvocationTargetException` trace (I wrote about those in [Reading Java Stack Traces Through Frameworks and Proxies](https://ggorantala.dev/java-stack-trace-framework-noise/?ref=ggorantala.dev)). `SHOW_HIDDEN_FRAMES` is the full picture, including the generated lambda class and the `LambdaForm` machinery; it implies the reflect frames too. The odd names (`lambda$options$4`, `Lab$$Lambda/0x...`) are covered in [Strange Java Stack Frames](https://ggorantala.dev/java-stack-trace-strange-frames/?ref=ggorantala.dev).

`RETAIN_CLASS_REFERENCE` is the third option and it isn't about hidden frames at all. It's the permission to call `getDeclaringClass()` and `getCallerClass()`.

## The numbers, with a big caveat

`cost` mode runs each approach 100,000 times at two stack depths, after warmup, and prints the last two rounds. This is a hand-rolled loop on a 2 vCPU container, not JMH. Treat the ratios as meaningful and the absolute numbers as decoration:

```log
  depth=10  getStackTrace=3634 ns  Throwable=3266 ns  walk(findFirst)=1296 ns  getCallerClass=634 ns
  depth=10  getStackTrace=3431 ns  Throwable=4371 ns  walk(findFirst)=1243 ns  getCallerClass=577 ns
  depth=200  getStackTrace=37102 ns  Throwable=35893 ns  walk(findFirst)=2095 ns  getCallerClass=619 ns
  depth=200  getStackTrace=35532 ns  Throwable=34950 ns  walk(findFirst)=1150 ns  getCallerClass=592 ns

```

The pattern is the point. The two array-based calls scale with depth (10x deeper, roughly 10x slower). The walker doesn't care: `findFirst` stays around a microsecond, and `getCallerClass()` stays near 600 ns in both rows. At depth 10 the gap is about 3x; at depth 200 it's about 30x. I haven't done the proper JMH version, and there's a wrinkle in the OpenJDK mailing list about `getCallerClass` versus the internal `Reflection.getCallerClass` being slower than people hoped, which I found but did not reproduce.

## Where people misread it

**"StackWalker is just faster Thread.getStackTrace."** Faster for partial reads. If you want every frame, `walk(s -> s.collect(...))` isn't magic; you still pay per frame.

**"getCallerClass() works everywhere."** It doesn't. Called directly from `main` it throws `java.lang.IllegalCallerException: no caller frame`, because there's nothing above `main`. And without `RETAIN_CLASS_REFERENCE` it throws `UnsupportedOperationException: This stack walker does not have RETAIN_CLASS_REFERENCE access`. I tripped on both. The first time I tested "from main" I called it through a helper, so it happily returned `class csvexport.Lab` and I thought the failure mode didn't exist.

**"It shows the whole story."** Not by default. The reflection and hidden frames are gone unless you opt in, which is exactly what you want for a caller check and exactly wrong for debugging a proxy problem.

**"It replaces the exception trace."** It doesn't. The trace in an exception is captured when the `Throwable` is constructed; the walker is a live view of right now. You can't walk a stack that has already unwound.

## When it's the right tool, and when it isn't

Reach for it for caller-sensitive logging (the `Audit.log` example), security-ish "who called me" checks, test helpers that need the calling test method, and anywhere you'd have written `new Throwable().getStackTrace()[1]` in a hot path.

Skip it for: anything that needs frames after the call has returned (collect them), dumping all threads (that's `Thread.getAllStackTraces`, or a thread dump), and virtual threads that you want to inspect from outside (the walker only walks the stack of the thread calling it; the shape of a virtual-thread stack is in [Virtual Thread Stack Traces in Java](https://ggorantala.dev/virtual-thread-stack-traces/?ref=ggorantala.dev)).

Cost to name: `RETAIN_CLASS_REFERENCE` keeps `Class` objects reachable from your frames while the walk runs, so don't stash `StackFrame`s in a long-lived cache. And a walker is not free; 600 ns per call is great against 35 µs, still not what you want inside a per-row loop on a million-row CSV export. Cache the answer per call site if the caller doesn't change.

## What I couldn't verify

I ran everything on JDK 21 only. I did not run JDK 9 through 17 or 25, so I'm not claiming anything about differences there; JEP 259 says the API arrived in JDK 9\. I did not read the HotSpot stack-walking code or `StackWalker.java`; everything above is observed output plus the Javadoc. I did not measure with JMH, and the numbers are from a 2-core container. Logging libraries that use this internally (Log4j2 is one) I did not run. This took me most of two evenings, mostly because I kept misreading my own outputs, and I'd rather you didn't repeat that.

## Takeaways

- `Thread.getStackTrace()` and `new Throwable().getStackTrace()` cost depth; `StackWalker` costs frames consumed. Measured on JDK 21: about 3x cheaper at depth 10 and about 30x at depth 200 for finding the caller.
- `getCallerClass()` needs `RETAIN_CLASS_REFERENCE`, and it throws `IllegalCallerException` if there's no caller (directly in `main`).
- The stream is only valid inside `walk`; collect what you need before returning.
- Default skips reflection and hidden frames; `SHOW_REFLECT_FRAMES` and `SHOW_HIDDEN_FRAMES` add them back.
- `Thread.getStackTrace()` includes itself as frame 0; the other two don't.

## Questions people type into search

**What is StackWalker in Java?** It's a class added in JDK 9 (JEP 259) that lets you traverse the call stack of the current thread lazily, as a `Stream<StackFrame>`, instead of materialising the whole thing as an array.

**Is StackWalker faster than Thread.getStackTrace()?** For partial reads, yes. On JDK 21 in my loop it was about 3x faster at stack depth 10 and about 30x faster at depth 200 when finding the caller. For reading every frame, the gap narrows. Not JMH; treat it as a ratio.

**How do I get the caller class in Java?** `StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE).getCallerClass()`. Without the option it throws `UnsupportedOperationException`; called from `main` it throws `IllegalCallerException`.

**How do I see reflection frames with StackWalker?** Build the walker with `Option.SHOW_REFLECT_FRAMES`, or `SHOW_HIDDEN_FRAMES` for the generated lambda and `LambdaForm` frames as well.