Skip to main content

Concurrency: Correctness

Correctness is the foundation of concurrent programming. Before thinking about performance, ensure your code produces the right answer in all thread-interleaving scenarios.


Why Concurrency Is Critical in LLD Interviewsโ€‹

Any LLD problem involving shared state โ€” parking lot spots, movie seats, inventory counts โ€” must handle concurrent access. An interviewer asking "what happens when two users book the same seat simultaneously?" is testing whether you think concurrently.

Interview Tip ๐ŸŽฏ

Proactively bring up concurrency. Don't wait to be asked. After designing your classes say: "Now let me think about concurrency โ€” this availableSeats counter will be accessed by multiple threads simultaneously, so I need to make it thread-safe."


Core Conceptsโ€‹

Atomicityโ€‹

An operation is atomic if it completes in a single, indivisible step โ€” no other thread can observe a partial result.

// โŒ Not atomic: read-modify-write is THREE operations
public class Counter {
private int count = 0;

public void increment() {
count++; // READ count, ADD 1, WRITE back โ€” another thread can interrupt between these!
}
}

// Thread A reads count = 5
// Thread B reads count = 5
// Thread A writes count = 6
// Thread B writes count = 6 โ† Lost update! Should be 7.

// โœ… Atomic using synchronized
public class Counter {
private int count = 0;

public synchronized void increment() { count++; }
public synchronized int get() { return count; }
}

// โœ… Better: use AtomicInteger (lock-free, faster)
public class Counter {
private final AtomicInteger count = new AtomicInteger(0);

public void increment() { count.incrementAndGet(); } // CAS loop โ€” atomic
public int get() { return count.get(); }
}

Visibilityโ€‹

A change made by one thread may not be visible to another thread due to CPU caches and instruction reordering.

// โŒ Visibility problem: thread B may never see flag = true
public class StopFlag {
private boolean running = true; // Not volatile!

public void stop() {
running = false; // Thread A writes to CPU cache
}

public void doWork() {
while (running) { // Thread B reads stale cached value โ€” infinite loop!
// ... work
}
}
}

// โœ… volatile ensures writes are immediately visible to all threads
public class StopFlag {
private volatile boolean running = true;

public void stop() { running = false; }
public void doWork() {
while (running) { /* ... */ }
}
}

The happens-before Relationshipโ€‹

Java's memory model defines happens-before: if action A happens-before action B, then A's effects are guaranteed to be visible to B.

Key happens-before rules:

  • synchronized block exit โ†’ synchronized block entry (on same lock)
  • volatile write โ†’ subsequent volatile read (on same variable)
  • Thread.start() โ†’ any action in the started thread
  • Thread completion โ†’ Thread.join() return

Data Racesโ€‹

A data race occurs when two threads access the same variable concurrently, at least one access is a write, and they're not synchronized.

// โŒ Classic data race: bank transfer
public class BankAccount {
private double balance;

public void transfer(BankAccount target, double amount) {
if (balance >= amount) { // Thread A checks
// Thread B also checks here โ€” both see sufficient balance!
balance -= amount; // Thread A deducts
target.balance += amount; // Thread A adds
// Thread B also deducts and adds โ€” money created from nothing!
}
}
}
// โœ… Fix: synchronize on both accounts (careful of deadlock โ€” see Coordination)
public void transfer(BankAccount other, double amount) {
// Always lock in consistent order to avoid deadlock
BankAccount first = this.id < other.id ? this : other;
BankAccount second = this.id < other.id ? other : this;

synchronized (first) {
synchronized (second) {
if (this.balance >= amount) {
this.balance -= amount;
other.balance += amount;
} else {
throw new InsufficientFundsException();
}
}
}
}

Thread-Safe Collectionsโ€‹

NeedUse
Thread-safe list with many readsCopyOnWriteArrayList
Thread-safe mapConcurrentHashMap
Thread-safe queue (producer-consumer)LinkedBlockingQueue
Thread-safe setConcurrentHashMap.newKeySet()
Thread-safe dequeConcurrentLinkedDeque
Priority queuePriorityBlockingQueue
// ConcurrentHashMap atomic operations
ConcurrentHashMap<String, Integer> inventory = new ConcurrentHashMap<>();

// โœ… Atomic: check-then-act in one step
inventory.compute("ITEM-001", (key, current) -> {
if (current == null || current == 0) throw new OutOfStockException();
return current - 1;
});

// โœ… Atomic: put if absent (useful for caching)
inventory.computeIfAbsent("ITEM-002", key -> loadFromDatabase(key));

// โœ… Atomic conditional update
inventory.merge("ITEM-003", 10, Integer::sum); // add 10 to existing value

The Synchronized Pitfallsโ€‹

Pitfall 1: Synchronizing on the Wrong Objectโ€‹

// โŒ Each instance synchronizes on "this" โ€” no mutual exclusion between instances
public class BadCache {
private static final Map<String, Object> CACHE = new HashMap<>();

public synchronized Object get(String key) { // "this" lock โ€” different per instance!
return CACHE.get(key);
}
}

// โœ… Synchronize on the shared resource
public class GoodCache {
private static final Map<String, Object> CACHE = new HashMap<>();
private static final Object LOCK = new Object(); // shared lock

public Object get(String key) {
synchronized (LOCK) {
return CACHE.get(key);
}
}
}

Pitfall 2: Holding Locks Too Longโ€‹

// โŒ Holds lock during slow I/O โ€” blocks all other threads
public synchronized void saveAndNotify(Order order) {
database.save(order); // Slow I/O โ€” holding lock!
emailService.send(order); // Slow network โ€” holding lock!
cache.put(order.getId(), order);
}

// โœ… Minimize critical section
public void saveAndNotify(Order order) {
database.save(order); // Do slow work outside lock
emailService.send(order); // Do slow work outside lock

synchronized (this) {
cache.put(order.getId(), order); // Only synchronize the shared state update
}
}

Pitfall 3: Compound Actions Without Atomicityโ€‹

// โŒ Check-then-act: not atomic even though each operation is synchronized
public synchronized boolean isEmpty() { return count == 0; }
public synchronized void remove() { count--; }

// In caller:
if (!queue.isEmpty()) { // Thread A: isEmpty() returns false
queue.remove(); // Thread B removes between these two lines!
// Thread A: remove() on empty queue โ€” bug!
}

// โœ… Make the compound action atomic
public synchronized void removeIfPresent() {
if (count > 0) count--; // check-and-act in one synchronized block
}

Java Memory Model in Practiceโ€‹

// Safe publication of objects across threads
public class SafePublication {

// โœ… volatile guarantees both visibility and ordering
private volatile Config config;

public void updateConfig(Config newConfig) {
config = newConfig; // Write is visible to all subsequent reads
}

// โœ… final fields are safely published after constructor completes
public static class ImmutablePoint {
private final int x;
private final int y;

public ImmutablePoint(int x, int y) {
this.x = x;
this.y = y;
// After constructor, x and y are safely visible to all threads
}
}

// โœ… Immutable objects are always thread-safe
public record Money(long cents, String currency) {} // Java record โ€” immutable by default
}

๐Ÿง  Senior Deep Dive: Hardware Reality & Advanced Patternsโ€‹

While new learners focus on software locks (synchronized), senior engineers must understand hardware implications.

1. False Sharing (The Cache Line Problem)โ€‹

Modern CPUs fetch memory from RAM in 64-byte chunks called Cache Lines. If two threads update two completely independent volatile variables that happen to sit next to each other in memory (in the same cache line), the CPU's cache coherence protocol (MESI) will constantly invalidate the entire cache line across cores. This causes a massive invisible performance penalty.

  • The Fix: Use the @Contended annotation in Java to pad the variables with empty bytes, forcing them into separate CPU cache lines.

2. The Double-Checked Locking (DCL) Trapโ€‹

A classic singleton pattern mistake that tests your knowledge of instruction reordering.

// โŒ BROKEN Double-Checked Locking (if instance is not volatile)
public class Singleton {
private static Singleton instance; // Missing volatile!

public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // The danger lies here!
}
}
}
return instance;
}
}

Why it fails: instance = new Singleton() is not atomic. It involves 3 steps: (1) allocate memory, (2) call the constructor, and (3) assign the reference. The JVM/CPU might reorder step 3 before step 2. A second thread could check instance == null, see it's false, and return a partially constructed object!

  • The Fix: You MUST declare private static volatile Singleton instance;. The volatile keyword inserts a memory barrier that prevents this specific instruction reordering.

Interview Concurrency Checklistโ€‹

When you present a design, ask yourself:

QuestionIf yes โ†’
Is there shared mutable state?Synchronize access or use concurrent collections
Is there a read-modify-write?Make it atomic (AtomicInteger or synchronized block)
Is there a check-then-act?Make the compound action atomic
Is there a field written by one thread, read by another?Mark volatile or use synchronization
Are collections shared across threads?Use concurrent collection variants
Does a class look thread-safe but have compound operations?Document the need for external synchronization

Next โ†’ Concurrency โ€” Coordination