Troubleshoot Slow Debugger

Other languages: 中文 Español Português

Although their overhead is generally minimal, Java debuggers can still incur significant runtime costs under certain circumstances. In an unfortunate scenario, the debugger can even completely freeze the VM.

Let’s examine the reasons behind these issues and their possible solutions.

Info icon

The specifics outlined in this article refer to JetBrains IDEs, and some of the mentioned features might not be available in other tools. Nevertheless, the general troubleshooting strategy should still apply.

Diagnose the cause

Before exploring possible solutions, it would be wise to identify the problem. The most common reasons for the debugger slowing down an application include:


IntelliJ IDEA eliminates any guesswork in identifying the cause of debugger performance issues by providing detailed statistics in the debugger’s Overhead tab:

Overhead tab in IntelliJ IDEA Overhead tab in IntelliJ IDEA

To access it, select Overhead from the Layout Settings tab. The Overhead tab will show the list of breakpoints and debugger features. Next to each breakpoint or feature, you can see how many times each debugger feature was used and the amount of time it took to execute.

Tip icon

In the Overhead tab, you can also temporarily turn off a resource-consuming feature by unchecking the feature’s corresponding checkbox.

Now that we’ve seen how to identify the source of the performance issue, let’s look at the most common causes and how to address them.

Method breakpoints

When using method breakpoints in Java, you might experience performance drops, depending on the debugger you’re using. . This is because the corresponding feature provided by the Java Debug Interface is notably slow.

To address this issue, IntelliJ IDEA offers emulated method breakpoints. These work just like regular method breakpoints, but they function more efficiently. Emulated method breakpoints involve a trick under the hood: instead of setting actual method breakpoints, the IDE replaces them with regular line breakpoints within all the method’s implementations across the project.

By default, all method breakpoints in IntelliJ IDEA are emulated:

Breakpoint settings popup with the 'Emulated' option enabled Breakpoint settings popup with the 'Emulated' option enabled

If you’re using a debugger that doesn’t have this feature and you encounter performance problems with method breakpoints, you can do the same trick manually. Visiting all the method implementations might be tedious, but it may pay off by saving you time while debugging.

‘Processing classes for emulated method breakpoints’ taking too long

If a method has a huge number of implementations, setting a method breakpoint on it might take some time. In this case, IntelliJ IDEA and Android Studio will show a dialog saying Processing classes for emulated method breakpoints.

If the process of emulating method breakpoints takes too long for you, consider using a line breakpoint instead. Alternatively, you can trade off some runtime performance by clearing the Emulated checkbox in the breakpoint’s settings.

Conditional breakpoints in hot code

Setting a conditional breakpoint in hot code might drastically slow down a debugging session, depending on how often this code is executed.

Consider the following code snippet:


public class Loop {

    public static final int ITERATIONS = 100_000;

    public static void main(String[] args) {
        var start = System.currentTimeMillis();
        var sum = 0;
        for (int i = 0; i < ITERATIONS; i++) {
            sum += i;
        }
        var end = System.currentTimeMillis();
        System.out.println(sum);
        System.out.printf("The loop took: %d ms\n", end - start);
    }
}
const val ITERATIONS = 100_000

fun main() = measureTimeMillis {
    var sum = 0
    for (i in 0 until ITERATIONS) {
        sum += i
    }
    println(sum)
}.let { println("The loop took: $it ms") }

Let’s set a breakpoint at sum += i and specify false as the condition. This effectively means that the debugger should never stop at this breakpoint. However, every time this line runs, the debugger would have to evaluate false.

Breakpoint settings dialog with condition set to 'false' Breakpoint settings dialog with condition set to 'false'

In this case, the results of running this code with and without the breakpoint were 39 ms and 29855 ms, respectively. Remarkably, even with as few as 100,000 iterations, the difference is huge!

It may be surprising that evaluating a seemingly trivial condition like false takes up so much time. This is because the elapsed time is not only due to the calculation of the expression’s result. It also involves handling debugger events and communicating with the debugger’s frontend.

The solution is straightforward. You can integrate the condition directly into the application code:


public class Loop {

    public static final int ITERATIONS = 100_000;

    public static void main(String[] args) {
        var start = System.currentTimeMillis();
        var sum = 0;
        for (int i = 0; i < ITERATIONS; i++) {
            if (false) { // condition goes here
                System.out.println("break") // breakpoint goes here
            }
            sum += i;
        }
        var end = System.currentTimeMillis();
        System.out.println(sum);
        System.out.printf("The loop took: %d ms\n", end - start);
    }
}
fun main() = measureTimeMillis {
    var sum = 0
    for (i in 0 until ITERATIONS) {
        if (false) { // condition goes here
            println("break") // breakpoint goes here
        }
        sum += i
    }
    println(sum)
}.let { println("The loop took: $it ms") }

With this setup, the VM will directly execute the code of the condition, and it might even optimize this code. Conversely, the debugger will only come into play upon hitting the breakpoint. While not required in most cases, this change can save you time if you need to conditionally suspend the program in the middle of a hot path.

The described technique works perfectly with classes with available source code. However, with compiled code, such as libraries, the trick might be harder to pull off. This is a special use case, which I will cover in a separate discussion.

Implicit evaluation

In addition to features – such as breakpoint conditions and watches – where you specify expressions yourself, there are also features that implicitly evaluate expressions for you.

Here’s an example:

IntelliJ IDEA's Variables view hides collection implementation details
and shows collection's contents in a easy-to-view form IntelliJ IDEA's Variables view hides collection implementation details
and shows collection's contents in a easy-to-view form

Whenever you suspend a program, the debugger displays the values of the variables that are available in the current context. Some types might have complex structures that are difficult to view and navigate. For your convenience, the debugger transforms them using special expressions called renderers.

Renderers can be trivial, like toString(), or more complex, such as those that transform the content of collections. They can be either built-in or custom.

Tip icon

IntelliJ IDEA’s debugger is very flexible in how it displays your data. The IDE even allows you to configure renderers using annotations, ensuring consistent class representations across a project with multiple contributors.

To learn more about configuring the format for displaying data, refer to IntelliJ IDEA’s documentation.

Typically, the overhead brought by debug renderers is negligible, but the impact ultimately depends on the particular use case. Indeed, if some of your toString() implementations contain code for mining crypto, the debugger will have a hard time showing the toString() value for that class!

If rendering a certain class proves to be slow, you can turn the corresponding renderer off. As a more flexible alternative, you can set up the renderer for use only when needed. On-demand renderers will only execute when you explicitly request to show their result.

High latency in remote debug sessions

From a technical standpoint, debugging a remote application is no different from debugging locally. Either way, the connection is established via a socket – we’re excluding the shared memory mode from this discussion – and the debugger isn’t even aware of where the host JVM runs.

However, one factor that might be unique to remote debugging is network latency. Certain debugger features perform several network round trips each time they are used. Combined with high latency, this can lead to a considerable drop in debugging performance.

If that’s the case, think about running the project locally, as it could save you a lot of time. Otherwise, you might benefit from temporarily turning off some of the advanced features.

Conclusion

In this article, we’ve learned how to fix the most common issues resulting in poor debugger performance. While the IDE will sometimes take care of that for you, it’s important to understand the underlying mechanisms. This empowers you to be more flexible, efficient, and creative in your day-to-day debugging.

I hope you found these tips and tricks useful. As always, your feedback is greatly appreciated! Feel free to reach out to me on X, LinkedIn, or Telegram.

Happy debugging!

all posts ->