I'm trying to deepen my understanding of Java's threading model.
To do this, I wrote a small test where I count how many times two threads increment a shared variable.
Here's what I did:
I declared a shared static variable in my main class:
private static int globalTotalCount = 0;
I started two threads:
Thread 1
: increments thread1Count
Thread 2
: increments thread2Count
Both threads also increment the shared variable globalTotalCount
.
At the end, I expect that:
thread1Count + thread2Count == globalTotalCount
However, in practice, the sum of the two thread counters does NOT match the final value of globalTotalCount. (most of the time)
Here is my code:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/**
* Demonstrates a race condition between two threads incrementing shared counters.
* The sum of thread-specific counts and the global total will often differ due to missing synchronization.
*/
public class RaceConditionDemo {
// Shared counters (not thread-safe)
private static int globalTotalCount = 0;
private static int thread1Count = 0;
private static int thread2Count = 0;
// Flag to signal threads to stop; volatile ensures visibility between threads
private volatile boolean shouldRun = true;
public RaceConditionDemo() {
// Create and use a thread pool for our worker threads
try (ExecutorService executor = Executors.newCachedThreadPool()) {
executor.execute(thread1Task);
executor.execute(thread2Task);
// Let threads run for 1 seconds
try {
Thread.sleep(1000);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
// Stop threads
shouldRun = false;
// Shutdown thread pool and wait for tasks to finish
executor.shutdown();
try {
if (!executor.awaitTermination(2, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
}
// Print results
System.out.println("Result:");
System.out.println("\tThread 1 Count:\t" + thread1Count);
System.out.println("\tThread 2 Count:\t" + thread2Count);
System.out.println("--------------------------------");
System.out.println("\tSum of thread counts:\t" + (thread1Count + thread2Count));
System.out.println("\tGlobal total count:\t\t" + globalTotalCount);
System.out.println("--------------------------------");
int difference = thread1Count + thread2Count - globalTotalCount;
if (difference != 0) {
System.out.println("Difference:\t" + difference + " => Race condition detected!");
} else {
System.out.println("Difference:\t" + difference + " => No race condition detected! (this time)");
}
}
// Worker for thread 1: increments its own and the global counter, prints its progress
private final Runnable thread1Task = () -> {
while (shouldRun) {
globalTotalCount++;
thread1Count++;
System.out.println("[Thread 1]\tCall:\t" + thread1Count + "\n");
try {
Thread.sleep(1);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
};
// Worker for thread 2: increments its own and the global counter, prints its progress
private final Runnable thread2Task = () -> {
while (shouldRun) {
globalTotalCount++;
thread2Count++;
System.out.println("[Thread 2]\tCall:\t" + thread2Count + "\n");
try {
Thread.sleep(1);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
};
public static void main(String[] args) {
new RaceConditionDemo();
}
}
i++
is probably not atomic in Java because atomicity is a special requirement which is not present in the majority of the uses of i++
. That requirement has a significant overhead: there is a large cost in making an increment operation atomic; it involves synchronization at both the software and hardware levels that need not be present in an ordinary increment.
You could make the argument that i++
should have been designed and documented as specifically performing an atomic increment, so that a non-atomic increment is performed using i = i + 1
. However, this would break the "cultural compatibility" between Java, and C and C++. As well, it would take away a convenient notation which programmers familiar with C-like languages take for granted, giving it a special meaning that applies only in limited circumstances.
Basic C or C++ code like for (i = 0; i < LIMIT; i++)
would translate into Java as for (i = 0; i < LIMIT; i = i + 1)
; because it would be inappropriate to use the atomic i++
. What's worse, programmers coming from C or other C-like languages to Java would use i++
anyway, resulting in unnecessary use of atomic instructions.
Even at the machine instruction set level, an increment type operation is usually not atomic for performance reasons. In x86, a special instruction "lock prefix" must be used to make the inc
instruction atomic: for the same reasons as above. If inc
were always atomic, it would never be used when a non-atomic inc is required; programmers and compilers would generate code that loads, adds 1 and stores, because it would be way faster.
In some instruction set architectures, there is no atomic inc
or perhaps no inc
at all; to do an atomic inc on MIPS, you have to write a software loop which uses the ll
and sc
: load-linked, and store-conditional. Load-linked reads the word, and store-conditional stores the new value if the word has not changed, or else it fails (which is detected and causes a re-try).