Skip to content

StackWalker in Java: Walk the Call Stack Without the Cost

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.

stack-traces jvm-internals
Gopi Gorantala
Reading Progress

On This Page

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

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

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:

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:

  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:

  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:

  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):

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

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:

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

Cost to name: RETAIN_CLASS_REFERENCE keeps Class objects reachable from your frames while the walk runs, so don't stash StackFrames 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.

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.

stack-tracesjvm-internals

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