When you have a problem with your application, how do you know where the error is? Hopefully, you are logging some useful information. In the case of really bad errors, you are logging a stack trace. Maybe you’ve seen that bizarre arrangement of methods, files, and line numbers. How the heck do you make sense of that thing? Why is it such a useful thing for debugging applications? Let’s learn how to read a stack trace.
In my 20 years of software development experience, I’ve seen a lot of stack traces. The Spring Framework, for example, can have some extremely verbose errors. But if you know how to read them, they tell you exactly what the problem is.
Sometimes a stack trace will point to the exact line of code that has the problem. Other times, it just gives you a starting point, but knowing where to start is usually half the battle!
Since it’s such a useful debugging tool, let’s dig in and learn how to read a stack trace.
Note that the examples are written in Java, since that’s the language I’m most familiar with, but the concepts are similar for whatever language you may be working with.
A Review of Program Call Stacks
Applications are made up of a series of function calls. They might be called procedures or methods, depending on the language, but the idea is the same: A reusable chunk of code is given a name, some input parameters, and an expected result or return value.
When a program enters a function, it sets up some space in memory called a stack frame. This memory, knows as the call stack, holds the input parameters as well as any local variables the function may need during it’s execution. Each time a function is called, a new frame is placed on “top” of the previous one, creating a “stack” of function calls. When the function returns, its frame is removed from the top of the stack, and the previous function picks up where it left off. When an error is encountered, the application stops what it’s doing and starts reading “down” the stack, recording the state of the call stack to include in the exception that is about to be thrown. This gives you, the developer, a breadcrumb trail to follow. It explains which sequence of functions were called and where the program was when the error occurred. Assuming you’ve built your application with the appropriate debugging details included, this will even include the line number in the source file that the error occurred on!
A Simple Example
Now that we understand what a call stack is, let’s look at a simple example of how it works in practice. For this example, consider the following program:
1 | public class SimpleExample { |
If you run this program with the argument “true”, you will receive the following output:
1 | Starting calculation |
Hmmm…a NullPointerException
is never good! What is going on here? Let’s figure out how to read this stack trace and determine what’s going on.
The first thing to notice is that the entry point of the program—the main
method—is at the end of the output. This corresponds to the “bottom” of the call stack. At the top of the output, corresponding with the “top” of the stack, is the method that was executing at the time NullPointerException was thrown. The way to read this is that main
called run
from line 3. run
called doWork
from line 7 and doWork
threw a NullPointerException at line 13.
Can you spot the error in the code now? Let’s walk through the analysis together.
The exception thrown was a NullPointerException. This means that the program attempted to use an object reference that didn’t point to anything. This exception was thrown from line 13.
1 | return calculateResult(input); |
There are really only two things on this line that could possibly be null
: the input parameter to calculateResult
or the return value. The input value is a String value, but it’s methods are not used, it’s just passed through to the method call. So the only thing that could be null
is the return value.
Looking at the signature of calculateResult
, you can see that it returns an Integer object, which could be null
. In fact, when we call that method with the value “true”, it will definitely be null
! However, the doWork
method would like to return an int
result, which cannot be null
. Java attempts to convert (unbox) the Integer to an int
. Since the value is null
, it can’t and throws the NullPointerException.
A Complex Example
Now let’s consider a more complex example:
1 | public class ComplexExample { |
If we run this program with the same input of “true”, the output will look like this:
1 | Starting calculation |
Here, we actually have two stack traces, but one is wrapped by another. The first part of the stack trace says that a java.lang.RuntimeException was thrown from the doWork
method on line 17. doWork
was called by run
on line 7, which was called by main
on line 3.
So far, this looks a lot like the previous example, so what’s with the rest of the output?
The doWork
method caught an exception that was thrown by validateInput
, wrapped it in a new RuntimeException and threw this new exception. The IllegalArgumentException thrown by the validateInput
method is the root cause
of the error, as indicated by the “Caused by” text. This means that this is the exception that caused the exception above it to be thrown.
Finally, the “… 2 more” at the end is simply a space saving technique, it just means that there were two more stack frames in the original IllegalArgumentException, but they’ve already been printed, as part of the stack for the RuntimeException.
But the RuntimeException has 3 lines in it’s stack, what gives?
If you look at the top line of the RuntimeException stack and the bottom line of the IllegalArgumentException stack, you’ll notice that it’s the same method: doWork
. You will almost always see this, since the method that catches the original exception is usually the one that wraps and re-throws it. I say “almost” and “usually”, because there are exceptions. Sometimes you will see code that catches an exception and then passes it to another method for further processing. T__hat method is the one that ends up throwing the outer exception.
Even this is fairly simple. In more complex programs, the chain of exceptions may be several levels deep and the length of the stack trace is much longer. This means that, when you’re debugging a problem, the first place you should go is the end of the stack trace. The exception at the bottom is the one that started it all. The rest are simply methods that caught the exception, wrapped or rethrew the original exception.
Conclusion
As you can see, a stack trace is a powerful tool to determine what went wrong with a program. It doesn’t always tell you exactly what went wrong, but it definitely gives you a starting point. As we saw in the first example, you have to have some understanding of how your program works as well as the nuances of the language in order to properly diagnose the error. But by looking at the stack trace, we were able to jump right to the line that had the problem and start analyzing from there.
It may look like a collection of random code, but now that you know how to read a stack trace, your ability to diagnose and fix problems in your software will be greatly enhanced.
Discussion Question: How have you used stack traces to solve a problem in the past? What are some of the things that have tripped you up when analyzing a stack trace?