Synchronisation & thread safety
Race conditions, synchronized, volatile, atomics, locks, concurrent collections and deadlocks.
In the last lesson, threads ran happily in parallel because they didn't share anything. The moment several threads read and write the same data, things can go wrong in ways that are hard to reproduce and debug. This lesson explains why, and shows the tools Java provides to write thread-safe code.
A race condition#
Two threads each increment a shared counter 100,000 times. We expect 200,000:
A typical run (the number changes each time):
count++ looks like one operation, but it's three: read count, add one, write it back. If both threads read 41 at the same moment, both write 42, and one increment is lost:
A race condition is when correctness depends on the timing of threads. Code that behaves correctly under concurrent use is thread-safe.
Two problems: atomicity and visibility#
- Atomicity: a compound action (read-modify-write, check-then-act) must happen as one indivisible step.
- Visibility: CPUs cache values and compilers reorder instructions, so a write by one thread may not be seen by another thread, possibly ever, unless there is a happens-before relationship (created by locks,
volatile, thread start/join, and so on).
synchronized: mutual exclusion#
Every Java object has an intrinsic lock (monitor). A synchronized block or method lets only one thread at a time hold that lock and run the protected code. It also guarantees visibility: everything written before releasing the lock is visible to the next thread that acquires it.
Synchronized blocks let you lock on a specific object and keep the critical section small:
synchronizedinstance methods lock onthis;static synchronizedmethods lock on theClassobject.- Locks are reentrant: a thread already holding a lock can enter other blocks guarded by the same lock.
- Every access to shared mutable state, reads included, must use the same lock.
- Keep critical sections short. Never do slow I/O while holding a lock if you can avoid it.
Check-then-act needs locking too#
Three withdrawals of 30 succeed; the other seven are refused.
volatile: visibility for simple flags#
A volatile field is always read from and written to main memory, so all threads see the latest value. It's ideal for a stop flag written by one thread and read by another:
Without volatile, the JIT compiler may hoist the read out of the loop, and the worker can spin forever. But volatile does not make count++ atomic. Use it only when each write is independent of the current value.
Atomic variables#
java.util.concurrent.atomic provides lock-free, thread-safe single variables using CPU compare-and-swap instructions:
Common operations: get, set, incrementAndGet, getAndIncrement, addAndGet, compareAndSet(expected, new), updateAndGet(fn). For counters and statistics, atomics are simpler and usually faster than locks.
Explicit locks: ReentrantLock#
java.util.concurrent.locks offers locks with features synchronized lacks: try-with-timeout, interruptible waits, fairness, and separate read/write locks:
The lock() / try / finally { unlock(); } pattern is mandatory. Forget the finally and an exception leaves the lock held forever. ReentrantReadWriteLock allows many concurrent readers but exclusive writers, which is useful for read-heavy caches.
Thread-safe collections#
ArrayList and HashMap are not thread-safe: concurrent modification can corrupt them. Use the concurrent collections instead:
ConcurrentHashMap's merge, compute and putIfAbsent are atomic. But if (!map.containsKey(k)) map.put(k, v) is still a check-then-act race; use putIfAbsent or computeIfAbsent instead.
Producer-consumer with a BlockingQueue
The queue handles all the locking and waiting. This is far simpler and safer than low-level wait()/notify(), which you should rarely need to write yourself.
Deadlock#
A deadlock happens when threads wait for each other's locks forever:
Prevention:
- Lock ordering: always acquire multiple locks in the same global order (e.g. by account ID).
- Use
tryLockwith a timeout and back off. - Hold as few locks as possible, for as short a time as possible.
- Diagnose a hung program with
jstack <pid>, which reports detected deadlocks.
Strategies for thread safety (best first)#
- Don't share: keep data confined to one thread (local variables are always thread-safe).
- Share immutable objects: records,
String,List.of(...)need no locking at all. - Use thread-safe library classes: atomics,
ConcurrentHashMap,BlockingQueue, executors. - Lock carefully with
synchronizedorReentrantLockwhen you must share mutable state.
Common mistakes#
- Synchronising writes but not reads.
- Using different locks to guard the same data.
- Thinking
volatilemakes compound operations atomic. - Using
HashMap/ArrayListfrom multiple threads. - Calling
unlock()outsidefinally. - Locking on publicly accessible objects or on
Stringliterals, which other code may also lock.
What's next#
Creating and coordinating threads by hand is error-prone. Next, ExecutorService and CompletableFuture let you submit tasks to thread pools, get results back, compose asynchronous pipelines, and use Java 21's virtual threads.
Check your understanding
Quick quiz
1.Why is
count++on a sharedintnot thread-safe?2.What does
volatileguarantee?3.Thread 1 holds lock A and waits for lock B; thread 2 holds lock B and waits for lock A. What is this?
Finished reading?
Mark this lesson complete to track your progress.