☕ Java

Stack Trace

A stack trace is the recorded sequence of method calls that were active at the moment an exception was thrown. It is the most important debugging tool in Java — a precise map from the exception back through every method call that led to it, with file names and line numbers at each level. Understanding how to read a stack trace, how the JVM records it, what information each line conveys, how to work with stack traces programmatically, and how to produce clean stack traces in production code is a foundational skill for every Java developer. This entry covers stack trace structure, reading nested and chained traces, programmatic access and manipulation, logging strategies, stack trace filtering, and common patterns for diagnosing real-world problems from stack traces.

Stack Trace Structure and How to Read It

A stack trace begins with the exception class name and message on the first line, followed by one line per stack frame in the call chain. Each frame line begins with "at" and contains the fully qualified class name, the method name, the source file name, and the line number in parentheses. The frame at the top of the list is the most recent call — the exact location where the exception was thrown. Each subsequent frame is the caller of the frame above it. Reading a stack trace correctly requires reading it from the top. The top frame tells you precisely what code threw the exception and on which line. The frame below it tells you which method called into that code, and so on. The bottom frame is typically the thread's entry point — main() for the main thread, or the Runnable.run() implementation for other threads. The "at" frames are not just a history of what happened — they are a map of who is responsible. If the exception is in library code (org.springframework, com.mysql, etc.), the responsibility is usually not in that library frame but in the application frame that called it. Scan down from the top until you find the first frame in your own application's package — that is usually where the bug is or where the wrong input was provided. Library code is almost always correct; it is the application code calling it incorrectly that causes the exception.
Java
// ── Reading a stack trace ────────────────────────────────────────────
//
// Exception in thread "main" java.lang.NullPointerException:  ← type + message
//     Cannot invoke "String.length()" because "str" is null  ← Java 14+ helpful NPE
//   at com.myapp.StringUtils.process(StringUtils.java:42)    ← THROWN HERE (line 42)
//   at com.myapp.OrderService.formatOrder(OrderService.java:87) ← called process()
//   at com.myapp.OrderController.getOrder(OrderController.java:53) ← called formatOrder()
//   at com.myapp.OrderController$$EnhancerBySpring.getOrder(Unknown Source) ← proxy
//   at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ← JVM internals
//   at org.springframework.web.servlet.FrameworkServlet.service(...) ← framework
//   at java.lang.Thread.run(Thread.java:834)                ← thread entry point
//
// Key observations:
// 1. Line 1: NullPointerException — str is null in process()
// 2. Line 2: process() at line 42 in StringUtils — thrown here
// 3. Line 3: formatOrder() called process() with null str
// 4. Line 4: getOrder() called formatOrder()
// 5. The bug is likely in getOrder() passing null to formatOrder()

// ── What each StackTraceElement contains ─────────────────────────────
try {
    throw new RuntimeException("demo");
} catch (RuntimeException e) {
    StackTraceElement[] frames = e.getStackTrace();
    StackTraceElement top = frames[0];   // where exception was thrown

    System.out.println(top.getClassName());    // fully qualified class
    System.out.println(top.getMethodName());   // method name
    System.out.println(top.getFileName());     // source file name
    System.out.println(top.getLineNumber());   // line number (or -1 if unknown)
    System.out.println(top.isNativeMethod());  // true for native methods
    System.out.println(top.getModuleName());   // module name (Java 9+, may be null)
}

// ── Stack frame patterns to recognise ────────────────────────────────
// Native method (no line number):
//   at java.io.FileInputStream.read0(Native Method)
//
// Unknown source (no debug info):
//   at com.obfuscated.Lib.method(Unknown Source)
//
// JVM-generated proxy class:
//   at com.myapp.Service$$EnhancerByCGLIB$$abc123.method(...)
//
// Lambda expression:
//   at com.myapp.Service.lambda$processOrder$0(Service.java:55)
//
// First find YOUR code — that is where the real investigation starts

Programmatic Stack Trace Access

The JVM records a stack trace automatically when any Throwable is constructed — not when it is thrown. The recording captures the current thread's call stack at the moment of construction. This is done by calling fillInStackTrace() in Throwable's constructor, which is an O(n) native operation where n is the depth of the call stack. For performance-sensitive code that constructs exceptions in tight loops (a common antipattern), this capture cost can be significant. The stack trace is accessible via getStackTrace(), which returns an array of StackTraceElement objects. Each element provides the class name, method name, file name, and line number. This array can be iterated, filtered, and analysed programmatically — useful for structured logging, error reporting, and diagnostic tools. Thread.currentThread().getStackTrace() retrieves the current thread's call stack without any exception involved, which is useful for logging the call context at any point in the code, not just when exceptions occur. Note that the first frame is always getStackTrace() itself, so useful frames start at index 1. The fillInStackTrace() method can be overridden in custom exception classes to suppress stack trace capture entirely — useful for exceptions that are used for control flow rather than error reporting, where the performance cost of stack trace capture is undesirable.
Java
// ── Access stack trace from exception ────────────────────────────────
public static String formatStackTrace(Throwable e) {
    StringBuilder sb = new StringBuilder();
    sb.append(e.getClass().getName())
      .append(": ")
      .append(e.getMessage())
      .append('
');

    for (StackTraceElement frame : e.getStackTrace()) {
        sb.append("  at ")
          .append(frame.getClassName())
          .append('.')
          .append(frame.getMethodName())
          .append('(')
          .append(frame.getFileName())
          .append(':')
          .append(frame.getLineNumber())
          .append(')')
          .append('
');
    }
    return sb.toString();
}

// ── Filter stack trace — show only application frames ─────────────────
public static String appStackTrace(Throwable e,
                                    String appPackage) {
    StringBuilder sb = new StringBuilder();
    sb.append(e.getClass().getSimpleName())
      .append(": ")
      .append(e.getMessage())
      .append('
');

    for (StackTraceElement frame : e.getStackTrace()) {
        if (frame.getClassName().startsWith(appPackage)) {
            sb.append("  at ").append(frame).append('
');
        }
    }
    return sb.toString();
}

// Usage: appStackTrace(e, "com.myapp") — only shows com.myapp frames

// ── Get current thread's stack without an exception ───────────────────
public static String currentCallStack() {
    StackTraceElement[] frames =
        Thread.currentThread().getStackTrace();
    StringBuilder sb = new StringBuilder("Call stack:
");
    for (int i = 2; i < frames.length; i++) {  // skip getStackTrace and this method
        sb.append("  ").append(frames[i]).append('
');
    }
    return sb.toString();
}

// ── Suppress stack trace for control-flow exceptions ─────────────────
// Constructing a stack trace is expensive (~microseconds)
// For exceptions used as signals rather than errors, suppress it:
public class RetrySignal extends RuntimeException {

    public RetrySignal(String reason) {
        super(reason,
              null,     // cause
              true,     // enableSuppression
              false);   // writableStackTrace = false → no stack capture
    }
}

// The 4-arg Throwable constructor controls stack trace capture
// Use when: exception is expected, frequent, and message is sufficient
// Do NOT use for genuine errors — debugging becomes impossible

// ── Stack trace to string for logging ─────────────────────────────────
public static String stackTraceToString(Throwable t) {
    StringWriter sw = new StringWriter();
    t.printStackTrace(new PrintWriter(sw, true));
    return sw.toString();
}

// Preferred over e.printStackTrace() in production —
// that writes to System.err, not your logging framework

Logging Stack Traces Correctly

The most common mistake with stack traces in production code is printing them to System.err with e.printStackTrace() instead of routing them through the application's logging framework. System.err output bypasses log level filtering, structured logging, log rotation, and log aggregation — it goes nowhere useful in most production environments. The correct approach is to pass the exception as the last argument to a SLF4J or Log4j2 logging call. SLF4J's logger.error("message", exception) formats the full exception including its class, message, and stack trace (and all chained causes) according to the configured appender — typically JSON in production, human-readable in development. This single call produces the complete diagnostic information needed. Log level selection for exceptions follows a clear hierarchy: ERROR for unexpected failures that require investigation; WARN for expected failures the application handled gracefully (retried successfully, used a fallback); DEBUG or TRACE for diagnostic information during development. Logging every exception at ERROR level is a common antipattern that creates alert fatigue — when everything is an error, nothing is. Reserve ERROR for things that genuinely require a developer to look at them. Stack trace noise is a separate problem: long stack traces containing many framework frames (Spring's proxies, Hibernate's internals, Tomcat's request processing) around a few application frames. Tools like Logback's ShortenedThrowableConverter and Log4j2's ExtendedThrowablePatternConverter can be configured to filter out common library frames, showing only application-relevant frames in the logs while still logging the full trace on demand.
Java
// ── Correct logging with SLF4J ────────────────────────────────────────
@Service
@Slf4j
public class OrderService {

    public void processOrder(Long orderId) {
        try {
            Order order = orderRepository.findById(orderId)
                .orElseThrow(() -> new OrderNotFoundException(orderId));
            processOrderItems(order);
            chargePayment(order);

        } catch (OrderNotFoundException e) {
            // Expected — WARN level, no stack trace needed (message is enough)
            log.warn("Order not found for processing: orderId={}", orderId);
            throw e;

        } catch (PaymentDeclinedException e) {
            // Business failure — WARN level with minimal context
            log.warn("Payment declined: orderId={} reason={}",
                orderId, e.getDeclineReason());
            throw e;

        } catch (Exception e) {
            // Unexpected — ERROR level with full stack trace
            // Pass exception as LAST argument — SLF4J renders full trace
            log.error("Unexpected error processing order: orderId={}",
                orderId, e);
            throw new ServiceException(
                "Order processing failed: " + orderId, e);
        }
    }
}

// ── WRONG patterns ────────────────────────────────────────────────────
catch (Exception e) {
    e.printStackTrace();  // WRONG — System.err, bypasses logging framework
}

catch (Exception e) {
    log.error(e.getMessage());  // WRONG — no stack trace, just the message
}

catch (Exception e) {
    log.error("Error: " + e);  // WRONG — calls toString(), no stack trace
}

// ── CORRECT ────────────────────────────────────────────────────────────
catch (Exception e) {
    log.error("Order processing failed: orderId={}", orderId, e);
    // SLF4J: message with params + exception as last arg = full stack trace
}

// ── Logback configuration — filter noisy frames ───────────────────────
// logback-spring.xml — show only first N frames from application packages:
// <configuration>
//   <conversionRule conversionWord="ex"
//       converterClass="ch.qos.logback.classic.pattern.ExtendedThrowableProxyConverter"/>
//   <appender name="CONSOLE" class="...ConsoleAppender">
//     <encoder>
//       <pattern>%d %-5p %logger{36} : %m%n%ex{5,
//           com.myapp,
//           OMIT:org.springframework,
//           OMIT:org.hibernate,
//           OMIT:com.sun,
//           OMIT:java.lang.reflect
//       }</pattern>
//     </encoder>
//   </appender>
// </configuration>

Stack Trace Analysis — Diagnosing Real Problems

Skilled stack trace analysis follows a systematic process. Start at the top of the trace for the exception type and message — they give the "what." Then scan downward through the frames looking for the first frame in your application code (your package prefix) — that is usually the "where" of the immediate cause. Then look at the frames surrounding it: what called into the problem code, and what was being called from it. In chained exceptions ("Caused by:" sections), the last "Caused by:" block is the root cause — start there for the "why." Common stack trace patterns have recognisable signatures. A NullPointerException with "Cannot invoke X because Y is null" (Java 14+) tells you exactly which variable was null and which method was called on it. A ClassCastException tells you the source type and target type. A StackOverflowError shows a repeated pattern of frames — the infinitely recursive method calls. A ConcurrentModificationException always points to concurrent modification of a collection without proper synchronisation. Asynchronous code, reactive pipelines, and frameworks that use thread pools break the intuitive stack trace reading because the stack trace for a task does not include the frames from the code that submitted the task. The stack trace shows only from the thread pool thread's run() method, not the call site that scheduled the work. Some frameworks (Spring's @Async, Project Reactor, CompletableFuture) add special frames or support asynchronous stack trace assembly to address this limitation.
Java
// ── Pattern 1: NullPointerException — Java 14+ helpful message ────────
// java.lang.NullPointerException:
//     Cannot invoke "com.myapp.Order.getTotal()" because "order" is null
//   at com.myapp.OrderService.calculateTax(OrderService.java:78)
//
// Diagnosis: order is null at line 78 in calculateTax()
// Fix: trace where order comes from — it was either not initialised
//      or findById() returned null and wasn't checked

// ── Pattern 2: StackOverflowError — infinite recursion ────────────────
// java.lang.StackOverflowError
//   at com.myapp.TreeNode.calculateDepth(TreeNode.java:23)
//   at com.myapp.TreeNode.calculateDepth(TreeNode.java:23)
//   at com.myapp.TreeNode.calculateDepth(TreeNode.java:23)
//   ... (same frame repeated thousands of times) ...
//
// Diagnosis: calculateDepth() calls itself without a base case
// Fix: add termination condition (if node == null return 0)

// ── Pattern 3: ClassCastException ────────────────────────────────────
// java.lang.ClassCastException:
//     class java.lang.String cannot be cast to class java.lang.Integer
//   at com.myapp.ConfigService.getIntValue(ConfigService.java:45)
//
// Diagnosis: a config value stored as String is being cast to Integer
// Fix: use Integer.parseInt() or add type checking

// ── Pattern 4: ConcurrentModificationException ────────────────────────
// java.util.ConcurrentModificationException
//   at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:911)
//   at java.util.ArrayList$Itr.next(ArrayList.java:861)
//   at com.myapp.UserService.removeInactive(UserService.java:67)
//
// Diagnosis: list modified while iterating at line 67
// Fix: use Iterator.remove(), removeIf(), or collect removals first

// ── Analysing async stack traces ──────────────────────────────────────
// CompletableFuture exception — trace starts at thread pool:
// java.lang.RuntimeException: processing failed
//   at com.myapp.AsyncService.process(AsyncService.java:42)  ← async code
//   at java.util.concurrent.CompletableFuture.uniApply(...)
//   at java.util.concurrent.ForkJoinTask.doExec(...)
//   at java.util.concurrent.ForkJoinPool.runWorker(...)
//   at java.util.concurrent.ForkJoinWorkerThread.run(...)
//
// Notice: NO frame showing who submitted the task — stack starts at thread pool
// Use MDC correlation IDs to link async work to the originating request

// ── Capturing context for async correlation ───────────────────────────
@Async
public CompletableFuture<Result> processAsync(Request req) {
    // MDC values from the calling thread should be propagated:
    // (Configure TaskDecorator in ThreadPoolTaskExecutor to copy MDC)
    log.info("Processing async: requestId={}", req.getId());
    try {
        return CompletableFuture.completedFuture(process(req));
    } catch (Exception e) {
        log.error("Async processing failed: requestId={}", req.getId(), e);
        throw e;
    }
}

Related Topics in Exception Handling

Exception Basics
An exception is an event that disrupts the normal flow of a program during execution. Java's exception system is a structured mechanism for signalling, propagating, and handling error conditions — a significant improvement over the older approach of returning special error codes that callers could silently ignore. Understanding exceptions means understanding the class hierarchy that categorises them, the distinction between checked and unchecked exceptions, what the JVM does when an exception is thrown, how the call stack is unwound, and what information an exception object carries. This entry covers the full exception hierarchy, the checked vs unchecked distinction and the reasoning behind it, exception propagation through the call stack, and the information model of exception objects.
try-catch
The try-catch statement is Java's primary mechanism for handling exceptions. Code that might throw an exception is placed in the try block; one or more catch blocks follow, each specifying the exception type to handle and providing the handling logic. When an exception is thrown inside the try block, execution of the try block immediately stops and the JVM searches the catch blocks in order for the first one whose type matches the thrown exception. Understanding the control flow precisely — what runs, what stops, and in what order — is essential for writing correct exception handling code. This entry covers try-catch control flow, exception type matching, catching by supertype, variable scope, and the key principles for writing good catch blocks.
Multiple catch
A single try block can be followed by multiple catch blocks, each handling a different exception type. Multiple catch blocks allow code to respond differently to different kinds of failures — recovering from an expected missing file, but reporting and rethrowing an unexpected database error. Java 7 introduced multi-catch syntax, allowing a single catch block to handle multiple unrelated exception types, eliminating the duplicate code that arises when different exceptions require the same handling. This entry covers the ordering rules, multi-catch syntax, the type of the caught variable in multi-catch, and the design patterns for structuring multiple exception handlers.
finally
The finally block contains code that must execute regardless of whether the try block completed normally, threw an exception that was caught, or threw an exception that was not caught. It is the mechanism for guaranteed cleanup — closing files, releasing locks, returning connections to a pool, rolling back transactions. The finally block runs in all scenarios except two: if the JVM exits (System.exit()) or if the thread is killed by an Error like StackOverflowError. Understanding when exactly finally runs, the interaction between finally and return statements, exception suppression when finally itself throws, and the modern alternative of try-with-resources is essential for writing correct resource management code in Java.