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.
On this page
- Picture it first: a room with one key
- Under the hood: fixing the lost-update counter
- Three ways to write it
- The lock is the object, not the code
- Choosing the lock object
- Guard every access, including reads
- Four facts you should be able to say in one breath
- What it costs, and how to pay less
- Real world
- Advanced: what HotSpot does with your lock
- 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
BLOCKEDthreads and a wait set - Name the lock each form takes: instance method locks **
this**,static synchronizedlocks the **Classobject, 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 -coutput and point at the **exception-pathmonitorexit** 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.
A monitor is a single-occupancy room with one 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.
Lock the fridge, not the whole kitchen
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.
The lock belongs to an object, not to the code
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:
- Mutual exclusion. Only one thread is inside at a time.
- 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."
Unlock, then lock: a visibility handshake
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
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,
usersandsessions), 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 earlyConcurrentHashMapused with 16 segments.
And two rules I treat as non-negotiable:
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
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.
From a thin lock to a fat ObjectMonitor
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.
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)
Further reading (13)
- Java Language Specification (Java SE 21) §17.1 Synchronization and §17.4.5 Happens-before Order: every object has a monitor; an unlock on a monitor happens-before every subsequent lock on that monitor
- JLS (Java SE 21) §8.4.3.6 synchronized Methods: instance methods lock this, static methods lock the Class object; equivalence with a synchronized block
- JLS (Java SE 21) §14.19 The synchronized Statement: the lock is released whether the block completes normally or abruptly
- JVM Specification (Java SE 21) monitorenter / monitorexit: entry count, reentrancy, ACC_SYNCHRONIZED methods; javap output in the lesson was produced with JDK 21.0.12
- JEP 390: Warnings for Value-Based Classes: javac [synchronization] warning and -XX:DiagnoseSyncOnValueBasedClasses (diagnostic flag; =1 fatal, =2 log)
- JEP 374: Deprecate and Disable Biased Locking: disabled by default in JDK 15; implementation later removed (JDK 18)
- JDK-8291555: Implement alternative fast-locking scheme: lock-stack based lightweight locking, JDK 21 (opt-in via experimental -XX:LockingMode=2; verified default is 1 on 21.0.12)
- JDK-8319251: Change LockingMode default from LM_LEGACY to LM_LIGHTWEIGHT: new lightweight locking becomes the default in JDK 23
- JEP 491: Synchronize Virtual Threads without Pinning: JDK 24: virtual threads can block inside synchronized and release their carrier
- Netflix Tech Blog: Java 21 Virtual Threads - Dude, Where's My Lock? (2024): production hang caused by virtual threads pinned in synchronized blocks on Java 21
- Apache Log4j 1.2 source: Category.callAppenders: synchronized(c) on every logger up to the root while appenders run: the classic logging bottleneck
- Java Concurrency in Practice (Goetz et al., 2006), chapters 2, 3 and 11: intrinsic locks, guarding all accesses, lock splitting and striping; uncontended sync 20-250 cycles, context switch 5,000-10,000 cycles; ConcurrentHashMap vs synchronizedMap scalability
- Collections.synchronizedMap (Java SE 21 API): one lock on the whole map; manual synchronization required for iteration