Why this matters
- The
count++race is the canonical concurrency bug, and understanding why it happens explains almost every other one. volatileis routinely used wheresynchronizedis required, which produces code that works in testing and fails under load.- Thread safety is the most common area for follow-up questions in a backend interview, because the wrong answer is so easy to give confidently.
A race condition
public class Counter {
private int count = 0;
public void increment() {
count++; // not one operation
}
public int get() {
return count;
}
}
Counter counter = new Counter();
ExecutorService pool = Executors.newFixedThreadPool(4);
for (int i = 0; i < 10_000; i++) {
pool.submit(counter::increment);
}
pool.shutdown();
pool.awaitTermination(1, TimeUnit.MINUTES);
System.out.println(counter.get()); // 9,847 — some number below 10,000
count++ compiles to three steps: read the field, add one, write it back. Two threads can read the same
value, both compute the same result, and both write it. One increment disappears.
The two problems, separately
- Atomicity. The read-modify-write sequence can be interleaved. Another thread may act between any two steps.
- Visibility. Each thread may cache the field in a CPU register or a core-local cache. A write by one thread is not guaranteed to become visible to another without a synchronisation point.
synchronized
Acquiring a monitor gives mutual exclusion and a memory barrier in one.
public class Counter {
private int count = 0;
public synchronized void increment() { // locks `this`
count++;
}
public synchronized int get() { // must also be synchronized, for visibility
return count;
}
}
What a monitor guarantees
- Mutual exclusion — only one thread holds a given monitor at a time, so the block is atomic with respect to other synchronised blocks on the same monitor.
- Visibility — releasing a monitor flushes the thread's writes; acquiring it invalidates cached reads. Everything done before the release is visible to the next acquirer.
- Reentrancy — a thread already holding a monitor can acquire it again, which is why one synchronised method may call another.
Both the read and the write must be synchronised. A synchronised increment with an unsynchronised get
fixes atomicity and leaves visibility broken.
Instance versus static locks
public class Service {
public synchronized void instanceMethod() { } // locks `this`
public static synchronized void staticMethod() { } // locks Service.class
private final Object lock = new Object();
public void narrow() {
prepare(); // not under the lock
synchronized (lock) {
critical(); // only this part is serialised
}
}
}
These are different monitors, so an instance method and a static method do not exclude each other.
volatile
volatile guarantees visibility and ordering. It does not make an operation atomic.
public class Service {
private volatile boolean running = true; // correct use: a simple flag
public void stop() {
running = false; // immediately visible to all threads
}
public void loop() {
while (running) { // re-read from memory each time
doWork();
}
}
}
Without volatile the JIT may hoist the field read out of the loop, caching true forever. The thread then
never stops — a real and reproducible bug.
private volatile int count;
public void increment() {
count++; // STILL BROKEN — volatile does not make read-modify-write atomic
}
| Aspect | Guarantee | volatile vs synchronized |
|---|---|---|
| Visibility | volatile: yes | synchronized: yes |
| Atomicity | volatile: no — only single reads and writes | synchronized: yes, for the whole block |
| Prevents reordering | volatile: yes | synchronized: yes |
| Blocks threads | volatile: never | synchronized: yes |
| Correct for | A flag, or publishing an immutable object | Any compound operation |
| Cost | A memory barrier | Barrier plus possible contention |
Visibility
Guaranteevolatile: yesvolatile vs synchronizedsynchronized: yesAtomicity
Guaranteevolatile: no — only single reads and writesvolatile vs synchronizedsynchronized: yes, for the whole blockPrevents reordering
Guaranteevolatile: yesvolatile vs synchronizedsynchronized: yesBlocks threads
Guaranteevolatile: nevervolatile vs synchronizedsynchronized: yesCorrect for
GuaranteeA flag, or publishing an immutable objectvolatile vs synchronizedAny compound operationCost
GuaranteeA memory barriervolatile vs synchronizedBarrier plus possible contention
Use volatile when one thread writes and others read. Use synchronized when anyone reads-then-writes.
Atomic classes
For single-variable compound operations, the atomic classes are faster than locking. They use a hardware compare-and-swap instruction rather than blocking.
private final AtomicInteger count = new AtomicInteger();
count.incrementAndGet(); // atomic, no lock
count.addAndGet(5);
count.compareAndSet(10, 20); // set to 20 only if currently 10
count.updateAndGet(value -> value * 2); // atomic read-modify-write with a function
private final AtomicReference<Config> config = new AtomicReference<>(initial);
config.updateAndGet(current -> current.withTimeout(30));
Compare-and-swap reads the value, computes a new one, and writes it only if the value has not changed since the read. If it has, it retries. No thread ever blocks, which is why this scales better than a lock under contention.
// For heavy concurrent counting, LongAdder beats AtomicLong
private final LongAdder requests = new LongAdder();
requests.increment();
long total = requests.sum();
LongAdder spreads increments across internal cells so threads rarely touch the same memory. AtomicLong
has every thread competing to swap one field, which becomes the bottleneck at high write rates.
ThreadLocal
Sometimes the answer is not to share at all. ThreadLocal gives each thread its own copy.
private static final ThreadLocal<SimpleDateFormat> FORMATTER =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
public String format(Date date) {
return FORMATTER.get().format(date); // each thread has its own, so no sharing
}
SimpleDateFormat is notoriously not thread-safe, and this was the standard workaround. The better modern
answer is DateTimeFormatter, which is immutable and needs no ThreadLocal at all.
void handle(Request request) {
CONTEXT.set(new RequestContext(request));
try {
process();
} finally {
CONTEXT.remove(); // mandatory on pooled threads — otherwise a leak and stale data
}
}
The hierarchy of solutions
In order of preference
- Do not share mutable state. Use locals, immutable objects, or records. This needs no synchronisation at all and is the real answer most of the time.
- Confine it to one thread, with
ThreadLocalor a single-threaded executor. - Use a concurrent collection —
ConcurrentHashMapand friends have the hard parts solved. - Use an atomic class for a single variable.
- Use a lock when an invariant spans several variables.
synchronizedfirst,ReentrantLockwhen you need its extra features.
Immutability deserves emphasis: an object that cannot change is thread-safe by construction. Publish a new
immutable snapshot through a volatile reference and you have a correct, lock-free design.
private volatile Config config = Config.initial();
public void reload(Config updated) {
this.config = updated; // one atomic reference write; readers see one or the other
}
Common misreadings
- "
volatilemakescount++safe." It makes the reads and writes visible. The sequence is still interruptible. - "Only the write needs synchronising." Readers need the same monitor, or they may never see the write.
- "
synchronizedon a getter is unnecessary." Without it, visibility is not guaranteed. - "Atomic classes replace locks." They cover one variable. An invariant across two still needs a lock.
- "
ConcurrentHashMapmakes my logic thread-safe." It makes each operation safe. Your invariants across operations are still yours. - "Race conditions always show up in testing." They depend on timing, core count and load. Passing tests prove nothing here.
- "
synchronizedis slow." Uncontended locks are heavily optimised. Contention is what costs, and the fix is a smaller critical section.
Quick recall
Everything you need if you only revisit this box.
- Shared mutable state needs atomicity and visibility.
count++is three operations, so it needs both. synchronizedgives mutual exclusion, visibility and reentrancy. Both reads and writes must be synchronised.- Instance and static
synchronizeduse different monitors. Prefer aprivate final Object lockover lockingthis. - Never lock on a
Stringliteral, a boxedInteger, or a field you reassign. volatilegives visibility and ordering, never atomicity. Correct for a flag or publishing an immutable object.- Atomic classes use compare-and-swap and never block;
LongAdderbeatsAtomicLongunder heavy write contention. Both are atomic per variable only. ThreadLocalmust beremove()d in afinallyblock on pooled threads.- Preference order: don't share → confine → concurrent collection → atomic → lock. Immutability is the strongest answer.
Test yourself
Answer these before moving on — recall is what makes it stick.