skip to content
everything.start.debugging

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

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.

hover or tap any line. both traces are real, from jdk 25. the red lines name what went wrong, the full-strength lines are your code, the faint ones are the JDK's.

the reading order that works almost every time:

  1. the headline. exception type plus message. the message is often the whole answer.
  2. the top frame. where it was thrown: Class.method(File.java:line).
  3. the first frame that's yours. skip the java.base/... lines, the JDK is very rarely the bug.
  4. Caused by: if there is one, scroll down. the last Caused by is 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.

one level deeper: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:

terminal
$ 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 null

maven, 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.

Main.java breakpoint
  1. 1public class Main {
  2. 2 static int sumTo(int n) {
  3. 3 int total = 0;
  4. 4 for (int i = 1; i < n; i++) {
  5. 5 total += i;
  6. 6 }
  7. 7 return total;
  8. 8 }
  9. 9
  10. 10 static double average(int n) {
  11. 11 int sum = sumTo(n);
  12. 12 return (double) sum / n;
  13. 13 }
  14. 14
  15. 15 public static void main(String[] args) {
  16. 16 double avg = average(4);
  17. 17 System.out.println("average of 1..4 = " + avg);
  18. 18 }
  19. 19}
frames
no thread paused.
variables
·
console
·

not running. click a line number to set a breakpoint, then resume.

press debug: the program runs until it hits the breakpoint on line 5. press resume to go round the loop again and watch 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.
quick check
you're paused on line 11, 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.

hover a stage. the IDE and the JVM are separate processes talking over a socket, which is why you can debug a program running on another machine (or in a container) the same way.

to debug a program you didn't start from your IDE, start it with the agent and attach to the port:

terminal
$ java -agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=5005 Main.java
Listening for transport dt_socket at address: 5005

suspend=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.

keylines
  • 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.