Skip to content
SparkTime, home
Java From Zero to Job-Ready
Lessons
1 · Java Foundations
2 · Object-Oriented Java
3 · Core Java
4 · Collections and Generics
5 · Functional Java
6 · The JVM and Concurrency
7 · Tools and Craft
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
12 · Interviews and Readiness
Lesson 22 of 41

Lesson 22 · Tools and Craft

Debugging: Errors, Stack Traces, the Debugger and Logging

A method for finding bugs instead of guessing: read compiler errors and stack traces properly, form and test hypotheses, use breakpoints and stepping in a debugger, log usefully at the right level, and reach for thread dumps and heap dumps when the problem is a hang or a leak.

On this page

Key takeaways

  • Debugging is the scientific method: reproduce the problem, shrink it, form a hypothesis, test it with evidence, fix the cause, and add a test so it cannot return. Guessing and changing code at random is the slow path.
  • A stack trace lists the call stack at the moment of the exception, most recent call first. Read the first line (type and message), find the first frame in your own code, and follow Caused by lines to the root cause.
  • A debugger lets you pause at breakpoints, step through code and inspect variables. Use conditional and exception breakpoints and the call stack view to find where actual behaviour departs from what you expected.
  • Log with a logging framework instead of System.out. Use levels deliberately, include context, log an exception once with its stack trace, and never log secrets or personal data.
  • For hangs, leaks and slowness, use evidence tools: thread dumps (jcmd Thread.print), heap dumps and Java Flight Recorder.

Every programmer spends a large share of their time looking at code that does not do what they expected. The difference between a beginner and an experienced developer is rarely that the experienced one writes fewer bugs. It is that they find bugs systematically: they gather evidence, narrow the search, and fix the cause instead of the symptom.

The earlier articles showed you error messages and exceptions. This one gives you a method, and the tools to follow it.

What you will learn

#
  • A debugging method that works on any bug.
  • How to read compiler errors and stack traces, including Caused by chains.
  • How to use a debugger effectively.
  • How to log well, and the difference between print debugging and logging.
  • Which tools to use for hangs, memory problems and slowness.

The method: debugging as an experiment

#

When a program misbehaves, resist the urge to change things. Change one thing at a time, and always know why. A reliable process:

  1. Reproduce it. If you cannot make the bug happen on demand, you cannot know whether you fixed it. Write down the exact inputs and steps.
  2. Shrink it. Remove everything that is not needed to trigger the bug, until the smallest possible example remains. Often the cause becomes visible during the shrinking.
  3. Read the evidence. Read the error message and stack trace completely. Note what you expected and what actually happened.
  4. Form a hypothesis. "The list is empty when process is called, because the loader failed silently." A good hypothesis can be proven wrong.
  5. Test the hypothesis with the cheapest experiment: a log line, a breakpoint, a small test, an assertion. Observe, do not assume.
  6. Fix the cause, not the symptom. If you wrap a NullPointerException in a try/catch without asking why the value was null, you have hidden the bug.
  7. Verify that the original problem is gone and that nothing else broke.
  8. Prevent a return. Add a test that fails without your fix and passes with it (article 25).

Some techniques that help:

  • Binary search the problem. If a result is wrong at the end of ten steps, check the state after step five. Correct there? Then the bug is in the second half. Repeat.
  • Explain it aloud ("rubber duck debugging"). Putting the problem into words often reveals the assumption that is wrong.
  • Question your assumptions. What are you sure is true? Verify it. Many long debugging sessions end with "the input was not what I thought".
  • Check what changed. If it worked yesterday, version control (article 24) can show the differences, and git bisect can find the commit that introduced the bug.

Kinds of errors

#

Kind

When it appears

How you find it

Compile-time error

javac refuses to compile

The message names the file, line and problem

Runtime exception

The program crashes or logs an exception

The stack trace names the line

Logic error

The program runs and gives the wrong answer

You must investigate: tests, logs, debugger

Hang or deadlock

The program stops responding

A thread dump shows what each thread is waiting for

Slowness or memory growth

The program gets slower or runs out of memory

Profiling and heap analysis

Reading compiler errors

#

Compiler messages are precise, and beginners often ignore the first line, which is the useful one. The commonest messages:

Message

Usual meaning

cannot find symbol

A name is misspelled, not imported, or out of scope (article 3)

incompatible types: X cannot be converted to Y

You are assigning or returning the wrong type

missing return statement

Some path through the method does not return a value

unreported exception X; must be caught or declared to be thrown

A checked exception is not handled (article 10)

variable x might not have been initialized

A local variable is read before it is assigned

class X is public, should be declared in a file named X.java

The file name does not match the public class

';' expected

A syntax error. The real mistake is often on the line before

Fix errors from the top: one early mistake can cause a cascade of later messages that disappear when it is fixed.

Reading a stack trace

#

When an exception is not handled, Java prints a stack trace: the call stack at the moment the exception was created (article 18). Here is a program with several layers and a wrapped cause, so you can see every part.

Java
// File: Main.javaimport java.io.IOException;public class Main {    static class ConfigException extends RuntimeException {        ConfigException(String message, Throwable cause) {            super(message, cause);        }    }    static String readFile(String name) throws IOException {        throw new IOException("cannot read " + name);          // the root cause    }    static String loadConfig(String name) {        try {            return readFile(name);        } catch (IOException e) {            throw new ConfigException("configuration could not be loaded", e);        }    }    static void startUp() {        String config = loadConfig("app.properties");        System.out.println(config);    }    public static void main(String[] args) {        try {            startUp();        } catch (ConfigException e) {            e.printStackTrace(System.out);        }    }}
Code
Main$ConfigException: configuration could not be loaded	at Main.loadConfig(Main.java:19)	at Main.startUp(Main.java:24)	at Main.main(Main.java:30)Caused by: java.io.IOException: cannot read app.properties	at Main.readFile(Main.java:12)	at Main.loadConfig(Main.java:17)	... 2 more

How to read it, from the top:

  1. The first line is the headline: the exception class and its message (Main$ConfigException: configuration could not be loaded). Read this first, every time.
  2. The lines starting with at are the call stack, most recent call first. Each line is class.method(file:line). The top frame is where the exception was created. The ones below are the callers, going back to main. Start from the top and find the first frame that is your own code, because frames from libraries are usually not where the bug is.
  3. Caused by: introduces the underlying exception that was passed as the cause when this one was wrapped (article 10). The root cause is the last Caused by in the chain, and it is often the most informative: here, IOException: cannot read app.properties. This is why you should always pass the cause when you wrap an exception.
  4. ... 2 more means the remaining frames are identical to the ones already shown above for the enclosing exception, so they are not repeated.

Common exceptions, and what they usually mean:

Exception

Typical cause

First thing to check

NullPointerException

A reference was null

The message names what was null, so find where it was supposed to be set (article 17)

ArrayIndexOutOfBoundsException, IndexOutOfBoundsException

An index outside 0..length-1

The loop bounds, and an empty collection

ClassCastException

A wrong cast, often from raw types

The actual runtime type of the object

NumberFormatException

Text was not a number

The exact input, including spaces

IllegalArgumentException, IllegalStateException

A method rejected its input or the object's state

The caller's value, and the order of calls

ConcurrentModificationException

A collection was changed during iteration

Where the removal or insertion happens (article 11)

StackOverflowError

Runaway recursion

The base case

OutOfMemoryError

The heap is exhausted

What is holding the references (article 18)

NoClassDefFoundError, ClassNotFoundException

A class is missing at runtime

The classpath and dependencies (article 23)

A worked example: a bug with no exception

#

The hardest bugs produce no error at all. Here is a binary search with a subtle mistake. It does not crash. It loops. We cannot run an endless loop in a demonstration, so the version below includes a step counter as a debugging guard, and prints the state of each step: the oldest and simplest debugging tool, print debugging.

Java
// File: Main.javapublic class Main {    static int buggySearch(int[] sorted, int target, int maxSteps) {        int low = 0;        int high = sorted.length - 1;        int steps = 0;        while (low <= high && steps < maxSteps) {            int mid = (low + high) / 2;            System.out.println("step " + steps + ": low=" + low + " high=" + high + " mid=" + mid                    + " value=" + sorted[mid]);            if (sorted[mid] == target) {                return mid;            }            if (sorted[mid] < target) {                low = mid;                 // the suspected bug            } else {                high = mid - 1;            }            steps++;        }        return -1;    }    static int fixedSearch(int[] sorted, int target) {        int low = 0;        int high = sorted.length - 1;        while (low <= high) {            int mid = (low + high) >>> 1;            if (sorted[mid] == target) {                return mid;            }            if (sorted[mid] < target) {                low = mid + 1;            } else {                high = mid - 1;            }        }        return -1;    }    public static void main(String[] args) {        int[] data = {2, 4, 6, 8, 10};        System.out.println("result: " + buggySearch(data, 10, 6));        System.out.println("fixed: " + fixedSearch(data, 10) + " " + fixedSearch(data, 5));    }}
Code
step 0: low=0 high=4 mid=2 value=6step 1: low=2 high=4 mid=3 value=8step 2: low=3 high=4 mid=3 value=8step 3: low=3 high=4 mid=3 value=8step 4: low=3 high=4 mid=3 value=8step 5: low=3 high=4 mid=3 value=8result: -1fixed: 4 -1

The printed states tell the story in seconds. From step 2 onward, low is 3, high is 4, mid is 3, and the values never change: the search is stuck. When the value at mid is too small, the code sets low = mid, but mid was already examined and rejected, so the range does not shrink. The fix is low = mid + 1. This is the method in action: reproduce (the input {2,4,6,8,10} and target 10), shrink, observe, form the hypothesis ("the range stops shrinking"), and then fix the cause. After the fix, add a test with the boundary cases: first element, last element, absent values and an empty array.

(In fixedSearch, (low + high) >>> 1 avoids a rarely met overflow bug in (low + high) / 2 when the sum of two large int values exceeds the int range, which is the overflow behaviour from article 2.)

The debugger

#

Print statements work, and they have costs: you must edit and recompile for each question, remove them later, and you only see what you decided to print in advance. A debugger lets you pause a running program and look at everything. Every Java IDE (IntelliJ IDEA, Eclipse, VS Code with the Java extensions) has one, and the JDK ships a command-line debugger called jdb.

The essentials

#

Feature

What it does

When to use it

Breakpoint

Pauses the program when execution reaches a line

To stop just before the suspect code

Conditional breakpoint

Pauses only when an expression is true (i == 9999, user.id() == 42)

When the bug appears only for particular data or after many iterations

Hit count / log-point breakpoint

Pauses after N hits, or prints a message without pausing

Loops, or when pausing would disturb timing

Exception breakpoint

Pauses where a given exception type is thrown

To find where a NullPointerException originates, even if it is caught elsewhere

Step over

Runs the current line, treating a method call as one step

To move through the code at the same level

Step into

Enters the method that the current line calls

To follow the logic into a call

Step out

Runs until the current method returns

To leave a method you stepped into

Resume

Continues until the next breakpoint

Variables view and watches

Shows local variables, fields and expressions

To compare the real state with what you expected

Call stack view

Shows the chain of calls that led here, and lets you inspect each frame

To see who called this method with these arguments

Evaluate expression

Runs an expression in the current context

To test a hypothesis without editing code

A debugging session, step by step

#

For the binary search above, a session in an IDE would go like this:

  1. Put a breakpoint on the line int mid = ... inside the loop.
  2. Start the program in debug mode. It stops at the first iteration. Look at the variables view: low = 0, high = 4.
  3. Resume repeatedly, and watch low, high and mid at each stop. Your hypothesis says the range stops shrinking, and you can see low stuck at 3.
  4. Add a watch for sorted[mid] < target, and see it is true each time while low does not change.
  5. Step over the if and observe the line low = mid setting low to the value it already had.

You have seen the cause instead of guessing it. A few habits make a debugger far more effective:

  • Form a question before you step: "I expect total to be 15 here." Then check it. Stepping aimlessly wastes time.
  • Use the call stack view when you stop in a method with a surprising argument: click the caller's frame to see where the bad value came from.
  • Do not step through library code. Use step over for framework and library calls, and step into only your own code.
  • Debugging concurrent code changes its timing, because pausing a thread changes the interleaving. A bug that vanishes under the debugger is a hint that the problem is a race.

Remote debugging lets an IDE attach to a JVM running elsewhere (a container, a server) when started with the right JVM option, and is invaluable and dangerous: never leave it enabled on a production system exposed to a network.

Logging

#

Print debugging is for you, now. Logging is for whoever runs your program later, possibly at three in the morning, in a place where you cannot attach a debugger. A logging framework is far better than System.out.println because it adds:

  • Levels, so you can switch detail on and off without changing code.
  • Context: timestamp, thread, logger name.
  • Destinations: console, files, and log collection systems.
  • Configuration at runtime, with no recompilation.
  • Performance: messages below the active level are cheap to skip.

Levels

#

The Java built-in logging API (java.util.logging) defines seven levels: SEVERE, WARNING, INFO, CONFIG, FINE, FINER and FINEST, with INFO as the default threshold. The libraries most Java applications use (SLF4J with Logback, which Spring Boot's starters use by default, and Log4j 2) name five that you will see in practice, and Spring Boot's documentation lists them: ERROR, WARN, INFO (the default), DEBUG and TRACE.

Level

Use for

Example

ERROR

An operation failed and someone should look

"Payment for order 42 failed"

WARN

Something unexpected happened, but the application carried on

"Retrying after timeout (attempt 2 of 3)"

INFO

Significant normal events, in small numbers

"Application started", "Order 42 created"

DEBUG

Detail useful for developers when diagnosing

"Loaded 12 applications from the database"

TRACE

Very fine-grained flow

"Entering calculateTotal(...)"

The important choice is what to leave at INFO and above, because that is what runs in production. If everything is INFO, the log is noise. If a real failure is only DEBUG, nobody sees it.

Java
// File: Main.javaimport java.util.logging.Formatter;import java.util.logging.Handler;import java.util.logging.Level;import java.util.logging.LogRecord;import java.util.logging.Logger;import java.util.logging.StreamHandler;public class Main {    private static final Logger LOG = Logger.getLogger("demo.orders");    // one logger per class, by name    static Handler consoleHandler() {        StreamHandler handler = new StreamHandler(System.out, new Formatter() {            @Override            public String format(LogRecord record) {                String line = record.getLevel() + ": " + formatMessage(record);                if (record.getThrown() != null) {                    line += " [" + record.getThrown().getClass().getSimpleName() + ": " + record.getThrown().getMessage() + "]";                }                return line + System.lineSeparator();            }        }) {            @Override            public synchronized void publish(LogRecord record) {                super.publish(record);                flush();            }        };        handler.setLevel(Level.ALL);        return handler;    }    static void process(int orderId) {        LOG.fine("entering process for order " + orderId);                // detail: hidden at INFO        LOG.log(Level.INFO, "processing order {0}", orderId);        try {            if (orderId % 2 == 1) {                throw new IllegalStateException("stock unavailable");            }            LOG.info("order " + orderId + " completed");        } catch (IllegalStateException e) {            LOG.log(Level.SEVERE, "order " + orderId + " failed", e);      // the exception is passed, once        }    }    public static void main(String[] args) {        LOG.setUseParentHandlers(false);        LOG.addHandler(consoleHandler());        LOG.setLevel(Level.INFO);        System.out.println("-- level INFO --");        process(1);        process(2);        LOG.setLevel(Level.FINE);                                          // change detail without touching the code        System.out.println("-- level FINE --");        process(2);    }}
Code
-- level INFO --INFO: processing order 1SEVERE: order 1 failed [IllegalStateException: stock unavailable]INFO: processing order 2INFO: order 2 completed-- level FINE --FINE: entering process for order 2INFO: processing order 2INFO: order 2 completed

At level INFO, the fine message was suppressed. After raising the level to FINE, it appears, with no change to the processing code. That is the point of levels. (The standard library's logging API is used here so that the demo needs no extra libraries. Most projects use SLF4J with Logback, as Spring Boot does by default, and its documentation explains that with the starters Logback is used, with routing so that libraries using other logging APIs also work. Spring Boot sets levels with properties such as logging.level.root=warn or logging.level.org.hibernate=error. We will use that in the Spring articles.)

With SLF4J the code looks like this (a fragment, because it needs a dependency that arrives with Maven in the next article):

Java
private static final Logger log = LoggerFactory.getLogger(OrderService.class);log.info("Order {} created for user {}", orderId, userId);     // {} placeholders: cheap if INFO is offlog.error("Could not save order {}", orderId, exception);       // exception LAST: the stack trace is logged

How to log well

#

Do:

  • Use one logger per class, named after it, so you can turn detail on for one part of the system.
  • Include context: identifiers and values that let you find the story (orderId, userId), not just "error occurred".
  • Use placeholders ({}) instead of string concatenation so the work is skipped when the level is disabled.
  • Log an exception once, at the place where you handle it, with the exception object (not just e.getMessage()) so the stack trace and causes are recorded.
  • Make logs searchable. Consistent wording and key=value pairs, or structured (JSON) logging with a correlation id that follows a request across components, make large systems debuggable.
  • Keep INFO quiet and meaningful.

Do not:

  • Log secrets or personal data: passwords, tokens, card numbers, full request bodies with personal details. Logs are copied, stored for months and read by many people. Debug and trace levels in production make this risk bigger.
  • Log and rethrow at every layer (the same exception then appears five times).
  • Log inside tight loops at a level that is enabled.
  • Swallow exceptions silently, or log only the message of an exception and lose the trace.
  • Use System.out.println or e.printStackTrace() in application code. They bypass levels and configuration.

When the problem is a hang, a leak or slowness

#

Some problems cannot be seen in one stack trace. The JDK includes diagnostic tools for them. Oracle's troubleshooting documentation recommends jcmd, Java Flight Recorder (JFR) and JDK Mission Control as the main tools.

Problem

Evidence to collect

Tool

The program is hung or stuck

A thread dump: the stack of every thread, showing which are blocked and on what, and any deadlock

jcmd <pid> Thread.print (the documentation recommends jcmd over the older jstack)

Memory grows or OutOfMemoryError

A heap dump and a class histogram, to see which objects dominate and what holds them

jcmd <pid> GC.heap_dump file and GC.class_histogram, or run with -XX:+HeapDumpOnOutOfMemoryError

Slowness or high CPU

A profile: where time and allocation go

Java Flight Recorder, viewed with JDK Mission Control

Garbage collection behaviour

GC statistics over time

jstat, or GC logging with -Xlog:gc

Which Java processes are running

Process ids

jps

The first step is always to identify the process with jps, and the second is to collect evidence before restarting the application, because a restart erases the evidence. Take two or three thread dumps a few seconds apart for a hang, since one snapshot may not show the pattern.

Assertions

#

Java has an assert statement for checking assumptions that should always be true. It is disabled by default and enabled with the -ea flag, so it costs nothing in production. Use it for internal invariants (conditions that indicate a bug in your own code), never for validating user input or arguments of public methods, because the check disappears when assertions are off.

Java
// File: Main.java// JVM: -eapublic class Main {    static int percentage(int part, int whole) {        assert whole > 0 : "whole must be positive but was " + whole;        return part * 100 / whole;    }    public static void main(String[] args) {        System.out.println(percentage(25, 200));        try {            percentage(1, 0);        } catch (AssertionError e) {            System.out.println("assertion failed: " + e.getMessage());        }    }}
Code
12assertion failed: whole must be positive but was 0

The second line of the program says that it was run with java -ea Main. Without -ea, percentage(1, 0) would divide by zero instead and throw an ArithmeticException, which is a good reminder that the assertion is a debugging aid and not an input check.

Common mistakes

#
  • Changing code before understanding the problem.
  • Ignoring the first line of an error message, or reading only the bottom of a stack trace.
  • Fixing the symptom (catching and hiding the exception) rather than the cause.
  • Making several changes at once, so you do not know which helped.
  • Debugging without a reproduction.
  • Printing with System.out in production code instead of logging.
  • Logging secrets, or logging the same error at every layer.
  • Restarting a hung service before collecting a thread dump.
  • Using assert for input validation.
  • Not writing a regression test after the fix.

Exercises

#

Exercise 1

Read the trace

#

An exception trace begins java.lang.IllegalStateException: order already shipped, with the first frame at Order.cancel(Order.java:41) and the next at OrderService.cancel(OrderService.java:18), then at OrderController.cancel(OrderController.java:12). Where is the exception created, who called it, and where would you put a breakpoint to find out why the order is already shipped?

Exercise 2: Root cause

#

A trace has three exceptions: ServiceException: cannot place order, Caused by: DataAccessException: query failed, Caused by: SQLException: connection refused. Which is the root cause, and what does it suggest?

Exercise 3: Choose the level

#

Choose ERROR, WARN, INFO or DEBUG for: (a) "application started on port 8080", (b) "payment gateway returned a 500 error for order 17", (c) "retrying request after a timeout", (d) "the query returned 12 rows".

Exercise 4: Debug the loop

#

for (int i = 0; i <= items.size(); i++) { System.out.println(items.get(i)); } throws IndexOutOfBoundsException at the end. Explain, describe how a conditional breakpoint would show the problem, and fix it.

Exercise 5: Plan the investigation

#

A web service becomes unresponsive every few hours, and a restart fixes it. Describe what you would collect, in what order, and why.

Show solutionHide solution

The exception is created in Order.cancel at line 41, where the state check happens. It was called by OrderService.cancel (line 18), which was called from OrderController.cancel. To find out why the order is already shipped, you need to look at what state the order had when cancel was called: put a breakpoint at the start of Order.cancel (or at line 41) and inspect the order, and use the call stack view to see which request and which order id reached it. You may also check the history of changes to that order, in logs or the database.

Exercise 2

Show solutionHide solution

The root cause is the last one: SQLException: connection refused. It suggests that the application could not reach the database at all, so look at the database being down or unreachable, the connection URL and port, network and firewall settings, and credentials or connection pool limits. The higher-level exceptions describe the consequences, not the origin.

Exercise 3

Show solutionHide solution

(a) INFO: a significant normal event. (b) ERROR: an operation failed and someone should look (include the order id and the status code). (c) WARN: unexpected, but handled by retrying. (d) DEBUG: detail useful when diagnosing and too noisy for production.

Exercise 4

Show solutionHide solution

Valid indexes run from 0 to size() - 1, but the condition i <= items.size() lets i reach size(), which is one past the end. A conditional breakpoint on the get line with the condition i == items.size() would stop exactly at the failing iteration, where you can see that i equals the size. Fix it with i < items.size(), or better, a for-each loop for (String item : items) which cannot go out of bounds.

Exercise 5

Show solutionHide solution

First, do not just restart. When it is unresponsive, take two or three thread dumps a few seconds apart (jcmd <pid> Thread.print) to see whether threads are blocked on locks, waiting on a connection pool or the database, or stuck in a deadlock. Check the logs around the time it started to hang. Collect GC statistics or logs and a heap dump (jcmd <pid> GC.heap_dump) to test the hypothesis of a memory leak and long garbage-collection pauses. Look at the connection pool and thread pool metrics. Run Java Flight Recorder to find hot spots if it is slow rather than stuck. Collect evidence before restarting, because a restart erases it, and form one hypothesis at a time.

Where to go next

#

Sources & further reading

  1. docs.oracle.com
  2. docs.oracle.com
  3. docs.spring.io
  • Guide · Technology

    Java Essentials for DSA

  • Guide · Technology

    Heaps and Priority Queues

  • Course · Technology

    DSA From Zero to Job-Ready: A Practical Guide with Java