Create two threads, have each increment a shared counter 100,000 times, and check the total.
It should be 200,000. It almost never is. That missing count is the entire reason
synchronization exists, and explaining why it goes missing — then choosing the right fix
among synchronized, ReentrantLock, volatile and atomics — is the most reliable way to
demonstrate real concurrency understanding in a senior Java interview.
The race condition, reproduced
Before fixing the problem, watch it happen. This program is deliberately broken:
public class RaceCondition {
private static int counter = 0;
public static void main(String[] args) throws InterruptedException {
Runnable work = () -> {
for (int i = 0; i < 100_000; i++) {
counter++; // looks atomic, is not
}
};
Thread t1 = new Thread(work);
Thread t2 = new Thread(work);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Expected 200000, got " + counter);
}
}
Run it five times and you will likely get five different results, all short of 200,000. The
culprit: counter++ compiles to three separate steps — read the value, add one, write it
back. When both threads read 41500 at the same moment, both write 41501, and one increment
evaporates. This interleaving is called a race condition, and no amount of testing on your
laptop proves its absence — it just proves you got lucky in those runs.
synchronized methods: one thread at a time
The synchronized keyword makes a thread acquire an object's monitor lock before entering
the guarded code. While one thread holds the lock, every other thread trying to enter code
guarded by the same lock goes to the BLOCKED state — described in detail in the
thread lifecycle — until the lock frees.
public class SafeCounter {
private int count = 0;
public synchronized void increment() {
count++; // now effectively atomic: lock, read-add-write, unlock
}
public synchronized int current() {
return count;
}
public static void main(String[] args) throws InterruptedException {
SafeCounter counter = new SafeCounter();
Runnable work = () -> {
for (int i = 0; i < 100_000; i++) {
counter.increment();
}
};
Thread t1 = new Thread(work);
Thread t2 = new Thread(work);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Count: " + counter.current()); // always 200000
}
}
Now the total is exactly 200,000 on every run. synchronized delivers two guarantees at once:
mutual exclusion (one thread in the critical section) and visibility (writes made
under the lock are visible to the next thread that acquires it — a happens-before edge in the
Java Memory Model). Interviewers love asking for both; most candidates remember only the first.
An instance method locks on this; a static synchronized method locks on the Class object.
Those are different locks — a common source of bugs when static and instance state mix.
synchronized blocks: shrink the critical section
Locking an entire method is often too coarse. A synchronized block lets you guard only the
lines that touch shared state, and lets you choose a private lock object so external code
cannot interfere with your locking:
import java.util.ArrayList;
import java.util.List;
public class AuditLog {
private final Object lock = new Object();
private final List<String> entries = new ArrayList<>();
public void record(String event) {
String stamped = System.currentTimeMillis() + " " + event; // no lock needed
synchronized (lock) {
entries.add(stamped); // only the shared mutation is guarded
}
}
public int size() {
synchronized (lock) {
return entries.size();
}
}
}
The timestamp formatting happens outside the lock — other threads are not forced to wait for work that touches no shared state. Keeping critical sections short is the single most effective synchronization optimization available to you.
Common mistake: A common mistake beginners make is synchronizing on a mutable or shared value like a
Stringliteral orInteger— literals are interned, so unrelated classes may accidentally share your lock (see how the String pool works). Always lock on aprivate final Objectyou construct yourself.
volatile: visibility without locking
Without synchronization, the JVM is allowed to cache variables in CPU registers or per-core caches — so one thread's write may simply never be seen by another. That is not a race over who writes first; it is a visibility failure. The classic symptom is a stop flag that never stops anything:
public class StoppableWorker {
private volatile boolean running = true;
public void stop() {
running = false; // guaranteed visible to the worker thread
}
public void workLoop() {
long iterations = 0;
while (running) {
iterations++; // without volatile, this loop may never exit
}
System.out.println("Stopped after " + iterations + " iterations");
}
public static void main(String[] args) throws InterruptedException {
StoppableWorker worker = new StoppableWorker();
Thread t = new Thread(worker::workLoop);
t.start();
Thread.sleep(100);
worker.stop();
t.join();
}
}
Remove volatile and, under a HotSpot server JIT, this loop can genuinely spin forever — the
compiler may hoist the running read out of the loop. With volatile, every read comes from
main memory and every write is published immediately.
What volatile does not give you is atomicity: volatile int count still loses updates
under count++, because read-add-write remains three steps. The rule of thumb: volatile is
for one-writer flags and safely publishing references; anything read-modify-write needs a lock
or an atomic class.
ReentrantLock: synchronized with superpowers
ReentrantLock (from java.util.concurrent.locks) does everything synchronized does, plus
the tricks synchronized cannot: attempt a lock without blocking, give up after a timeout,
respond to interruption while waiting, and hand out multiple Condition queues.
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public class InventoryService {
private final ReentrantLock lock = new ReentrantLock();
private int stock = 10;
public boolean reserve(int qty) {
boolean acquired = false;
try {
acquired = lock.tryLock(200, TimeUnit.MILLISECONDS);
if (!acquired) {
return false; // do not hang the request thread — fail fast
}
if (stock >= qty) {
stock -= qty;
return true;
}
return false;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
} finally {
if (acquired) {
lock.unlock();
}
}
}
}
The pattern to internalize: lock() (or a successful tryLock) is always paired with
unlock() in a finally block. Unlike synchronized, which releases its monitor
automatically even when exceptions fly, a forgotten unlock() leaves the lock held forever.
tryLock with a timeout is also a practical
deadlock defense — a thread that cannot get the second
lock backs off instead of waiting forever.
Interview note: "ReentrantLock vs synchronized" is a standard question at the 3-years-experience level. The strong answer: prefer
synchronizedfor simplicity and automatic release; switch toReentrantLockonly when you need tryLock, timed or interruptible acquisition, fairness, or multiple conditions.
Atomic classes: lock-free single variables
For the counter problem specifically, locking is heavier than necessary. AtomicInteger uses
the CPU's compare-and-swap (CAS) instruction — an optimistic, lock-free update that retries on
conflict instead of blocking:
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicCounter {
private static final AtomicInteger counter = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Runnable work = () -> {
for (int i = 0; i < 100_000; i++) {
counter.incrementAndGet(); // atomic read-add-write via CAS
}
};
Thread t1 = new Thread(work);
Thread t2 = new Thread(work);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Count: " + counter.get()); // always 200000
}
}
No thread ever blocks, no thread ever enters BLOCKED — a losing thread just retries its CAS.
The same idea scales up: AtomicLong, AtomicReference, and LongAdder for extremely hot
counters. The whole java.util.concurrent package is built on these primitives, including
ConcurrentHashMap — compared against plain HashMap in
HashMap vs ConcurrentHashMap.
Choosing the right tool
| Situation | Reach for |
|---|---|
| Boolean flag, one writer | volatile |
| Single counter or reference | AtomicInteger / AtomicReference |
| Multi-step invariant (check-then-act) | synchronized block |
| Need tryLock, timeout, fairness, conditions | ReentrantLock |
| Shared map/list under concurrency | Concurrent collections |
| State that never changes | Immutability — no synchronization at all |
Pro tip: The best synchronization is the kind you delete. Before reaching for any lock, ask whether the state can be immutable, thread-confined (each task owns its own data — the default when you structure work as independent tasks), or replaced by a
java.util.concurrentutility. Manual locking should be the last resort, not the first instinct — every lock you write is a deadlock you might debug later.
Synchronization questions escalate fast: from "what does synchronized do" to "why is volatile
not enough for count++" to "design a thread-safe rate limiter". If you can reproduce the race
condition from the first example on demand, name both guarantees of synchronized, and
justify each row of the table above, you are operating at the level these interviews target.
Frequently Asked Questions
What does the synchronized keyword do in Java?
What is the difference between synchronized and volatile?
When should I use ReentrantLock instead of synchronized?
Is AtomicInteger faster than synchronized?
What does reentrant mean in ReentrantLock?
Why is String a good candidate for sharing between threads?
Want to Build Your Career in Java Full Stack with AI?
Join CodeBegun and train with working industry engineers — Explore the Program

