Race Conditions and Synchronization
A counter that loses count
This program looks obviously correct and is not:
class Counter {
private int count = 0;
void increment() {
count++;
}
int get() { return count; }
}
Counter counter = new Counter();
Thread a = new Thread(() -> { for (int i = 0; i < 100_000; i++) counter.increment(); });
Thread b = new Thread(() -> { for (int i = 0; i < 100_000; i++) counter.increment(); });
a.start(); b.start();
a.join(); b.join();
System.out.println(counter.get()); // expected 200000 — usually less
Run it and you get some number below 200,000, different each time.
Why
count++ is not one operation. It is three:
- Read the current value
- Add one
- Write the result back
Two threads can interleave between those steps:
thread A reads count -> 5
thread B reads count -> 5
thread A computes 6, writes -> 6
thread B computes 6, writes -> 6
Two increments happened; the count rose by one. The update from A was overwritten.
This is a race condition: the result depends on the timing of threads, and that timing is not under your control.
Almost nothing is atomic
An operation is atomic if it cannot be observed half-done. Very little is:
count++andcount += 1— not atomicx = y + 1— not atomic- Reading or writing a single
intor reference — atomic - Reading or writing a
longordouble— not guaranteed on all platforms, unless declaredvolatile
Assume any operation touching shared mutable data needs protection unless you can point to a specific rule saying otherwise.
synchronized
Marking a method synchronized means only one thread may be inside it at a time, for a given object:
class Counter {
private int count = 0;
synchronized void increment() {
count++;
}
synchronized int get() { return count; }
}
Now the total is 200,000 every time.
Every Java object has an intrinsic lock. A synchronized instance method acquires that object’s lock on entry and releases it on exit — including when an exception is thrown.
get() must be synchronized too. Locking only the writer still leaves readers able to see a stale or half-written value. Both sides of shared state need protection.
You can also lock a smaller region:
class Tracker {
private final Object lock = new Object();
private int count = 0;
void record() {
expensiveWorkNeedingNoLock();
synchronized (lock) { // only this part is protected
count++;
}
}
}
Holding a lock for less time lets more work proceed in parallel. Using a dedicated private lock object — rather than this — also prevents outside code from locking on your object and interfering.
volatile
volatile solves a different problem: visibility, not atomicity.
Without it, one thread’s write may never become visible to another. The compiler and processor are allowed to cache values in registers, so this loop can run forever even after running becomes false:
class Worker {
private boolean running = true; // BUG: not volatile
void stop() { running = false; }
void run() {
while (running) { // may never see the change
doWork();
}
}
}
Adding volatile forces reads and writes to go to main memory:
private volatile boolean running = true;
volatile does not make operations atomic
private volatile int count = 0;
void increment() {
count++; // STILL a race condition
}volatile guarantees each read sees the latest write. It does nothing about the read-modify-write gap, so count++ is still broken.
Use volatile for a simple flag written by one thread and read by others. For anything read-then-modified, you need synchronized or an atomic type.
Atomic types
For counters and similar, java.util.concurrent.atomic is simpler and faster than locking:
import java.util.concurrent.atomic.AtomicInteger;
class Counter {
private final AtomicInteger count = new AtomicInteger();
void increment() { count.incrementAndGet(); }
int get() { return count.get(); }
}
incrementAndGet performs the whole read-modify-write as one indivisible step, using a processor instruction rather than a lock.
Choosing a tool
| Situation | Use |
|---|---|
| A flag set by one thread, read by others | volatile boolean |
| A counter or accumulator | AtomicInteger / AtomicLong |
| Several fields that must change together | synchronized |
| A shared collection | A concurrent collection |
| Nothing genuinely shared | Nothing — the best option |
Deadlock
Locks introduce a failure of their own. Two threads each holding a lock the other wants will both wait forever:
// thread A
synchronized (lockOne) {
synchronized (lockTwo) { ... }
}
// thread B
synchronized (lockTwo) { // opposite order — deadlock risk
synchronized (lockOne) { ... }
}
Neither can proceed. The program does not crash; it simply stops.
Avoiding deadlock
- Always acquire multiple locks in the same order everywhere in the program. That alone prevents most deadlocks.
- Hold one lock at a time when you can.
- Never call unknown code while holding a lock — it might try to acquire another.
- Keep locked regions short.
The better approach
Locks are a way to make shared mutable state safe. The alternative is to not share mutable state.
Designs that avoid the problem
- Immutable objects. A
finalfield set in the constructor and never changed is safe to share with any number of threads, with no locking. - Confinement. Give each thread its own data and combine results at the end.
- Message passing. Use a
BlockingQueueso threads hand work to each other instead of sharing variables. - Do it on one thread. If the work fits in the main loop, that removes the entire category of bug.
Every one of these is easier to reason about than a correctly-locked shared object.
Why these bugs are so unpleasant
A race condition may appear in one run in ten thousand, vanish under a debugger, and behave differently on another machine. Tests passing does not mean the code is correct — only that the bad interleaving did not occur this time.
That is why the design-level advice above matters more than the locking mechanics. Code with nothing shared cannot race.
Common mistakes
- Synchronising the writer but not the reader.
- Expecting
volatileto make++safe. - Locking on a mutable field, so the lock object changes and threads lock different things.
- Locking on
this, letting outside code interfere. - Inconsistent lock ordering, risking deadlock.
- Using
sleepto avoid a race. It hides the bug rather than fixing it. - Assuming a passing test proves correctness.
Practice
- Write the broken counter and run it ten times with two threads doing 100,000 increments each. Record the results.
- Fix it with
synchronizedand confirm you get 200,000 every time. - Fix it instead with
AtomicIntegerand confirm the same. - Write a loop controlled by a non-volatile
booleanflag set by another thread. If it does not hang, explain why that does not prove the code correct. - Rewrite exercise 1 so each thread counts into its own local variable and the totals are added at the end. Note that no synchronisation is needed.
Hints
- Ten runs; the variation is the point.
- Both
incrementandget. incrementAndGet().- Whether it hangs depends on the JVM and machine. Working by luck is still a bug.
- This is confinement, and it is usually both the simplest and fastest answer.