Java Synchronization
Coordinate access to shared state across threads in Java with the synchronized keyword and intrinsic locks.
Java Synchronization
The multithreading intro warned about three failure modes — races, visibility bugs, and deadlocks. synchronized is Java's first answer to the first two. It gives a block of code two guarantees at once: mutual exclusion (only one thread in there at a time) and memory visibility (writes done inside the block by one thread are seen by the next thread that enters it). Those two guarantees, combined, are enough to make a vast amount of multithreaded code correct.
This chapter is the conceptual one — what synchronized does, what an intrinsic monitor is, what kinds of races it does and doesn't fix. The next chapter, synchronized blocks, shows the syntactic forms and how to choose between them.
The race the keyword exists to fix
class Counter {
int n;
void increment() { n++; }
}
Counter c = new Counter();
// Thread A and Thread B both call c.increment() a million times.
// After both finish, what is c.n?n++ is one source line and three bytecode operations: load n, add 1, store n. If thread A loads n=42, then thread B loads n=42 before A stores, both add 1 and both store 43. One increment is lost. Run the program a million times each thread and c.n is consistently less than 2_000_000.
synchronized is the fix:
class Counter {
int n;
synchronized void increment() { n++; }
}Now only one thread at a time executes increment on this Counter. The other waits at the door. Result: c.n == 2_000_000, every run.
What a monitor is
Every Java object has, hidden inside the JVM, an associated lock called the intrinsic monitor (or monitor lock). It's just a long-ish data structure with two pieces of state: an owner thread (or null) and a wait queue. A thread that enters a synchronized block:
- Tries to acquire the monitor of the object the
synchronizedis on. - If the monitor is unowned, takes it (now
owner == self) and proceeds. - If the monitor is owned by another thread, this thread transitions to
BLOCKEDand joins the wait queue. - When the owner exits the block, the JVM releases the monitor and one waiter wins it.
The monitor is per-object. Two Counter instances have two separate monitors; threads operating on different Counters don't block each other. That's important — synchronisation is on the object, not "on the method."
synchronized (someObject) {
// critical section: only one thread at a time
// holds someObject's monitor inside this block
}synchronized on an instance method is sugar for synchronized (this). On a static method, it's sugar for synchronized (Counter.class) — the monitor of the Class object.
Visibility, not just exclusion
Mutual exclusion is the obvious part. The less obvious — and more important — part is the happens-before relationship the JVM gives you for free:
Everything one thread does before releasing a monitor is guaranteed to be visible to any thread that afterwards acquires the same monitor.
That sentence is what makes synchronized correct, not merely "first-come-first-served." Without it, two threads can use a synchronized block, agree on mutual exclusion, and still see each other's writes in the wrong order — because the CPU caches and the JIT are otherwise free to reorder. The release/acquire pair installs a memory barrier that forces the CPU and JIT to flush and reload.
The implication: any field a multi-threaded program reads or writes outside a synchronized block (and not via volatile, an atomic, or another java.util.concurrent primitive) has no visibility guarantee. A thread can write done = true and another thread can see done = false forever. We'll come back to this when we cover volatile and the memory model.
What synchronized does not fix
Four things newcomers often expect from synchronized that it does not deliver:
- It doesn't lock the data.
synchronized (list)doesn't prevent other code from touchinglist; it prevents another thread from holding the same monitor. If some other code path operates onlistwithout acquiring the same monitor, the protection is gone. - It doesn't compose across objects.
synchronized (a); synchronized (b);is two separate acquisitions; if another thread acquires them in the opposite order you have a deadlock. - It doesn't speed anything up. Locks are pure overhead. Use them only where correctness requires it.
- It doesn't fix all races. Compound actions like "check then act" still race even with each individual op synchronised.
if (map.containsKey(k)) map.put(k, v)is broken even ifcontainsKeyandputare individually thread-safe — the gap between the two calls is unprotected. UseputIfAbsentor a single synchronised block around both.
Reentrancy
The intrinsic monitor is reentrant: a thread that already holds a monitor can enter another synchronized block on the same object without blocking on itself. That's why this works:
class Account {
synchronized void deposit(int x) { balance += x; }
synchronized void transferTo(Account other, int x) {
deposit(-x); // re-enters same monitor — fine
other.deposit(x); // acquires other's monitor too
}
}If monitors weren't reentrant, the inner call to deposit would block on the monitor the outer call already holds — instant self-deadlock. Reentrancy makes calling another synchronised method on the same object safe.
The flip side: each acquisition needs a matching release. The JVM keeps a count; the monitor is released when the count drops to zero.
What to synchronise on
A few rules that prevent most of the lock-misuse bugs:
- Synchronise on a private lock object, not on
this. External code can alsosynchronized (yourInstance); that lets a caller hold your lock for as long as they want. A privatefinal Object lock = new Object();is your own and nobody else can grab it. - Don't synchronise on
Stringliterals or boxed primitives. They're interned/cached; twosynchronized ("foo")blocks in different parts of your code share a monitor with anybody else who also said"foo". - Don't synchronise on a reference that can change.
synchronized (myField)wheremyFieldmay be reassigned is two different monitors over time. The compiler can't tell; the bug is silent. - Keep the critical section small. The more you do inside a
synchronizedblock, the longer everyone else waits. Hold the lock while changing the shared state, not while doing the surrounding I/O.
A worked example: with and without the lock
The program below runs the same shared-counter workload three ways: no synchronisation, synchronized method, and synchronized block on a dedicated lock object. The numbers show that the first form loses updates and the other two don't.
What to take from the run:
- The
unsafeline consistently lost updates — final value somewhere below the expected1_000_000. Two threads doingn++race on read-modify-write; some increments vanish. Even when the test passes on a single run by luck, the JIT, the OS scheduler, or a different CPU will eventually shake it loose. Unsynchronised mutation of a shared field is broken. - Both safe variants produced the exact expected count, every time. Mutual exclusion is the obvious part of what
synchronizedis doing; the less visible part is that the valuevalue()reads is the latest one written byincrement— that's the visibility guarantee. Without the monitor pair, the read could legitimately see a stale cached copy. - The wall-clock numbers for
sync methodandsync blockwere both noticeably higher thanunsafe. Locks aren't free — every entry/exit does a memory barrier and (under contention) a thread context switch. Synchronise where correctness requires; don't sprinkle for "safety." - The
sync block on private lockvariant is what production code uses. Thesync methodform locks onthis, which any external caller can also acquire — they can starve you out by holding your own lock. A private lock object you never expose is yours alone. - The reentrancy block ran without deadlocking.
outer()already held the monitor ofthis;inner()re-entered it without blocking. That's why a synchronised method can freely call another synchronised method on the same object — without reentrancy, half the standard library would deadlock.
What's next
The next chapter, Java Synchronized Blocks, drills into the syntactic forms — method, block, static — and the rules for choosing the right lock object.
Practice
Two threads each call `counter.increment()` on a `Counter` whose `n` field is an unsynchronised `int`. After both finish 1,000,000 increments, what does `counter.n` typically show?