What does volatile guarantee, and what does it not?
volatile guarantees visibility and ordering: a write is seen by any later read, and operations are not reordered across it. It never guarantees atomicity, so volatile count++ still loses updates — it is three operations, and volatile makes each of them visible without making the trio indivisible.
The Answer
Say this in the room. 45 seconds.
- There are three separate problems in shared-memory concurrency: atomicity, visibility and ordering. Most wrong answers collapse them into one.
volatilefixes visibility — a write is seen by any thread that reads the field afterwards — and ordering — nothing is reordered across it.volatiledoes nothing for atomicity.count++is read, add, write; making each step visible does not make the three indivisible.- So a
volatilecounter still loses updates, and loses a lot of them. - For atomicity you need mutual exclusion (
synchronized,ReentrantLock) or a CAS type (AtomicInteger). Both also give you visibility, so they are strictly stronger. synchronizedprotects a monitor, not a block of code. Two methods guarding different objects do not exclude each other, no matter how they read.- And the important one: you cannot establish thread-safety by running it. A visibility bug that never reproduces on your laptop is still a bug.
Understand It
Three problems, not one
| Problem | The question it answers | Fixed by |
|---|---|---|
| Atomicity | Can another thread see this operation half-done? | synchronized, Lock, atomic types |
| Visibility | If I write, will another thread ever read the new value? | volatile, synchronized, atomic types |
| Ordering | Can the compiler or CPU move these operations past each other? | volatile, synchronized, atomic types |
volatile sits in the bottom two rows and nowhere else. Almost every "but I made it volatile" bug is someone using it for the top row.
volatile does not make count++ atomic
count++ compiles to three operations: read the field, add one, write it back. volatile guarantees each of those reads and writes is seen by other threads. It does not stop another thread reading between your read and your write.
Eight threads, a hundred thousand increments each, on a volatile int:
VolatileCounter counter = new VolatileCounter();
hammer(counter::increment);
int expected = THREADS * PER_THREAD;
System.out.println("volatile int, " + THREADS + " threads x " + PER_THREAD + " increments each");
System.out.println(" expected : " + expected);
System.out.println(" actual : " + counter.count);
System.out.println(" lost updates? : " + (counter.count < expected ? "yes" : "no"));volatile int, 8 threads x 100000 increments each
expected : 800000
actual : 269044
lost updates? : yesThe exact number varies on every run and on every machine — that is why this block is verified for shape rather than value. What does not vary is that it is wrong, and wrong by a lot. Two thirds of the increments vanished with no exception, no warning and no log line.
Read that number again if you have ever shipped a volatile counter. It is not a rare interleaving; it is the normal case.
What does work, and why both
AtomicInteger atomic = new AtomicInteger();
hammer(atomic::incrementAndGet);
SynchronizedCounter guarded = new SynchronizedCounter();
hammer(guarded::increment);
int expected = THREADS * PER_THREAD;
System.out.println("expected : " + expected);
System.out.println("AtomicInteger : " + atomic.get());
System.out.println("synchronized : " + guarded.get());expected : 800000
AtomicInteger : 800000
synchronized : 800000Exact, both of them, every run.
AtomicInteger uses a compare-and-swap: read the value, compute the new one, and swap it in only if the field still holds what you read. If it does not, retry. No thread blocks, so under light contention this is the fastest option — and under heavy contention the retries are wasted work, and a lock can win.
synchronized takes a monitor, so exactly one thread is inside at a time. It also establishes a happens-before edge on entry and exit, which is why you get visibility for free and do not need volatile on fields you only touch inside the lock.
Neither is "the right answer" in general. AtomicInteger for a single field; a lock when you need two or more fields to change together, because a pair of atomics is not an atomic pair.
synchronized protects a monitor, not code
This is the second-biggest source of wrong answers. synchronized does not mean "one thread at a time in this method". It means "one thread at a time holding this object's monitor". Guard different objects and there is no exclusion at all:
Locks locks = new Locks();
System.out.println("two different monitors:");
CountDownLatch inside = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
Thread a = thread(() -> locks.holdA(inside, release));
Thread b = thread(() -> locks.enterB(inside));
b.join();
release.countDown();
a.join();
System.out.println("one shared monitor:");
Object shared = new Object();
CountDownLatch inside2 = new CountDownLatch(1);
CountDownLatch release2 = new CountDownLatch(1);
CountDownLatch atTheDoor = new CountDownLatch(1);
Thread c = thread(() -> locks.holdShared(shared, inside2, release2));
Thread d = thread(() -> locks.enterShared(shared, inside2, atTheDoor));
atTheDoor.await();
Thread.sleep(20);
release2.countDown();
c.join();
d.join();two different monitors:
A: inside synchronized(lockA)
B: inside synchronized(lockB), while A still holds lockA
A: leaving lockA
one shared monitor:
A: inside synchronized(shared)
B: waiting for the shared monitor
A: leaving shared
B: inside synchronized(shared), only after A leftBoth halves are forced by latches, so that ordering is fixed rather than lucky. In the first half B walks straight into its critical section while A is still inside its own. Both methods are synchronized. Neither excludes the other.
Two consequences worth carrying into an interview:
- A
synchronizedinstance method locksthis. Asynchronizedstatic method locks theClassobject. They are different monitors, so an instance method and a static method do not exclude each other even in the same class. - Locking on a field you reassign, or on a boxed
Integeror internedString, is a bug. In the first case you are locking different objects over time; in the second, unrelated code can lock the same object.
What volatile is actually for
Not counters. Three real uses:
A one-way flag. One thread writes, others read, and no read-modify-write is involved:
private volatile boolean shutdown = false;
public void stop() { shutdown = true; }
public void run() {
while (!shutdown) { pollOnce(); }
}
Without volatile there is no guarantee the loop ever observes the write. The JIT is entitled to hoist a non-volatile field read out of the loop, because in a single-threaded reading of the code nothing changes it.
Safe publication of an immutable object. Assign a fully built, never-mutated object to a volatile field, and any thread that reads the reference sees the object fully constructed. This is how you swap a config or a lookup table at runtime with no lock.
Double-checked locking — which needs volatile and does not work without it:
private volatile Cache instance;
public Cache get() {
Cache local = instance;
if (local == null) {
synchronized (this) {
local = instance;
if (local == null) instance = local = new Cache();
}
}
return local;
}
Without volatile, another thread can see a non-null reference to an object whose constructor has not finished, because the write publishing the reference may be reordered ahead of the writes initialising the fields. This is the canonical example of why ordering is a separate problem from visibility.
The part that makes this a senior question
Everything above can be reasoned about. Almost none of it can be tested.
A missing volatile may work for years. It depends on the JIT's decisions, the number of cores, the memory ordering of the CPU architecture, and how much other work happens to interleave. x86 has a comparatively strong memory model; ARM does not — which is why "it works in production" started meaning less once servers moved to ARM.
So a passing test tells you nothing about a visibility bug. The only reliable tool is the happens-before relationship: if there is no ordering edge between a write in one thread and a read in another, the read is not guaranteed to see the write, and no amount of running it establishes otherwise.
That is also why the atomicity demonstration above is the one this page can show you. It fails reliably. The visibility bug is the one that waits.
Interviewer's Next Move
The question after the question. This is what the round is actually testing.
1. "What does volatile guarantee?" Visibility and ordering. A write to a volatile field is visible to any thread that reads it afterwards, and operations are not reordered across it. It guarantees nothing about atomicity.
2. "So why is a volatile counter still broken?"
count++ is read, add, write — three operations. Volatile makes each step visible without making the trio indivisible, so two threads can read the same value and both write back the same increment. At eight threads and 800,000 increments the loss is typically most of them.
3. "How do you fix it?"
AtomicInteger for one field, using compare-and-swap. A lock when two or more fields must change together, because two atomics do not make an atomic pair. Both also give visibility, so volatile becomes unnecessary.
4. "When is volatile the right tool?" A one-way flag that one thread writes and others read; safe publication of an immutable object; and double-checked locking, which is broken without it. Anything with a read-modify-write is not on that list.
5. "Does a synchronized instance method exclude a synchronized static method in the same class?"
No. The instance method locks this; the static method locks the Class object. Different monitors, no exclusion. This is the question that separates people who know the mechanism from people who know the keyword.
6. "Why does double-checked locking need volatile?" Without it, the write that publishes the reference can be reordered before the writes that initialise the object, so another thread can see a non-null reference to a half-constructed object. Ordering, not visibility, is the failure.
7. "Your test passes. Is the code thread-safe?" No conclusion follows. A visibility bug depends on the JIT, the core count and the CPU's memory model, so it can hide for years and appear on a different architecture. Correctness here is argued with happens-before, not demonstrated by a green test.
8. "AtomicInteger or a lock, under heavy contention?" CAS retries when the swap fails, so at high contention an atomic can burn CPU losing races while a lock parks the thread and lets one winner proceed. Measure it. The default choice is the atomic; the informed choice depends on contention.
Code traps
Trap A — predict before you run:
public class Registry {
private volatile Map<String, String> entries = new HashMap<>();
public void put(String k, String v) {
entries.put(k, v);
}
}
Answer
volatile here protects the reference, not the map. Concurrent put calls corrupt the HashMap exactly as they would without it. volatile on a mutable collection is one of the most common false-confidence bugs in Java.
Two correct fixes, depending on intent: use a ConcurrentHashMap, or keep volatile and replace the whole map with a new immutable copy on every write — which is right when reads vastly outnumber writes.
Trap B:
public synchronized void transfer(Account to, int amount) {
this.balance -= amount;
to.balance += amount;
}
Answer
The method locks this, so it protects this account and not to. Two concurrent transfers in opposite directions each hold their own monitor and both write the other's balance unguarded — money is created or destroyed.
Locking both accounts introduces the classic deadlock instead, unless you always acquire them in a consistent global order, for example by account id. That ordering requirement is the real answer to "how do you prevent a deadlock".
Trap C:
private Integer counter = 0;
public void increment() {
synchronized (counter) {
counter++;
}
}
Answer
Broken twice. counter++ on a boxed Integer replaces the object, so each thread locks a different monitor after the first increment — and Integer values up to 127 are cached and shared, so unrelated code locking on the same value locks against you. Lock on a private final Object, or use AtomicInteger and no lock at all.
Common wrong answers
| Said in interviews | Reality |
|---|---|
| "volatile makes the operation atomic." | It makes each read and write visible. count++ is three of them. |
| "volatile is a lightweight synchronized." | It does two of synchronized's three jobs and omits the one people need most. |
| "synchronized means one thread in the method." | One thread holding that monitor. Different objects, no exclusion. |
| "volatile on my map makes it thread-safe." | It protects the reference. The map is corrupted exactly as before. |
| "I tested it with threads and it passed." | A visibility bug is not falsified by a passing run, on your machine, today. |
| "AtomicInteger is always faster than a lock." | CAS retries under contention. At high contention a lock can win. |
Check Yourself
Q1. Why does volatile fix a while (!shutdown) loop but not a count++?
Answer
The loop only needs visibility — one thread writes, others read, with no read-modify-write. count++ needs atomicity: it is read, add, write, and volatile makes each step visible without preventing another thread from acting between your read and your write.
Q2. Two synchronized methods in one class. Name the case where they do not exclude each other.
Answer
When one is static and the other is not: the instance method locks this, the static method locks the Class object. Also when they are called on two different instances, since each has its own monitor — which is the correct behaviour, and the bug when the state they guard is shared.
Q3. Your integration test hammers the class with 50 threads and passes. What have you proved?
Answer
That this JIT, on this many cores, with this CPU's memory model, on this run, did not expose a defect. Nothing about atomicity is proved either, since an interleaving may simply not have occurred. Reason with happens-before; use a passing test as weak evidence, never as an argument.
Practice
| Tier | Exercise | Time |
|---|---|---|
| Warm-up | Watch volatile lose two thirds of the increments | 10 min |
| Challenge | Fix four broken guards | 25 min |
| Production | The balance that created money | 45 min |
| Interview | Full round replay | 10 min |
What changed, and when
Version boundaries are where follow-ups live. Know the change and the behaviour it replaced.
- Java 5 (1.5)
volatile gained a happens-before guarantee — a write to a volatile field is visible to every later read of it, and surrounding operations are not reordered across it.
Before Java 5 (1.5): volatile only stopped the compiler caching the field in a register. Nothing ordered other writes around it, which is why double-checked locking was broken however carefully it was written.
- Java 24
A virtual thread that blocks on a synchronized monitor no longer pins its carrier thread, so synchronized code stops capping virtual-thread scalability.
Before Java 24: Blocking inside synchronized pinned the carrier platform thread. A synchronized bottleneck could starve a virtual-thread workload, and the workaround was to replace it with a ReentrantLock.
Practice ladder
Reading this page is not knowing it. Four tiers, ending in a real incident.
- Warm-up10 min
Watch volatile lose two thirds of the increments
One concept, guided. Near-impossible to fail.
- Challenge25 min
Fix four broken guards
Edge cases. You have to reason, and two valid fixes differ.
- Production incident45 min
The balance that created money
A real incident: symptom first, cause hidden, tradeoff at the end.
- Interview replay10 min
Full round replay — volatile and synchronized
Timed verbal replay with pass/fail criteria per follow-up.
Where this question goes next
- happens before — not written yet
- atomic vs lock — not written yet
- concurrenthashmap internals — not written yet
- executor service — not written yet
Every runnable example above was compiled and executed against openjdk 21.0.11 on this build, and its output diffed against what this page claims. Last updated 2026-08-27.