Mastering Debugging in Java

Estafet consultants occasionally produce short, practical tech notes designed to help the broader software development community and colleagues. 

If you would like to have a more detailed discussion about any of these areas and/or how Estafet can help your organisation and teams with best practices in improving your SDLC, you are very welcome to contact us at enquiries@estafet.com 

Introduction

In this tech note, we will explore a few debugging techniques, and learn how to use debugging features in IntelliJ that are often overlooked.

Debugging is a key part of every developer’s toolset, it is the process of detecting and correcting errors in an application. Today we will talk about debugging using debuggers, which can sometimes also be used to understand further how a specific piece of functionality works and behaves. It’s also one of the things that makes a good and experienced programmer stand out. The reason for that is simple – to debug a complex piece of software, you must first learn how to walk around the execution flow and manipulate it through the debugger. We are talking about a deeper understanding here, imagine the case where the issue at hand is not that obvious and is rooted deep into complex business logic (or even worse, you discover a bug in a framework). That’s where experience with debugger features and creativity comes into play.

Prerequisites

The article assumes that you have a basic understanding of writing and running Java applications with the IntelliJ IDE and uses examples written in Java version 17 and IntelliJ version 2023.3.6. 

What Is a Debugger

A debugger is a separate program that attaches itself to other programs (often referred to as “targets” for debugging). The purpose of this is to create a controlled execution of the target program, allowing you to “plug yourself into” the execution flow of the program, acquiring access to its memory, and allowing you to “pause” execution and observe the entire state of the program in real-time. You are not only limited to observing – debuggers allow you to alter the state of a debugged program, allowing you to change variables and move the instruction pointer to a location of your choice. Modern IDEs like IntelliJ come with a debugger making it easy to debug your code from the same place that you write it, effectively “masking” the fact that it’s a separate program.

Using a Debugger

Before getting into the more advanced features, let’s remind ourselves of the simplest form of debugging: a local setup with IntelliJ.

Debugging Local Code

The simplest form of debugging is launching a debugging session locally. In modern-day applications, it’s rarely the case that we can easily run complex software like that, but in the cases where we can – this is definitely the easiest and most intuitive way. IntelliJ makes it even easier. Let’s use a simple Fibonacci calculator as an example.

You probably know that you can place a breakpoint by clicking on the line number.

Placing a breakpoint

Starting a debugging session by clicking “Debug” (Shift + F9).

Once you run the app in Debug mode, it will start with a debugger attached. The first time a breakpoint is hit, the execution flow will pause and the debugging interface will be shown to you.

Simple breakpoint hit

As you can see we called the Fibonacci function with a parameter of 10 and upon the first hit we have “n=10” in the output.

Debugging Tricks

We covered simple debugging with simple breakpoints. But did you know that the debugger actually has some really cool advanced features? Let’s start with the simplest one.

Changing Variable Values Runtime

Using the above example, when you right-click “n=10” in the variables window and click on “Set Value” (F2) you can modify the current variable’s value while the app is paused. Upon resuming the execution, the variable will retain the updated value. This is especially useful when you want to test how code will behave with a specific value without making any code changes.

Right-click on the “n” variable in the variables window

Changing the value to 2

When you resume the flow after the change, it would continue as though you called the function with a value for “n” of 2. You have to be very careful when doing this, notice how I chose the value 2. If I had chosen 1 for example, I would’ve missed the  “if (n <= 1)” branch above. This can be the desired behaviour though, as it provides another way to observe a different code path without creating any code changes.

Conditional Breakpoints

Sometimes, a bug only shows up under specific conditions, or maybe you just want to pause execution only when a specific condition occurs. This is useful in cases where you have an infinite loop, and you want to debug inside of it. This can be really hard with a regular breakpoint, you can understand why. Or even in our case with the Fibonacci recursion – what if we wanted the 40th number in the sequence but we wanted to pause execution on the very last recursion (n = 1)? Are we just going to “Step Over” 39 times?  What if we wanted to stop execution only when a boolean is being set?

That’s where Conditional Breakpoints come into play. They allow us to set a breakpoint to pause execution only when a particular condition is met, transforming a broad search into a targeted one.

Right-click on the line number and click “Add Conditional Breakpoint”

The simple condition window opens, let’s give it a condition

“n == 1” as we just discussed and hit done

This is what the newly placed conditional breakpoint looks like

Now when we start a new debug session, on the very last function call (n=1) we should get a hit

And voila, we get a hit when “n” is 1, the very last function call

The conditional breakpoint “extended menu” gives us even more complex options. You can get there by clicking on “More (Ctrl+Shift+F8)” in the simple conditional breakpoint menu. As the saying goes, “the possibilities are endless” in the advanced window.

Advanced conditional breakpoint window

We can trigger the breakpoint on specific instance types present, class types, logging, etc. We won’t cover all these options now since they all behave similarly to the simple example we briefly covered.

Exception Breakpoints

The Exception Breakpoints are your safety net, catching errors as they fall through the cracks. We can break execution flow on the exceptions you’re hunting – caught or uncaught. This allows us to pinpoint a specific exception being thrown and observe the state of the application at that time. Let’s have a look at an example with a new piece of code.

ArrayList example

Access index without upper bounds check

As you can see we have a piece of code that accesses an ArrayList with a check for lower bounds, but no such check for upper bounds. We caught both possibilities for an exception, but we forgot to rethrow after logging the second one. You might be wondering, why would we want to break on specific exceptions being raised, over just stepping step by step until the exception is hit. There are a few good reasons for that.

When an exception occurs, understanding the context in which it was thrown is crucial. Exception Breakpoints bring you directly to the line of code where the exception is thrown, enabling you to inspect the state of all relevant variables and the call stack, in real-time. This immediate access helps pinpoint the cause of the exception more quickly than if you were to rely on post-mortem logs or attempt to recreate the issue step by step. In our simple example, the immense value may not be visible at first glance, but you will find this technique useful in large enterprise codebases.

Sometimes, exceptions are caught and handled in such a way that they do not visibly affect the program’s outcome, perhaps logging a warning or error message instead. These handled exceptions might indicate underlying issues that could lead to more significant problems down the line. Exception Breakpoints help uncover these hidden issues early by breaking on caught exceptions, allowing developers to address potential bugs before they escalate.

Without Exception Breakpoints, developers might have to step through their code line by line or litter their code with logging statements to catch the moment an exception is thrown. This process can be time-consuming, especially in large codebases or when the exact conditions causing the exception are unknown. Exception Breakpoints streamline this process by jumping directly to the problematic code, saving valuable development time.

To set up an exception breakpoint:

  1. Navigate to Run > View Breakpoints (or press Ctrl+Shift+F8).
  2. Click on the + button on the left side of the Breakpoints dialog and select Java Exception Breakpoints.
  3. In the “Add Java Exception Breakpoint” dialog, type IndexOutOfBoundsException and select it. Make sure to check both Caught exception and Uncaught exception options to catch the exception whether it’s caught by a catch block or not.
  4. Apply the changes and close the dialog.

Adding Java Exception breakpoint

Adding IndexOutOfBounds exception

Now when we launch a new debugging session and pass an index that is out of bounds, we should trigger the breakpoint.

Exception breakpoint triggered right on the problematic code, allowing us to observe the application state

Reset Frame

Imagine you could step back in time, retrace your steps, and explore different paths. The Reset Frame feature does just that, allowing you to rewind the execution flow. Revisit the problematic execution moment, test different scenarios, and observe their outcomes without the burden of starting over.

Let’s test this with a simple factorial calculator.

Factorial calculator snippet

To utilise “frame resetting” we have to:

  1. Set a Breakpoint: Set a breakpoint on the line “return n * factorial(n – 1);” in the factorial method.
  2. Start Debugging: Start your application in debug mode. The debugger will halt execution at the breakpoint.
  3. Step Over: Use the “Step Over” feature (usually F8 in IntelliJ) to execute the current line. Since this is a recursive call, you just accidentally stepped over an iteration you intended to inspect more closely.
  4. Reset Frame: If you’ve stepped over a recursive call and want to go back to see the values or how the method was called, you can use the Reset Frame feature. In IntelliJ, you can find this option by right-clicking on the stack frame you wish to return to in the Frames panel (located on the left side of the Debug window) and selecting “Reset Frame”. This action will roll back the state to before the current method invocation.
  5. Re-Inspect: After dropping the frame, you can inspect the values passed to the method again or step into the method call to see its execution with the current parameters.

Stack frame window after the above steps

Notice how we have on the right [1] and [2]. [1] Was the previous stack frame, the first entry to the function, [2] is the new stack frame after the recursive call. Let’s go back to the first frame by “dropping” the current one.

Reset (rewind) the current stack frame

 The last frame is dropped

As you can see, the value of “n” is now 5, we essentially went back in time through the stack frames. We can do this every time we “miss” a step by stepping over it – just reset the frame, follow the execution flow again, and “step into” this time.

Note: any global state is not “rewinded”, and neither are any I/O operations affected by the method.

Evaluate Expression

This is one of the most powerful features of a debugger. Using the “evaluate expression” input field we can inspect the current state of the application, and actively interact with it by crafting our own code snippets and directly executing them, testing hypotheses, or even performing operations without altering the source code.

Imagine you’re paused at a breakpoint inside a method, and you’re curious about how a particular change would affect the outcome. With “Evaluate Expression”, you can modify variables, call methods, and evaluate complex expressions without restarting your debug session. It’s particularly useful for debugging conditional logic, where you can manually adjust variables to test different branches without needing to set up new test cases for each scenario.

Let’s have a look at an example using inheritance. The setup is simple, we have an “Animal” base class and derived animal classes. We then create objects of these types and store them in an “Animal” array.

Creating a few animals and looping through them

When placing a breakpoint on the for loop, using the “Evaluate expression” feature we can identify the object types, and modify lists during the paused execution flow. Let’s say we wanted to add another animal to test how it would affect the execution flow.

Preparing to add a new object to the list

After pressing “enter” to execute the code snippet

It’s amazing what you can do with this feature, you can add, and remove items from lists, you can log to the console various things, attempt a tricky conversion, you are free to insert almost any code snippet into the execution flow, without the need for a code change – this alone is incredibly powerful, allowing you to create very specific scenarios that would otherwise require a lot of code changes.

Conclusion

In this short article, we managed to cover several often forgotten debugging tricks and techniques. You now know how to use Conditional Breakpoints, Exception Breakpoints, Reset Frame, and Evaluate Expression in IntelliJ. Keep them in mind the next time you launch the debugger!

By Antonio Lyubchev, Consultant at Estafet

Stay Informed with Our Newsletter!

Get the latest news, exclusive articles, and updates delivered to your inbox.