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
- 21File I/O and Serialization
- 22Debugging: Errors, Stack Traces, the Debugger and Logging
- 23Maven: What It Does and How to Use It
- 24Git for Java Projects: Commits, Branches, Merging and Pull Requests
- 25Testing with JUnit and Mockito: Unit Tests, Mocks and Their Limits
- 26Project 1: Build an Expense Tracker CLI in Plain Java
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
- 36Security Fundamentals and Spring Security: Passwords, JWT, Access Control, CORS and Secrets
- 37Testing Spring Boot Applications: Slice Tests, MockMvc, Real Databases and the Test Pyramid
- 38Production Practices: Configuration, Logging, Docker and Health Checks for Spring Boot
- 39Project 3: The Job Application Tracker API, Assembled and Reviewed
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.
For
Learners who can write programs and now need a repeatable method for finding and fixing bugs, and for logging in a way that helps in production.
Level
Before you start
Exceptions (article 10) and the JVM stack model (article 18). An IDE such as IntelliJ IDEA or VS Code with Java support is useful for the debugger section.
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 bychains. - 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:
- 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.
- 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.
- Read the evidence. Read the error message and stack trace completely. Note what you expected and what actually happened.
- Form a hypothesis. "The list is empty when
processis called, because the loader failed silently." A good hypothesis can be proven wrong. - Test the hypothesis with the cheapest experiment: a log line, a breakpoint, a small test, an assertion. Observe, do not assume.
- Fix the cause, not the symptom. If you wrap a
NullPointerExceptionin atry/catchwithout asking why the value was null, you have hidden the bug. - Verify that the original problem is gone and that nothing else broke.
- 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 bisectcan find the commit that introduced the bug.
Kinds of errors
#Kind | When it appears | How you find it |
|---|---|---|
Compile-time error |
| 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 |
|---|---|
| A name is misspelled, not imported, or out of scope (article 3) |
| You are assigning or returning the wrong type |
| Some path through the method does not return a value |
| A checked exception is not handled (article 10) |
| A local variable is read before it is assigned |
| The file name does not match the public class |
| 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.
// 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); } }}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 moreHow to read it, from the top:
- The first line is the headline: the exception class and its message (
Main$ConfigException: configuration could not be loaded). Read this first, every time. - The lines starting with
atare the call stack, most recent call first. Each line isclass.method(file:line). The top frame is where the exception was created. The ones below are the callers, going back tomain. 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. Caused by:introduces the underlying exception that was passed as the cause when this one was wrapped (article 10). The root cause is the lastCaused byin 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.... 2 moremeans 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 |
|---|---|---|
| A reference was | The message names what was null, so find where it was supposed to be set (article 17) |
| An index outside | The loop bounds, and an empty collection |
| A wrong cast, often from raw types | The actual runtime type of the object |
| Text was not a number | The exact input, including spaces |
| A method rejected its input or the object's state | The caller's value, and the order of calls |
| A collection was changed during iteration | Where the removal or insertion happens (article 11) |
| Runaway recursion | The base case |
| The heap is exhausted | What is holding the references (article 18) |
| 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.
// 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)); }}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 -1The 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 ( | 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 |
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:
- Put a breakpoint on the line
int mid = ...inside the loop. - Start the program in debug mode. It stops at the first iteration. Look at the variables view:
low = 0,high = 4. - Resume repeatedly, and watch
low,highandmidat each stop. Your hypothesis says the range stops shrinking, and you can seelowstuck at 3. - Add a watch for
sorted[mid] < target, and see it is true each time whilelowdoes not change. - Step over the
ifand observe the linelow = midsettinglowto 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
totalto 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.
// 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); }}-- 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 completedAt 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):
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 loggedHow 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.printlnore.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 |
|
Memory grows or | A heap dump and a class histogram, to see which objects dominate and what holds them |
|
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 |
|
Which Java processes are running | Process ids |
|
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.
// 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()); } }}12assertion failed: whole must be positive but was 0The 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.outin 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
assertfor 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
#- Next: Maven: What It Does and How to Use It teaches how builds, dependencies and logging libraries are managed.
- For turning a found bug into a safety net: Testing with JUnit and Mockito.
- For production logging and diagnostics in Spring Boot: Production Practices: Configuration, Logging, Docker and Health Checks.
Sources & further reading
- Technology
- Software Engineering
- #debugging
- #Java
- #Java backend
- #Java basics
- #Java interview
- #JPA
- #learn Java
- #logging
- #Spring Boot
- #SQL
Connected lessons
References
- Null and Optional
- Testing with JUnit and Mockito: Unit Tests, Mocks and Their Limits
- Git for Java Projects: Commits, Branches, Merging and Pull Requests
Show all 11 →
- How the JVM Runs Your Code: Memory, GC and JIT
- Maven: What It Does and How to Use It
- Production Practices: Configuration, Logging, Docker and Health Checks for Spring Boot
- Decisions, Loops, Methods and Scope
- Variables, Types, Operators and Arrays
- Exceptions: Handling Errors Properly
- Lists and Sets
- File I/O and Serialization
Linked from 5 guides
Other pages in the library that point here.
Keep reading
More in Technology →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

