Ch. 7 · Java

Java synchronized Explained: Locks, Visibility and Reentrancy

What synchronized guarantees in Java: mutual exclusion, visibility and reentrancy, plus how to guard multi-field invariants with one lock.

~7 min readintermediateupdated Oct 6, 2026

The typical interview question is “What does synchronized do?”, often followed by a buggy class and “Is this thread-safe?” A candidate who answers “it lets only one thread in at a time” has given half the answer. Interviewers want to hear about visibility, which lock is being used, why reads need the lock too, and how to protect an invariant that spans several fields.

The concept underpins most of Java concurrency. Even if you mostly use higher-level tools such as ConcurrentHashMap or AtomicLong, you need to reason about locks to know when those tools are not enough.

Before you start

You should know how to start threads or submit tasks to an executor, and what a race condition is. Basic awareness of the Java Memory Model helps: without synchronization, one thread is not guaranteed to see another thread’s writes. The thread visibility and happens-before note covers that in depth.

The short answer

synchronized acquires an object’s intrinsic lock (its monitor) for the duration of a method or block. It guarantees mutual exclusion, so only one thread at a time runs code guarded by that lock, and visibility, because releasing a lock happens-before any later acquisition of the same lock, so the next thread sees all writes made inside. Instance methods lock this, static methods lock the Class object, and blocks lock whatever object you name. Protect an invariant by guarding every read and write of all its fields with the same lock.

How it works

There are three forms:

public class Counter {
    private long count;
    private static long instances;
    private final Object lock = new Object();

    public synchronized void increment() { count++; }          // locks this

    public static synchronized void created() { instances++; } // locks Counter.class

    public void reset() {
        synchronized (lock) {                                   // locks a private object
            count = 0;
        }
    }
}
java

That class contains a deliberate bug: increment locks this, but reset locks lock. Two different locks provide no mutual exclusion against each other, so a reset can interleave with an increment. Every access to count must use the same lock.

Mutual exclusion matters because many operations are not atomic. count++ is three steps: read, add, write. Two threads can both read 41 and both write 42.

Visibility matters because the JIT compiler and the CPU may keep values in registers or caches and reorder writes. The Java Memory Model defines a happens-before relationship: an unlock on a monitor happens-before every subsequent lock on that same monitor. All writes before the unlock are therefore visible to the thread that next acquires the lock.

Intrinsic locks are reentrant. A thread that holds a lock can acquire it again, so a synchronized method can call another synchronized method on the same object without deadlocking itself. The JVM counts the holds and releases the lock when the count drops to zero. Locks are released automatically when the block exits, including by an exception.

Step-by-step walkthrough

Step 1: Write down the invariant

An invariant is a rule that must always hold between fields. For a bank account transfer system: the sum of all balances never changes. For a range object: lower <= upper. For a bounded buffer: 0 <= size <= capacity. You cannot protect what you have not named.

Step 2: Choose one lock for all fields in the invariant

public class Range {
    private int lower = 0;
    private int upper = 10;

    public synchronized void setLower(int v) {
        if (v > upper) throw new IllegalArgumentException("lower > upper");
        lower = v;
    }

    public synchronized void setUpper(int v) {
        if (v < lower) throw new IllegalArgumentException("upper < lower");
        upper = v;
    }

    public synchronized boolean contains(int x) {
        return lower <= x && x <= upper;
    }
}
java

Without a shared lock, one thread could set lower = 8 while another sets upper = 5; each check passes against the old value of the other field, and the range ends up with lower > upper. Making each field volatile would not help, because the check and the write are still separate steps.

Step 3: Guard reads as well as writes

Unsynchronized reads can see stale values, or a mix of old and new fields that never existed together. contains above is synchronized for that reason. A common shortcut is to synchronize setters only; it breaks both visibility and consistency.

Step 4: Keep critical sections short and lock-private

private final Object lock = new Object();

public void record(Event e) {
    String line = format(e);          // slow work outside the lock
    synchronized (lock) {
        buffer.add(line);
        if (buffer.size() >= 100) flushPending = true;
    }
}
java

Locking a private final object stops outside code from locking the same monitor and causing contention or deadlock. Avoid I/O or remote calls while holding a lock.

Step 5: Order multiple locks consistently

void transfer(Account from, Account to, long amount) {
    Account first = from.id() < to.id() ? from : to;
    Account second = first == from ? to : from;
    synchronized (first) {
        synchronized (second) {
            from.withdraw(amount);
            to.deposit(amount);
        }
    }
}
java

If thread A locks account 1 then 2 while thread B locks 2 then 1, both wait forever. Always acquiring the lower id first removes the cycle.

Worked scenario

An inventory service tracks stock and reservations:

class Inventory {
    private int available;
    private int reserved;

    synchronized void reserve(int qty) {
        if (available < qty) throw new IllegalStateException("insufficient stock");
        available -= qty;
        reserved += qty;
    }

    int total() {                        // not synchronized
        return available + reserved;
    }
}
java

A dashboard polls total() and occasionally shows a number lower than the real stock. total runs without the lock, so it can read available after reserve decremented it but reserved before it was incremented, or see stale cached values altogether. The invariant available + reserved == stock holds at every lock release, but an unsynchronized reader can observe the middle of an update.

The fix guards the read with the same lock, so it only sees states between complete updates:

synchronized int total() {
    return available + reserved;
}
java

If the dashboard polls very frequently and contention matters, an alternative is to keep both values in one immutable object referenced by an AtomicReference and replace it with compareAndSet, so readers get a consistent snapshot without locking.

Common mistake

  • “synchronized is only about one thread at a time.” It also guarantees visibility between threads that use the same lock.
  • Synchronizing only writers. Unsynchronized readers can see stale or inconsistent state.
  • Using different locks for the same data. A synchronized method and a block on another object do not exclude each other.
  • Locking on a String literal or boxed value. Interned literals and cached Integer values are shared across the JVM, so unrelated code may lock the same object.
  • Assuming volatile fixes compound invariants. volatile provides visibility for single reads and writes, not atomic check-then-act across fields.

Verify the behavior

This harness shows lost updates without a shared lock. It is a demonstration, not a proof; for real verification, use jcstress:

public class LostUpdates {
    static int unsafeCount;
    static int safeCount;
    static final Object LOCK = new Object();

    public static void main(String[] args) throws InterruptedException {
        Runnable work = () -> {
            for (int i = 0; i < 1_000_000; i++) {
                unsafeCount++;
                synchronized (LOCK) { safeCount++; }
            }
        };
        Thread t1 = new Thread(work), t2 = new Thread(work);
        t1.start(); t2.start();
        t1.join(); t2.join();
        System.out.println("unsafe=" + unsafeCount + " safe=" + safeCount);
        // safe is always 2000000; unsafe is usually lower
    }
}
java

join also establishes happens-before, which is why the main thread reliably sees the final counts.

Follow-up questions

What is the difference between synchronized and ReentrantLock? ReentrantLock offers tryLock with timeouts, interruptible acquisition, optional fairness and multiple condition variables. It must be released in a finally block. synchronized is simpler and released automatically.

Do virtual threads change the advice? On Java 21, a virtual thread blocking inside synchronized pins its carrier thread. JDK 24 removed most pinning (JEP 491). On 21, prefer ReentrantLock around long blocking operations in virtual-thread code.

What are wait and notify? Methods on Object used with the intrinsic lock to wait for a condition. Always call wait in a loop that rechecks the condition, because spurious wakeups are allowed.

Is a synchronized method on a static field safe if called on different instances? Only if the method is static or locks a shared object. An instance method locks this, so different instances do not exclude each other.

Interview exercise

A class exposes synchronized void add(String item) on a private List<String> and a List<String> items() method that returns the list directly. Is the class thread-safe? What would you change?

Answer and reasoning

No. add is guarded, but items() publishes the internal list, so callers can iterate or modify it without the lock. A caller iterating while another thread calls add can get ConcurrentModificationException or see inconsistent state, and writes through the returned reference bypass the lock entirely.

Fix it by never letting the guarded object escape: make items() synchronized and return a snapshot such as List.copyOf(items). Alternatively, if reads vastly outnumber writes, replace the list with a CopyOnWriteArrayList and drop the lock. The general principle: a lock protects data only if every access path goes through it, so encapsulation is part of thread safety.

Continue learning

Practise more with the Java interview questions and the Java MCQs. Related notes: thread visibility and happens-before, concurrent collections and compound operations and virtual threads. The official tutorial on synchronized methods covers the basics, and the Java Language Specification, chapter 17 defines the memory model.

More in Java

read ✓Java · hard

Java CompletableFuture Error Composition

How exceptions travel through CompletableFuture chains, when to use exceptionally, handle or whenComplete, and why causes arrive wrapped.

~7 min readread →
esc