Every Java object can act as a lock. The synchronized keyword acquires an object's intrinsic lock, usually called its monitor, on entry to a block or method and releases it on exit, including exit by exception. While one thread holds the monitor, any other thread trying to enter code synchronized on the same object blocks. Synchronization also does something less obvious and just as important: it publishes memory. Writes made before releasing a monitor are guaranteed visible to the next thread that acquires it.
This article explains what synchronized compiles to, how the monitor behaves, what memory guarantees it gives, how wait and notify work, the pitfalls that cause real outages, how it interacts with virtual threads on current JDKs, and how to diagnose contention and deadlock. The goal is to let you use it confidently and recognise when a different tool fits better.
What object are you locking?
The first question for any synchronized code is which object is locked. Mutual exclusion only exists between threads that lock the same object.
| Form | Monitor acquired |
|---|---|
synchronized void m() | this, the receiver instance |
static synchronized void m() | the Class object, for example Counter.class |
synchronized (obj) { ... } | the monitor of whatever obj refers to at that moment |
A classic bug follows directly: an instance method declared synchronized that updates a static field. Each instance locks itself, so two threads using two instances update the shared field concurrently. Protect static state with a static lock. A second bug is reassigning the lock field: synchronized (list) followed by list = new ArrayList<>() means later threads lock a different object. Lock fields should be final.
The defensive idiom is a private final lock object. Locking on this exposes the lock to any code that holds a reference to your object, and that code can hold it for as long as it likes.
public final class Inventory {
private final Object lock = new Object();
private final Map<String, Integer> stock = new HashMap<>();
public void add(String sku, int qty) {
synchronized (lock) {
stock.merge(sku, qty, Integer::sum);
}
}
public int get(String sku) {
synchronized (lock) { // reads need the lock too, for visibility
return stock.getOrDefault(sku, 0);
}
}
}
What the compiler emits
A synchronized method is marked with the ACC_SYNCHRONIZED flag in the class file, and the JVM acquires and releases the monitor around the call. A synchronized block compiles to explicit monitorenter and monitorexit instructions, and javac adds an exception handler so the monitor is released if the body throws. Running javap -c on a block that increments a counter shows the shape (abridged; constant pool indices will differ):
6: monitorenter
7: aload_0
8: dup
9: getfield #13 // Field count:I
12: iconst_1
13: iadd
14: putfield #13 // Field count:I
17: aload_1
18: monitorexit // normal exit
19: goto 27
22: astore_2
23: aload_1
24: monitorexit // exceptional exit
25: aload_2
26: athrow
27: return
Exception table:
from to target type
7 19 22 any
22 25 22 anyTwo monitorexit instructions, one per exit path, are why you can never forget to unlock with synchronized, unlike ReentrantLock, where the finally block is your job.
What object are you locking?
The first question for any synchronized code is which object is locked. Mutual exclusion only exists between threads that lock the same object.
| Form | Monitor acquired |
|---|---|
synchronized void m() | this, the receiver instance |
static synchronized void m() | the Class object, for example Counter.class |
synchronized (obj) { ... } | the monitor of whatever obj refers to at that moment |
A classic bug follows directly: an instance method declared synchronized that updates a static field. Each instance locks itself, so two threads using two instances update the shared field concurrently. Protect static state with a static lock. A second bug is reassigning the lock field: synchronized (list) followed by list = new ArrayList<>() means later threads lock a different object. Lock fields should be final.
The defensive idiom is a private final lock object. Locking on this exposes the lock to any code that holds a reference to your object, and that code can hold it for as long as it likes.
public final class Inventory {
private final Object lock = new Object();
private final Map<String, Integer> stock = new HashMap<>();
public void add(String sku, int qty) {
synchronized (lock) {
stock.merge(sku, qty, Integer::sum);
}
}
public int get(String sku) {
synchronized (lock) { // reads need the lock too, for visibility
return stock.getOrDefault(sku, 0);
}
}
}
What the compiler emits
A synchronized method is marked with the ACC_SYNCHRONIZED flag in the class file, and the JVM acquires and releases the monitor around the call. A synchronized block compiles to explicit monitorenter and monitorexit instructions, and javac adds an exception handler so the monitor is released if the body throws. Running javap -c on a block that increments a counter shows the shape (abridged; constant pool indices will differ):
6: monitorenter
7: aload_0
8: dup
9: getfield #13 // Field count:I
12: iconst_1
13: iadd
14: putfield #13 // Field count:I
17: aload_1
18: monitorexit // normal exit
19: goto 27
22: astore_2
23: aload_1
24: monitorexit // exceptional exit
25: aload_2
26: athrow
27: return
Exception table:
from to target type
7 19 22 any
22 25 22 anyTwo monitorexit instructions, one per exit path, are why you can never forget to unlock with synchronized, unlike ReentrantLock, where the finally block is your job.
Inside the monitor
Conceptually a monitor has an owner, a hold count, an entry set of threads waiting to acquire it and a wait set of threads that called wait(). Intrinsic locks are reentrant: if the owner enters another block synchronized on the same object, the hold count goes up instead of deadlocking, and the lock is free only when the count returns to zero. That is what lets one synchronized method call another on the same object.
HotSpot implements this in two tiers. When there is no contention, it records the lock state in the object's header with a few atomic instructions and never allocates a full monitor. When threads contend, the lock is inflated to a heavyweight monitor with queues; a contending thread may spin briefly in case the owner releases soon, then parks. Biased locking, an older optimisation that let one thread re-lock an object without atomics, was deprecated and disabled by default in JDK 15 (JEP 374), so do not tune for it on modern JDKs. The practical consequence is that uncontended synchronized is cheap, while contended synchronized costs context switches, and contention, not the keyword, is what you should measure.
Visibility and happens-before
The Java Memory Model says that an unlock of a monitor happens-before every subsequent lock of that same monitor. Everything a thread wrote before releasing the lock is visible to the next thread that acquires it. Without that edge, the JIT may keep a field in a register, and another core may never see the update.
class Worker implements Runnable {
private boolean stopped; // neither volatile nor guarded
public void stop() { stopped = true; }
public void run() {
while (!stopped) { doWork(); } // may never observe the write
}
}The loop above may run forever, because the JIT is allowed to hoist the read of stopped out of the loop. Making both stop() and the read synchronized on the same lock fixes it, and so does declaring the field volatile, which is the better choice for a single flag. Use synchronized when you need atomicity over several fields or a read-modify-write; use volatile when a single variable only needs visibility. Readers need the lock as well as writers: an unsynchronized getter can return stale or half-updated state even if every writer is synchronized.
wait, notify and guarded blocks
wait(), notify() and notifyAll() let threads wait for a condition while holding the monitor. They must be called while owning the monitor, or they throw IllegalMonitorStateException. wait() atomically releases the monitor and suspends the thread; when woken, the thread must reacquire the monitor before wait() returns. Because wakeups can be spurious and another thread may change the condition between the notify and the reacquire, always wait in a loop that re-tests the condition.
public final class BoundedBuffer<T> {
private final Object lock = new Object();
private final Deque<T> items = new ArrayDeque<>();
private final int capacity;
public BoundedBuffer(int capacity) { this.capacity = capacity; }
public void put(T item) throws InterruptedException {
synchronized (lock) {
while (items.size() == capacity) lock.wait();
items.addLast(item);
lock.notifyAll(); // wake takers (and putters, who re-check)
}
}
public T take() throws InterruptedException {
synchronized (lock) {
while (items.isEmpty()) lock.wait();
T item = items.removeFirst();
lock.notifyAll();
return item;
}
}
}Producers and consumers here share one wait set, so notify() could wake a thread of the wrong kind and lose the signal; notifyAll() is the safe default. When you need separate conditions for "not full" and "not empty", ReentrantLock with two Condition objects is cleaner, and in most production code an ArrayBlockingQueue is better still.
Pitfalls that cause outages
- Locking on boxed values and string literals.
Integer.valueOf(1)is cached and string literals are interned, so unrelated code can end up locking the same object. Since JDK 16 (JEP 390), javac warns under-Xlint:synchronizationwhen you synchronize on an instance of a value-based class such asIntegerorOptional. - Calling alien code while holding a lock. Invoking listeners, callbacks or overridable methods inside a synchronized block lets unknown code acquire other locks in unknown order. Copy what you need, release, then call out.
- Double-checked locking without volatile. Checking a lazily initialised field outside the lock and again inside it is only correct if the field is
volatile. Without it, another thread can see a non-null reference to an object whose constructor writes are not yet visible. Prefer the holder-class idiom, where a nested static class initialises the instance and the JVM's class initialisation guarantees supply the locking for free. - Locking on getClass(). In a class meant for subclassing,
synchronized (getClass())locks a different object for each subclass, so instances of two subclasses do not exclude each other. Use a class literal such asBase.classor, better, a private static final lock. - Slow work inside the lock. I/O, remote calls or heavy computation inside a critical section serialise every caller behind it. Compute outside, publish inside.
- Assuming synchronized collections compose. Each call on
Collections.synchronizedMapis atomic, but check-then-act sequences are not. Hold the map's lock across the sequence, or useConcurrentHashMap.computeIfAbsent.
Deadlock and lock ordering
Intrinsic locks cannot time out and cannot be abandoned, so two threads that take two locks in opposite orders wait forever. A money transfer that locks the source account, then the destination, deadlocks as soon as transfers in both directions run at once. A thread dump shows it plainly; jstack <pid> and jcmd <pid> Thread.print both end with a section like this:
Found one Java-level deadlock:
=============================
"transfer-2":
waiting to lock monitor 0x00007f3a2c004e20 (object 0x0000000711a2b3c8, a Account),
which is held by "transfer-1"
"transfer-1":
waiting to lock monitor 0x00007f3a2c007d10 (object 0x0000000711a2b3e0, a Account),
which is held by "transfer-2"The fix is a global order: whenever code must hold more than one lock, acquire them in a single canonical order. A stable unique key works well.
static void transfer(Account a, Account b, long cents) {
Account first = a.id() < b.id() ? a : b;
Account second = a.id() < b.id() ? b : a;
synchronized (first.lock()) {
synchronized (second.lock()) {
a.debit(cents);
b.credit(cents);
}
}
}If no such order exists, or you need to give up after a timeout, use ReentrantLock.tryLock with a timeout and back off; that is the clearest signal that synchronized is the wrong tool.
synchronized and virtual threads
Virtual threads, final in JDK 21, run on a small pool of carrier platform threads and unmount from the carrier when they block. In JDK 21 through 23, a virtual thread that blocked while inside a synchronized block or method could not unmount and pinned its carrier, so heavily synchronized code could exhaust the carrier pool and stall. JDK 24 delivered JEP 491, which lets virtual threads acquire, hold and release monitors and call Object.wait() without pinning. On JDK 24 and later, the remaining pinning cases are blocking while a native frame is on the stack, such as during JNI or FFM downcalls, and blocking during class initialisation.
Two practical consequences. On JDK 21 to 23 with virtual threads, replace synchronized blocks that perform blocking I/O in hot paths with ReentrantLock, or upgrade. On any version, record the JFR event jdk.VirtualThreadPinned to find what still pins, rather than guessing.
Choosing a tool and finding contention
| Need | Use |
|---|---|
| Mutual exclusion over a few fields, short critical section | synchronized on a private final lock |
| Visibility of a single flag or reference | volatile |
| Atomic counter or single-variable update | AtomicLong, LongAdder |
| Timeout, interruptible acquire, fairness, several conditions | ReentrantLock |
| Many readers, rare writers | ReentrantReadWriteLock or StampedLock |
| Shared map or queue | ConcurrentHashMap, BlockingQueue |
To diagnose contention, record a JFR session and look at the jdk.JavaMonitorEnter event, which records threads that blocked entering a monitor, with the monitor class, duration and stack, and jdk.JavaMonitorWait for time spent in wait(). A handful of call sites usually account for most blocked time, and narrowing those critical sections is the highest-return fix.
What to do next
- For every synchronized region, write down which object it locks and confirm that every access to the guarded state uses the same lock, reads included.
- Replace
synchronizedonthis, class objects, boxed values and strings with private final lock objects; compile with-Xlint:synchronization. - Move I/O, callbacks and heavy computation out of critical sections.
- Put every wait() in a while loop and default to notifyAll().
- Define a lock order for code that takes more than one lock, and practise reading a deadlock in
jcmd <pid> Thread.print. - If you run virtual threads, check your JDK version and record
jdk.VirtualThreadPinned. - Read on: the Java Memory Model, volatile, ReentrantLock and virtual threads.