Synchronisation Mechanisms

Locks, latches, barriers, semaphores, and the Java Memory Model

← Back to Index

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
 */
Prerequisite: race conditions and basic locks

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

Synchronising on the wrong object
// โŒ 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 alien methods inside synchronized blocks
// โŒ 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
Condition.wait() without a loop
// โŒ 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

๐ŸŽ“ Junior level

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).

๐Ÿ”ฅ Senior level

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.