One keyword - synchronized - and two threads stop corrupting each other's data. But what did the keyword actually buy you? The JVM spec has a surprisingly small answer: one mechanism, the monitor, and a handful of bytecode instructions. Let's open it up with real javap output, a live look at a blocked thread, and the Linux primitive the JVM leans on when a thread truly has to wait.
One construct: the monitor
The JVM spec (JVMS 2.11.10) is blunt: "The Java Virtual Machine supports synchronization of both methods and sequences of instructions within a method by a single synchronization construct: the monitor."
Every object can act as a monitor. synchronized (lock) { ... } means: enter the monitor associated with lock; only one thread can own it at a time; everyone else waits. That is the entire mechanism - everything else is how it is compiled and what waiting costs.
Here is the journey of two threads and one monitor, animated:
Two forms, two very different bytecodes
A synchronized block and a synchronized method look alike in Java. In bytecode they are nothing alike. This class:
public class SyncBytecode { private final Object lock = new Object(); private int count; public void incrementBlock() { synchronized (lock) { count++; } } public synchronized void incrementMethod() { count++; } } javap -c on the block form - real output:
public void incrementBlock(); Code: 0: aload_0 1: getfield #7 // Field lock:Ljava/lang/Object; 4: dup 5: astore_1 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 19: goto 27 22: astore_2 23: aload_1 24: monitorexit 25: aload_2 26: athrow 27: return Exception table: from to target type 7 19 22 any 22 25 22 any Read it closely - there are two monitorexit instructions. One on the normal path (offset 18), one inside a compiler-generated cleanup (offset 24) guarded by an exception-table row of type any. If count++ somehow threw, control would jump to offset 22, the monitor would still be released, and then athrow would rethrow. That is the spec's promise, made visible: "the executing thread enters a monitor, invokes the method itself, and exits the monitor whether the method invocation completes normally or abruptly." A forgotten unlock on the exception path is impossible by construction.
The method form? javap -v shows there are no monitor instructions in its code at all - just a flag on the method:
public synchronized void incrementMethod(); flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED Per the same spec section, "method-level synchronization is performed implicitly, as part of method invocation and return... the ACC_SYNCHRONIZED flag... is checked by the method invocation instructions." The entering and exiting still happen; they are just the JVM's job at the call boundary rather than explicit bytecodes.
What waiting looks like
A thread that wants a held monitor doesn't spin visibly - it changes state. The Thread.State JavaDoc defines BLOCKED as: "A thread that is blocked waiting for a monitor lock is in this state." You can watch it happen. Two threads, one lock, the holder sleeping inside its critical section:
before start: holder=NEW waiter=NEW while contended: holder=TIMED_WAITING waiter=BLOCKED waiter got the lock after finish: holder=TERMINATED waiter=TERMINATED Real output, one run. The holder is TIMED_WAITING (it is inside Thread.sleep - a timed wait, holding the monitor the whole time; sleep never releases locks). The waiter is BLOCKED - parked on the monitor, making zero progress, until the holder's monitorexit lets it in. Note the distinction the states encode: BLOCKED means "waiting for a monitor"; WAITING/TIMED_WAITING mean "waiting for something else" (a notify, a sleep, a join).
Where the kernel genuinely appears
If nobody else wants the monitor, entering it is pure user-space work in the JVMs we know: an atomic compare-and-swap in the runtime's own code, no system call, no scheduler, no kernel. That is an implementation strategy, not a spec promise - the JVMS defines the monitor's behavior, not how a JVM parks threads.
Contention changes the story. When a thread genuinely cannot proceed, it must stop running, and stopping a thread so it can be woken later is an operating system service. On Linux, the primitive built for exactly this is the futex. From futex(2): "The futex() system call provides a method for waiting until a certain condition becomes true... When using futexes, the majority of the synchronization operations are performed in user space. A user-space program employs the futex() system call only when it is likely that the program has to block for a longer time until the condition becomes true."
And this is not just theory on the machine that ran the demo above. Tracing the same BlockedState run with strace on this Linux sandbox counted the real syscalls:
% time seconds usecs/call calls errors syscall ------ ----------- ----------- --------- --------- ---------------- 100.00 0.036076 49 728 55 futex 728 futex calls across the JVM's threads in that one short, contended run. Two honest readings of that number. What it shows: this JVM, on this OS, reaches for the futex syscall routinely - the user-space-plus-kernel design from the man page is really in use. What it does not show: strace -c aggregates process-wide, so we cannot attribute individual calls to our two threads' monitor contention specifically - some of that traffic is the JVM's ordinary internal bookkeeping, and a handful of EAGAIN errors are normal futex races. Treat it as a process-level observation, not causal proof about the monitor's internals. And the scope stands as before: this is the HotSpot-based OpenJDK 21 on Linux that we measured - other JVMs and other operating systems park threads differently, and the spec guarantees none of it.
What it costs
How much does an uncontended synchronized cost? Honestly: more than a plain call, less than folklore says, and pinning down the number is genuinely hard. An uncontended monitor enter/exit is real extra work around your code - an atomic operation, bookkeeping on the object, a memory barrier's worth of ordering guarantees - where a plain call has none of that. Under contention it gets more expensive in a different way: threads stop and sleep on the OS path from the previous section, and waking them later costs far more than any single instruction.
Why not just show you a number here? Because a quick nanoTime loop cannot produce a fair one. The JIT treats a synchronized empty-ish method and a plain one differently: it can fold, coalesce or eliminate work in one path but not the other, so a homegrown loop ends up measuring the optimizer as much as the monitor. JVM engineers use JMH, the OpenJDK benchmarking harness built to defend against exactly those artifacts. So take the qualitative version, which is solid: synchronization costs something even when nobody competes, contention costs a lot more, and both are worth paying exactly where correctness requires them - nowhere else.
The free bonus: visibility
Mutual exclusion is only half of what a monitor gives you. The Java memory model adds a guarantee (JLS 17.4.5): "An unlock on a monitor happens-before every subsequent lock on that monitor." Everything thread A wrote before releasing the monitor is visible to thread B after it acquires the same monitor. No stale caches, no half-seen objects - this is why synchronized fixes both races and visibility bugs at once.
Habits worth keeping
- Lock on a private final object, like the
lockfield above - never onthis(callers can grab it too) and never on a String (the string pool means unrelated code may share your monitor - see the strings walkthrough). - Keep critical sections tiny. Every nanosecond inside is a nanosecond every other thread waits.
- Never
sleepwhile holding a monitor unless that is genuinely the intent - sleeping holds the lock. - Reach for
java.util.concurrent(locks with timeouts, concurrent collections, atomics) before building anything elaborate out of rawsynchronized.
Sources
- JVMS 2.11.10: Synchronization
- Java 21 API: Thread.State
- JLS 17.4.5: Happens-before Order
- Linux man-pages: futex(2)
Written with AI assistance; every bytecode listing, thread state and syscall trace above comes from real compiles, runs and strace counts on JDK 21 (Temurin 21.0.13) on this Linux sandbox - the trace is a process-wide observation, and HotSpot's internal lock implementation is version-specific. The animation is an illustration of the monitor's logical behavior.
