1. Getting to know race conditions (race condition)
Let’s recall the race condition — a situation where the program’s result depends on the order in which threads gain access to shared data or resources. If the execution order changes, the result becomes unpredictable. It’s like you and a friend trying to edit the same document at the same time: whoever types faster wins, and the final text may end up very strange.
In Java (and in any other language with multithreading support), a race condition appears when multiple threads simultaneously read and/or modify the same variable without proper synchronization.
Why does a race condition occur?
Java threads run in parallel. If two threads access the same variable at the same time (for example, increment a shared counter), they can “interfere” with each other. Even if an operation seems atomic (for example, counter++), it actually isn’t!
How does counter++ work?
The increment operation consists of several steps:
- Read the current value of the variable from memory.
- Increase that value by one.
- Write the new value back to memory.
If, at the same time, another thread also does counter++, they can both read the same value, both increment it, and both write the same result — in the end, one increment gets “lost.”
2. Race condition example: counter increment
Let’s write a simple program that starts several threads, each of which increments a shared counter by 1. You’d think if we start 1000 threads, the final value should be 1000. Let’s check!
public class RaceConditionDemo {
static int counter = 0;
public static void main(String[] args) throws InterruptedException {
int threads = 1000;
Thread[] threadArray = new Thread[threads];
for (int i = 0; i < threads; i++) {
threadArray[i] = new Thread(() -> {
counter++; // Dangerous operation!
});
threadArray[i].start();
}
// Wait for all threads to finish
for (int i = 0; i < threads; i++) {
threadArray[i].join();
}
System.out.println("Expected: " + threads);
System.out.println("Actual: " + counter);
}
}
Expected output:
Expected: 1000
Actual: 843
The value may differ on each run: sometimes 900, sometimes 700, and sometimes even 1000 — but very rarely.
Why does this happen?
Threads simultaneously read the value of counter, increment it, and write it back. If two threads read the same value, both increment it, and both write it back, one increment is lost. As a result, the final value is always less than expected.
3. Another example: a bank without synchronization
Let’s imagine that we have a bank account, and two threads withdraw money at the same time.
public class BankAccount {
private int balance = 100;
public void withdraw(int amount) {
if (balance >= amount) {
// Simulating a long operation
try { Thread.sleep(1); } catch (InterruptedException ignored) {}
balance -= amount;
}
}
public int getBalance() {
return balance;
}
}
public class BankDemo {
public static void main(String[] args) throws InterruptedException {
BankAccount account = new BankAccount();
Thread t1 = new Thread(() -> account.withdraw(100));
Thread t2 = new Thread(() -> account.withdraw(100));
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Expected: 0 or 100");
System.out.println("Actual balance: " + account.getBalance());
}
}
Sometimes both threads will see that the account has 100 and both will withdraw the money. As a result, the balance becomes -100! (In real life that doesn’t happen, but in code — easily.)
4. Useful nuances
Consequences of a race condition
A race condition isn’t just about “weird” results. It’s a real headache for developers because:
- Bugs don’t always manifest. Sometimes the program works correctly, sometimes it doesn’t. It all depends on how threads “managed” to execute their actions.
- Testing doesn’t guarantee success. You can run the program many times and everything looks fine, then suddenly it breaks.
- Bugs are hard to catch. Behaviour depends on CPU speed, system load, and other running programs.
- Critical failures can occur: data loss, incorrect calculations, application crashes.
Real-world examples
- Financial applications: incorrect balance calculation, double charges.
- Servers: lost messages, incorrect request handling.
- Games: character “teleporting,” incorrect scoring.
Why doesn’t testing save you from race conditions?
A race condition is a classic “Heisenbug” (a bug that disappears when you try to catch it). Even if you run tests a thousand times and don’t see the error — that doesn’t mean it isn’t there! It all depends on how the OS schedules threads. Sometimes everything goes smoothly, and sometimes threads “collide” and the problem appears.
How to avoid race conditions?
- Synchronization: use the synchronized keyword for methods or code blocks so that only one thread can modify shared data at any given moment.
- Atomic operations: use classes from the java.util.concurrent.atomic package (for example, AtomicInteger) that provide safe operations without explicit synchronization.
- Immutability: if an object cannot be changed, a race condition is impossible.
Example with synchronization
public class SafeCounter {
private int counter = 0;
public synchronized void increment() {
counter++;
}
public int getValue() {
return counter;
}
}
Now, if multiple threads call increment(), only one thread can execute this method at any given moment.
5. Common mistakes when working with shared variables across threads
Mistake #1: Naïve confidence in the safety of simple operations.
Many think that counter++ is a single operation and nothing bad can happen. In reality, it’s three operations, and another thread can interleave between them.
Mistake #2: Using plain variables for inter-thread communication.
If multiple threads write to and read the same variable without synchronization — hello, race condition!
Mistake #3: Expecting the bug to show up every time.
A race condition may appear only sometimes, which makes it especially insidious. Don’t assume that if everything worked in tests, everything is fine.
Mistake #4: Ignoring synchronization when working with collections.
Regular collections like ArrayList are not thread-safe. If multiple threads add or remove elements, failures and even crashes are possible.
Mistake #5: Trying to “fix” a race condition using delays.
For example, via Thread.sleep(10) or other “magic” pauses. This approach doesn’t solve the problem; it only masks it. The real solution is synchronization or atomic operations.
GO TO FULL VERSION