A race condition happens when two threads read and write the same data at the same time and the result depends on the order they happen to run in. Java gives you several ways to prevent it, and the right one depends on whether you are protecting a compound operation, publishing a value, or updating a single number.
What does a race condition look like?
The classic shape is a read-modify-write on a shared field. counter = counter + 1 looks like one operation, but it is three: read the value, add one, write it back. If two threads interleave between the read and the write, one of the increments is lost.
public class RaceDemo {
static int counter = 0;
static void increment() {
counter = counter + 1;
}
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[4];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(RaceDemo::increment);
}
for (Thread t : threads) {
t.start();
}
for (Thread t : threads) {
t.join();
}
System.out.println("expected 40000, got " + counter);
}
}expected 40000, got 4The number printed is the evidence. Four threads each ran ten thousand increments, so the answer should be forty thousand. Anything lower means some increments were overwritten - the classic lost update. Run it a few times and the number changes, because the interleaving changes. A test that is sometimes right is worse than a test that always fails, which is why this bug hides so well in production.
How does synchronized fix it?
synchronized attaches a monitor to an object. A thread that enters a synchronized block or method holds that object's monitor, and every other thread that tries to enter it waits. The critical section becomes atomic with respect to that monitor: one thread at a time.
public class SynchronizedFix {
private int counter = 0;
synchronized void increment() {
counter = counter + 1;
}
public static void main(String[] args) throws InterruptedException {
SynchronizedFix shared = new SynchronizedFix();
Thread[] threads = new Thread[4];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(shared::increment);
}
for (Thread t : threads) {
t.start();
}
for (Thread t : threads) {
t.join();
}
System.out.println("expected 40000, got " + shared.counter);
}
}expected 40000, got 4The same program now prints exactly forty thousand, every time. Notice how the fix is one keyword on the method - and that the method is an instance method, so the monitor belongs to the SynchronizedFix instance. All four threads share one instance, so they share one monitor and take turns.
Two details matter. A synchronized method locks the whole method body; a synchronized (someObject) { ... } block locks only the code inside, which is usually what you want when the critical section is small. And a static synchronized method locks the class object rather than the instance - mixing the two on the same data is a classic mistake.
What does volatile actually guarantee?
volatile is about visibility, not mutual exclusion. It guarantees that a write by one thread is immediately visible to other threads, and it stops the compiler and processor from caching a value in a register or reordering operations around it. It does not make counter = counter + 1 atomic, because the read-modify-write sequence is still three separate steps.
import java.util.concurrent.atomic.AtomicInteger;
public class VolatileVsAtomic {
static volatile int visible = 0;
static AtomicInteger atomic = new AtomicInteger();
public static void main(String[] args) throws InterruptedException {
Thread reader = new Thread(() -> {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
visible = 42;
atomic.set(7);
});
reader.start();
reader.join();
System.out.println("visible : " + visible);
System.out.println("atomic : " + atomic.get());
atomic.incrementAndGet();
atomic.addAndGet(5);
System.out.println("after two updates : " + atomic.get());
}
}visible : 42
atomic : 7
after two updates : 13The program shows what volatile is good for: a flag or a status that one thread sets and another reads. It is the wrong tool for a counter. When you need a single field updated atomically, reach for an atomic class instead.
When should you use an atomic class?
Classes in java.util.concurrent.atomic give you atomic operations on a single variable: incrementAndGet, addAndGet, compareAndSet. They are built on the same low-level hardware support that the runtime uses for locks, and they are much cheaper than synchronising.
Two rules to remember. Use compareAndSet - the compare-and-swap primitive - for anything that depends on the previous value, and keep the whole compound operation inside a loop. And do not put a volatile and an atomic on the same field; pick one.
When do you reach for an explicit Lock?
A ReentrantLock does what synchronized does, with more control. You can try to acquire it with a timeout, acquire it optionally without blocking, or hold several locks in a defined order. The cost is that you must release it in a finally block.
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Locks {
private final Lock lock = new ReentrantLock();
private int value = 0;
void update() {
lock.lock();
try {
int current = value;
Thread.sleep(1);
value = current + 1;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
Locks shared = new Locks();
Thread[] threads = new Thread[4];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(shared::update);
}
for (Thread t : threads) {
t.start();
}
for (Thread t : threads) {
t.join();
}
System.out.println("expected 20000, got " + shared.value);
}
}expected 20000, got 4That finally is not optional. If the body of the critical section throws, the lock would stay held forever and every other thread would block permanently. The same applies to the interrupt handling: the catch block restores the interrupt flag so the cancellation is not lost.
Which mechanism should you use?
| Need | Tool |
|---|---|
| Protect a compound operation | synchronized or a Lock |
| Publish a value to other threads | volatile |
| Update one number atomically | AtomicInteger and friends |
| Try without blocking | tryLock |
| Many readers, few writers | ReadWriteLock or a concurrent collection |
The habits to keep
- Keep the critical section as small as you can - lock for the minimum time.
- Never call slow code such as I/O while holding a lock.
- Always release a
Lockin afinallyblock. - Use one monitor for one piece of shared data.
- Prefer concurrent collections and atomics over hand-rolled locking for simple cases.
- Test concurrent code many times - a race that passes once can still be there.
Try it yourself: write a class with a plain int field and a method that increments it. Run four threads against it and print the result. Now add synchronized to the method and run it again. Then replace the field with an AtomicInteger and use incrementAndGet instead. Run all three versions ten times each and note how the plain version varies while the other two do not.