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:

  1. Find all shared mutable variables
  2. Identify every code path that reads or writes those variables
  3. 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 this as a lock exposes your synchronization to external code. A private final Object lock = new Object() gives you full control.

Achieving Thread Safety

ApproachMechanismOverheadBest For
ImmutabilityNo mutation after constructionZero runtime costValue objects, configuration, messages
SynchronizationLocks/monitors enforce mutual exclusionContext switching, contentionComplex invariants spanning multiple variables
Atomic operationsHardware CAS instructionsMinimal (no kernel transition)Single-variable updates, counters, flags
Thread-local storageEach thread owns its copyMemory per threadPer-request context, non-shared accumulators
ConfinementRestrict access to one threadZero synchronization costGUI 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 synchronized block 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:

  1. A lock (only one thread can hold it)
  2. A wait set (threads waiting for a condition)
  3. 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 ReentrantLock from java.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:

  1. Reads the current value at a memory location
  2. Compares it to an expected value
  3. If they match, writes a new value; otherwise, does nothing
  4. 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:

  1. Declare all fields final (or equivalent)
  2. Don't provide setter methods
  3. Make the class non-extendable (final class or private constructor + factory)
  4. Deep-copy mutable objects passed to the constructor
  5. 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 AAction BGuarantee
Unlock monitor MLock monitor M (same M, different thread)All writes before unlock are visible after lock
Write to volatile VRead of volatile VAll writes before the volatile write are visible after the volatile read
thread.start()First action in started threadAll writes before start() are visible in new thread
Last action in threadthread.join() returnsAll 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.
  • final fields have special semantics: if an object is properly constructed (no this escape), 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, and CompletableFuture encapsulate 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

ApproachAdvantagesDisadvantagesPerformance
Synchronization (locks)Simple mental model; protects compound actions; well-understoodContention under load; risk of deadlock; coarse granularity hurts throughputMedium - context switches on contention
Atomic operationsNo blocking; no deadlock; excellent for single variablesCannot protect multi-variable invariants; CAS retry loops under high contentionHigh - hardware-level, no kernel calls
ImmutabilityZero synchronization cost; no bugs possible; freely shareableObject creation overhead; not suitable for all state; requires new object per changeHighest - no coordination needed
Thread-local storageNo contention; simple API; per-thread isolationMemory overhead per thread; leaks in thread pools if not cleared; not for shared stateHigh - no synchronization
ConfinementEliminates shared state entirely; simple reasoningRequires message passing; latency for cross-thread communication; architectural constraintHigh - but adds messaging overhead
VolatileLightweight visibility guarantee; no blockingOnly for single reads/writes; no atomicity for compound operationsHigh - 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 ReentrantLock with two Condition objects, or use a BlockingQueue implementation.

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

  1. "What's the expected contention level?" - Low contention favors simple locks; high contention favors lock-free structures or striped designs.

  2. "Is the shared state read-heavy or write-heavy?" - Read-heavy workloads benefit from ReadWriteLock or copy-on-write structures; write-heavy workloads need fine-grained locking or atomics.

  3. "Are we targeting a specific memory model (JMM, C++ memory model, Go's happens-before)?" - Guarantees differ across languages and runtimes.

  4. "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.

  5. "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

  1. 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

  2. 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.

  3. The Art of Multiprocessor Programming - Herlihy, Shavit (2008). Formal treatment of lock-free and wait-free algorithms, CAS-based data structures, and linearizability.

  4. A Practical Lock-Freedom - Keir Fraser (2004, PhD thesis). Introduces practical lock-free techniques including multi-word CAS and software transactional memory.

  5. C++ Memory Model - Boehm, Adve. "Foundations of the C++ Concurrency Memory Model" (PLDI 2008). Relevant for understanding memory ordering beyond Java.