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:

'Create breakpoint' button is shown near the stack trace in the console 'Create breakpoint' button is shown near the stack trace in the console

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 .

A highlighted line indicates that the breakpoint worked and the program has been suspended A highlighted line indicates that the breakpoint worked and the program has been suspended

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:

Selecting the 'parse()' frame in the debugger's 'Threads' tab Selecting the 'parse()' frame in the debugger's 'Threads' tab

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.

Tip icon

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:

Evaluating the same failing code in the 'Evaluate' dialog results also results in NumberFormatException Evaluating the same failing code in the 'Evaluate' dialog results also results in NumberFormatException

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:

After clicking 'Evaluate' for the corrected code, the result shows a valid return value After clicking 'Evaluate' for the corrected code, the result shows a valid return value

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:

The console says 'Failed to parse elevation for file: ./data/816.json' The console says 'Failed to parse elevation for file: ./data/816.json'

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:

Pointing at the 'Reset frame' icon at the 'parse()' frame in the 'Threads' tab Pointing at the 'Reset frame' icon at the 'parse()' frame in the 'Threads' tab
Info icon

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 saying classes were reloaded A balloon appears saying classes were reloaded

A balloon appears confirming that the fix has made its way to the running application.

Tip icon

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:

A popup appears in the top-right corner of the editor prompting to reload the files A popup appears in the top-right corner of the editor prompting to reload the files

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:

Unchecked 'Caught exception' box in the 'Exception breakpoint' dialog that opened on clicking the breakpoint icon in the gutter Unchecked 'Caught exception' box in the 'Exception breakpoint' dialog that opened on clicking the breakpoint icon in the gutter
Tip icon

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:

Pointing at the 'Resume Program' button in the debugger's toolbar Pointing at the 'Resume Program' button in the debugger's toolbar

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.

The console shows the application output along with the list of errors The console shows the application output along with the list of errors

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!

all posts ->