Skip to content

Java Exception With No Stack Trace: Why Traces Go Missing

A Java exception with no stack trace, or one cut off at 1024 frames, isn't a logging bug. Why the JVM drops traces, how to spot it, and what to change.

stack-traces troubleshooting
Gopi Gorantala
Reading Progress

On This Page

You've already searched the message. There's nothing to search, because the log line is just this:

java.lang.NullPointerException

No message, no at lines, no Caused by. My lab currency converter did exactly this: a perfectly good, fully-described NullPointerException for the first few thousand calls, then this one, same code, same input, same JVM. That's the java exception no stack trace problem, and it's a JVM feature, not a logging bug. I'll show you the exact switch that does it (OpenJDK 21.0.12.1, Linux, console output, java Lab fast), and the other four ways a trace goes missing or gets cut. Start with how to read a Java stack trace if frames are new to you; this one is about the ones that aren't there.

Where the frames should be

Here's the same exception from the same line of source, a moment apart. First occurrence:

java.lang.NullPointerException: Cannot invoke "String.trim()" because "currency" is null
	at Lab.toUsd(Lab.java:7)
	at Lab.convert(Lab.java:12)
	at Lab.main(Lab.java:69)

And the one my loop printed on iteration 6,534 (it moves around; I got 6,178 and others on other runs):

java.lang.NullPointerException

Two things disappeared, not one. The frames are gone and so is the helpful message about "currency" is null. If you've been logging only e.getMessage(), you'd have seen null. Hold that thought, because it's how this goes unnoticed for weeks.

Short version: the JVM stopped filling it in

My first guess was the logger. It wasn't; I printed with System.out and got the same thing. My second guess was a wrapper that rebuilt the exception without a cause. Also no.

The real answer: after a throw site has been hot for a while and the JIT compiler (C2) has optimised the method, HotSpot is allowed to throw a preallocated exception object instead of building a new one. Building a new one is the expensive part, since that's where the stack walk happens. The preallocated one has no frames and no message. The flag is OmitStackTraceInFastThrow, it's true by default, and its own help text says: "Omit backtraces for some 'hot' exceptions in optimized code."

The first, full traces are still in your logs. They're just earlier than the one you're staring at. Search backwards.

Make it vanish on purpose

Pinned: OpenJDK 21.0.12.1 (Ubuntu build), default flags, no logging libraries. Keep the blank lines; the line numbers in every trace here depend on them.

Lab.java:

import java.util.Map;

public class Lab {
    static final Map<String, Double> RATES = Map.of("EUR", 1.08, "GBP", 1.27);

    static double toUsd(String currency, double amount) {
        Double rate = RATES.get(currency.trim());   // NPE when currency is null
        return rate == null ? Double.NaN : amount * rate;
    }

    static double convert(String currency, double amount) {
        return toUsd(currency, amount);
    }

    static class QuietException extends RuntimeException {
        QuietException(String m) { super(m, null, false, false); }
    }

    static class OverrideException extends RuntimeException {
        OverrideException(String m) { super(m); }
        @Override public synchronized Throwable fillInStackTrace() { return this; }
    }

    static int depth(int n) {
        if (n == 0) throw new IllegalStateException("bottom");
        return depth(n - 1) + 1;
    }

    static void fast() {
        for (int i = 1; i <= 300_000; i++) {
            try {
                convert(i % 2 == 0 ? null : "EUR", 10);
            } catch (NullPointerException e) {
                if (e.getStackTrace().length == 0) {
                    System.out.println("trace went empty at iteration " + i);
                    e.printStackTrace(System.out);
                    return;
                }
            }
        }
        System.out.println("never went empty");
    }

    public static void main(String[] args) {
        switch (args[0]) {
            case "fast" -> fast();
            case "quiet" -> {
                try { throw new QuietException("rate feed down"); }
                catch (QuietException e) { e.printStackTrace(System.out); }
            }
            case "override" -> {
                try { throw new OverrideException("rate feed down"); }
                catch (OverrideException e) { e.printStackTrace(System.out); }
            }
            case "depth" -> {
                try { depth(5000); }
                catch (IllegalStateException e) {
                    System.out.println("frames in trace: " + e.getStackTrace().length);
                    StackTraceElement[] st = e.getStackTrace();
                    System.out.println("first: " + st[0]);
                    System.out.println("last: " + st[st.length - 1]);
                }
            }
            case "msg" -> {
                try { convert(null, 1); }
                catch (Exception e) { System.out.println("LOG: " + e.getMessage()); }
            }
            case "first" -> {
                try { convert(null, 1); }
                catch (Exception e) { e.printStackTrace(System.out); }
            }
        }
    }
}

Compile with debug info and run each case:

javac -g Lab.java
java Lab first       # one NPE, full trace
java Lab fast        # loops until an NPE comes back with zero frames
java -XX:-OmitStackTraceInFastThrow Lab fast
java Lab quiet
java Lab override
java Lab depth
java -XX:MaxJavaStackTraceDepth=50 Lab depth
java -XX:MaxJavaStackTraceDepth=0 Lab depth
java Lab msg

What I got, in order:

$ java Lab fast
trace went empty at iteration 6534
java.lang.NullPointerException

$ java -XX:-OmitStackTraceInFastThrow Lab fast
never went empty

$ java Lab quiet
Lab$QuietException: rate feed down

$ java Lab override
Lab$OverrideException: rate feed down

$ java Lab depth
frames in trace: 1024
first: Lab.depth(Lab.java:25)
last: Lab.depth(Lab.java:26)

$ java -XX:MaxJavaStackTraceDepth=50 Lab depth
frames in trace: 50
first: Lab.depth(Lab.java:25)
last: Lab.depth(Lab.java:26)

$ java -XX:MaxJavaStackTraceDepth=0 Lab depth
frames in trace: 5002
first: Lab.depth(Lab.java:25)
last: Lab.main(Lab.java:56)

$ java Lab msg
LOG: Cannot invoke "String.trim()" because "currency" is null

The iteration number in fast will differ on your machine (it depends on when your JIT decides the method is hot). If it says never went empty, your JIT hasn't compiled the method yet. Raise the loop count. Under -XX:TieredStopAtLevel=1 (C1 only) and -Xint I never saw an empty trace at all, which matches "optimized code" in the flag text.

Three variations worth a minute. First, which exceptions get this treatment. I wrote a second probe (Kinds.java, below) that throws eight kinds from tiny methods and checks for an empty trace:

import java.util.function.IntConsumer;
import java.util.logging.*;

public class Kinds {
    static int[] arr = new int[2];
    static Object[] objs = new String[1];
    static Object o = Integer.valueOf(1);
    static String s = null;

    static void npe(int i)  { s.length(); }
    static void aioobe(int i) { arr[5 + (i & 1)] = 1; }
    static void div(int i)  { int z = i - i; System.out.print(""); int r = 10 / z; }
    static void cce(int i)  { String x = (String) o; }
    static void ase(int i)  { objs[0] = Integer.valueOf(i); }
    static void neg(int i)  { int[] a = new int[-1 - (i & 1)]; }
    static void iae(int i)  { throw new IllegalArgumentException("explicit " + i); }
    static void sioobe(int i) { "abc".charAt(10); }

    static void probe(String name, IntConsumer c) {
        for (int i = 1; i <= 500_000; i++) {
            try { c.accept(i); }
            catch (Throwable e) {
                if (e.getStackTrace().length == 0) {
                    System.out.printf("%-10s empty at iteration %d  -> %s%n", name, i, e);
                    return;
                }
            }
        }
        System.out.printf("%-10s never empty%n", name);
    }

    public static void main(String[] args) {
        if (args[0].equals("kinds")) {
            probe("npe", Kinds::npe); probe("aioobe", Kinds::aioobe); probe("div", Kinds::div);
            probe("cce", Kinds::cce); probe("ase", Kinds::ase); probe("negsize", Kinds::neg);
            probe("explicit", Kinds::iae); probe("sioobe", Kinds::sioobe);
        } else if (args[0].equals("log")) {
            Logger log = Logger.getLogger("lab");
            try { npe(1); } catch (Exception e) {
                log.log(Level.SEVERE, e.getMessage());
                log.log(Level.SEVERE, "rate lookup failed", e);
            }
        }
    }
}

Compile and run it three times:

cd k && javac -g Kinds.java && java Kinds kinds
npe        empty at iteration 7552  -> java.lang.NullPointerException
aioobe     empty at iteration 6914  -> java.lang.ArrayIndexOutOfBoundsException
div        empty at iteration 7141  -> java.lang.ArithmeticException
cce        empty at iteration 5841  -> java.lang.ClassCastException
ase        empty at iteration 6506  -> java.lang.ArrayStoreException
negsize    never empty
explicit   never empty
sioobe     never empty

Five went blank on all three runs: NullPointerException, ArrayIndexOutOfBoundsException, ArithmeticException, ClassCastException, ArrayStoreException. The negative array size, a StringIndexOutOfBoundsException from charAt, and an exception I threw with new never did, in 500,000 iterations. That's JDK 21 on my box and it's a list I measured, not one I read in the VM source. Treat other versions as unverified.

Second variation: an exception you throw new yourself is never affected. The VM can only swap in a shared object for exceptions the JIT itself raises from implicit checks (null, bounds, divide, cast, store). Your own throw new IllegalArgumentException(...) always builds a real one.

If your run shows ForkJoinPool or a different trace shape, you changed the code, not the lesson. Environment note: -Xint never produced an empty trace in my runs, so anything that keeps the code unoptimised (a debugger session, a short-lived local run) can make "can't reproduce locally" the usual end of this story. That part is my inference.

Java exception no stack trace: five ways to get an empty one

Annotated, the fast-throw case from above:

java.lang.NullPointerException   (1)
  1. Class name only. No : message (my reading: the helpful text is built when a fresh NPE is created, and the shared object never goes through that; I didn't confirm it in the source), and no at lines because getStackTrace() is an empty array. e.getStackTrace().length == 0 is the cheapest test for it, and it's what my probe uses.

That's one of five distinct ways to see a trace with no frames, or fewer than you expected. They look alike in a log and have different cures:

1. Fast throw (OmitStackTraceInFastThrow). Real exception types, hot code, no frames, no message. Cure: look for an earlier full occurrence; if there isn't one, restart or run with -XX:-OmitStackTraceInFastThrow.

2. writableStackTrace=false. The four-argument constructor RuntimeException(message, cause, enableSuppression, writableStackTrace) — my QuietException — skips the stack capture. The output above is just Lab$QuietException: rate feed down. Libraries do this on purpose for control-flow exceptions. You'll know because the class is custom and the message is intact. The message surviving is the discriminator versus fast throw.

3. An overridden fillInStackTrace(). Same result (my OverrideException), different mechanism: the constructor still calls it, and the override returns this without filling anything. Old-school optimisation trick. Search the codebase and your dependencies for fillInStackTrace.

4. Depth truncation (MaxJavaStackTraceDepth). The default is 1024 on this JDK (I checked with -XX:+PrintFlagsFinal). A 5,000-deep recursion gave me a trace of exactly 1,024 frames. Which ones? The 1,024 newest. The oldest, the ones that tell you who started the whole thing, are dropped, and nothing in the output says so. I printed a 3,000-deep one (D.java, one line: static int d(int n){ if(n==0) throw new IllegalStateException("bottom"); return d(n-1)+1; } called from main with 3000, uncaught) with the default handler and counted 1,025 lines: the header plus 1,024 frames, no ... N more, no marker. The last line is just another at D.d(D.java:1).

Setting the flag to 0 returned all 5,002 frames, ending properly at Lab.main(Lab.java:56). So 0 means unlimited here. I only saw that in behaviour; I couldn't pull javaClasses.cpp far enough to read the code that does it, so I'm saying what I observed, not what the source says.

5. Throwing away the trace in your own code or logging. catch (Exception e) { log(e.getMessage()); }. The JVM did its job, you dropped the result. See below.

Why the JVM hands out a blank exception

I spent most of a day on the wrong theory here. I assumed fast throw was a counter: "after N throws of the same exception". It isn't, or if it is, it isn't visible. The iteration at which my loop went empty moved between 5,841 and 9,050 across the runs above, and the cases that never hit it never hit it. What matters is that the method got compiled by C2 with a profile that says "this check has failed before", and then C2 emits a jump to a cheap shared throw instead of the full slow path.

That's a hypothesis assembled from the flag's help text and the behaviour. What I did establish, with Same.java, a 20-line loop, is that the blank ones are all one object:

empty=58654 identical-to-previous-empty=58653

Of 100,000 iterations, 58,654 came back with no frames, and every one of them was the same instance as the previous blank one (==, not equals). So the JVM isn't creating a cheaper exception. It's handing out a singleton. That has a nasty side effect: if anything in your code calls addSuppressed on one of those, or initCause, it's mutating a shared object. I didn't test that and I'd avoid finding out in production.

The cost it's saving is real. Building a trace means walking the stack and allocating an array of frames per throw, and a hot loop that throws thousands of NPEs per second spends noticeable time there. I'm not going to put numbers on it here; the cost of exceptions is covered separately, and I'd measure before claiming anything.

Where the frames come from the rest of the time, how Throwable prints them, and the ... N more rule are in the Caused by article. I'd link to the JDK source at a tag but this run I only got a one-line help string for the flag from globals.hpp at jdk-21+35, not the code that applies it. I'm leaving it at that rather than guessing.

One more version note, unverified: I ran 21 only. I expect fast throw has been default for a long time, and I expect the helpful-message loss to only matter on JDK 14+, where there's a message to lose. I didn't run JDK 8, 17, or 25.

Three things that look like a missing trace and aren't

A message-only log line. LOG: Cannot invoke "String.trim()" because "currency" is null came from my msg case: a getMessage() call. The trace exists; you didn't print it. JUL does the right thing when you give it the throwable (log.log(Level.SEVERE, "rate lookup failed", e) printed both the message and two frames in my run) and nothing when you give it a string. Same idea in SLF4J, covered in logging Java exceptions properly.

A log shipper that split the trace. Forty events instead of one, and your search shows the first. The trace is intact upstream.

An exception that was created far from where it was thrown. Frames are there; they point somewhere you didn't expect. That's the created-versus-thrown rule in the hub article.

The discriminator for fast throw specifically: the class has a standard JDK name, the message is missing even though a similar earlier line had one, and the same exception shows up with frames earlier in the log.

Flags, code and log changes, with prices

-XX:-OmitStackTraceInFastThrow. Works (my second fast run never went empty). Cost: you give back the optimisation for every hot implicit exception in the JVM. In a service where a null check fails thousands of times per second, that's CPU and allocation you wanted to avoid. It's a flag that gets copied into start scripts with no comment about why. If you can't say what exception it protects you from, remove it; if you can, say it in a comment.

Fix the code that throws a hot NPE. Honestly the better answer. If a null is common, check it and throw something with context. That also stops the spam.

Don't use writableStackTrace=false for anything you'd want to debug. It's fine for a control-flow signal that's thrown and caught 100 frames up. It isn't fine if it ever escapes.

Raise MaxJavaStackTraceDepth only if you have deep recursion in your trace on purpose. It's a cap on memory per exception. 0 removes the cap; I'd be careful with that in a service that can recurse infinitely.

Log the throwable, not its message. Free.

Finding the first full trace in a pile of logs

In production, the question isn't "how do I get a trace back". It's "where's the last good one". Search for the exception class name followed by a colon or a newline plus at, sort by time, and take the earliest. If your aggregator groups by fingerprint, I'd expect an empty-frames NPE to land in its own group, since there are no frames to fingerprint on (I didn't run an aggregator): a "new" error that is the old one in disguise.

If your pipeline can see frame counts, alert on zero-frame exceptions of standard JDK types.

Versions: everything here was run on OpenJDK 21.0.12.1. The flag exists with the same name on the other LTS lines as far as I know; I didn't run 8, 17, or 25 and I didn't run Logback or Log4j2. Only JUL.

The bits worth keeping

  • An exception with no frames and no message from a standard JDK type means fast throw until proven otherwise. The earlier full trace is in your logs.
  • On JDK 21, NullPointerException, ArrayIndexOutOfBoundsException, ArithmeticException, ClassCastException and ArrayStoreException went blank in my loops; exceptions you throw new never do.
  • All the blank ones are the same object.
  • The default depth cap is 1,024 frames, drops the oldest, and prints no marker.
  • getMessage() in a log call throws away the trace before the JVM gets a say.

Quick questions that come up

Why does my Java NullPointerException have no stack trace? Most often because the JVM optimised a hot throw site and reuses a preallocated exception with no frames. Check getStackTrace().length, find an earlier occurrence in your logs, or run with -XX:-OmitStackTraceInFastThrow (JDK 21, default is on).

How many frames does a Java stack trace keep? 1,024 by default (MaxJavaStackTraceDepth, verified on JDK 21). The newest are kept; the oldest are dropped, without any marker. -XX:MaxJavaStackTraceDepth=0 returned the whole stack in my test.

Is it safe to turn off OmitStackTraceInFastThrow? It's safe in the sense that nothing breaks. It costs CPU and allocation on hot exception paths, so measure first.

Can an exception be created without a stack trace on purpose? Yes, through the protected constructor with writableStackTrace=false or by overriding fillInStackTrace. Don't do it for exceptions that can reach a log.

stack-tracestroubleshooting

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