Chapter 06 · Article 34 of 55
Thread Safety and Synchronization
Thread safety is the property of code that guarantees correct behavior when accessed by multiple threads simultaneously, without requiring external coordination. A piece of code…
Article outline17 sections on this page+
Overview
Thread safety is the property of code that guarantees correct behavior when accessed by multiple threads simultaneously, without requiring external coordination. A piece of code is thread-safe if it functions correctly during simultaneous execution by multiple threads, regardless of the scheduling or interleaving of those threads by the runtime environment.
Why it matters:
- Modern systems are inherently concurrent - web servers handle thousands of requests, microservices process events in parallel, and even mobile apps run background tasks alongside UI threads.
- Incorrect concurrent code produces heisenbugs - defects that appear intermittently, are nearly impossible to reproduce in testing, and manifest catastrophically in production under load.
- Data corruption from unsafe concurrent access can cascade: corrupted indexes, lost transactions, inconsistent caches, and security vulnerabilities.
Definition: Code is thread-safe when it maintains its invariants and produces correct results regardless of how many threads execute it concurrently, without requiring the caller to provide additional synchronization.
Thread safety exists on a spectrum - from fully immutable objects (inherently safe) to unprotected mutable shared state (inherently unsafe). The goal is to choose the lightest-weight mechanism that guarantees correctness for your specific access pattern.
Levels of thread safety:
- Immutable - objects cannot be modified after creation; always safe
- Unconditionally thread-safe - mutable but internally synchronized; callers need no external coordination (e.g.,
ConcurrentHashMap) - Conditionally thread-safe - some operations require external locking for compound actions (e.g., iterating over
Collections.synchronizedList) - Not thread-safe - callers must provide all synchronization (e.g.,
ArrayList,HashMap)
Understanding where your code falls on this spectrum is the first step toward correct concurrent design.
Race Conditions
A race condition occurs when the correctness of a program depends on the relative timing or interleaving of multiple threads. The outcome becomes non-deterministic.
ASCII Timeline - Interleaved Read-Modify-Write
Shared variable: counter = 0
Thread A Thread B
──────── ────────
READ counter (0)
READ counter (0)
COMPUTE 0 + 1
COMPUTE 0 + 1
WRITE counter = 1
WRITE counter = 1
Expected: counter = 2
Actual: counter = 1 ← LOST UPDATE
Common Patterns
Check-then-act:
if (map.containsKey(key)) { // Check
return map.get(key); // Act - another thread may have removed key
}
Read-modify-write:
counter = counter + 1;
// Decomposes to: READ → COMPUTE → WRITE (3 separate operations)
Both patterns are unsafe because the compound action is not atomic - another thread can observe or modify state between the individual steps.
Consequences of race conditions:
- Lost updates (as shown above - increments disappear)
- Dirty reads (observing partially-written state)
- Stale data (acting on outdated values)
- Violated invariants (e.g., a transfer debits one account but crashes before crediting the other)
Race conditions are particularly dangerous because they may pass all unit tests (single-threaded) and only manifest under production load with specific timing.
Critical Sections
A critical section is a code region that accesses shared mutable state and must not be executed by more than one thread at a time.
Identifying critical sections:
- Find all shared mutable variables
- Identify every code path that reads or writes those variables
- Any sequence where a thread relies on the state remaining unchanged between operations is a critical section
Mutual exclusion requirement: Only one thread may execute inside a critical section at any given time. All other threads attempting entry must wait (block) until the occupying thread exits.
The challenge is making critical sections as small as possible - large critical sections reduce concurrency and create bottlenecks, while sections that are too small may fail to protect compound invariants.
Design principles for critical sections:
- Minimize scope: Hold locks for the shortest duration possible. Move I/O, computation, and logging outside the critical section.
- Consistent ordering: When multiple locks are needed, always acquire them in the same order across all threads to prevent deadlock.
- Don't call alien methods: Avoid calling user-supplied callbacks or overridable methods while holding a lock - you cannot predict what they will do (they might try to acquire the same lock, causing deadlock).
- Prefer private lock objects: Using
thisas a lock exposes your synchronization to external code. A privatefinal Object lock = new Object()gives you full control.
Achieving Thread Safety
| Approach | Mechanism | Overhead | Best For |
|---|---|---|---|
| Immutability | No mutation after construction | Zero runtime cost | Value objects, configuration, messages |
| Synchronization | Locks/monitors enforce mutual exclusion | Context switching, contention | Complex invariants spanning multiple variables |
| Atomic operations | Hardware CAS instructions | Minimal (no kernel transition) | Single-variable updates, counters, flags |
| Thread-local storage | Each thread owns its copy | Memory per thread | Per-request context, non-shared accumulators |
| Confinement | Restrict access to one thread | Zero synchronization cost | GUI frameworks, actor model, channel-based designs |
Synchronized Blocks
The monitor concept provides mutual exclusion by associating a lock with an object. Only one thread can hold the monitor at a time.
Synchronized Method
class BankAccount:
balance = 0
synchronized method withdraw(amount):
if balance >= amount:
balance = balance - amount
return true
return false
Synchronized Block (finer granularity)
class TransferService:
method transfer(from, to, amount):
synchronized(from.lock):
synchronized(to.lock):
if from.balance >= amount:
from.balance -= amount
to.balance += amount
Intrinsic locks (also called monitor locks):
- Every object has an associated intrinsic lock
- A
synchronizedblock acquires the lock on entry and releases it on exit (even if an exception is thrown) - Locks are reentrant - a thread that already holds a lock can re-acquire it without deadlocking
- The lock is associated with the object instance for instance methods, or the Class object for static methods
Monitor concept: A monitor combines mutual exclusion with the ability to wait for a condition. The monitor has:
- A lock (only one thread can hold it)
- A wait set (threads waiting for a condition)
- Operations:
wait()releases the lock and enters the wait set;notify()/notifyAll()wakes waiting threads
Limitations of intrinsic locks:
- Cannot interrupt a thread waiting to acquire a lock
- Cannot attempt to acquire a lock without blocking (no tryLock)
- Cannot specify fairness policy
- For these advanced needs, use
ReentrantLockfromjava.util.concurrent.locks
Volatile Keyword
The volatile qualifier provides a visibility guarantee: any write to a volatile variable is immediately visible to all threads reading that variable. It prevents the compiler and CPU from caching the value in registers or reordering instructions around it.
When Volatile Suffices
- A single flag read by many threads, written by one:
volatile boolean shutdownRequested - Publishing an immutable object reference after construction
When Synchronization Is Needed
- Compound operations (check-then-act, read-modify-write)
- Multiple variables that form a single invariant
Happens-Before with Volatile
A write to a volatile variable happens-before every subsequent read of that same variable. This means all memory writes performed by Thread A before writing the volatile are visible to Thread B after it reads that volatile.
Thread A: x = 42; volatileFlag = true; // x=42 is visible...
Thread B: if (volatileFlag) { use(x); } // ...here, guaranteed
Atomic Operations
Compare-and-Swap (CAS)
CAS is a hardware-level instruction that atomically:
- Reads the current value at a memory location
- Compares it to an expected value
- If they match, writes a new value; otherwise, does nothing
- Returns whether the swap succeeded
function compareAndSwap(memoryLocation, expectedValue, newValue):
// Executed atomically by hardware
if memoryLocation.value == expectedValue:
memoryLocation.value = newValue
return true
return false
Atomic Variables
AtomicInteger, AtomicLong, AtomicReference wrap CAS in a convenient API:
atomicCounter = new AtomicInteger(0)
atomicCounter.incrementAndGet() // atomic read-modify-write
atomicCounter.compareAndSet(expected, newVal) // CAS
Lock-Free Programming Basics
Lock-free algorithms use CAS in a retry loop - if the CAS fails (another thread modified the value), the thread re-reads and retries. This guarantees system-wide progress: at least one thread always succeeds.
Progress guarantees:
- Wait-free: Every thread completes in a bounded number of steps (strongest guarantee, hardest to achieve)
- Lock-free: At least one thread makes progress in a finite number of steps (system-wide progress guaranteed)
- Obstruction-free: A thread makes progress if all other threads are suspended (weakest useful guarantee)
The ABA problem: A CAS may succeed incorrectly if a value changes from A → B → A between the read and the CAS. The thread sees A (matching its expected value) but the state has changed semantically. Solutions include versioned references (AtomicStampedReference) that pair the value with a monotonically increasing version number.
Pseudocode Implementation
Thread-Safe Counter - Three Approaches
1. Synchronized:
class SyncCounter:
count = 0
synchronized method increment():
count = count + 1
synchronized method get():
return count
2. Atomic:
class AtomicCounter:
count = new AtomicInteger(0)
method increment():
count.incrementAndGet()
method get():
return count.get()
3. Lock-Free (CAS loop):
class LockFreeCounter:
count = new AtomicInteger(0)
method increment():
loop:
current = count.get()
next = current + 1
if count.compareAndSet(current, next):
return
// CAS failed - another thread won; retry
Thread-Safe Singleton - Double-Checked Locking
class Singleton:
static volatile instance = null
static method getInstance():
if instance == null: // First check (no lock)
synchronized(Singleton.class):
if instance == null: // Second check (with lock)
instance = new Singleton()
return instance
The volatile keyword prevents instruction reordering that could expose a partially-constructed object.
Thread-Safe Collection Wrapper
class SynchronizedList<T>:
private list = new ArrayList<T>()
private lock = new ReentrantLock()
method add(item):
lock.acquire()
try:
list.add(item)
finally:
lock.release()
method get(index):
lock.acquire()
try:
return list.get(index)
finally:
lock.release()
method iterate(consumer):
lock.acquire()
try:
for item in list:
consumer.accept(item)
finally:
lock.release()
Immutability as Thread Safety
Immutable objects are inherently thread-safe because their state cannot change after construction. No synchronization is ever needed - any number of threads can read the object concurrently without risk.
Why It Works
- No write operations exist → no race conditions possible
- Safe publication: once a reference to an immutable object is visible, the object's state is guaranteed to be fully constructed and consistent
- No defensive copying needed when sharing across threads
Designing Immutable Classes
class ImmutableConfig:
private final host // all fields are final
private final port
private final options // deep copy mutable collections
constructor(host, port, mutableOptionsList):
this.host = host
this.port = port
this.options = unmodifiableCopy(mutableOptionsList)
// No setters - only getters
method getHost(): return host
method getOptions(): return options // already unmodifiable
Rules:
- Declare all fields
final(or equivalent) - Don't provide setter methods
- Make the class non-extendable (final class or private constructor + factory)
- Deep-copy mutable objects passed to the constructor
- Don't expose mutable internal state
Thread-Local Storage
Thread-local storage gives each thread its own independent copy of a variable. Threads never see each other's values - eliminating sharing eliminates races.
Use cases:
- Per-request context (user ID, transaction ID, locale)
- Non-thread-safe objects that are expensive to create (SimpleDateFormat, database connections)
- Accumulators in parallel computations
class RequestContext:
private static threadLocal = new ThreadLocal<Context>()
static method set(ctx):
threadLocal.set(ctx)
static method get():
return threadLocal.get()
static method clear():
threadLocal.remove() // Prevent memory leaks in thread pools
// Usage in a web server:
method handleRequest(request):
RequestContext.set(new Context(request.userId, request.traceId))
try:
processBusinessLogic() // Any code can call RequestContext.get()
finally:
RequestContext.clear() // Critical in pooled threads
Warning: In thread-pool environments, always clear thread-local values after use - threads are reused, and stale values leak between requests.
Happens-Before Relationship
The happens-before relationship is the foundation of the Java Memory Model (and similar models in other languages). It defines when one thread's write is guaranteed to be visible to another thread's read.
Key Happens-Before Rules
| Action A | Action B | Guarantee |
|---|---|---|
| Unlock monitor M | Lock monitor M (same M, different thread) | All writes before unlock are visible after lock |
| Write to volatile V | Read of volatile V | All writes before the volatile write are visible after the volatile read |
thread.start() | First action in started thread | All writes before start() are visible in new thread |
| Last action in thread | thread.join() returns | All writes in terminated thread are visible after join |
Memory Model Basics
Without happens-before guarantees, the JVM/CPU may:
- Cache variables in CPU registers (invisible to other cores)
- Reorder instructions for performance
- Delay flushing writes to main memory
Synchronization primitives (locks, volatile, atomics) establish happens-before edges that force visibility and ordering.
Practical implications:
- If you access shared data without establishing a happens-before relationship, you have a data race - the behavior is undefined (in C++) or unpredictable (in Java).
- The happens-before relationship is transitive: if A happens-before B, and B happens-before C, then A happens-before C. This transitivity is what makes volatile flags work for publishing complex state.
finalfields have special semantics: if an object is properly constructed (nothisescape), all threads will see the correct values of its final fields without synchronization.
Real-World Examples
Concurrent Counters
Web servers tracking request counts, metrics collectors aggregating events - use AtomicLong or LongAdder (striped counters for high contention).
Shared Caches
A ConcurrentHashMap storing computed results. Writers populate entries; readers access them. The map's internal segmentation provides fine-grained locking without external synchronization.
Connection Pools
A bounded pool of database connections shared across request threads. A BlockingQueue holds available connections - take() blocks when empty, put() returns connections. The queue handles all synchronization internally.
Request-Scoped Data
Thread-local storage carries user authentication, transaction context, and trace IDs through the call stack without passing them as parameters. Frameworks like Spring use this pattern extensively (SecurityContextHolder, RequestContextHolder).
Practical Considerations
When choosing a thread-safety strategy for real systems:
- Measure contention first. Profile under realistic load before optimizing. Many "hot" locks turn out to be uncontended in practice.
- Prefer higher-level abstractions.
ConcurrentHashMap,BlockingQueue, andCompletableFutureencapsulate complex synchronization correctly. Don't reinvent them. - Test with stress tools. Use tools like
jcstress, ThreadSanitizer (TSan), or custom stress harnesses that amplify timing variations to expose races. - Document thread-safety guarantees. Annotate classes with
@ThreadSafe,@NotThreadSafe, or@GuardedBy("lock")to communicate intent to future maintainers.
Advantages & Disadvantages of Each Approach
| Approach | Advantages | Disadvantages | Performance |
|---|---|---|---|
| Synchronization (locks) | Simple mental model; protects compound actions; well-understood | Contention under load; risk of deadlock; coarse granularity hurts throughput | Medium - context switches on contention |
| Atomic operations | No blocking; no deadlock; excellent for single variables | Cannot protect multi-variable invariants; CAS retry loops under high contention | High - hardware-level, no kernel calls |
| Immutability | Zero synchronization cost; no bugs possible; freely shareable | Object creation overhead; not suitable for all state; requires new object per change | Highest - no coordination needed |
| Thread-local storage | No contention; simple API; per-thread isolation | Memory overhead per thread; leaks in thread pools if not cleared; not for shared state | High - no synchronization |
| Confinement | Eliminates shared state entirely; simple reasoning | Requires message passing; latency for cross-thread communication; architectural constraint | High - but adds messaging overhead |
| Volatile | Lightweight visibility guarantee; no blocking | Only for single reads/writes; no atomicity for compound operations | High - memory barrier only |
Interview Follow-ups
Q1: What is the difference between synchronized and volatile?
A: synchronized provides both mutual exclusion (only one thread in the critical section) and visibility (changes are flushed to main memory on unlock). volatile provides only visibility - it guarantees that reads see the latest write, but does not prevent race conditions in compound operations like counter++. Use volatile for simple flags; use synchronized or atomics for read-modify-write patterns.
Q2: Can a thread-safe class still have concurrency bugs when composed with other classes?
A: Yes. Individual thread-safe operations can compose into unsafe sequences. For example, calling if (!map.containsKey(k)) { map.put(k, v); } on a ConcurrentHashMap is a race condition even though each individual call is thread-safe. The solution is to use atomic compound operations like putIfAbsent() or external synchronization over the compound action.
Q3: Why is double-checked locking broken without volatile?
A: Without volatile, the JVM may reorder the object construction. A thread can observe a non-null reference to a partially-constructed object - fields may not yet be initialized. The volatile keyword establishes a happens-before edge that prevents this reordering, ensuring the object is fully constructed before the reference becomes visible.
Hints-Only Questions
Q4: How would you implement a thread-safe bounded buffer (producer-consumer)?
- Hint 1: Consider a circular array with separate head/tail pointers protected by conditions (not-full, not-empty).
- Hint 2: Look into
ReentrantLockwith twoConditionobjects, or use aBlockingQueueimplementation.
Q5: How does ConcurrentHashMap achieve better throughput than Collections.synchronizedMap()?
- Hint 1: Think about lock granularity - what if you could lock individual buckets instead of the entire map?
- Hint 2: Consider how read operations might proceed without locking at all (volatile reads of node values).
Counter Questions to Ask Interviewer
-
"What's the expected contention level?" - Low contention favors simple locks; high contention favors lock-free structures or striped designs.
-
"Is the shared state read-heavy or write-heavy?" - Read-heavy workloads benefit from
ReadWriteLockor copy-on-write structures; write-heavy workloads need fine-grained locking or atomics. -
"Are we targeting a specific memory model (JMM, C++ memory model, Go's happens-before)?" - Guarantees differ across languages and runtimes.
-
"Is the system running on a single node or distributed?" - Thread-level synchronization doesn't help across nodes; you'd need distributed locks (ZooKeeper, Redis) or CRDTs.
-
"What's the failure mode preference - correctness at the cost of latency, or best-effort with eventual consistency?" - This determines whether strong synchronization or optimistic approaches are appropriate.
References & Whitepapers
-
Java Memory Model (JSR-133) - Manson, Pugh, Adve. "The Java Memory Model" (POPL 2005). Defines happens-before semantics, volatile guarantees, and final field semantics. JSR-133 FAQ
-
Java Concurrency in Practice - Goetz, Peierls, Bloch, Bowbeer, Holmes, Lea (2006). The definitive guide to writing correct concurrent Java programs. Covers safety, liveness, performance, and testing.
-
The Art of Multiprocessor Programming - Herlihy, Shavit (2008). Formal treatment of lock-free and wait-free algorithms, CAS-based data structures, and linearizability.
-
A Practical Lock-Freedom - Keir Fraser (2004, PhD thesis). Introduces practical lock-free techniques including multi-word CAS and software transactional memory.
-
C++ Memory Model - Boehm, Adve. "Foundations of the C++ Concurrency Memory Model" (PLDI 2008). Relevant for understanding memory ordering beyond Java.