PrepZone Logo
PrepZone

Thread Safety

Race conditions, what synchronized and volatile each guarantee, and where ThreadLocal fits.

Why this matters

  • The count++ race is the canonical concurrency bug, and understanding why it happens explains almost every other one.
  • volatile is routinely used where synchronized is required, which produces code that works in testing and fails under load.
  • Thread safety is the most common area for follow-up questions in a backend interview, because the wrong answer is so easy to give confidently.

A race condition

Java
public class Counter {
    private int count = 0;

    public void increment() {
        count++;              // not one operation
    }

    public int get() {
        return count;
    }
}
Java
Counter counter = new Counter();
ExecutorService pool = Executors.newFixedThreadPool(4);
for (int i = 0; i < 10_000; i++) {
    pool.submit(counter::increment);
}
pool.shutdown();
pool.awaitTermination(1, TimeUnit.MINUTES);

System.out.println(counter.get());     // 9,847 — some number below 10,000
Unsynchronised — update lost
A reads 5
B reads 5Before A writes
A writes 6
B writes 6Expected 7
Synchronised — correct
A acquires the lock
A reads 5, writes 6
A releases, B acquires
B reads 6, writes 7
count++ is read, add, write — three steps. Two threads can read the same value before either writes, so one increment disappears. A lock makes the three steps atomic.

count++ compiles to three steps: read the field, add one, write it back. Two threads can read the same value, both compute the same result, and both write it. One increment disappears.

The two problems, separately

  • Atomicity. The read-modify-write sequence can be interleaved. Another thread may act between any two steps.
  • Visibility. Each thread may cache the field in a CPU register or a core-local cache. A write by one thread is not guaranteed to become visible to another without a synchronisation point.

synchronized

Acquiring a monitor gives mutual exclusion and a memory barrier in one.

Java
public class Counter {
    private int count = 0;

    public synchronized void increment() {      // locks `this`
        count++;
    }

    public synchronized int get() {              // must also be synchronized, for visibility
        return count;
    }
}

What a monitor guarantees

  • Mutual exclusion — only one thread holds a given monitor at a time, so the block is atomic with respect to other synchronised blocks on the same monitor.
  • Visibility — releasing a monitor flushes the thread's writes; acquiring it invalidates cached reads. Everything done before the release is visible to the next acquirer.
  • Reentrancy — a thread already holding a monitor can acquire it again, which is why one synchronised method may call another.

Both the read and the write must be synchronised. A synchronised increment with an unsynchronised get fixes atomicity and leaves visibility broken.

Instance versus static locks

Java
public class Service {
    public synchronized void instanceMethod() { }     // locks `this`
    public static synchronized void staticMethod() { } // locks Service.class

    private final Object lock = new Object();
    public void narrow() {
        prepare();                     // not under the lock
        synchronized (lock) {
            critical();                 // only this part is serialised
        }
    }
}

These are different monitors, so an instance method and a static method do not exclude each other.

volatile

volatile guarantees visibility and ordering. It does not make an operation atomic.

Java
public class Service {
    private volatile boolean running = true;     // correct use: a simple flag

    public void stop() {
        running = false;                          // immediately visible to all threads
    }

    public void loop() {
        while (running) {                          // re-read from memory each time
            doWork();
        }
    }
}

Without volatile the JIT may hoist the field read out of the loop, caching true forever. The thread then never stops — a real and reproducible bug.

Java
private volatile int count;

public void increment() {
    count++;         // STILL BROKEN — volatile does not make read-modify-write atomic
}
AspectGuaranteevolatile vs synchronized
Visibilityvolatile: yessynchronized: yes
Atomicityvolatile: no — only single reads and writessynchronized: yes, for the whole block
Prevents reorderingvolatile: yessynchronized: yes
Blocks threadsvolatile: neversynchronized: yes
Correct forA flag, or publishing an immutable objectAny compound operation
CostA memory barrierBarrier plus possible contention
  • Visibility

    Guaranteevolatile: yes
    volatile vs synchronizedsynchronized: yes
  • Atomicity

    Guaranteevolatile: no — only single reads and writes
    volatile vs synchronizedsynchronized: yes, for the whole block
  • Prevents reordering

    Guaranteevolatile: yes
    volatile vs synchronizedsynchronized: yes
  • Blocks threads

    Guaranteevolatile: never
    volatile vs synchronizedsynchronized: yes
  • Correct for

    GuaranteeA flag, or publishing an immutable object
    volatile vs synchronizedAny compound operation
  • Cost

    GuaranteeA memory barrier
    volatile vs synchronizedBarrier plus possible contention

Use volatile when one thread writes and others read. Use synchronized when anyone reads-then-writes.

Atomic classes

For single-variable compound operations, the atomic classes are faster than locking. They use a hardware compare-and-swap instruction rather than blocking.

Java
private final AtomicInteger count = new AtomicInteger();

count.incrementAndGet();                  // atomic, no lock
count.addAndGet(5);
count.compareAndSet(10, 20);               // set to 20 only if currently 10
count.updateAndGet(value -> value * 2);    // atomic read-modify-write with a function

private final AtomicReference<Config> config = new AtomicReference<>(initial);
config.updateAndGet(current -> current.withTimeout(30));

Compare-and-swap reads the value, computes a new one, and writes it only if the value has not changed since the read. If it has, it retries. No thread ever blocks, which is why this scales better than a lock under contention.

Java
// For heavy concurrent counting, LongAdder beats AtomicLong
private final LongAdder requests = new LongAdder();
requests.increment();
long total = requests.sum();

LongAdder spreads increments across internal cells so threads rarely touch the same memory. AtomicLong has every thread competing to swap one field, which becomes the bottleneck at high write rates.

ThreadLocal

Sometimes the answer is not to share at all. ThreadLocal gives each thread its own copy.

Java
private static final ThreadLocal<SimpleDateFormat> FORMATTER =
        ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

public String format(Date date) {
    return FORMATTER.get().format(date);     // each thread has its own, so no sharing
}

SimpleDateFormat is notoriously not thread-safe, and this was the standard workaround. The better modern answer is DateTimeFormatter, which is immutable and needs no ThreadLocal at all.

Java
void handle(Request request) {
    CONTEXT.set(new RequestContext(request));
    try {
        process();
    } finally {
        CONTEXT.remove();        // mandatory on pooled threads — otherwise a leak and stale data
    }
}

The hierarchy of solutions

In order of preference

  • Do not share mutable state. Use locals, immutable objects, or records. This needs no synchronisation at all and is the real answer most of the time.
  • Confine it to one thread, with ThreadLocal or a single-threaded executor.
  • Use a concurrent collection — ConcurrentHashMap and friends have the hard parts solved.
  • Use an atomic class for a single variable.
  • Use a lock when an invariant spans several variables. synchronized first, ReentrantLock when you need its extra features.

Immutability deserves emphasis: an object that cannot change is thread-safe by construction. Publish a new immutable snapshot through a volatile reference and you have a correct, lock-free design.

Java
private volatile Config config = Config.initial();

public void reload(Config updated) {
    this.config = updated;        // one atomic reference write; readers see one or the other
}

Common misreadings

  • "volatile makes count++ safe." It makes the reads and writes visible. The sequence is still interruptible.
  • "Only the write needs synchronising." Readers need the same monitor, or they may never see the write.
  • "synchronized on a getter is unnecessary." Without it, visibility is not guaranteed.
  • "Atomic classes replace locks." They cover one variable. An invariant across two still needs a lock.
  • "ConcurrentHashMap makes my logic thread-safe." It makes each operation safe. Your invariants across operations are still yours.
  • "Race conditions always show up in testing." They depend on timing, core count and load. Passing tests prove nothing here.
  • "synchronized is slow." Uncontended locks are heavily optimised. Contention is what costs, and the fix is a smaller critical section.

Quick recall

Everything you need if you only revisit this box.

  • Shared mutable state needs atomicity and visibility. count++ is three operations, so it needs both.
  • synchronized gives mutual exclusion, visibility and reentrancy. Both reads and writes must be synchronised.
  • Instance and static synchronized use different monitors. Prefer a private final Object lock over locking this.
  • Never lock on a String literal, a boxed Integer, or a field you reassign.
  • volatile gives visibility and ordering, never atomicity. Correct for a flag or publishing an immutable object.
  • Atomic classes use compare-and-swap and never block; LongAdder beats AtomicLong under heavy write contention. Both are atomic per variable only.
  • ThreadLocal must be remove()d in a finally block on pooled threads.
  • Preference order: don't share → confine → concurrent collection → atomic → lock. Immutability is the strongest answer.

Test yourself

Answer these before moving on — recall is what makes it stick.