[THREADING 101] 04 – Locks and Conditions

[

Why Explicit Locks?

The synchronized keyword handles most locking needs, but it has rigid semantics. The java.util.concurrent.locks package provides explicit lock implementations with richer capabilities:

  • Try-lock — attempt to acquire without blocking forever
  • Timed lock — give up after a timeout
  • Interruptible lock — can be interrupted while waiting
  • Fairness — first-come-first-served ordering
  • Multiple conditions — separate wait sets for different events
  • Non-block-structured — lock and unlock in different scopes

ReentrantLock

The most commonly used explicit lock. It works like synchronized but with explicit lock() and unlock() calls.

Basic Usage

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class SafeCounter {
    private final Lock lock = new ReentrantLock();
    private int count = 0;

    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock(); // ALWAYS in finally — guarantees release on exception
        }
    }

    public int getCount() {
        lock.lock();
        try {
            return count;
        } finally {
            lock.unlock();
        }
    }
}

Why “Reentrant”?

A reentrant lock can be acquired multiple times by the same thread without deadlocking:

lock.lock();        // hold count = 1
lock.lock();        // hold count = 2 (same thread — allowed)
// ... work ...
lock.unlock();      // hold count = 1
lock.unlock();      // hold count = 0 — fully released

This is essential when a synchronized method calls another synchronized method on the same object. Without reentrancy, the thread would deadlock on itself.

Fair vs Unfair Locks

Lock unfairLock = new ReentrantLock();       // Default: unfair
Lock fairLock = new ReentrantLock(true);     // Fair: FIFO ordering
AspectUnfair (default)Fair
ThroughputHigherLower (10-100x slower under contention)
Starvation possible?YesNo
OrderingNo guaranteeFIFO
Use whenPerformance matters, starvation unlikelyAll threads must get equal access

Fair locks guarantee that the longest-waiting thread gets the lock next, but at a significant performance cost because every acquisition requires checking the queue.

tryLock — Non-Blocking Acquisition

if (lock.tryLock()) {
    try {
        // Critical section — lock acquired
    } finally {
        lock.unlock();
    }
} else {
    // Lock was not available — do fallback logic
    System.out.println("Could not acquire lock, skipping operation");
}

tryLock with Timeout

try {
    if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
        try {
            // Got the lock within 500ms
        } finally {
            lock.unlock();
        }
    } else {
        // Timed out — lock was not available within 500ms
    }
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
}

lockInterruptibly — Interruptible Waiting

try {
    lock.lockInterruptibly(); // Can be interrupted while waiting
    try {
        // Critical section
    } finally {
        lock.unlock();
    }
} catch (InterruptedException e) {
    // Thread was interrupted while waiting for the lock
    Thread.currentThread().interrupt();
}

This is useful for implementing cancellable operations. With synchronized, a thread waiting for a monitor lock cannot be interrupted.

Monitoring and Diagnostics

ReentrantLock exposes state information useful for building watchdog services:

ReentrantLock lock = new ReentrantLock();

lock.getHoldCount();          // How many times current thread holds this lock
lock.isHeldByCurrentThread(); // Does the current thread own this lock?
lock.isLocked();              // Is any thread holding this lock?
lock.hasQueuedThreads();      // Are threads waiting for this lock?
lock.getQueueLength();        // How many threads are waiting?

ReentrantReadWriteLock

Separates read and write access. Multiple readers can hold the read lock simultaneously, but only one writer can hold the write lock (and no readers during a write).

import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class Cache<K, V> {
    private final Map<K, V> map = new HashMap<>();
    private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
    private final Lock readLock = rwLock.readLock();
    private final Lock writeLock = rwLock.writeLock();

    public V get(K key) {
        readLock.lock();
        try {
            return map.get(key);
        } finally {
            readLock.unlock();
        }
    }

    public void put(K key, V value) {
        writeLock.lock();
        try {
            map.put(key, value);
        } finally {
            writeLock.unlock();
        }
    }

    public int size() {
        readLock.lock();
        try {
            return map.size();
        } finally {
            readLock.unlock();
        }
    }
}

Lock Compatibility Matrix

Read Lock HeldWrite Lock Held
Read Lock Request✓ Allowed✗ Blocked
Write Lock Request✗ Blocked✗ Blocked

Performance Characteristics

  • Read-heavy workloads (90%+ reads): ReadWriteLock provides up to 3x performance gain over exclusive locking.
  • Write-heavy workloads: No benefit — use a regular ReentrantLock instead.
  • Lock downgrade: A thread holding the write lock can acquire the read lock, then release the write lock (keeping only the read lock). The reverse (upgrading read to write) is not supported and will deadlock.
// Lock downgrade pattern
writeLock.lock();
try {
    // Modify shared state
    map.put(key, computeValue());
    
    // Downgrade: acquire read lock before releasing write lock
    readLock.lock();
} finally {
    writeLock.unlock(); // Release write, keep read
}
try {
    // Continue with read-only access
    return map.get(key);
} finally {
    readLock.unlock();
}

StampedLock (Java 8+)

An advanced lock that adds optimistic reading — readers don’t actually acquire a lock, they just check if a write occurred:

import java.util.concurrent.locks.StampedLock;

public class Point {
    private double x, y;
    private final StampedLock lock = new StampedLock();

    public void move(double deltaX, double deltaY) {
        long stamp = lock.writeLock();
        try {
            x += deltaX;
            y += deltaY;
        } finally {
            lock.unlockWrite(stamp);
        }
    }

    public double distanceFromOrigin() {
        // Optimistic read — no lock acquired
        long stamp = lock.tryOptimisticRead();
        double currentX = x;
        double currentY = y;
        
        // Check if a write happened during our read
        if (!lock.validate(stamp)) {
            // Write occurred — fall back to regular read lock
            stamp = lock.readLock();
            try {
                currentX = x;
                currentY = y;
            } finally {
                lock.unlockRead(stamp);
            }
        }
        
        return Math.sqrt(currentX * currentX + currentY * currentY);
    }
}

StampedLock Trade-offs

ProsCons
Optimistic reads avoid lock contention entirelyNot reentrant — cannot re-enter from same thread
Higher throughput than ReadWriteLock for short readsNo Condition support
Supports lock conversion (read → write)More complex API — easy to misuse
Not interruptible in default mode

Conditions

Conditions are the explicit-lock equivalent of wait()/notify(). They allow threads to wait for specific events within a lock.

public class BoundedQueue<T> {
    private final Queue<T> queue = new LinkedList<>();
    private final int capacity;
    private final Lock lock = new ReentrantLock();
    private final Condition notFull = lock.newCondition();
    private final Condition notEmpty = lock.newCondition();

    public BoundedQueue(int capacity) {
        this.capacity = capacity;
    }

    public void put(T item) throws InterruptedException {
        lock.lock();
        try {
            while (queue.size() == capacity) {
                notFull.await(); // Wait until space is available
            }
            queue.add(item);
            notEmpty.signal(); // Signal ONE waiting consumer
        } finally {
            lock.unlock();
        }
    }

    public T take() throws InterruptedException {
        lock.lock();
        try {
            while (queue.isEmpty()) {
                notEmpty.await(); // Wait until item is available
            }
            T item = queue.poll();
            notFull.signal(); // Signal ONE waiting producer
            return item;
        } finally {
            lock.unlock();
        }
    }
}

Conditions vs wait/notify

Featurewait()/notify()Condition
Multiple wait sets per lock✗ (one per object)✓ (multiple Conditions per Lock)
Timed waitwait(ms)await(time, unit), awaitNanos(), awaitUntil(Date)
Interruptible✓ + awaitUninterruptibly()
Signal specificitynotify() picks randomsignal() picks random from THIS Condition
Spurious wakeupsPossiblePossible — always use while loop

Key Condition Methods

MethodDescription
await()Release lock, sleep until signal or interrupt
awaitUninterruptibly()Like await but ignores interrupts
awaitNanos(long nanos)Timed await, returns remaining nanos
await(long time, TimeUnit unit)Timed await
awaitUntil(Date deadline)Wait until absolute time
signal()Wake up one waiting thread
signalAll()Wake up all waiting threads

Critical Rule

signal() will only reach the awaiting thread after the signalling thread releases the lock:

lock.lock();
try {
    // Modify shared state
    dataReady = true;
    condition.signal(); // Queues the wakeup — doesn't deliver yet
} finally {
    lock.unlock(); // NOW the awaiting thread can actually wake up
}

Watchdog Pattern

For production systems, implement a lock monitoring service that detects hung threads:

public class LockWatchdog {
    private final ScheduledExecutorService scheduler = 
        Executors.newSingleThreadScheduledExecutor();
    private final Map<ReentrantLock, Long> lockAcquisitionTimes = 
        new ConcurrentHashMap<>();
    private final long maxHoldTimeMs;

    public LockWatchdog(long maxHoldTimeMs) {
        this.maxHoldTimeMs = maxHoldTimeMs;
        scheduler.scheduleAtFixedRate(this::checkLocks, 1, 1, TimeUnit.SECONDS);
    }

    public void registerLock(ReentrantLock lock) {
        lockAcquisitionTimes.put(lock, System.currentTimeMillis());
    }

    public void unregisterLock(ReentrantLock lock) {
        lockAcquisitionTimes.remove(lock);
    }

    private void checkLocks() {
        long now = System.currentTimeMillis();
        lockAcquisitionTimes.forEach((lock, acquiredAt) -> {
            if (now - acquiredAt > maxHoldTimeMs && lock.isLocked()) {
                System.err.println("WARNING: Lock held for " + 
                    (now - acquiredAt) + "ms — possible deadlock");
                // Log thread info, send alert, etc.
            }
        });
    }
}

Choosing the Right Lock

Summary

  • ReentrantLock: The go-to explicit lock. Use when you need tryLock, timeout, fairness, or conditions.
  • ReentrantReadWriteLock: Use when reads vastly outnumber writes (3x speedup possible).
  • StampedLock: Use for ultra-high read throughput with optimistic reads. Not reentrant.
  • Conditions: The explicit-lock version of wait/notify, with support for multiple wait sets per lock.
  • Always unlock in a finally block. Always.
  • Fair locks prevent starvation but cost throughput.

References

About the author

Maksim

I build AI-powered products and lead engineering teams. I've launched platforms from zero to millions of users and learned most lessons the hard way. I write about the gap between engineering theory and practice, what actually matters when building products, and the decisions that shape teams and systems.

Add Comment

By Maksim

Maksim

Get in touch

Reach out if you want to discuss engineering leadership, collaborate on something interesting, or suggest topics you'd like me to write about.