Synchronization and Locks in Java
Executive Summary
Synchronization and locks in Java start with synchronized, which attaches an intrinsic lock to every object: one thread inside the region at a time, with a memory visibility guarantee. The lock is also reentrant, so a synchronized method can call another synchronized method on the same lock without self-blocking. The correct idiom for block-level locking is a private final lock object, never this and never a mutable field. After all, the lock you mean is the lock you can defend.
Meanwhile, volatile solves visibility. Without it, a thread may cache a field indefinitely and never see another thread’s write, per the Java memory model, JLS Chapter 17. With it, every read sees the latest write, and the happens-before ordering holds. volatile is for one-writer status flags and similar publication patterns, never for count++, which needs atomicity, not visibility. ReentrantLock adds what the intrinsic lock lacks: tryLock with a timeout for backpressure, interruptible waiting, and fairness, always in the lock-try-finally-unlock idiom. ReadWriteLock separates the read lock, shared by many readers, from the write lock, exclusive, for read-mostly structures. Deadlock is a circular wait between two lock pairs held in opposite orders. You fix it with a global lock ordering, acquiring locks in a consistent sequence, or with tryLock with backoff.
The escalation ladder stays the same from the threads article. Redesign for immutability first. Then use atomics for one variable, synchronized for simple invariants, and ReentrantLock when you need its powers. Above all, lock as little as you can prove correct.
synchronized: The Intrinsic Lock
Every Java object carries a monitor, an intrinsic lock that synchronized uses. A synchronized method locks the receiver, this; a synchronized block locks the object you name. One thread holds the lock at a time, and others block. Also, the entry and exit of the region are memory barriers: everything written inside is visible to whoever acquires the lock next. The guarantee is exactly what a multi-step invariant needs:
class Catalog {
private final Map<String, Book> books = new HashMap<>(); // NOT thread-safe alone
public synchronized void add(Book book) { // locks this Catalog instance
books.put(book.isbn(), book);
}
public synchronized Book find(String isbn) { // same lock: writes and reads ordered
return books.get(isbn);
}
}
Two properties make the intrinsic lock pleasant to use. First, it is reentrant. The thread holding the lock can enter other regions on the same lock, so add can call a synchronized helper without deadlocking on itself. Second, it is impossible to forget. The JVM releases the lock when the region exits, normally or by exception, so there is no unlock to leak. However, the block form buys you a smaller region and a lock of your choosing, and the choosing is where code goes wrong:
// WRONG: locking a mutable field - the field changes, the lock changes, two
// threads can be inside "the same" region holding different locks
private List<Book> books = new ArrayList<>();
public void add(Book b) { synchronized (books) { books.add(b); } }
public void replace(List<Book> fresh) { books = fresh; } // silently swaps the lock
// RIGHT: a private final lock object, invisible outside, immutable for life
class Catalog {
private final Map<String, Book> books = new HashMap<>();
private final Object lock = new Object();
public void add(Book book) {
synchronized (lock) { // the lock you mean, forever
books.put(book.isbn(), book);
}
}
}
The delta: the wrong version synchronized on data that code can reassign, so the mutual exclusion is a coincidence of timing. In contrast, the right version synchronized on a dedicated object nobody else can lock, not callers, not subclasses, not the Object class’s wait and notify machinery. Synchronizing on this in a public class has the same exposure: any caller can synchronized(yourCatalog) and interfere. The private final lock object is the idiom that ends all of that. The same reasoning applies beyond the JVM: Thread Safety in Backend Systems covers locks, atomics, and production concurrency patterns across languages.
volatile: Visibility, Not Atomicity
Why volatile exists at all is a memory-model story, and three sentences carry the practical core. The memory model lets threads cache field values in registers and CPU caches. As a result, a write by one thread may sit invisible to another thread for an arbitrarily long time. The Java memory model, JLS Chapter 17, defines happens-before. Certain actions establish ordering guarantees that make earlier writes visible to later reads. Acquiring a lock, entering synchronized, is one such action, which is why the synchronized catalog above needs no volatile anywhere. volatile makes a single field’s reads and writes participate in that ordering without any lock:
// WRONG: volatile does not fix the counter, the classic misuse
private volatile int count = 0;
void increment() { count++; } // still read, add, write: still loses updates
// RIGHT: volatile for a status flag - one writer, many readers
private volatile boolean running = true;
void stop() { running = false; } // the write is visible at once
void runLoop() {
while (running) { doWork(); } // sees the stop request promptly
}
// without volatile: this loop may cache 'running' and NEVER see the stop
In short, the flag case is the honest summary of volatile’s job: publication of a single value, one writer, readers that need the latest value promptly. It also has a second legitimate role in safe publication of immutable structures. Still, the working rule stands: volatile answers “will other threads see my write”, never “can two threads update safely”. When the answer needs to be the second one, you want atomics from the threads article or a lock from this one.
ReentrantLock: When the Intrinsic Lock Is Not Enough
ReentrantLock is the same mutual exclusion with three powers the intrinsic lock lacks, and one cost. You release it yourself, so the idiom is mandatory. The ReentrantLock API offers tryLock with a timeout, bounded waiting instead of indefinite blocking. It also offers interruptible acquisition, so you can cancel a stuck lock wait, and fairness, for the rare case where acquisition order matters. The idiom first, because it is the difference between a lock and a leak:
private final ReentrantLock lock = new ReentrantLock();
// WRONG: an exception skips the unlock, and the lock is held forever
lock.lock();
doWork(); // throws? nobody ever unlocks: every future thread blocks
lock.unlock();
// RIGHT: lock, try, finally unlock - the shape is never negotiable
lock.lock();
try {
doWork();
} finally {
lock.unlock(); // runs on every path out of the region
}
The power that changes architecture is tryLock, which converts an unbounded wait into a decision:
if (lock.tryLock(1, TimeUnit.SECONDS)) { // bounded, interruptible wait
try {
doWork();
} finally {
lock.unlock();
}
} else {
handleBusy(); // backpressure, skip, retry, log: a CHOICE, not a hang
}
With an intrinsic lock, the second thread’s options are wait indefinitely or do nothing. With tryLock, the system can degrade: skip a cache refresh, sample less often, queue for later. That single pattern, bounded waiting with an alternative path, is why you will see ReentrantLock inside frameworks and rarely inside business code. Business code rarely needs the powers, but the pattern is the difference between a stuck request and a slow one.
ReadWriteLock: Many Readers, One Writer
Code reads most shared structures far more often than it writes them, and a single lock serializes readers who could have safely shared. ReadWriteLock splits the concern. Any number of readers can hold the read lock concurrently, while the write lock is exclusive, blocking all readers and writers, per the ReadWriteLock contract:
private final ReadWriteLock lock = new ReentrantReadWriteLock();
private final Map<String, Book> books = new HashMap<>();
Book find(String isbn) {
lock.readLock().lock();
try {
return books.get(isbn); // many readers in here at once
} finally {
lock.readLock().unlock();
}
}
void add(Book book) {
lock.writeLock().lock();
try {
books.put(book.isbn(), book); // writers exclusive with everyone
} finally {
lock.writeLock().unlock();
}
}
The pattern pays off exactly when reads dominate: a catalog, a config view, a route table. It loses when writes are frequent, because read-lock churn under contention is not free. Before reaching for it, check the next article’s ConcurrentHashMap, which often gives read-mostly maps better throughput with none of the ceremony. Also, StampedLock, its optimistic reads, is the specialist tool when profiling says you need it.
Deadlock: The Circular Wait
Two locks, two threads, opposite orders. This is the entire recipe for deadlock, the bug that turns your concurrency into a statue:
// Thread 1 calls transfer(a, b) // Thread 2 calls transfer(b, a)
// with the naive implementation:
void transfer(Account from, Account to, BigDecimal amount) {
synchronized (from) { // T1 locks a, T2 locks b
synchronized (to) { // T1 wants b, T2 wants a
from.debit(amount); // both wait forever, holding what the other needs
to.credit(amount);
}
}
}
Thread 1: HOLDS Lock a ---- wants ----> Lock b
^
|
Thread 2: HOLDS Lock b <--- wants ---- |
(circular wait: no thread can proceed)
The fix is the discipline this whole section exists to teach: global lock ordering. In other words, when any code path can acquire two locks, every code path must acquire them in the same order. Any stable rule works, such as an account number or a record id:
// RIGHT: always lock the lower id first: the circular wait is impossible
void transfer(Account from, Account to, BigDecimal amount) {
var first = from.id().compareTo(to.id()) < 0 ? from : to;
var second = first == from ? to : from;
synchronized (first) {
synchronized (second) {
from.debit(amount);
to.credit(amount);
}
}
}
Ordering is the primary fix, but two others belong in the toolbox. The first is tryLock with backoff: acquire one lock, tryLock the second, and on failure release and retry after a pause, which breaks cycles under contention. The second is lock coarsening, one lock covering both accounts, which trades throughput for simplicity and often makes the right call at low contention.
What you cannot do is detect deadlocks at compile time. Instead, the tool that catches them is a thread dump, and Part 6’s profiling article shows you how to read one, two threads, two locks, each holding what the other wants, frozen in the act.
How Real Systems Do This
The honest production picture: explicit locking in application code is rare, and its rarity is the system working as designed. Business code reaches for immutable messages, atomics for counters, and concurrent collections for shared structures. All of these move synchronization into tested library code. Where you do find synchronization and locks in Java production code, they cluster in exactly the places this article’s powers justify. Examples are framework internals with tryLock backoff, caches and registries with ReadWriteLock, and resource managers that must coordinate two resources. There, teams either write the ordering discipline down or eventually meet the deadlock.
My deadlock story is the classic two-lock shape wearing modern clothes. A service kept a per-user cache guarded by one lock and a global statistics structure guarded by another. One code path, cache eviction, took the cache lock then updated stats: cache, stats. Another path, a metrics sweep, iterated the stats first and then consulted cache entries: stats, cache.
The deadlock needed simultaneous eviction and sweep. As a result, it surfaced roughly once a week in production, always at night, and never in tests, because tests never ran the two paths at the same instant. The thread dump settled it in one look. It showed two threads, each in BLOCKED state, each holding the lock the other wanted. The fix was the ordering rule, always stats after cache, everywhere. We wrote it into the code review checklist so it outlived everyone’s memory of the incident. The lesson that stuck with me: deadlocks are not timing bugs in the loose sense. Instead, they are ordering bugs with timing triggers, and the discipline is cheap while the diagnosis is not.
Decision Framework for Synchronization and Locks in Java
- Can the shared state be redesigned away, results as immutable records, messages instead of mutation? Do that first: the threads article’s hierarchy holds.
- Is the shared thing a single variable? Atomics, not locks.
- Do several operations on shared state need to be atomic together, and is the structure simple? synchronized, method or block, on a private final lock object.
- Must the wait be bounded, interruptible, or fair? ReentrantLock, in the lock-try-finally-unlock idiom, no exceptions.
- Is the structure read-mostly, and is a concurrent collection unsuitable for a real reason? ReadWriteLock, and expect the read lock to carry the traffic.
- Can two locks ever be held at once, in any code path? Define the global order now, document it, and check new paths against it in review.
- Is the flag the only shared thing, one writer, many readers? volatile, and nothing more.
- Is a lock region doing I/O, network, database, or calling unknown code? Shrink it: a held lock plus an unbounded call is a latent deadlock or a throughput ceiling.
When NOT to Use This
- Do not lock what immutability, atomics, or concurrent collections can protect. After all, every explicit lock is invariant surface area you now maintain.
- Do not use volatile for compound operations, count++, check-then-act, accumulation: visibility without atomicity is a race with better marketing.
- Do not hold a lock across I/O or unknown callbacks. The lock protects data, and a listener that acquires its own lock turns your region into half a deadlock recipe.
- Do not expose your lock. For example, synchronized on this, on public objects, or on mutable fields all let strangers participate in your synchronization discipline.
- Do not choose ReentrantLock for simple mutual exclusion. The intrinsic lock is shorter, impossible to leak, and sufficient, so use the extra powers only when you need them.
- Do not add fairness or priority without a measured problem: fair locks trade throughput for ordering you probably cannot observe.
Common Mistakes
- Synchronizing on a mutable or non-final field: reassignment swaps the lock mid-flight. As a result, two threads end up inside the same region on different locks.
- volatile on a counter: the most common wrong fix in Java concurrency. It publishes each update while the increments still lose each other.
- ReentrantLock without try-finally: one exception path leaks the lock forever. Then every future thread blocks on a region nobody is in.
- Acquiring two locks in path-dependent order: the deadlock will find the opposite-order path eventually. Usually it happens under the load you did not test.
- Double-checked locking without volatile: another thread can see the pattern’s published instance half-constructed. This memory-model subtlety is one that held-initialize-on-demand idioms avoid entirely.
- Over-synchronizing: locking an entire class around every method serializes the code back to single-threaded. You get all the cost and none of the benefit.
- Calling wait or notify outside synchronized: IllegalMonitorStateException. Also, the underlying protocol, wait and notify for condition queues, is specialist machinery the concurrent collections replace for almost everyone.
Key Takeaways
- synchronized uses an object’s intrinsic lock: mutual exclusion plus memory visibility, reentrant, and impossible to leak. The block form should lock a private final object, never this or a mutable field.
- volatile is visibility, not atomicity: right for one-writer status flags and wrong for counters. It is also the most commonly misapplied keyword in Java concurrency.
- ReentrantLock adds tryLock with timeout, interruptible waiting, and fairness, in the mandatory lock-try-finally-unlock idiom.
- ReadWriteLock shares the read lock and excludes the write lock: read-mostly structures only, and ConcurrentHashMap is often the better answer.
- Deadlock is a circular wait from path-dependent lock ordering. The fixes are a global ordering rule, tryLock with backoff, or lock coarsening.
- The escalation ladder is the whole strategy. Redesign for immutability, then use atomics for one variable, synchronized for simple invariants, and ReentrantLock for its powers. Above all, lock as little as you can prove.
- A thread dump is the deadlock diagnosis tool: two BLOCKED threads, two locks, each holding what the other wants.
- Production code locks less than tutorials suggest: the concurrent collections in the next article are why.
FAQ
What does synchronized do in Java?
It locks an object’s intrinsic monitor so one thread executes the region at a time. It also establishes happens-before ordering, so writes inside the region are visible to the next holder. It is reentrant and released automatically when the region exits, even on exceptions.
When should I use volatile in Java?
When one thread writes a field and others read it, and you need prompt visibility with no compound operations on the value. Examples are status flags, configuration switches, published immutable references. Not for count++ or check-then-act logic, which need atomicity, not visibility.
What is ReentrantLock in Java?
An explicit lock with the same mutual exclusion as synchronized. It adds bounded waiting via tryLock with a timeout, interruptible acquisition, and an optional fairness policy. You must release it yourself, always in a finally block.
What causes deadlock in Java?
A circular wait: two threads each hold one lock and need the other’s, because different code paths acquired two locks in opposite orders. A global lock ordering rule prevents it, and tryLock with backoff breaks it.
What is ReadWriteLock in Java?
A lock pair: a shared read lock that many readers can hold simultaneously, and an exclusive write lock that blocks everything. It suits read-mostly shared structures like caches and registries, where a single lock would serialize readers needlessly.
Conclusion
You can now read and write the lock layer of synchronization and locks in Java. You know the intrinsic monitor with its automatic discipline and volatile with its narrow honest job. You also know ReentrantLock and ReadWriteLock with their extra powers, and the deadlock recipe with its ordering cure. Just as important, you know when not to be here: most shared state belongs to immutability, atomics, or a well-tested concurrent collection.
The next article covers those collections: ConcurrentHashMap and its striped internal locking, CopyOnWriteArrayList for read-mostly lists, BlockingQueue for producer-consumer pipelines, and ConcurrentLinkedQueue. These are the tools that absorb most of the synchronization this article taught, so your code reads like data structure use rather than lock management.
Lock as little as you can prove correct, and order what you must lock. The dump at 3 a.m. is the grade.
Last updated on 5 September 2026.
