Back to Blog
Java

Java volatile vs synchronized: Visibility and Atomicity

Understand the difference between volatile and synchronized in Java: visibility, atomicity, memory semantics, and when to use each.

concurrencyvolatilesynchronizedmemory visibilityatomicityJava memory model
Diagram comparing volatile field access and synchronized block in Java concurrency

When two threads access the same field, Java's memory model does not guarantee that one thread sees the other's write unless you establish a happens-before relationship. Both volatile and synchronized create such relationships, but they do so in different ways and with different guarantees. The choice between volatile and synchronized comes down to whether you need visibility alone or visibility plus atomicity.

What volatile Actually Guarantees

A volatile field has special visibility rules in the Java Memory Model. A write to a volatile field happens-before any subsequent read of that same field, so a later read sees that write instead of a stale cached value.

public class Flag { private volatile boolean running = true; public void stop() { running = false; } public void work() { while (running) { // do work } } }

Here, a read of running in work() that happens after the write in stop() is guaranteed to see false. Without volatile, the loop may never see the change because the JIT compiler or CPU may keep the old value in a register or thread-local cache.

However, volatile does not provide atomicity. Compound operations like counter++ are not safe with volatile alone because they involve a read-modify-write sequence that can interleave between threads.

What synchronized Adds

synchronized provides mutual exclusion: only one thread can execute a block or method guarded by the same monitor at a time. This prevents race conditions on compound operations. It also provides visibility: writes made before leaving a synchronized block are visible to code that later enters a block guarded by the same monitor, because the unlock happens-before the subsequent lock.

public class Counter { private int count = 0; public synchronized void increment() { count++; } public synchronized int getCount() { return count; } }

Here, increment() is atomic with respect to other synchronized methods on the same instance. The count++ operation cannot be interleaved with another increment() call. Visibility is also handled because the lock acquisition and release establish happens-before edges.

Memory Model and Happens-Before Rules

The Java Memory Model defines several happens-before rules. Two are central here:

  • A write to a volatile field happens-before every subsequent read of that field.
  • An unlock (exiting a synchronized block) happens-before every subsequent lock (entering a synchronized block) on the same monitor.

These rules guarantee that all memory operations performed before the volatile write or before the unlock are visible to code that performs the subsequent volatile read or acquires the monitor afterward. This is stronger than just the field itself; it also applies to any other variables written before the volatile write or before the synchronized block exits.

When volatile Is Sufficient

Use volatile when you need to publish a single value that is read frequently and written occasionally, and the write does not depend on the current value. Typical cases:

  • A boolean flag to signal shutdown or cancellation.
  • A reference to an immutable object that is replaced atomically.
  • A status field that is updated by one thread and read by many.

volatile does not acquire a lock, so it avoids lock contention and thread suspension. It is also simpler to reason about when the operation is a simple assignment.

When synchronized Is Required

Use synchronized when you need to protect compound operations or invariants that span multiple fields. For example, updating a counter, adding to a collection while checking its size, or modifying several fields that must be consistent together.

synchronized also allows you to wait and notify using wait(), notify(), and notifyAll(), which are not possible with volatile. If you need blocking coordination, synchronized is the way.

Performance and Contention

volatile reads and writes are generally cheaper than acquiring a lock because they do not involve lock contention or thread suspension. However, they still require memory barriers that can prevent some CPU optimizations. Under heavy write contention, the cost of cache coherence can be non-trivial.

synchronized has overhead from lock acquisition and release, and under contention, threads may block and be rescheduled. JVMs use lock optimizations to reduce some of this cost, but contention still hurts throughput. If you only need visibility, volatile avoids that overhead. If you need atomicity, synchronized is the straightforward choice, though java.util.concurrent.atomic classes like AtomicInteger offer lock-free atomic updates that can be faster under moderate contention.

Common Pitfalls and Alternatives

A common mistake is using volatile for a counter because the field is marked volatile. This still loses updates. Another pitfall is assuming that volatile on a reference makes all fields of the referenced object visible. It does not; only the reference itself is volatile.

For counters, consider AtomicInteger or LongAdder. For more complex state, use synchronized, or ReentrantLock when you need advanced features like tryLock() or timed waits. For immutable snapshots, you can also use volatile references to immutable objects.

The decision between volatile and synchronized should be based on whether the operation is a single field write or a compound action. If you need both visibility and atomicity, use synchronized or an atomic class. If you only need visibility of a single field, volatile is the lighter-weight tool.

Java volatile vs synchronized: Visibility, Atomicity, and When to Use Each | RYUSLOG DEV