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;
}
}
}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;
}
}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;
}
}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);
}
}
}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;
}
}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;
}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
Integervalues are shared across the JVM, so unrelated code may lock the same object. - Assuming volatile fixes compound invariants.
volatileprovides 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
}
}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.