How to Debug Race Conditions Between Threads in Java
How many days off have been marred by debugging race conditions and deadlocks in complex multithreaded, Java code? You’ve probably vowed, Never again and embarked on a quest to always catch race condition errors early by writing tests and debugging.
Multithreaded applications are a great way to improve performance, but they also make routine tasks like debugging a little more complicated. In this tutorial, you’ll learn an overview of what race conditions are, what multithreaded Java code is used for, and finally how to debug race conditions in Java using a few different methods.
What Is Multithreading?
Multithreading is the ability for a program to run two or more threads concurrently, where each thread can handle a different task at the same time.
More specifically, a program is divided into two or more sub programs, which can be implemented at the same time in parallel:
private static void computeCounter(){
Counter counter = new Counter() ;
Thread threadOne = new Thread(getRunnable(counter , "Thread one count is: "));
Thread threadTwo = new Thread(getRunnable(counter , "Thread two count is: "));
threadOne.start();
threadTwo.start();
}
Benefits of a Multithreaded Application
- It enables you to do multiple things at a time.
- You can divide a long process into threads and execute them in parallel, which will eventually increase the speed of program execution.
- Simultaneous access to multiple applications.
- It improves performance by optimizing available resources, especially when your PC has multiple CPUs.
By default, all your program code runs on the main thread, which means if you have a 4-core CPU, only one core will be used to execute your code. This means that code will be executed one after the other, and only until the previous program is finished will the next be executed. On the other hand, if you use multithreading with three threads running concurrently, each thread will occupy separate cores and the program will be executed in parallel, reducing the amount of time to execute your program.
The following is a simple program that computes flight charges for each flight category. The category will be checked and a charge set on each.
public class FlightCategories {
static final int FIRST_CLASS = 1;
static final int BUSINESS_CLASS = 2;
static final int ECONOMY_CLASS = 3;
public static void main(String[] args){
System.out.println("===== Application Running =====");
try {
FlightCharges firstClass = new FlightCharges(FIRST_CLASS);
firstClass.computeFlightCharges();
FlightCharges businessClass = new FlightCharges(BUSINESS_CLASS);
businessClass.computeFlightCharges();
FlightCharges economyClass = new FlightCharges(ECONOMY_CLASS);
economyClass.computeFlightCharges();
System.out.println("First class charges are "+firstClass.getCharges());
System.out.println("Business class charges are "+businessClass.getCharges());
System.out.println("Economy class charges are "+economyClass.getCharges());
} catch (Exception e){
System.out.println("Encountered error "+e.getMessage());
}
}
}
public class FlightCharges {
private int category = 0;
private double charges = 0;
public FlightCharges(int category) {
this.category = category;
}
public double getCharges() {
return charges;
}
private void setCharges(double charges) {
this.charges = charges;
}
public void computeFlightCharges(){
try {
switch (category){
case 1:
setCharges(500.0);
break;
case 2:
setCharges(250.0);
break;
case 3:
setCharges(150.0);
break;
}
} catch (Exception e){
System.out.println("Exception "+e.getMessage());
}
}
}
Observe the time it took to execute it.
It took roughly 443 ms to execute this simple program. Now repeat the same process using multithreading as shown below:
public class FlightCategoriesMultithreading {
static final int ECONOMY_CLASS = 3;
static final int BUSINESS_CLASS = 2;
static final int FIRST_CLASS = 1;
public static void main(String[] args){
System.out.println("===== Application Running =====");
try {
FlightChargesMultithreading firstClass = new FlightChargesMultithreading(FIRST_CLASS);
Thread t1 = new Thread(firstClass);
t1.start();
FlightChargesMultithreading businessClass = new FlightChargesMultithreading(BUSINESS_CLASS);
Thread t2 = new Thread(businessClass);
t2.start();
FlightChargesMultithreading economyClass = new FlightChargesMultithreading(ECONOMY_CLASS);
Thread t3 = new Thread(economyClass);
t3.start();
System.out.println("First class charges are "+firstClass.getCharges());
System.out.println("Business class charges are "+businessClass.getCharges());
System.out.println("Economy class charges are "+economyClass.getCharges());
} catch (Exception e){
System.out.println("Encountered error "+e.getMessage());
}
}
}
public class FlightChargesMultithreading implements Runnable{
private int category = 0;
private double charges = 0;
public FlightChargesMultithreading(int category) {
this.category = category;
}
public double getCharges() {
return charges;
}
private void setCharges(double charges) {
this.charges = charges;
}
@Override
public void run() {
try {
switch (category){
case 1:
setCharges(500.0);
break;
case 2:
setCharges(250.0);
break;
case 3:
setCharges(150.0);
break;
}
} catch (Exception e){
System.out.println("Exception "+e.getMessage());
}
}
}
It only took 150 ms to execute the same program! For a simple program like this, this might not seem much, but the more complex a program gets, multithreading becomes crucial to performance.
Challenges of Multithreaded Applications
Concurrency greatly improves performance but at the detriment of debugging. Why? Because it’s easy to catch errors in a single thread but notoriously difficult to replicate bugs when tracking multiple threads.
Take, for example, the previous code that simply computed flight charges depending on flight categories. When logging the code, it will take some time to check the category of flight and set charges to it. The extra time could often fix the issue—most multithreading bugs are sensitive to the timing of events in your application, so running your code under a debugger changes the timing. Your debugger may fail to replicate the bug.
The bottom line is that multithreading is an important and useful aspect of development, but comes with its fair share of challenges.
What Are Race Conditions?
Detecting race conditions is particularly difficult. A race condition is a behavior that’s dependent on a “race” between two threads as to which one will be executed first. Two or more threads access the same variable or data in a way where the final result stored in the variable depends on how thread access to the variable is scheduled.
Thread (t1) in the FlightCategoriesMultithreading.java
example above will certainly finish first in most ideal situations and this will be your expected result. The problem occurs in rare cases when one of the remaining threads (t2 or t3) finishes first. In a more complex multithreaded application running hundreds of threads, this is a potential debugging nightmare and replicating these bugs would be hard!
Essentially, the two threads read and write the same variables or data concurrently instead of consecutively, using either of these patterns:
- Check, then act.
- Read, modify, write, where the modified value depends on the previously read value.
Because the thread access to the variables or data is not atomic, it can cause some issues. Here’s a diagram to illustrate this:
What Should Happen
First the CPU 1 thread will read the value of the counter class into a local CPU register where the thread is running, increment the value by 1, and write that value back to main memory. When the second thread (CPU 2) reads the count, it reads the value 1 incremented by 1 and then writes the value 2 back to main memory.
What Actually Happens
Instead of sequential access as shown in the diagram above, the threads use interleaved access. Thread 1 reads the value 0 from the counter into a CPU register, and at the exact same time, thread 2 reads the value 0 from the counter into a local CPU register for thread 2.
This causes both threads to increment the value that they have stored in their own CPU and write it back. So thread 1 will increment from 0 to 1 and write back 1, but thread 2 will also increment from 0 to 1 and write back 1. So, we expect the counter to read 2, but because of the interleaved access, it reads 1.
As an example, the following code snippet simply iterates over the counter a million times and prints out the value of both threads to the console:
public class RaceConditionExample {
private static Runnable getRunnable(Counter counter , String output) {
return () -> {
for (int i = 0 ; i < 1_000_000 ; i++){
counter.counterThenGet();
}
System.out.println(output + counter.getCount());
} ;
}
public static void main(String[] args) {
computeCounter();
}
private static void computeCounter(){
Counter counter = new Counter() ;
Thread threadOne = new Thread(getRunnable(counter , "Thread one count is: "));
Thread threadTwo = new Thread(getRunnable(counter , "Thread two count is: "));
threadOne.start();
threadTwo.start();
}
}
The following code increments the counter after each iteration:
public class Counter {
private int count = 0 ;
public void counterThenGet() {
this.count++ ;
}
public long getCount() {
return count;
}
}
How to Prevent Race Conditions
A critical section is where the race condition occurs.
public void counterThenGet() {
this.count++ ; //CRITICAL SECTION
}
The way to fix the critical section is to make it atomic, meaning that only one thread can execute within the critical section at a given time. Making the critical section atomic yields the sequential access to counter.
To make it atomic, wrap the section in a synchronized lock:
public class CounterWithSynchronizedLock {
private int count = 0 ;
private final Object lock = new Object() ;
public void counterThenGet() {
synchronized (lock){
this.count++ ;
}
}
public long getCount() {
return count;
}
}
Only a single thread can execute within the lock at a given time, avoiding a situation where two threads read the value of the counter, then increment it. Whatever thread is going to be executed will read the value, increment it, and write it back as a single atomic operation.
The Importance of Visibility in Multithreading
The importance of visibility (the memory that an executing thread can see once it’s written) in multithreaded applications cannot be belabored. When thread 1 writes to the memory before thread 2 reads it, the result is a race condition. It’s therefore imperative for Java developers to design, write, and test multithreaded Java applications with the basic tools of thread synchronization:
- Using a synchronized keyword: A synchronized keyword ensures that an unlock happens before every subsequent lock on the same monitor.
- Using a volatile keyword: A volatile keyword ensures that a write to a variable happens before subsequent reads of that variable.
- Static initialization: Static initialization ensures that the entire program is executed once when the class is first accessed and creates an instance of it. This is done by the class loader so that the JVM guarantees thread safety.
Debugging Race Conditions
There are a couple of ways to debug multithreaded Java applications, but we’ll focus on the three most common ways here.
1. Run Code as Sequential Code
Not every bug is related to race conditions, so it’s possible that debugging in a sequential environment would be easier. Alter your code so that it runs sequentially in order to make sure that the bug does not appear in a sequential run.
For example, #program omp parallel
for:
public void doSomething(){
for (int i = 0; i < n; i++)
{
// some code here
}
}
Becomes #pragma omp parallel for num_thread(N)
public void doSomething(){
OMP.setNumThreads(N);
//omp parallel for
for (int i = 0; i < n; i++)
{
// some code here
}
}
2. Use Log Statements
From within a thread, use logging statements to output debug information to the debugger so that you can form a post-mortem chain that can be followed. For example:
LOG.debug(PrintStringDataFromObject)
3. Use IntelliJ Debugging Tools
Using the IntelliJ remote debugger, you can test multithreaded code and reproduce race condition bugs.
However, when debugging production applications, we recommend using Lightrun. You can create a Lightrun account here.
In our multithreading example, the two threads share a counter. Each thread makes a million iterations while incrementing the counter at each iteration consequentially. This would mean that both threads should give you 2 million iterations in total.
To test this scenario, use breakpoints as provided by IntelliJ IDEA.
Set a breakpoint at the getter that fetches the counter.
You will then configure the breakpoint to only suspend the thread in which it was hit. This suspends both threads at the same line. To do this, right-click on the breakpoint, then click Thread.
To initiate debugging, click Run near the RaceConditionExample class and select Debug.
Thread 1 prints a result to the window console that is not 2 million. This is an expected result.
Resume the thread by pressing F9 or clicking the Resume button in the upper left of the Debug window.
From the results, you can see that race conditions are realized. The second thread should be 2 million but instead 1.9 million is printed.
Now run the second code with atomic implementation of the critical section and observe the differences.
From the images above, notice that your race condition bug has been solved and your second iteration totals to 2 million, a result that your code anticipated as the correct output.
Conclusion
Multithreading has lots of advantages, but can quickly become a debugging nightmare. Mastering the art of debugging is one of the most useful skills that you as a Java developer can use to make your development process smooth. Add some tools for advanced debugging in Java to your arsenal, like Lightrun, to combine them with your new knowledge about preventing race conditions, and enjoy the advantages of multithreaded applications with none of their detriments!
It’s Really not that Complicated.
You can actually understand what’s going on inside your live applications.