Course contents

synchronized and monitors

Fix the lost-update counter with synchronized, learn exactly which object each form locks, pick a safe lock, and understand the visibility promise, the costs and what HotSpot does under the hood.

FreeMust know for interviewsLesson22 min readCoreReviewed Oct 2026 · Java 21
On this page
  1. Picture it first: a room with one key
  2. Under the hood: fixing the lost-update counter
  3. Three ways to write it
  4. The lock is the object, not the code
  5. Choosing the lock object
  6. Guard every access, including reads
  7. Four facts you should be able to say in one breath
  8. What it costs, and how to pay less
  9. Real world
  10. Advanced: what HotSpot does with your lock
  11. How to say it in the interview

🔑 What are synchronized and monitors?

Every Java object carries a built-in lock plus a small waiting room. That pair is called a monitor. The synchronized keyword says "take this object's lock before running this code, and give it back when you're done". Only one thread can own a given monitor at a time, so synchronized turns a racy count++ into something only one thread does at once.

In the last lesson, Race conditions, we ended on a painful note: four threads each added 1,000,000 to a shared counter, and the total came out short by millions. For this lesson, we'll fix that counter with synchronized, then learn what it really locks, which object you should lock on, the two promises it makes (one of which most people forget) and what it costs. Interviewers love this topic because the keyword looks trivial, and then one follow-up ("what does a static synchronized method lock?") shows whether you understand the model or memorised the syntax.

Below the line (out of scope)

  • Locking the same monitor twice from one thread: Reentrancy
  • Waiting for a condition inside a lock: wait(), notify() and notifyAll()
  • Thread states, including BLOCKED: Thread lifecycle and states
  • The full memory model gets its own lessons later ("Happens-before", "Visibility and reordering"), and so do explicit locks ("ReentrantLock and Condition")

The 30-second answer

Every object has a monitor: a lock with one owner at a time plus a queue of threads waiting to get in. A synchronized instance method locks this; a static synchronized method locks the Class object; a synchronized (x) block locks whatever object x is. Threads only block each other if they lock the same object. It gives two guarantees: mutual exclusion, and visibility, because an unlock happens-before every later lock of the same monitor. It's reentrant, the lock is always released when the block exits, even by exception, and waiting threads show as BLOCKED. It has no timeout, no interruptible wait and no fairness; for those you reach for ReentrantLock.

What you’ll learn

  • Explain a monitor in plain words: one lock with a single owner, an entry set of BLOCKED threads and a wait set
  • Name the lock each form takes: instance method locks **this**, static synchronized locks the **Class object, a block locks any object you pass**
  • Predict who blocks whom: same object contends, different objects run in parallel, static and instance never block each other
  • Choose a safe lock (private final Object lock) and spot broken ones: String literals, boxed values, reassigned fields, value-based classes
  • State both guarantees, mutual exclusion and visibility (unlock happens-before the next lock of the same monitor), and why getters need the lock too
  • Read javap -c output and point at the **exception-path monitorexit** that makes synchronized release on throw
  • Reason about cost: uncontended is one CAS, contended means parking; shrink, split and stripe locks, and never hold one during I/O
  • Describe HotSpot's thin to fat lock inflation, the removal of biased locking (JEP 374) and the virtual-thread pinning fix in JDK 24 (JEP 491)

Interview radar

Asked very often: How does synchronized work? What does a static synchronized method lock? Core follow-up: Can two threads call two different synchronized methods on the same object at the same time? Production angle: Your service's p99 jumps under load and thread dumps show 150 threads BLOCKED on one logger. What happened?

01Picture it first: a room with one key

Picture a single-occupancy room, like the one bathroom in a small café. There's one key on a hook by the door. You take the key, go in, lock the door, do your thing, come out and hang the key back. If someone arrives while you're inside, they don't barge in. They wait by the door.

That's a monitor. The room is the code you're protecting (the critical section). The key is the object's lock. The people waiting outside are the entry set: threads that want the lock and are parked until it's free. Here's the sketch I draw on a whiteboard.

Fig. 1

A monitor is a single-occupancy room with one key

A monitor is a single-occupancy room with one keyA room with one person inside holding the only key. The hook by the door is empty. Three people queue at the door in a waiting area labelled BLOCKED. When the person inside leaves, they hang the key back and exactly one person from the queue takes it and goes in.the room (critical section)T1 (owner)keyheld by T1hookemptydoorwaiting room (entry set)T2T3T4all BLOCKED, no CPU usednext one in1One owner at a time: whoever holds the key is inside.2Leaving (normally or by exception) hangs the key back; one waiter gets it.
One key, one owner. T1 holds the key and is inside. T2, T3 and T4 wait at the door, BLOCKED and using no CPU. When T1 leaves, one of them gets the key.

Two details from the café are worth keeping. First, the key is attached to the room, not to the person. Second, people waiting outside aren't burning energy pacing around; they're sitting down. In Java terms, waiting threads are in the BLOCKED state and don't spin on the CPU.

02Under the hood: fixing the lost-update counter

Try it yourself first

Before reading on, predict the output. Four threads each call increment() 1,000,000 times on an unsafe counter, then on a counter whose methods are synchronized. Will the safe one be exactly 4,000,000? Every run?

Here's the counter from the race-conditions lesson, next to the fixed version. The only change is one keyword on each method.

public class SyncCounter {
    static class UnsafeCounter {
        private int count;
        void increment() { count++; }
        int get() { return count; }
    }

    static class SafeCounter {
        private int count;
        synchronized void increment() { count++; }
        synchronized int get() { return count; }
    }

    public static void main(String[] args) throws InterruptedException {
        UnsafeCounter unsafe = new UnsafeCounter();
        SafeCounter safe = new SafeCounter();
        run(unsafe::increment);
        run(safe::increment);
        System.out.println("expected = 4000000");
        System.out.println("unsafe   = " + unsafe.get());
        System.out.println("safe     = " + safe.get());
    }

    static void run(Runnable increment) throws InterruptedException {
        Thread[] threads = new Thread[4];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1_000_000; j++) increment.run();
            });
            threads[i].start();
        }
        for (Thread t : threads) t.join();
    }
}

I ran java SyncCounter.java three times on JDK 21 (Apple M-series laptop) and lined up the two interesting rows. The unsafe number changes every run and loses roughly 3 million updates; the safe one is exact every time:

run 1: unsafe = 1089814   safe = 4000000
run 2: unsafe = 1089979   safe = 4000000
run 3: unsafe = 1066671   safe = 4000000

Notice three things.

  • count++ is three steps (read, add, write). The lock makes those three steps happen as one unit: nobody else can start their own read until you've written.
  • The getter is synchronized too. That isn't decoration; we'll see why in a minute.
  • The lock didn't make the code faster. It made it correct. Four threads now take turns on one counter, so this is actually slower than one thread doing all 4,000,000 increments.

03Three ways to write it

synchronized comes in three shapes. They all do the same thing (lock a monitor, run, unlock). What differs is which object's monitor gets locked.

You write Lock taken on Typical use
synchronized void inc() this (the instance) Protect fields of one object
static synchronized void inc() Counter.class (the Class object) Protect static fields
synchronized (lock) { ... } whatever lock refers to Lock a smaller region, or a private lock object

A synchronized instance method is exactly the same as wrapping its whole body in synchronized (this) { ... }. A static one is the same as synchronized (Counter.class) { ... }. The JLS spells this out in §8.4.3.6, and it's the line I want to hear in an interview.

The block form is the one you'll use most in real code, because it lets you choose the lock and keep the locked region small. Think of it this way: a synchronized method locks the whole kitchen while you cook. A synchronized block locks only the fridge, for the few seconds you're taking eggs out.

Fig. 2

Lock the fridge, not the whole kitchen

Lock the fridge, not the whole kitchenLeft: a synchronized method locks the whole kitchen, so a second cook waits outside while the first one chops, cooks and plates, even though only the fridge is shared. Right: a synchronized block locks only the fridge, so both cooks work at the same time and only queue for a few seconds at the fridge.synchronized methodwhole kitchen lockedchopfridgecookcook 2 waitsheld for 10 minsynchronized blockchopfridgelockedcookcook 1cook 2held for 5 secKeep the critical section to the lines that touch shared state.Everything else (parsing, logging, I/O) goes outside the lock.
Method vs block. Locking the method holds the lock for the whole job. A block holds it only for the lines that touch shared state, so the other cook queues for seconds instead of minutes.

Here's what that looks like in code. Parsing and logging happen outside; only the shared map update is inside.

private final Object lock = new Object();
private final Map<String, Integer> hits = new HashMap<>();

void record(String rawLine) {
    String page = parsePage(rawLine);          // slow, touches nothing shared
    synchronized (lock) {
        hits.merge(page, 1, Integer::sum);     // the only shared state
    }
    log.debug("recorded {}", page);            // I/O stays outside the lock
}

04The lock is the object, not the code

This is the bit that trips people up. synchronized doesn't lock a method or a line of code. It locks an object. Two threads only wait for each other if they're trying to lock the same object.

Try it yourself first

A class Room has synchronized void use(), synchronized void clean() and static synchronized void inspect(), each sleeping 500 ms. Which of these pairs runs in parallel (~500 ms) and which takes turns (~1000 ms)? a.use() + b.use(), a.use() + a.use(), a.use() + a.clean(), a.use() + Room.inspect().

public class WhatIsLocked {
    static class Room {
        synchronized void use() { pause(); }               // locks this Room object
        synchronized void clean() { pause(); }             // same lock: this
        static synchronized void inspect() { pause(); }    // locks Room.class
    }

    public static void main(String[] args) throws InterruptedException {
        Room a = new Room();
        Room b = new Room();
        time("a.use()   + b.use()  ", () -> a.use(), () -> b.use());
        time("a.use()   + a.use()  ", () -> a.use(), () -> a.use());
        time("a.use()   + a.clean()", () -> a.use(), () -> a.clean());
        time("a.use()   + inspect()", () -> a.use(), Room::inspect);
    }

    static void time(String label, Runnable first, Runnable second) throws InterruptedException {
        Thread t1 = new Thread(first);
        Thread t2 = new Thread(second);
        long start = System.nanoTime();
        t1.start();
        Thread.sleep(50);                                  // let t1 grab its lock first
        t2.start();
        Thread.sleep(50);
        Thread.State seen = t2.getState();
        t1.join();
        t2.join();
        long ms = (System.nanoTime() - start) / 1_000_000;
        System.out.printf("%s took %4d ms, t2 was %s%n", label, ms, seen);
    }

    static void pause() {
        try { Thread.sleep(500); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }
}

The output on JDK 21 (timings vary by a few ms; the pattern doesn't). The "parallel" rows take ~555 ms rather than 500 because t2 starts 50 ms late on purpose:

a.use()   + b.use()   took  560 ms, t2 was TIMED_WAITING
a.use()   + a.use()   took 1005 ms, t2 was BLOCKED
a.use()   + a.clean() took 1006 ms, t2 was BLOCKED
a.use()   + inspect() took  555 ms, t2 was TIMED_WAITING

Read it row by row. Two different objects have two different keys, so a and b run together. The same object twice means one waits, and you can see it caught in BLOCKED. Two different synchronized methods on the same object still block each other, because they share one key: this. And the static method locks Room.class, which is a completely separate object, so it doesn't block the instance method at all.

Fig. 3

The lock belongs to an object, not to the code

The lock belongs to an object, not to the codeThree panels. Left: two Counter objects a and b, each with its own key, so two threads run at the same time. Middle: two threads on the same object a; one runs and the other waits. Right: a static synchronized method uses the key of Counter.class, which is a different key from any instance, so it does not block instance methods.two objects, two keysakey Abkey BT1: a.inc()T2: b.inc()run togethersame object, one waitsakey AT1: a.inc()T2: a.get()T2 is BLOCKEDstatic = the class's keyCounter.classkey C (one per class)akey Adifferentkeysstatic sync -> key Cinstance sync -> key Ano blockingRule: two threads block each other only if they synchronize on the SAME object.
What actually gets locked. Two objects have two keys. One object means one key, whichever synchronized method you call. A static method uses the class's own key, so it never blocks instance methods (and doesn't protect instance fields).
Watch out

That last row is a real bug pattern. If a static synchronized method and a plain synchronized method both touch the same static field, they're using two different locks, so the field isn't protected at all. Pick one lock per piece of state.

05Choosing the lock object

Since everything depends on which object you lock, picking it is a design decision. Here's how I rank the options.

My default: a private final lock object. private final Object lock = new Object(); Nobody outside your class can see it, so nobody can accidentally (or deliberately) lock it and stall you. And final means it can never be swapped for a different object halfway through.

synchronized (this) or synchronized methods: fine, with one catch. this is public. Any code holding a reference to your object can write synchronized (yourObject) and hold your lock for as long as it likes. For small internal classes that's fine. For library classes, prefer the private lock.

Then there are locks that look fine and are quietly broken:

Lock on What goes wrong
A String literal, synchronized ("LOCK") Literals are interned: every class in the JVM that uses "LOCK" shares the same object, so unrelated code blocks you
A boxed Integer/Long/Boolean Integer.valueOf(42) is cached, so the "private" lock is shared JVM-wide; and count++ on an Integer field creates a new object, so each thread may lock a different one
A field you reassign lock = new Object() mid-flight means two threads can lock two different objects and both get in
Any value-based class (Integer, Optional, LocalDate, ...) Identity isn't guaranteed; the JDK warns about this and can be told to fail fast

That last row deserves a demo. Since Java 16 (JEP 390), javac warns when you synchronize on a value-based class, and HotSpot has a diagnostic flag that logs or crashes on it at run time:

public class BadLocks {
    private final Integer lock = 42;          // a boxed Integer: value-based, cached

    void work() {
        synchronized (lock) {
            System.out.println("got the Integer lock");
        }
    }

    public static void main(String[] args) {
        new BadLocks().work();
    }
}
$ java -XX:+UnlockDiagnosticVMOptions -XX:DiagnoseSyncOnValueBasedClasses=2 BadLocks.java
BadLocks.java:5: warning: [synchronization] attempt to synchronize on an instance of a value-based class
        synchronized (lock) {
        ^
1 warning
[0.192s][info][valuebasedclasses] Synchronizing on object 0x000000067f8c6508 of klass java.lang.Integer
[0.192s][info][valuebasedclasses] 	at BadLocks.work(BadLocks.java:5)
...
got the Integer lock

With =1 instead of =2, the JVM stops with a fatal error at that line. That's a handy setting for a test suite.

06Guard every access, including reads

Here's a bug I see in code reviews all the time. The writes are synchronized, the getter isn't, and someone says "it's only a read, it can't break anything".

It can. synchronized gives you two guarantees, and the second one is the one people forget:

  1. Mutual exclusion. Only one thread is inside at a time.
  2. Visibility. When a thread unlocks a monitor, everything it wrote becomes visible to the next thread that locks the same monitor.

Without the second one, a thread can keep reading a stale value from its CPU cache or register, possibly forever. The JLS phrases it as: an unlock on a monitor happens-before every subsequent lock on that same monitor (§17.4.5). In plain words, unlock and lock are a handshake: "I'm done, here's everything I changed."

Fig. 4

Unlock, then lock: a visibility handshake

Unlock, then lock: a visibility handshakeTwo swimlanes. Thread A enters synchronized(lock), writes count = 42 and ready = true, then unlocks, which publishes its writes. Thread B later locks the same object and is guaranteed to see count 42 and ready true. An arrow labelled happens-before joins A's unlock to B's lock.Thread AThread Blockcount = 42ready = trueunlock12locksees 42, true3happens-before1A does its writes inside the lock.2Unlock flushes them: "here, I am done."3B locks the SAME object and sees everything A wrote.
Unlock, then lock. Thread A's writes inside the lock are published when it unlocks. Thread B is guaranteed to see them only because it locks the same object afterwards. A reader that skips the lock gets no such promise.

Watch it go wrong. A worker spins on an unsynchronized isRunning() while main stops it through a synchronized setter:

public class StaleRead {
    private boolean running = true;

    synchronized void stop() { running = false; }
    boolean isRunning() { return running; }          // BUG: no lock on the read

    public static void main(String[] args) throws InterruptedException {
        StaleRead flag = new StaleRead();
        Thread worker = new Thread(() -> {
            long spins = 0;
            while (flag.isRunning()) spins++;
            System.out.println("worker saw stop after " + spins + " spins");
        });
        worker.setDaemon(true);
        worker.start();
        Thread.sleep(500);
        flag.stop();
        worker.join(2_000);
        System.out.println("worker still running 2 s after stop()? " + worker.isAlive());
    }
}
worker still running 2 s after stop()? true

The JIT compiler saw a loop that reads a plain field nobody in the loop writes, so it read it once and kept the answer. Add synchronized to isRunning() and the same program prints worker saw stop after 204993358 spins and false: the worker stops right after stop(). (The stale version may occasionally exit on a different JVM or machine; "may never see it" is the point, not "never sees it".) (For a lone flag like this, volatile is the lighter fix; that's the "volatile" lesson.) We go deep on why reordering and caching happen in the planned "Happens-before" lesson. For now, the rule is: every access to shared mutable state, reads included, goes through the same lock.

07Four facts you should be able to say in one breath

It's reentrant. A thread that already holds a monitor can enter it again (a synchronized method calling another synchronized method on this doesn't deadlock itself). The JVM keeps a count; see Reentrancy.

Exceptions release the lock. You never write an unlock(), so you can't forget one. javac makes sure of it. Here's javap -c (JDK 21) for a tiny synchronized (lock) { count++; }, with the six count++ instructions squashed onto one line:

       6: monitorenter
       7-14: aload_0, dup, getfield count, iconst_1, iadd, putfield count   (count++)
      17: aload_1
      18: monitorexit                       // normal exit
      19: goto          27
      22: astore_2
      23: aload_1
      24: monitorexit                       // exception path: release, then rethrow
      25: aload_2
      26: athrow
      27: return
    Exception table:
       from    to  target type
           7    19    22   any
          22    25    22   any

Notice the two monitorexit instructions. The exception table says "if anything is thrown between 7 and 19, jump to 22", and 22 releases the lock before rethrowing. Synchronized methods don't even get these instructions: the method carries an ACC_SYNCHRONIZED flag and the JVM does the enter and exit itself.

Waiting threads are BLOCKED. A thread queued for a monitor shows BLOCKED (on object monitor) in a thread dump, with a waiting to lock <0x...> line pointing at the object. That's how you find the hot lock in production; see Thread lifecycle and states.

There's no timeout, no interrupt and no fairness. A thread waiting for a monitor waits forever; interrupt() won't pull it out; and there's no promise the longest waiter goes next. When you need tryLock(500, ms), lockInterruptibly(), a fair queue or several conditions on one lock, that's ReentrantLock (the "ReentrantLock and Condition" lesson).

08What it costs, and how to pay less

Performance angle

An uncontended lock is cheap: Java Concurrency in Practice puts it at roughly 20 to 250 clock cycles, and on modern HotSpot it's one compare-and-swap on the object header. A contended lock is where the money goes: losing threads may park in the OS, and a context switch costs in the order of 5,000 to 10,000 cycles (several microseconds). So the question isn't "is synchronized slow?". It's "how often do threads collide on this lock, and how long do they hold it?"

That gives you three levers.

  • Hold it for less time. Smallest possible critical section. Compute outside, publish inside.
  • Split the lock. If one lock guards two unrelated things (say, users and sessions), give each its own lock. Threads touching different things stop colliding.
  • Stripe the lock. For one big structure, use N locks, each guarding a slice (locks[key.hashCode() & (N - 1)]). This is the idea early ConcurrentHashMap used with 16 segments.

And two rules I treat as non-negotiable:

Where candidates slip

Don't hold a lock during I/O or while calling code you don't control. A network call inside synchronized turns a 200 ms remote hiccup into 200 ms of every other thread being BLOCKED. Calling a listener or callback (alien code) under your lock invites deadlock, because that code may take its own locks in the opposite order. Copy what you need inside the lock, then do the slow or foreign work outside.

Finally, the cheapest lock is the one you don't need. Immutable objects (all fields final, no setters, like String or a record of immutable values) can be shared freely with no lock at all. Confinement keeps state owned by one thread (a local variable, a ThreadLocal, an actor-style single consumer) so nobody else ever touches it. Reach for synchronized when state really is shared and mutable.

09Real world

In the real world

Log4j 1.x and the root-logger bottleneck. In Log4j 1.2, every log call walks up the logger hierarchy in Category.callAppenders, entering synchronized (c) on each logger, and the appenders write to disk or console while that lock is held. Every logger ends at the root, so under load every logging thread in the JVM queues on one monitor. In a thread dump it looks like a wall of threads BLOCKED in callAppenders, all waiting to lock the same RootLogger. It's a textbook case of holding a lock during I/O, and one of the reasons Log4j 2 shipped async loggers built on a lock-free queue.

Hashtable and Collections.synchronizedMap. Both guard every operation with one lock on the whole map, so throughput stops growing past one thread. Java Concurrency in Practice (chapter 11) measures ConcurrentHashMap scaling with thread count while the synchronized map flattens out, which is why ConcurrentHashMap (lock striping, later CAS per bin) replaced them.

Netflix, 2024: "Java 21 Virtual Threads, Dude, Where's My Lock?" Netflix services on Java 21 virtual threads hung outright. The cause: a virtual thread blocking inside synchronized stays pinned to its carrier platform thread. With all carriers pinned while waiting for a lock that only an unmounted virtual thread could release, nothing could make progress. JDK 24 fixed the root cause (JEP 491).

10Advanced: what HotSpot does with your lock

The mark word. Every object starts with a header. Its first word, the mark word, normally holds the identity hash code, GC age bits and a few lock bits. That's where locking state lives.

Thin (lightweight) locks. When a thread enters an unlocked monitor, HotSpot tries one compare-and-swap (CAS) on the mark word. If it wins, it owns the lock, with no OS call and no extra memory. Most locks in real programs are only ever taken this way, because they're rarely contended.

Inflation to a fat lock. When a second thread shows up while the lock is held, or when someone calls wait() (which needs a wait set), HotSpot inflates the lock: it allocates a C++ ObjectMonitor with an owner, an entry queue, a wait set and a recursion count, and points the mark word at it. Threads may spin briefly, then park in the OS. Idle monitors can be deflated later.

Fig. 5

From a thin lock to a fat ObjectMonitor

From a thin lock to a fat ObjectMonitorThree stages left to right. No lock: the object header mark word holds the hash and GC age. Thin lock: one thread wins a compare-and-swap on the header, no OS call. Fat lock: on contention or wait(), the JVM inflates to an ObjectMonitor with an owner, an entry queue and a wait set, and threads may park in the OS.unlockedmark word:hash · age · 01thin lockone CAS on the headerno OS call, nanosecondsfat lockObjectMonitor:owner · entry queuewait set · recursionsentercontentionor wait()exit, nobody else cameMost locks never leave the thin path: no contention, no OS.Fat locks can park threads in the kernel: microseconds, not nanoseconds.HotSpot can deflate idle monitors later.
Thin to fat. An uncontended enter is one CAS on the header. Contention or wait() inflates the lock into an ObjectMonitor, where losing threads can park in the kernel.

Biased locking is gone. HotSpot used to go one step further and "bias" a lock to the first thread, so even the CAS was skipped. It made revocation (when another thread arrived) expensive and the code complex, and modern CAS is cheap. JEP 374 disabled and deprecated it in JDK 15, and the code was removed in JDK 18. If an old tuning guide says -XX:+UseBiasedLocking, ignore it on Java 21.

A new lightweight locking mode. JDK 21 added an alternative fast-locking scheme (JDK-8291555): instead of writing a pointer to a stack-allocated lock record into the mark word, each thread keeps a small lock stack of the objects it holds. In JDK 21 it's opt-in (-XX:+UnlockExperimentalVMOptions -XX:LockingMode=2; the default is 1, the legacy scheme). JDK 23 made it the default (JDK-8319251). Your Java code doesn't change either way.

Elision and coarsening. The JIT can remove a lock entirely when escape analysis proves the object never leaves the thread (lock elision: a local StringBuffer's locks vanish). It can also merge back-to-back blocks on the same object into one (lock coarsening), so sb.append(a); sb.append(b); takes the lock once.

Virtual thread pinning. In JDK 21 to 23, a virtual thread that blocks while holding a monitor (or while waiting to enter one) can't unmount, so it pins its carrier thread. That's the Netflix story above, and why early advice said "replace synchronized with ReentrantLock around blocking calls". JEP 491 in JDK 24 reworked monitors so virtual threads can block inside synchronized and release their carrier. On 24+, that old advice is mostly obsolete.

Where candidates slip

Many candidates say "synchronized is slow, always use ReentrantLock". That was arguably true around Java 5. Today both use a CAS on the fast path, and under contention their throughput is close. Choose ReentrantLock for the features (timeouts, interruptibility, fairness, multiple conditions), not for speed. And on JDK 21 to 23 with virtual threads, choose it around blocking calls to avoid pinning.

synchronized ReentrantLock
Release Automatic, even on exceptions You must unlock() in finally
Timeout / try No tryLock(), tryLock(time, unit)
Interruptible wait No lockInterruptibly()
Fairness No promise Optional fair mode
Conditions One wait set (wait/notify) Many Conditions
Waiters in a thread dump BLOCKED (on object monitor) WAITING (parking)
Virtual threads (JDK 21-23) Pins the carrier when blocked Doesn't pin

11How to say it in the interview

When I ask "how does synchronized work?", I'm listening for three things. Do you say it locks an object (and can you name which object for each form)? Do you mention visibility, not only mutual exclusion? And do you know its limits (no timeout, no interrupt) well enough to say when you'd switch?

The follow-ups I use are short. "What does a static synchronized method lock?" The Class object, so it doesn't block instance methods. "Can two threads call two different synchronized methods on the same object at once?" No, both lock this; but one can run a non-synchronized method freely, and two threads can run the same synchronized method on two different objects. "synchronized vs ReentrantLock?" Same core semantics; ReentrantLock adds tryLock with timeout, interruptible waits, fairness and multiple conditions, at the cost of manual unlock() in finally.

In the interview

"Every Java object has a monitor: a lock with one owner at a time, plus a queue of threads waiting for it. synchronized on an instance method locks this, on a static method it locks the Class object, and a block locks whatever object I pass. So two threads only contend if they lock the same object. It gives me mutual exclusion and visibility: whatever I wrote before unlocking is guaranteed visible to the next thread that locks the same monitor, which is why reads need the lock too. It's reentrant, and the compiler emits a second monitorexit on the exception path, so the lock is always released. Waiting threads are BLOCKED, with no timeout and no interrupt. In practice I lock a private final object, keep the critical section tiny and never do I/O inside it. If I need tryLock, timeouts or fairness, I use ReentrantLock."

On the whiteboard

  • Monitor = lock (1 owner) + entry set (BLOCKED) + wait set
  • instance method → this · static → X.class · block → any object
  • Same object = contend · different objects = parallel · static ≠ instance
  • Guarantees: mutual exclusion + visibility (unlock hb next lock, JLS 17.4.5)
  • Lock on private final Object; never String literals, boxed values, reassigned fields
  • Reentrant; released on exception (2× monitorexit); no timeout / interrupt / fairness
  • Uncontended ≈ one CAS; contended = park, µs; keep it small, no I/O
  • HotSpot: thin (CAS) → fat ObjectMonitor; biased gone (JEP 374); pinning fixed in 24 (JEP 491)
Practise this topicSynchronization: interview questions & scenarios
Further reading (13)