Synchronization & Locks — Choosing Your Weapon

Locks are how you build correctness back on top of an untrusted memory model. Java gives you at least four flavors, and picking the wrong one is either a bug or a performance disaster. This file walks the whole family: intrinsic monitors, explicit locks, read/write locks, and stamped locks — plus how to not deadlock while using them.

The underlying model is the same in every case: a thread acquires exclusive (or shared) access to a critical section, does its work, and releases. Everything else is ergonomics and specialization.


1. synchronized — The Intrinsic Lock

Every Java object carries an intrinsic monitor. synchronized acquires it.

public synchronized void transfer(Account other, BigDecimal amount) {
    // locks THIS object's monitor
}

public static synchronized void log(String msg) {
    // locks the Class object's monitor (NOT `this` — static methods have no `this`)
}

public void update() {
    synchronized (lock) {          // explicit monitor object
        // ...
    }
}

Properties:

  • Reentrant. A thread holding a monitor can re-enter any synchronized block on the same object without deadlocking itself.

  • Uncancellable. No tryLock variant. If someone holds it, you wait. Forever.

  • No fairness control. Wakeup order on notify is undefined.

  • Bytecode-level. monitorenter and monitorexit opcodes. The JIT can biased-lock, thin-lock, or inflate to heavyweight based on contention.

  • Structured. Lock and unlock are automatically paired with the enclosing block. You cannot leak a lock.

Best used for: short, simple critical sections where you don’t need advanced features.

⚠️ Java 21+ virtual thread pitfall: synchronized blocks pin the virtual thread to its carrier while held. In Java 21-23, this can starve your carrier pool. Java 24 (JEP 491) fixes this for most cases. If your code runs on Java 21-23 and uses virtual threads, prefer ReentrantLock for any block that performs I/O.


2. ReentrantLock — Explicit and Flexible

java.util.concurrent.locks.ReentrantLock is synchronized with knobs.

private final ReentrantLock lock = new ReentrantLock();

public void update() {
    lock.lock();
    try {
        // critical section
    } finally {
        lock.unlock();  // ALWAYS in finally, ALWAYS.
    }
}

What it adds over synchronized:

Feature

Method

Try without blocking

tryLock() — returns immediately

Try with timeout

tryLock(long, TimeUnit)

Interruptible acquisition

lockInterruptibly()

Fairness

new ReentrantLock(true) — FIFO order

Multiple condition variables

lock.newCondition()

No pinning of virtual threads

Uses LockSupport.park, integrates with the Loom scheduler

When to reach for it:

  • You need tryLock — e.g., “if I can’t get the lock in 100ms, degrade gracefully.”

  • You need to hold a lock across method boundaries (rare, dangerous, but legal).

  • You need multiple Condition variables on the same lock (see §6).

  • You are on Java 21-23 and want to avoid virtual-thread pinning.

Fairness cost: a fair ReentrantLock is roughly 10-100× slower under contention than an unfair one, because it disables barging. Only turn it on when starvation is a real, observed problem.


3. ReadWriteLock — Concurrent Readers, Exclusive Writers

When reads vastly outnumber writes, an exclusive lock wastes throughput. ReentrantReadWriteLock maintains two locks internally: many readers OR one writer.

private final ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
private final Lock readLock  = rw.readLock();
private final Lock writeLock = rw.writeLock();

public Value get(Key k) {
    readLock.lock();
    try { return map.get(k); }
    finally { readLock.unlock(); }
}

public void put(Key k, Value v) {
    writeLock.lock();
    try { map.put(k, v); }
    finally { writeLock.unlock(); }
}

Rules:

  • Multiple threads can hold the read lock simultaneously.

  • Only one thread can hold the write lock, and no readers hold theirs during that time.

  • Downgrade is legal: hold write, acquire read, release write. Cheap.

  • Upgrade is NOT legal: hold read, try to acquire write — deadlocks against yourself. Release the read lock first.

When it wins: read:write ratio > ~10:1, and each read section is non-trivial (> a few hundred ns). For very short critical sections, the extra bookkeeping of RW locks makes them slower than plain ReentrantLock. Benchmark before assuming.

When to skip it: if your data structure is available as a concurrent variant (ConcurrentHashMap, ConcurrentSkipListMap), use that. It’s lock-free or fine-grained and will beat a big RW lock every time.


4. StampedLock — Optimistic Reading (Advanced)

StampedLock (Java 8+) adds a third mode: optimistic read. You get a stamp cheaply, read the fields, then validate the stamp. If validation fails, you fall back to a real read lock.

private final StampedLock sl = new StampedLock();
private double x, y;

public double distanceFromOrigin() {
    long stamp = sl.tryOptimisticRead();
    double localX = x, localY = y;
    if (!sl.validate(stamp)) {         // a writer touched us; retry with real lock
        stamp = sl.readLock();
        try {
            localX = x;
            localY = y;
        } finally {
            sl.unlockRead(stamp);
        }
    }
    return Math.sqrt(localX * localX + localY * localY);
}

Properties:

  • Not reentrant. Do not re-enter.

  • Optimistic reads don’t block writers. This is the whole point.

  • Excellent for read-heavy short critical sections where you read a small number of fields and the write path is rare.

⚠️ Warning: StampedLock is easy to misuse. The stamp is a long primitive that must be threaded correctly. If you make a mistake, you get a silent race, not a compile error. Only use it after profiling shows ReentrantReadWriteLock is a bottleneck, and cover it with jcstress tests.


5. Deadlock — The Bug That Ends Careers

Deadlock occurs when threads acquire locks in different orders. Textbook example:

// Thread 1:
synchronized (accountA) {
    synchronized (accountB) { /* transfer */ }
}

// Thread 2:
synchronized (accountB) {
    synchronized (accountA) { /* transfer */ }
}

Thread 1 holds A, waits for B. Thread 2 holds B, waits for A. Nothing moves.

Prevention playbook:

  1. Global lock ordering. Impose a total order on lockable resources (e.g., by System.identityHashCode, or by a stable ID) and always acquire in that order.

    Account first  = a.id() < b.id() ? a : b;
    Account second = a.id() < b.id() ? b : a;
    synchronized (first) { synchronized (second) { /* transfer */ } }
    
  2. Lock with timeout. tryLock(long, TimeUnit) — if you can’t get it in bounded time, back off and retry. Prevents deadlock in exchange for possible livelock.

  3. Reduce lock scope. Hold locks for as little code as possible. Never hold a lock while calling out to unknown code (an event handler, a callback).

  4. Avoid nested locks. Two locks is where most deadlocks live. If you can’t avoid it, document the ordering next to every acquisition.

Detection at runtime: jstack <pid> prints deadlock chains automatically at the bottom. You’ll practice reading these in file 06.


6. Condition Variables — Wait/Signal Done Right

Before java.util.concurrent, you used Object.wait() / notify() / notifyAll(). These still work, but Condition from ReentrantLock is strictly better:

private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull  = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
private final Queue<T> queue = new ArrayDeque<>();
private final int capacity;

public void put(T item) throws InterruptedException {
    lock.lock();
    try {
        while (queue.size() == capacity) {
            notFull.await();               // release lock, wait
        }
        queue.add(item);
        notEmpty.signal();                 // wake one consumer
    } finally {
        lock.unlock();
    }
}

public T take() throws InterruptedException {
    lock.lock();
    try {
        while (queue.isEmpty()) {
            notEmpty.await();
        }
        T item = queue.remove();
        notFull.signal();
        return item;
    } finally {
        lock.unlock();
    }
}

The two rules of await:

  1. Always call await inside a while loop checking the condition. Never if. Spurious wakeups happen. signalAll also wakes threads whose condition isn’t yet true.

  2. You must hold the lock when calling await or signal. await releases it while waiting and reacquires it before returning.

Two conditions on one lock is the big win over Object.wait: you can wake only producers or only consumers, not both. With Object.notifyAll you wake everyone and let them sort it out — correct, but wasteful.


7. Decision Table — Which Lock When?

Situation

Reach for

Short critical section, no advanced features, Java 24+

synchronized

Same, but on Java 21-23 with virtual threads

ReentrantLock

Need tryLock, timeout, or interruptible

ReentrantLock

Read-heavy (>10:1), non-trivial reads

ReentrantReadWriteLock

Read-heavy, tiny critical sections, profiled bottleneck

StampedLock

Multiple wait conditions on same shared state

ReentrantLock + multiple Conditions

Shared map/queue

Concurrent collection, no lock at all

Read-only shared state

Immutability, no lock at all


⚠️ What Most People Get Wrong

They lock too much (huge critical sections that serialize the whole app) and too little (missing the compound invariant). They put unlock() outside a finally — an exception in the middle of the critical section leaks the lock and the next acquirer blocks forever. They synchronize on a Long or a String interned constant, which “works” until someone else in the same JVM synchronizes on the same interned instance and deadlocks with them. Always lock on a private final Object created solely as a lock.

And the biggest one: they choose synchronized by habit instead of by fit. Modern Java gives you five options for a reason.


Return to README.md · Next: 03_executors_and_thread_pools.md