What is Synchronisation?
Synchronisation is the set of mechanisms that control how threads access shared state and coordinate with each other. Without it, concurrent reads and writes produce undefined behaviour: lost updates, stale reads, and corrupted data structures.
Java's concurrency toolkit provides tools at two levels: mutual
exclusion (only one thread at a time can access a resource โ
locks, synchronized, atomics) and thread
coordination (threads signal each other about work completion,
resource availability, or phase boundaries โ latches, barriers, semaphores).
Knowing which tool fits which problem is what separates a concurrent
programmer from someone who sprinkles synchronized everywhere
and hopes for the best.
/*
* Synchronisation tool selection guide:
*
* Problem Tool
* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
* Protect shared mutable state synchronized / ReentrantLock
* Read-heavy, write-rare state ReadWriteLock / StampedLock
* Single variable, simple ops AtomicInteger / AtomicReference
* One-time gate (N threads wait) CountDownLatch
* Reusable phase barrier CyclicBarrier
* Limit concurrent access (N slots) Semaphore
* Exchange data between two threads Exchanger
* Visibility only, no atomicity volatile
*/
This page assumes you understand race conditions, synchronized,
volatile, and AtomicInteger. Those are covered
in Multithreading & Concurrency.
Here we go deeper: advanced lock types, coordination primitives, and the
Java Memory Model.
ReentrantLock โ When synchronized Is Not Enough
synchronized is simple and sufficient for most cases. Switch to
ReentrantLock when you need: timed locking, interruptible
waiting, fairness, multiple conditions, or lock inspection.
class BoundedBuffer<T> {
private final Queue<T> queue = new ArrayDeque<>();
private final int capacity;
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
public BoundedBuffer(int capacity) { this.capacity = capacity; }
public void put(T item) throws InterruptedException {
lock.lock();
try {
while (queue.size() == capacity) notFull.await(); // releases lock while waiting
queue.add(item);
notEmpty.signal(); // wake one consumer
} finally {
lock.unlock(); // ALWAYS in finally
}
}
public T take() throws InterruptedException {
lock.lock();
try {
while (queue.isEmpty()) notEmpty.await();
T item = queue.poll();
notFull.signal(); // wake one producer
return item;
} finally {
lock.unlock();
}
}
}
// Two separate Conditions mean producers only wake producers-that-are-waiting,
// and consumers only wake consumers-that-are-waiting โ no spurious wakeups of wrong side.
// tryLock with timeout โ avoids blocking forever, prevents some deadlocks
if (lock.tryLock(100, TimeUnit.MILLISECONDS)) {
try { doWork(); }
finally { lock.unlock(); }
} else {
metrics.increment("lock.timeout");
throw new ServiceUnavailableException("Resource contended");
}
// lockInterruptibly โ gives up if Thread.interrupt() is called
try {
lock.lockInterruptibly(); // throws InterruptedException if interrupted while waiting
try { doWork(); } finally { lock.unlock(); }
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
// graceful shutdown: task was cancelled while waiting for lock
}
// Fair lock: threads acquire in FIFO order of waiting โ prevents starvation
// Cost: lower throughput due to OS scheduling overhead
ReentrantLock fairLock = new ReentrantLock(true); // fair=true
ReadWriteLock and StampedLock
When reads are far more frequent than writes, a single exclusive lock is wasteful โ readers block each other unnecessarily. Read-write locks allow multiple concurrent readers OR one exclusive writer, never both at the same time.
ReentrantReadWriteLock
class ConfigCache {
private final Map<String, String> config = new HashMap<>();
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock read = rwLock.readLock();
private final Lock write = rwLock.writeLock();
// Many threads can read simultaneously
public String get(String key) {
read.lock();
try { return config.get(key); }
finally { read.unlock(); }
}
// Only one writer at a time โ blocks all readers during write
public void reload(Map<String, String> newConfig) {
write.lock();
try {
config.clear();
config.putAll(newConfig);
} finally { write.unlock(); }
}
}
StampedLock โ optimistic reads (Java 8+)
// StampedLock goes further: optimistic reads require NO lock acquisition.
// Assume no writer is active, read, then validate. If invalidated, fall back to read lock.
// Best throughput for read-heavy workloads.
class Point {
private double x, y;
private final StampedLock sl = new StampedLock();
public double distanceFromOrigin() {
// Optimistic read: no lock, just a stamp
long stamp = sl.tryOptimisticRead();
double cx = x, cy = y;
// Validate: was a write happening while we read? If yes, retry with real lock.
if (!sl.validate(stamp)) {
stamp = sl.readLock();
try { cx = x; cy = y; }
finally { sl.unlockRead(stamp); }
}
return Math.sqrt(cx * cx + cy * cy);
}
public void move(double deltaX, double deltaY) {
long stamp = sl.writeLock();
try { x += deltaX; y += deltaY; }
finally { sl.unlockWrite(stamp); }
}
}
/*
* Performance hierarchy (read-heavy workloads, many threads):
* StampedLock (optimistic) > ReentrantReadWriteLock > ReentrantLock > synchronized
*
* Caveat: StampedLock is NOT reentrant and has no Condition support.
* Don't use it if you need either of those.
*/
Thread Coordination Primitives
These tools don't protect data โ they coordinate when threads can proceed. Choosing the right one makes intent explicit and avoids ad-hoc flag-and-notify patterns.
CountDownLatch โ one-time gate
// N threads (or events) must complete before others proceed.
// Count goes down to zero; cannot be reset.
// Pattern 1: wait for N services to initialise before serving traffic
int serviceCount = 3;
CountDownLatch ready = new CountDownLatch(serviceCount);
List.of("db", "cache", "kafka").forEach(name ->
executor.submit(() -> {
initialise(name);
ready.countDown(); // signal: this service is up
})
);
ready.await(); // main thread blocks until all 3 services are ready
// ready.await(30, TimeUnit.SECONDS); timeout variant
startServingTraffic();
// Pattern 2: starting gun โ release N workers simultaneously
// (removes warm-up variation in benchmarks)
CountDownLatch startGun = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(workerCount);
workers.forEach(w -> executor.submit(() -> {
startGun.await(); // wait for the gun
w.run();
done.countDown(); // signal completion
}));
startGun.countDown(); // release all workers simultaneously
done.await(); // wait for all to finish
CyclicBarrier โ reusable phase synchronisation
// All threads must reach the barrier before any can proceed.
// Unlike CountDownLatch, CyclicBarrier resets and can be reused.
// Classic use: parallel computation in phases
int workers = 4;
CyclicBarrier barrier = new CyclicBarrier(workers, () -> {
// Barrier action: runs once when all threads arrive (on the last thread)
mergePartialResults();
log.info("Phase complete, next phase starting");
});
for (int i = 0; i < workers; i++) {
final int partition = i;
executor.submit(() -> {
for (int phase = 0; phase < 3; phase++) {
processPartition(partition, phase);
barrier.await(); // wait for all others to finish this phase
// barrier resets automatically โ all 4 threads proceed to next phase
}
});
}
Semaphore โ bounded concurrency
// Semaphore holds N permits. acquire() takes one; release() returns one.
// Use to limit concurrent access to a shared resource.
// Pattern: connection pool with max 10 concurrent connections
Semaphore permits = new Semaphore(10);
public Response callExternalApi(Request req) {
if (!permits.tryAcquire(500, TimeUnit.MILLISECONDS)) {
throw new TooManyRequestsException("API rate limit reached");
}
try {
return httpClient.send(req);
} finally {
permits.release(); // always return the permit
}
}
// Pattern: rate limiting โ allow 5 requests per second
// (use with fixed delay reset to restore permits each second)
// Binary semaphore (1 permit) as an unfair mutex โ
// unlike ReentrantLock, can be released from a DIFFERENT thread
Semaphore mutex = new Semaphore(1);
Exchanger โ hand off between two threads
// Two threads meet at an exchange point and swap objects.
// Classic use: double-buffer producer-consumer (fill one buffer while other is consumed)
Exchanger<List<Event>> exchanger = new Exchanger<>();
// Producer thread: fills a buffer, then swaps with consumer
executor.submit(() -> {
List<Event> buffer = new ArrayList<>();
while (true) {
buffer.add(readNextEvent());
if (buffer.size() == 100) {
buffer = exchanger.exchange(buffer); // swap with consumer's empty buffer
buffer.clear();
}
}
});
// Consumer thread: processes the full buffer, returns empty one
executor.submit(() -> {
List<Event> buffer = new ArrayList<>();
while (true) {
buffer = exchanger.exchange(buffer); // receive full buffer, give back empty
buffer.forEach(this::processEvent);
}
});
Java Memory Model โ The Foundation
The Java Memory Model (JMM) defines when a write by one thread becomes visible to another. Without visibility guarantees, the JVM and CPU can reorder instructions, cache values in registers, or defer writes โ all for performance. The JMM establishes happens-before rules that constrain this reordering.
/*
* HAPPENS-BEFORE (hb): if action A hb action B, then
* B is guaranteed to see all writes performed by A.
*
* Key happens-before relationships:
*
* 1. Monitor lock:
* unlock(m) hb lock(m)
* Any write before unlock is visible to any thread that subsequently locks m.
*
* 2. volatile:
* write(v) hb read(v)
* A volatile write is visible to all subsequent reads of that variable.
*
* 3. Thread start:
* start(t) hb any action in t
* Everything done before t.start() is visible inside thread t.
*
* 4. Thread join:
* all actions in t hb t.join()
* Everything done in t is visible to whoever calls t.join().
*
* 5. Object construction:
* constructor end hb finalizer start
* final fields: safely published after constructor completes.
*
* WITHOUT a hb relationship, you have no visibility guarantee,
* regardless of what the code looks like.
*/
Safe publication โ sharing objects between threads
// โ Unsafe publication: another thread may see partial construction
class Holder {
int value;
Holder(int v) { this.value = v; }
}
Holder holder; // shared field โ no synchronisation
// Thread A:
holder = new Holder(42); // Thread B may see: null, or Holder with value=0
// โ
Safe publication mechanisms:
// 1. volatile field
volatile Holder holder = new Holder(42);
// 2. static initialiser (class loading is thread-safe)
static final Holder HOLDER = new Holder(42);
// 3. Immutable object: all fields final โ safely published via any mechanism
record ImmutableHolder(int value) {}
// 4. ConcurrentHashMap, BlockingQueue, AtomicReference โ their puts/offers
// establish happens-before for whoever reads the value later
// 5. Synchronised access on the same monitor
The double-checked locking pitfall (and fix)
// โ Broken: the JMM allows object = new Singleton() to be reordered as:
// 1. allocate memory
// 2. assign reference to 'object' โ other threads see non-null before step 3
// 3. initialise fields
class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null)
instance = new Singleton(); // โ broken without volatile
}
}
return instance;
}
}
// โ
Fix 1: volatile prevents the reordering
private static volatile Singleton instance;
// โ
Fix 2: Initialization-on-demand holder โ better, no volatile needed
class Singleton {
private static class Holder {
static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() { return Holder.INSTANCE; }
}
// Class loading of Holder is lazy and thread-safe โ JVM guarantees it.
Common Pitfalls
// โ Different threads synchronise on DIFFERENT String instances
// String.intern() might help but never rely on it
synchronized ("shared") { ... } // โ string literals ARE interned, but
synchronized (new String("s")) { ... } // โ different objects = no mutual exclusion
// โ Boolean wrapper: only two instances exist โ accidental sharing
Boolean flag = true;
synchronized (flag) { ... } // โ any code using Boolean.TRUE shares this lock
// โ
Always use a dedicated private final Object as lock
private final Object lock = new Object();
synchronized (lock) { ... } // โ
private, can't be accidentally shared
// โ Calling unknown external code while holding a lock
// The external code may: acquire another lock (โ deadlock risk),
// spawn threads that try to acquire your lock, or run for a long time.
synchronized (this) {
listener.onEvent(event); // โ don't know what listener does
database.save(entity); // โ I/O inside a lock โ terrible for performance
}
// โ
Collect what you need under the lock, act outside it
List<EventListener> snapshot;
synchronized (this) {
snapshot = List.copyOf(listeners); // defensive copy, fast
}
snapshot.forEach(l -> l.onEvent(event)); // โ
outside lock
// โ Spurious wakeups: threads can wake without notify()
// Always re-check the condition in a while loop
synchronized (this) {
if (queue.isEmpty()) wait(); // โ may wake spuriously, queue still empty
process(queue.poll());
}
// โ
while loop re-checks the condition
synchronized (this) {
while (queue.isEmpty()) wait(); // โ
keep waiting until genuinely non-empty
process(queue.poll());
}
Interview Questions
Q: What is the difference between CountDownLatch and CyclicBarrier?
CountDownLatch: one or more threads wait for N events (or threads)
to complete. The count only goes down; once at zero it cannot be reset. Use for
one-time events: service startup, test coordination. CyclicBarrier:
N threads all wait for each other to reach a point, then all proceed together.
Resets automatically for the next phase. Use for iterative parallel algorithms
where threads must synchronise between phases.
Q: What does a Semaphore do?
A semaphore maintains N permits. acquire() takes one permit
(blocking if none available); release() returns one. It limits
the number of threads concurrently accessing a resource โ a connection pool,
rate limiter, or any shared resource with fixed capacity. Unlike a lock, a
semaphore can be released from a different thread than the one that acquired it.
Q: When would you use ReentrantLock over synchronized?
When you need features synchronized doesn't provide:
tryLock() (non-blocking attempt), tryLock(timeout)
(bounded wait), lockInterruptibly() (give up on interrupt),
fair ordering (new ReentrantLock(true)), or multiple
Condition objects per lock for fine-grained wait/notify. For
simple mutual exclusion, prefer synchronized โ it's simpler and
the JVM can optimise it (biased locking, lock elision).
Q: What is the Java Memory Model and what does happens-before mean?
The JMM is the specification that defines which writes are visible to which
reads across threads. The JVM and CPU can reorder instructions freely unless
constrained by happens-before rules. A happens-before (hb) relationship between
action A and B guarantees: all effects of A are visible to B. Key hb sources:
monitor unlock โ subsequent lock on the same monitor; volatile write โ subsequent
read of the same variable; Thread.start() โ any action in the
started thread; Thread.join() โ any action after join returns.
Without hb, there is no visibility guarantee regardless of what the code looks
like โ even if a thread writes and another reads immediately after.
Q: What is the difference between StampedLock and ReentrantReadWriteLock?
ReentrantReadWriteLock: readers share a lock; writers get
exclusive access; readers block writers but not each other. Under extreme
read-heavy workloads, writers can starve. StampedLock adds
optimistic reads: no lock acquisition at all โ just take a stamp,
read, validate. If a write happened during the read, retry with a real read
lock. This can give significantly higher throughput for read-dominated
workloads. Caveats: StampedLock is NOT reentrant, has no Condition support,
and its API is more complex. Don't use it unless benchmarks show a bottleneck
with ReentrantReadWriteLock.
Q: Why is double-checked locking broken without volatile, and what
is the correct singleton pattern?
The JMM permits the JVM to reorder steps of object construction: a reference
can be assigned to the field before the constructor body completes. Thread B
can observe a non-null reference but read uninitialised fields. Adding
volatile prevents this specific reordering โ a volatile write
establishes happens-before for all subsequent reads. The cleanest solution
avoids volatile entirely: the initialization-on-demand holder pattern
uses a private static nested class. Class initialisation is performed by the
JVM under a class-level lock, and the initialised state is safely published
to all threads through the class loading happens-before guarantee. No
synchronized, no volatile, lazy, and correct.