debugging and stack traces
breakpoints, stepping, and reading a stack trace without panicking.
- chapter
- chapter 01.06
- reading time
- 7 min read
a stack trace is a list of calls, newest first: the top line is where things broke, the bottom is where it all started, and the line to fix is usually the first one that's yours. a debugger lets you pause the program on a breakpoint, look at every variable, and walk forward one line at a time.
the picture
your program printed the wrong number, or crashed with forty lines of red text. kinda scary the first time. here's the thing: both are evidence, not punishment.
a stack trace is the program's last words: "i was in this method, which was called by that one, which was called by that one". a debugger is a pause button with an x-ray: freeze the program mid-run and look inside every frame on the call stack.
reading a stack trace
the headline: which thread died (main), the exception type, and the message. since java 14 a NullPointerException says exactly what was null: the variable email.
the reading order that works almost every time:
- the headline. exception type plus message. the message is often the whole answer.
- the top frame. where it was thrown:
Class.method(File.java:line). - the first frame that's yours. skip the
java.base/...lines, the JDK is very rarely the bug. Caused by:if there is one, scroll down. the lastCaused byis the original problem, everything above it is wrapping.
then ask the question the trace can't answer: why was email null? in the first trace, the crash is in domainOf, but the cause is in main, three frames down: someone asked about a user that doesn't exist.
why the null message sometimes says "<local1>"+
the helpful null messages (java 14, JEP 358) name the variable that was null, but only if the class file remembers local variable names. javac drops them unless you pass -g. the same program, compiled both ways:
$ javac Main.java && java Main
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.indexOf(int)" because "<local1>" is null
$ javac -g Main.java && java Main
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.indexOf(int)" because "email" is nullmaven, gradle and IDEs compile with debug info by default, so in a real project you get the names. fields and method results are named either way (because "Main.name" is null, because the return value of "java.util.Map.get(Object)" is null), since those names are in the class file anyway.
a debugger
the program below should print 2.5 (the average of 1, 2, 3 and 4). it prints 1.5. you could stare at it. or you could watch it run.
- 1public class Main {
- 2 static int sumTo(int n) {
- 3 int total = 0;
- 4 for (int i = 1; i < n; i++) {
- 5 total += i;
- 6 }
- 7 return total;
- 8 }
- 9
- 10 static double average(int n) {
- 11 int sum = sumTo(n);
- 12 return (double) sum / n;
- 13 }
- 14
- 15 public static void main(String[] args) {
- 16 double avg = average(4);
- 17 System.out.println("average of 1..4 = " + avg);
- 18 }
- 19}
not running. click a line number to set a breakpoint, then resume.
i. click any line number to add or remove a breakpoint; click a frame to see that method's variables. the execution is a real recording from the JVM.see it? i goes 1, 2, 3 and then the loop ends. it never reaches 4, because the condition is i < n instead of i <= n. total comes out as 6 instead of 10. that's an off-by-one, the most common bug there is, and the debugger found it in four clicks.
- breakpoint
- "pause here". the program runs at full speed until some thread is about to run that line.
- resume
- run until the next breakpoint (or the end).
- step over
- run the current line, including any methods it calls, and stop at the next line of this method.
- step into
- if the current line calls a method, go inside it and stop at its first line.
- step out
- finish the current method and stop back in the caller, right after the call.
- frames
- the call stack while paused. pick an older frame to see the caller's variables at the moment it made the call.
int sum = sumTo(n);. you suspect sumTo. which button gets you inside it?how it works underneath
a debugger isn't magic, and it isn't part of your IDE. the JVM itself can be paused and inspected, and it speaks a protocol for exactly that.
hover a stage.
to debug a program you didn't start from your IDE, start it with the agent and attach to the port:
$ java -agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=5005 Main.java
Listening for transport dt_socket at address: 5005suspend=y makes it wait for a debugger before running main. then "attach to remote JVM" in your IDE, port 5005. address=5005 only listens on localhost; address=*:5005 listens on every network interface, which is what you'd need inside a container and exactly what you never want in production: anyone who can reach that port can run code in your JVM.
and yes, System.out.println("here: " + x) is a legit debugging tool. it's fast for one question. the debugger wins when you don't know which question to ask yet.
- a stack trace is the call stack at the moment of the crash, newest call on top.
- read: headline → top frame → first frame that's yours → the last
Caused by:. - never swallow exceptions. wrap them with the original as the cause.
- breakpoint to pause, step over to stay in this method, step into to follow a call, step out to go back up.
- the debugger talks to the JVM over JDWP. same tool, local or remote.