Efficient Debugging Exceptions
Other languages: Español 한국어 Português 中文
In your journey with Java, one of the earliest concepts to learn is exceptions. This term defines an unexpected scenario during a program execution, such as a network failure or unexpected end-of-file. Or, as the Oracle documentation puts it:
The class Exception and its subclasses are a form of Throwable that indicates conditions that a reasonable application might want to catch.
If your program is equipped with a “plan b” to effectively handle these situations, it will continue to operate smoothly regardless of whether any of them occur. Otherwise, the program may crash unexpectedly or end up in an incorrect state.
When a program fails due to an exception, you’ll need to debug it. Programming languages facilitate debugging exception-related errors by providing a stack trace – a special message pointing at the code path that has led to the failure. This information is incredibly useful, and sometimes enough; however, there are cases when we might benefit from additional details and techniques.
In this article, we’ll walk through a case study, focusing on debugging an exception that happens during JSON parsing. In doing so, we’ll move beyond just looking at stack traces and discover the benefits of using the debugger.
Example application
The example application for this use case will be a small Java program that parses a set of JSON files that contain data about airports worldwide. The files include details, such as airports’ IATA code, country, latitude, and longitude. Here’s an example of an entry:
{
"iso_country": "AR",
"iata_code": "MDQ",
"longitude_deg": "-57.5733",
"latitude_deg": "-37.9342",
"elevation_ft": "72",
"name": "Ástor Piazzola International Airport",
"municipality": "Mar del Plata",
"iso_region": "AR-B"
}
The program is pretty straightforward. It iterates over a set of files,
reads and parses each one, filters the airport objects against
input restrictions, such as "country=AR"
, then prints the list of matching airports:
public class Airports {
static Path input = Paths.get("./data");
static Gson gson = new Gson();
static {
System.setOut(new PrintStream(System.out, true, StandardCharsets.UTF_8));
}
public static void main(String[] args) throws IOException {
List<Predicate<Airport>> filters = new ArrayList<>();
for (String arg : args) {
if (arg.startsWith("country=")) {
filters.add(airport -> airport.isoCountry.equals(arg.substring("country=".length()))) ;
} else if (arg.startsWith("region=")) {
filters.add(airport -> airport.isoRegion.equals(arg.substring("region=".length()))) ;
} else if (arg.startsWith("municipality=")) {
filters.add(airport -> airport.municipality.equals(arg.substring("municipality=".length()))) ;
}
}
try (Stream<Path> files = Files.list(input)) {
Stream<Airport> airports = files.map(Airports::parse);
for (Predicate<Airport> f : filters) {
airports = airports.filter(f);
}
airports.forEach(System.out::println);
}
}
static Airport parse(Path path) {
try {
JsonObject root = gson.fromJson(Files.readString(path), JsonObject.class);
String name = root.get("name").getAsString();
String isoCountry = root.get("isoCountry").getAsString();
String iataCode = root.get("iataCode").getAsString();
String longitudeDeg = root.get("longitudeDeg").getAsString();
String latitudeDeg = root.get("latitudeDeg").getAsString();
String municipality = root.get("municipality").getAsString();
String isoRegion = root.get("isoRegion").getAsString();
Integer elevationFt = Integer.parseInt(root.get("elevationFt").getAsString());
return new Airport(name, isoCountry, iataCode, longitudeDeg, latitudeDeg, elevationFt, municipality, isoRegion);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
record Airport(String name, String isoCountry, String iataCode, String longitudeDeg, String latitudeDeg, Integer elevationFt, String municipality, String isoRegion) { }
}
The problem
When we run the program, it fails with a NumberFormatException
.
Here is the stack trace that we get:
Exception in thread "main" java.lang.NumberFormatException: For input string: ""
at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
at java.base/java.lang.Integer.parseInt(Integer.java:672)
at java.base/java.lang.Integer.parseInt(Integer.java:778)
at dev.flounder.Airports.parse(Airports.java:53)
at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
at java.base/java.util.Iterator.forEachRemaining(Iterator.java:133)
at java.base/java.util.Spliterators$IteratorSpliterator.forEachRemaining(Spliterators.java:1939)
at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
at dev.flounder.Airports.main(Airports.java:39)
It is pointing at the line 53 of the Airports.java
file.
By looking at this line, we can tell there is a problem in converting the
elevationFt
property to a number for one of the airports.
The exception message tells us this is because this property is blank in the corresponding file.
Is stack trace not enough?
While the stack trace above already gives us a lot of information, it is limited in several ways. It points at the lines of code that are failing, but does it tell us which data file is the cause? The name of the file would be useful if we want to inspect it more closely or correct the data. Unfortunately, the runtime details like these are lost when the application crashes.
There is a way, however, to suspend the application before it has terminated, which allows you to access context that is not captured in stack traces and logs. Moreover, with a suspended application, you can prototype, test, and even load the fix while the application is still running.
Suspend at an exception
First off, let’s start with setting an exception breakpoint. You can do that either in breakpoints dialog or by clicking Create breakpoint right in the stack trace:
Rather than targeting a specific line, this type of breakpoint
suspends the application just before an exception is thrown.
Currently, we are interested in NumberFormatException
.
So, we suspended the application when it was going to throw the exception. The error has already occurred, but the application has not yet crashed. We are just in time, so let’s see what this gives us.
In the Threads tab, go to the parse()
frame:
Just look at how much more information we have now:
we see the file name where the error happens,
the file’s full content, all the nearby variables,
and, of course, the property that is causing the error.
It becomes evident that the problem is
in the file called 816.json
because it lacks the elevationFt
property
We can now proceed to fix the issue. Depending on our use case, we might want to just fix the data, or fix the way the program handles the error. Fixing data is straightforward, so let’s see how the debugger can help us with error handling.
Prototype the fix
Evaluate expression
is a great tool for prototyping changes, including
fixes to your existing code. Exactly what we are looking for.
Select the entire
parse()
method body and go to
Run | Debugging Actions | Evaluate Expression.
If you select code before opening the Evaluate dialog, the selected snippet gets copied over, so you don’t have to enter it manually.
When we evaluate the code from the method, it throws an exception, just like it did in the program code:
Let’s change the code a little bit by making it store a
null
when it encounters missing data.
Also, let’s add a print statement to output the error to the standard error stream:
try {
JsonObject root = gson.fromJson(Files.readString(path), JsonObject.class);
String name = root.get("name").getAsString();
String isoCountry = root.get("isoCountry").getAsString();
String iataCode = root.get("iataCode").getAsString();
String longitudeDeg = root.get("longitudeDeg").getAsString();
String latitudeDeg = root.get("latitudeDeg").getAsString();
String municipality = root.get("municipality").getAsString();
String isoRegion = root.get("isoRegion").getAsString();
Integer elevationFt;
try {
elevationFt = Integer.parseInt(root.get("elevationFt").getAsString());
} catch (NumberFormatException e) {
elevationFt = null;
System.err.println("Failed to parse elevation for file: " + path);
}
return new Airport(name, isoCountry, iataCode, longitudeDeg, latitudeDeg, elevationFt, municipality, isoRegion);
} catch (IOException e) {
throw new RuntimeException(e);
}
By running the adjusted code in the Evaluate dialog,
we can verify that this code really handles the error as expected.
Now it sets the corresponding field to null
instead of crashing the app:
Not only can we test that the method returns without throwing the exception, but also see how the error text will look like in the console:
Reset frame
Ok, we fixed the code so that the error doesn’t happen in the future, but can we undo the error itself? Actually, we can! IntelliJ IDEA’s debugger lets us pop the faulty frame off the stack and execute the method from scratch.
Click Reset Frame in the Threads tab:
Reset Frame only rolls back the internal state of a frame. In our case, the method is pure, which means it doesn’t change anything outside its local scope, so this is not a problem. Otherwise, if changes are done to the global application’s state, these will not be reverted. Keep such effects in mind when using features like Evaluate and Reset Frame
Fix and hot-reload
After discarding the error and pretending it never happened, we can also deliver the fix to the runtime. Since the execution point is currently outside the method that we want to change, and we didn’t change the method’s signature, we can use the Reload Changed Classes option, which I already covered in one of the earlier posts.
First, copy the corrected code from the Evaluate dialog to the editor.
You can find the previously evaluated code by browsing the history (⌥↓ / Alt↓).
After replacing the code in the Airports
class, you can load it
to the running JVM. For this, select Run | Debugging Actions | Reload Changed Classes.
A balloon appears confirming that the fix has made its way to the running application.
If you are using the current EAP version of IntelliJ IDEA (2024.3 EAP1), try out the new hot reload button, which appears right in the editor:
Uncaught exception filter
If we resume the application now, it will again be suspended at the same exception. Why is this happening?
We modified the code to catch NumberFormatException
.
This fix prevents application crashes, but it doesn’t prevent the exception from being thrown.
So, the breakpoint still fires each time the exception is raised, even though it will eventually be caught.
Let’s tell IntelliJ IDEA that we want to suspend the application only when an uncaught exception occurs. For this, right-click the breakpoint in the gutter and clear the Caught exception checkbox:
The exception breakpoint icon only appears in the gutter when the application is suspended at an exception. Alternatively, you can configure breakpoints through Run | View Breakpoints.
With this setup, the application will run uninterrupted unless an
unhandled NumberFormatException
occurs.
Resume and enjoy
We can now resume the applicatifon:
It runs just fine, notifying us about the missing data in the console.
Note how 816.json
is on the list of errors,
confirming that we actually processed this file, not just skipped it.
Actually, there are two entries for 816.json
– one from our expression evaluation experiment,
and the other put there by the program itself,
after we have fixed and re-run the parse()
method.
Summary
In this post, we learned how to efficiently debug Java exceptions by gathering additional context that points at the cause of the failure. Then, using the capabilities of the debugger, we were able to undo the error, turn the debug session into a sandbox for testing a fix, and deliver it to the runtime right in the middle of the crash, all without restarting the application.
These simple techniques can be very powerful and save you a lot of time in certain situations. In the upcoming posts, we will discuss more debugging tips and tricks, so stay tuned!