Java Thread Safety: Synchronization and Atomic Variables
java thread safety: Learn how to make Java code thread-safe with synchronized, volatile, atomic variables, and concurrent collections, and when to choose each approach.
java thread safety requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.
Consider a simple counter shared by multiple threads:
public class Counter { private int count = 0; public void increment() { count++; } public int getCount() { return count; } }
If two threads call increment() concurrently, the final value may be 1 instead of 2. The count++ operation is not atomic; it reads the value, adds one, and writes it back. Between the read and the write, another thread can modify the field. This is a race condition, and the code is not thread-safe. Java thread safety is the discipline of ensuring that shared state remains correct when accessed by multiple threads.
The Core Problem: Shared Mutable State
Thread safety breaks down when multiple threads read and write the same mutable field without coordination. The counter example fails because the increment sequence is interleaved. The problem is not the operation itself but the lack of a guarantee that the read-modify-write cycle is performed as a single unit.
To make the counter thread-safe, you must control access to the shared field. Java provides several mechanisms, each with different tradeoffs in performance, complexity, and flexibility.
The Java Memory Model and Visibility
The Java Memory Model (JMM) defines when one thread's changes are visible to another. Without proper synchronization, a thread may see a stale value because the compiler or CPU can reorder instructions or cache data in registers. The volatile keyword addresses visibility and ordering for a single field.
public class VolatileFlag { private volatile boolean running = true; public void stop() { running = false; } public void work() { while (running) { // do work } } }
Writing to a volatile field establishes a happens-before relationship: any subsequent read of that field sees the write. However, volatile does not provide atomicity. A volatile counter still fails with concurrent increments because the read and write are separate operations. Use volatile only when the shared field is written by one thread and read by others, or when updates are atomic (like a flag).
Synchronized Methods and Blocks
The synchronized keyword provides both mutual exclusion and visibility. A synchronized block or method acquires a monitor lock, ensuring that only one thread executes the guarded code at a time. When a thread exits the block, its changes are visible to any thread that subsequently enters the same lock.
public class SynchronizedCounter { private int count = 0; public synchronized void increment() { count++; } public synchronized int getCount() { return count; } }
Synchronizing both increment and getCount is essential. If only the write is synchronized, a reader can still see a stale value. The lock is reentrant: a thread that already holds the lock can enter other synchronized methods on the same object. This avoids self-deadlock when methods call each other.
Synchronized blocks offer finer control than methods:
public void addIfAbsent(String key, String value) { synchronized (lock) { if (map.containsKey(key)) { map.put(key, value); } } }
Using a dedicated lock object reduces contention when different parts of a class need separate locks. However, synchronized code incurs overhead from lock acquisition and release, especially under high contention.
Atomic Variables for Lock-Free Updates
The java.util.concurrent.atomic package provides classes like AtomicInteger, AtomicLong, and AtomicReference. They use compare-and-set (CAS) operations, which are atomic at the hardware level. CAS updates a value only if it still equals an expected value, retrying on failure. This avoids blocking threads.
import java.util.concurrent.atomic.AtomicInteger; public class AtomicCounter { private final AtomicInteger count = new AtomicInteger(0); public void increment() { count.incrementAndGet(); } public int getCount() { return count.get(); } }
Atomic variables provide thread safety without locks, which often scales better under low to moderate contention. They also support compound operations like compareAndSet and updateAndGet. For simple counters or sequence generators, atomic classes are the most direct solution.
Concurrent Collections for Shared Data
When multiple threads read and write a collection, use classes from java.util.concurrent instead of synchronizing manually. ConcurrentHashMap is a thread-safe map with high concurrency: reads are lock-free, and writes are synchronized per bucket. CopyOnWriteArrayList is ideal for read-heavy workloads where iteration occurs more often than modification.
import java.util.concurrent.ConcurrentHashMap; public class Cache { private final ConcurrentHashMap<String, String> store = new ConcurrentHashMap<>(); public String get(String key) { return store.get(key); } public void put(String key, String value) { store.put(key, value); } }
These collections handle the synchronization internally, so you do not need external locks. They also provide atomic methods like putIfAbsent and computeIfAbsent, which are useful for avoiding check-then-act races.
Thread Confinement and Immutability
Sometimes the simplest way to achieve thread safety is to avoid sharing state. Thread confinement restricts a mutable object to a single thread. ThreadLocal provides per-thread instances:
private static final ThreadLocal<SimpleDateFormat> formatter = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
Each thread gets its own SimpleDateFormat, eliminating contention because SimpleDateFormat is not thread-safe. Immutability is another form of confinement: if an object's fields are final and the object is safely published, it is inherently thread-safe. For example:
public final class Point { private final int x; private final int y; public Point(int x, int y) { this.x = x; this.y = y; } }
Immutable objects can be shared freely without synchronization, as long as they are not modified after construction.
Choosing the Right Thread-Safety Mechanism
The decision depends on the nature of the shared state and the access pattern. The following table summarizes the main options:
| Mechanism | Best For | Limitations |
|---|---|---|
volatile | Single-writer flags, visibility guarantees | No atomic compound operations |
synchronized | Complex critical sections, multiple statements | Lock contention, blocking |
| Atomic classes | Simple counters, sequence numbers, CAS | Limited to single variable |
| Concurrent collections | Shared maps, lists, queues | Higher memory overhead, specific APIs |
| ThreadLocal | Per-thread state, non-thread-safe utilities | Memory leak risk if not removed |
| Immutable objects | Values that never change after construction | Requires careful design |
Use volatile when a field is written by one thread and read by many. Use synchronized when you need to protect multiple statements or when the critical section is complex. Prefer atomic classes for simple counters. Choose concurrent collections for shared data structures. Rely on thread confinement or immutability when the design permits.
Performance and Contention Tradeoffs
Lock-based synchronization has a cost: acquiring and releasing a monitor can involve OS-level operations, especially under contention. When many threads compete for the same lock, they block and resume, which adds latency and reduces throughput. Atomic variables avoid blocking by spinning on CAS, but under high contention they can cause cache-coherence traffic and retries.
In practice, the right choice depends on the expected contention level. For low contention, atomic variables are often faster than synchronized blocks. For high contention, a well-designed lock (e.g., ReentrantLock) may perform better because it can park threads instead of spinning. Concurrent collections are tuned for common access patterns and are usually the safest starting point for shared data structures.
Measuring performance is essential. Do not assume one mechanism is always faster. Profile your application under realistic load to identify bottlenecks. Also consider the cost of memory visibility: a non-volatile read can be optimized by the JIT, while a volatile read prevents certain reorderings. These effects are subtle and can affect overall throughput.
Finally, remember that thread safety is not just about correctness under concurrent access; it also affects maintainability. Overusing synchronization can make code hard to reason about and introduce deadlocks. Prefer simpler, more constrained mechanisms when they meet the requirements. A clear design that minimizes shared mutable state is often the most effective way to achieve thread safety in Java.