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
synchronizedblock on the same object without deadlocking itself.Uncancellable. No
tryLockvariant. If someone holds it, you wait. Forever.No fairness control. Wakeup order on
notifyis undefined.Bytecode-level.
monitorenterandmonitorexitopcodes. 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 |
|
Try with timeout |
|
Interruptible acquisition |
|
Fairness |
|
Multiple condition variables |
|
No pinning of virtual threads |
Uses |
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
Conditionvariables 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:
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 */ } }
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.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).
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:
Always call
awaitinside awhileloop checking the condition. Neverif. Spurious wakeups happen.signalAllalso wakes threads whose condition isn’t yet true.You must hold the lock when calling
awaitorsignal.awaitreleases 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+ |
|
Same, but on Java 21-23 with virtual threads |
|
Need |
|
Read-heavy (>10:1), non-trivial reads |
|
Read-heavy, tiny critical sections, profiled bottleneck |
|
Multiple wait conditions on same shared state |
|
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