W3docs

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:

  1. Tries to acquire the monitor of the object the synchronized is on.
  2. If the monitor is unowned, takes it (now owner == self) and proceeds.
  3. If the monitor is owned by another thread, this thread transitions to BLOCKED and joins the wait queue.
  4. 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:

  1. It doesn't lock the data. synchronized (list) doesn't prevent other code from touching list; it prevents another thread from holding the same monitor. If some other code path operates on list without acquiring the same monitor, the protection is gone.
  2. 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.
  3. It doesn't speed anything up. Locks are pure overhead. Use them only where correctness requires it.
  4. 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 if containsKey and put are individually thread-safe — the gap between the two calls is unprotected. Use putIfAbsent or 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 also synchronized (yourInstance); that lets a caller hold your lock for as long as they want. A private final Object lock = new Object(); is your own and nobody else can grab it.
  • Don't synchronise on String literals or boxed primitives. They're interned/cached; two synchronized ("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) where myField may 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 synchronized block, 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.

java— editable, runs on the server

What to take from the run:

  • The unsafe line consistently lost updates — final value somewhere below the expected 1_000_000. Two threads doing n++ 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 synchronized is doing; the less visible part is that the value value() reads is the latest one written by increment — that's the visibility guarantee. Without the monitor pair, the read could legitimately see a stale cached copy.
  • The wall-clock numbers for sync method and sync block were both noticeably higher than unsafe. 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 lock variant is what production code uses. The sync method form locks on this, 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 of this; 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

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?