IntelliJ IDEA 2023.3 Help

Tutorial: Find a memory leak

We often find ourselves in situations where code is not working properly, and we have no idea where to even begin investigating.

Can’t we just stare at the code until the solution eventually comes to us? Sure, but this method probably won’t work without deep knowledge of the project and a lot of mental effort. A smarter approach would be to use the tools you have at hand. They can point you in the right direction.

In this tutorial, we’ll look at how we can use some of IntelliJ IDEA's built-in tools to investigate a runtime problem.

The problem

Let’s start with cloning the following repository: https://github.com/flounder4130/party-parrot.

After that, launch the application using the Parrot run configuration included with the project.

Run the application

  • Press Alt+Shift+F10 then select Parrot.

    The Parrot run configuration in the Run menu

The app seems to work well: you can tweak the animation color and speed. However, it’s not long before things start going wrong.

After working for some time, the animation freezes with no indication of what the cause is. There can also be a OutOfMemoryError, whose stack trace doesn’t tell us anything about the origin of the problem.

The parrot animation has frozen

There is no reliable way of telling how exactly the problem will manifest itself. The interesting thing about the animation freeze is that we can still use the rest of the UI.

Debugger

It seems we have a bug. Let’s try using the debugger!

Pause an app

  1. First we need to run the app in debug mode. Press Alt+Shift+F9, then select Parrot.

    The Parrot run configuration in the Debug menu
  2. Wait until the animation freezes. Go to Run | Debugging Actions | Pause Program.

We get the list of threads with their current stack traces.

Threads panel in the Debugger

Unfortunately, this did not tell us much because all the threads involved in the parrot party are in the waiting state. We don’t even know if the threads are waiting for a lock or have just finished their current work. Clearly, we need to try another approach.

CPU and Memory Live Charts

Since we are getting an OutOfMemoryError, a good starting point for analysis is CPU and Memory Live Charts. They allow us to visualize real-time resources usage for the processes that are running. Let’s open the charts for our parrot app and see if we can spot anything when the animation freezes.

Open CPU and memory live charts

  1. In the main menu, go to View | Tool Windows | Profiler.

  2. Right-click the necessary process in the Profiler tool window and select CPU and Memory Live Charts.

    Accessing live charts for a running process

    A new tab opens in which you can see the amount of resources the selected process consumes.

    CPU and Memory live charts

Indeed, we see that the memory usage is going up continually before reaching a plateau. This is precisely the moment when the animation hangs, and there seems to be no way out of this.

Memory leak is visible on CPU and Memory Live Charts

This gives us a clue. Usually, the memory usage curve is saw-shaped: the chart goes up when new objects are allocated and periodically goes down when the memory is reclaimed by the garbage collector. You can see an example of this in the picture below:

Saw-like memory consumption on CPU and Memory Live Charts

If the saw teeth become too frequent, it means that a lot of objects are being created and the garbage collector is often called to reclaim the memory. If we see a plateau, it means the garbage collector can’t free up any.

We can test whether garbage collection yields any results right in CPU and memory live charts.

Call garbage collection

  • If you need to test how garbage collection works under specific conditions, you can request it from CPU and Memory Live Charts. For that, click the Perform GC button.

    Perform GC button

Memory usage does not go down after it reaches the plateau. This supports our hypothesis that there are no objects eligible for garbage collection.

Since there is not enough memory, a naïve solution would be to just add more memory.

Add memory to a run configuration

  1. Hold Shift and click the run configuration on the main toolbar.

    Opening the run configuration settings using the main toolbar
  2. In the VM options field, enter -Xmx1024M. This will increase the memory heap to 1024 megabytes.

    -Xmx1024m in the VM options field

Run the application again. Alas, regardless of the available memory, the parrot runs out of memory anyway. Again, we see the same picture. The only visible effect of extra memory was that we delayed the end of the “party”.

CPU and Memory Live Charts show that the memory leak is still there

Allocation profiling

Since we know our application never gets enough memory, we might want to analyze its memory usage.

Run with profiler

  • Press Alt+Shift+F10. Select Parrot | Profile with 'IntelliJ Profiler'.

    The Profile option in the Run menu

While running, the profiler records the application state when objects are placed on the heap. This data is then aggregated in a human-readable form to give us an idea of what the application was doing when allocating these objects.

After running the profiler for some time, let’s open the report and see what’s there.

Open the profiler report

  • Click the balloon that appears near the Profiler tool window button.

    A balloon appears when the snapshot is ready

By default, IntelliJ Profiler shows CPU samples data. To analyze memory allocations, we need to switch to that mode:

Switch viewing mode

  • Use the menu in the top-right corner of the tool window.

    The Show menu in the top-right corner of the Profiler tool window

There are several views available for the collected data. In this tutorial, we will use the flame graph. It aggregates the collected stacks in a single stack-like structure, adjusting the element width according to the number of collected samples. The widest elements represent the most massively allocated types during the profiling period.

Flame graph

An important thing to note here is that a lot of allocations don’t necessarily indicate a problem. A memory leak happens only if the allocated objects can't be garbage-collected because they are referenced from somewhere in the running application. While allocation profiling doesn't tell us anything about the garbage collection, it can still give us hints for further investigation.

Let’s see where the two most massive elements, byte[] and int[], come from. The top of the stack tells us that these arrays are created during image processing by the code from the java.awt.image package. The bottom of the stack tells us that all this happens in a separate thread managed by an executor service. We aren’t looking for bugs in library code, so let’s look at the user code that’s in between.

Going from top to bottom, the first application method we see is recolor(), which in turn is called by updateParrot(). Judging by the name, this method is exactly what makes our parrot move. Let’s see how this is implemented and why it needs that many arrays.

Jump to source

  • Click at the frame and select Jump to Source. This will take us to the source code of the corresponding method.

    Jump to Source menu item for a frame in the flame graph

After navigating to the declaration site, we see the following code:

public void updateParrot() { currentParrotIndex = (currentParrotIndex + 1) % parrots.size(); BufferedImage baseImage = parrots.get(currentParrotIndex); State state = new State(baseImage, getHue()); BufferedImage coloredImage = cache.computeIfAbsent(state, (s) -> Recolor.recolor(baseImage, hue)); parrot.setIcon(new ImageIcon(coloredImage)); }

It seems that updateParrot() takes some base image and then recolors it. In order to avoid extra work, the implementation first tries to retrieve the image from some cache. The key for retrieval is a State object, whose constructor takes a base image and a hue.

Analyze data flow

Using the built-in static analyzer, we can trace the range of input values for the State constructor call.

Analyze method input

  • Right-click the baseImage constructor argument, then from the menu, select Analyze | Data Flow to Here.

    Data flow tree

Expand the nodes and pay attention to ImageIO.read(path.toFile()). It shows us that the base images come from a set of files. If we double-click this line and look at the PARROTS_PATH constant that is nearby, we discover the files location:

The contents of the resources folder in the Project tool window

That’s ten base images that correspond to the possible positions of the parrot. Well, what about the hue constructor argument? Let's run data flow analysis for the hue argument:

Analyzing constructor arguments in the data flow tree

If we inspect the code that modifies the hue variable, we see that it has a starting value of 50. Then it is either set with a slider or updated automatically from the updateHue() method. Either way, it is always within the range of 1 to 100.

So, we have 100 variants of hue and 10 base images, which should guarantee that the cache never grows bigger than 1000 elements. Let’s check if that holds true.

Conditional breakpoints

Now, this is where the debugger can be useful. We can check the size of the cache with a conditional breakpoint.

Let’s set a breakpoint at the update action and add a condition so that it only suspends the application when the cache size exceeds 1000 elements.

Set a conditional breakpoint

  1. Click the gutter at line 85 and select Line breakpoint.

    Choosing the breakpoint type
  2. Right-click the breakpoint and enter cache.size() > 1000 in the Condition field.

    Entering the condition for a breakpoint

Now run the app in debug mode.

The highlighted line indicates the execution point

Indeed, we stop at this breakpoint after running the program for some time. So we can be sure that the problem is in the cache.

Inspect the code

Ctrl+B on cache takes us to its declaration site:

private static final Map<State, BufferedImage> cache = new HashMap<>();

If we check the documentation for HashMap, we’ll find that its implementation relies on the equals() and hashcode() methods, and the type that is used as the key has to correctly override them. Let’s check it. Ctrl+B on State takes us to the class definition.

Seems like we have found the culprit: the implementation of equals() and hashcode() isn’t just incorrect. It’s completely missing!

Override methods

Writing implementations for equals() and hashcode() is a mundane task. Luckily, IntelliJ IDEA can generate them for us.

Generate hashcode() and equals()

  1. While in the State class, start typing equals, and the IDE will understand what we want.

    The code completion popup suggesting to generate equals()
  2. Just accept the suggestion and click Next until the methods appear at the caret.

    Generate equals() and hashCode() dialog

Check the fix

Let’s restart the application and see if things have improved. Again, we can use CPU and Memory Live Charts for that:

CPU and Memory Live Charts show that the memory leak has gone

That is much better!

Summary

In this tutorial, we looked at how we can start with the general symptoms of a problem and then, using our reasoning and the variety of tools IntelliJ IDEA offers us, narrow the scope of the search step-by-step until we find the exact line of code that’s causing the problem. More importantly, we made sure that the parrot party will go on no matter what!

Last modified: 11 February 2024