IntelliJ IDEA 2024.1 Help

Tutorial: Detect concurrency issues

This tutorial introduces you to debugging multithreaded programs using IntelliJ IDEA.

When writing multithreaded apps, we must be extra careful as we may introduce bugs that will then be very hard to catch and fix. Concurrency-related bugs are trickier than those in a single-threaded application because of their random nature. An app may run flawlessly a thousand times and then fail unexpectedly for no obvious reason.

In this tutorial, we'll analyze a code example that demonstrates the core principles of debugging and analyzing a multithreaded app.

Problem

A common example of a concurrency-related bug is a race condition. It happens when some shared data is modified by several threads at the same time. The code may work fine as long as the modifications made by the two threads don't overlap.

Thread 1 had finished writing by the time reading in thread 2 started

Such overlapping may be very rare and lead us into thinking there is no flaw in the code. However, when the thread operations do overlap, the data gets corrupted.

Thread 1 hadn't started writing by the time thread 2 read the value

If we don't take this into account, there is no guarantee that the threads will not operate on the data simultaneously, especially if we deal with something more complex than just reading and writing. Luckily, Java has built-in synchronization mechanisms that ensure only one thread works with the data at a time.

Let's consider the following code:

import java.util.ArrayList; import java.util.Collections; import java.util.List; public class ConcurrencyTest { static final List a = Collections.synchronizedList(new ArrayList()); public static void main(String[] args) { Thread t = new Thread(() -> addIfAbsent(17)); t.start(); addIfAbsent(17); t.join(); System.out.println(a); } private static void addIfAbsent(int x) { if (!a.contains(x)) { a.add(x); } } }

The addIfAbsent method checks if a list contains a specific element, and if not, adds it. We call this method twice from different threads. Both times we pass the same integer value (17), and because of the guard condition (!a.contains(x)), only the first thread to call that method should be able to add the value. The use of SynchronizedList is supposed to protect us against race conditions. Finally, the System.out.println(a) statement prints out the contents of the list.

If we were to use this code for a long time, we would see that at times it still produces unexpected results.

To find the cause, let's examine how the code operates and see if we really managed to prevent race conditions.

Reproduce the bug

Using the IntelliJ IDEA debugger, you can test the multithreaded design of your application and reproduce concurrency-related bugs by controlling individual threads rather than the entire application.

  1. Set a breakpoint at the statement that adds elements to the list.

    A breakpoint is set at line 17
  2. Configure the breakpoint to only suspend the thread in which it was hit. This will ensure that both threads were suspended at the same line. To do this, right-click the breakpoint, then click Thread.

    The Thread button
  3. Start the debug session by clicking the Run button near the main method and selecting Debug.

    The popup that appears on clicking the Run icon in the gutter

    When the program has run, both threads are individually suspended in the addIfAbsent method. Now you can switch between the threads (in the Frames or Threads tab) and control the execution of each thread.

    At this point, both threads have checked that the list does not contain 17 and are ready to add the number to the list.

  4. Switch to Thread-0.

    Thread selector in the Frames tab
  5. Resume the thread by pressing F9 or clicking the Resume button in the left part of the Debug tool window.

    After you resume Thread-0, it proceeds with adding 17 to the list and is then terminated. After that, the debugger automatically switches back to the main thread.

  6. Resume the main thread to let it execute the remaining statements and then terminate.

  7. Review the program output in the Console tab.

    The Console tab of the Debug tool window

The output ([17, 17]) demonstrates that it was possible for the two threads to add the same value bypassing the guard condition and synchronization. We used the debugger to simulate the way it happened, which showed us that a race condition exists, and we need to correct our approach.

Correct the program

As we have just seen, SynchronizedList did not provide as much protection as we expected. It made sure that only one of the threads modifies the list at a time. However, we should have still taken into account that checking if (!a.contains(x)) and modifying a.add(x) were not an atomic operation. For this reason, both threads were able to evaluate the condition before any of them added anything to the list.

Let's correct the code by wrapping the condition in a synchronization block.

private static void addIfAbsent(int x) { synchronized (a) { if (!a.contains(x)) { a.add(x); } } }

We can now repeat the procedure with the corrected code and make sure that the issue no longer reproduces.

Last modified: 26 May 2024