What does volatile guarantee, and what does it not?
It guarantees visibility and ordering: a volatile write happens-before every later read of that field, so everything the writer did beforehand becomes visible to the reader. It guarantees nothing about atomicity, so an increment is still a race and a volatile reference says nothing about the object it points to.
What the interviewer is scoring
- Does the candidate separate visibility from atomicity instead of describing volatile as a lightweight lock
- Whether the happens-before edge is stated as covering writes made before the volatile write, not only the field itself
- That the reason a non-volatile flag can spin forever is attributed to permitted compiler optimisation rather than to CPU caches alone
- Can they say why double-checked locking is broken without a volatile field, in terms of what a second thread observes
- Whether they notice that marking a reference volatile publishes the reference and not the contents of the object
Answer
Visibility and ordering, not mutual exclusion
The Java Memory Model does not describe threads reading and writing a single shared memory. It describes which writes a read is allowed to observe, and it deliberately permits a great deal: the compiler may reorder statements, the JIT may hoist a field read into a register, and the hardware may make one core's store visible to another core later than the program order suggests. All of that is legal as long as a correctly synchronised program cannot tell.
volatile narrows what is legal for one field, in two ways. A read of a volatile field always returns the most recent write to it, so the value cannot be cached across the read. And the write establishes a happens-before edge: everything the writing thread did before the volatile write is visible to any thread that subsequently reads that field and sees the new value. The second half is the part worth stating, because it is what makes volatile useful for anything beyond a single flag. The field is not just visible itself; it acts as a barrier that carries the writes that preceded it.
What volatile does not do is exclude. Two threads can be inside the same method touching the same volatile field simultaneously, so any operation that reads a value, computes from it and writes it back remains a race.
Why the flag loop is not a caching story
The textbook example is a worker polling a boolean running that another thread clears to stop it. Without volatile, the loop can run forever, and candidates almost always explain this as the value being stuck in a CPU cache. That is a possible mechanism, but the likelier one is more mundane and worth knowing: nothing inside the loop writes running, so the JIT is entitled to hoist the read out of the loop entirely and compile it into while (true). No amount of cache coherence protocol will rescue you from a read that no longer happens.
public final class Worker implements Runnable {
// Without volatile the JIT may hoist this read out of the loop below.
private volatile boolean running = true;
@Override
public void run() {
while (running) {
doUnitOfWork();
}
}
public void stop() {
running = false; // publishes to any thread that later reads `running`
}
private void doUnitOfWork() { }
}
Where volatile stops being enough
An increment on a volatile field is a load, an add and a store. Two threads can both load 7, both store 8, and one update is gone. volatile makes each of those three steps visible; it does not make the sequence indivisible. The fix is either a lock or a class from java.util.concurrent.atomic, whose compare-and-set loop retries until its read is still current. AtomicInteger and friends carry the same memory semantics as a volatile field on top of that atomicity, which is why they are a strict upgrade rather than a different tool.
import java.util.concurrent.atomic.AtomicLong;
final class Counters {
private volatile long viaVolatile;
private final AtomicLong viaAtomic = new AtomicLong();
void bump() {
viaVolatile++; // read-modify-write: updates are lost
viaAtomic.incrementAndGet(); // one atomic operation, same visibility
}
long volatileValue() { return viaVolatile; }
long atomicValue() { return viaAtomic.get(); }
}
There is one narrow atomicity guarantee volatile does add. Reads and writes of a non-volatile long or double are not required to be atomic, so a 64-bit value can in principle be observed half-updated. Declaring it volatile forbids that. This is a real clause in the specification and a fine detail to mention, but do not present it as the main point, because the tearing case is far rarer in practice than the lost-update case above.
Double-checked locking, and what the second thread sees
The classic lazy singleton reads the field, and only if it is null takes a lock and reads again. Without volatile on the field it is broken, and the failure is precise rather than vague. The constructor's writes to the new object's fields and the write of the reference into the static field can be observed out of order by a thread that never took the lock. So a second thread can see a non-null reference and hand its caller a half-built object whose fields are still zero and null. Nothing throws.
public final class Registry {
// volatile is load-bearing: it orders the constructor's writes before the
// publication of the reference, so no thread can see a partial object.
private static volatile Registry instance;
private final int size;
private Registry() {
this.size = expensiveSetup();
}
public static Registry getInstance() {
Registry local = instance; // one volatile read on the hot path
if (local == null) {
synchronized (Registry.class) {
local = instance;
if (local == null) {
instance = local = new Registry();
}
}
}
return local;
}
public int size() { return size; }
private static int expensiveSetup() { return 42; }
}
Having explained it, say what you would write instead. The initialisation-on-demand holder idiom puts the instance in a nested static class, and the JVM's own class-initialisation locking gives you laziness and thread safety with no volatile read on the hot path and no way to get it wrong. Double-checked locking earns its keep when the thing being guarded is a per-instance field rather than a static, which is exactly the case the holder idiom cannot cover.
The mistake that survives code review
Declaring a reference volatile protects the reference. It says nothing whatsoever about the object on the other end of it. A volatile Map<String, String> cache guarantees that a thread which sees a new map sees a fully constructed one, and guarantees nothing at all about two threads calling put on the same map. This is the shape of bug that reviews miss, because the field carries a keyword that looks like it addressed concurrency. The pattern that does work is to make the referenced object immutable and replace it wholesale, so the volatile write publishes a new consistent snapshot rather than guarding a mutable one.
The related point is that final fields have their own guarantee, and it is stronger for the immutable case: a thread that observes a properly constructed object through any reference sees its final fields correctly initialised, with no volatile and no lock. That is why immutability is the cheapest concurrency strategy in Java, and why "make it final and copy on write" beats "make it volatile" whenever you have the choice.
Likely follow-ups
- Why is the initialisation-on-demand holder idiom usually a better singleton than double-checked locking?
- What does the final-field guarantee give you that volatile does not?
- When would LongAdder beat AtomicLong, and why?
- Does making every field volatile make a class thread-safe?
Related questions
- How would you design a thread-safe component, and why is adding synchronized to every method not a design?hardSame kind of round: concept7 min
- How do you tell whether a value escapes to the heap, and how would you find the allocations that are costing you?hardSame kind of round: concept6 min
- How do you decide between BFS and DFS - and how do you recognise a graph problem that nobody described as a graph?mediumSame kind of round: coding4 min
- How do goroutines leak, and how would you find a leak in a running service?mediumSame kind of round: concept5 min
- Your function returns nil on the success path, the caller checks err != nil, and the check fires anyway. What happened?hardSame kind of round: concept4 min
- Design rejected the native control, so you are building a custom one. How do you make it accessible?hardSame kind of round: concept5 min
- A client hangs up halfway through a request. How do you structure a Go HTTP service so it stops doing the work?mediumSame kind of round: concept7 min
- You run ten coroutines concurrently and one of them raises. What happens to the other nine?hardSame kind of round: concept5 min