Skip to content

Preventing Race Conditions in Java (Practical Mitigation Techniques)

This part focuses on practical concurrency control mechanisms, including:

  • synchronized and ReentrantLock
  • The volatile keyword
  • Atomic variables and concurrent data structures
  • Happens-before guarantees revisited
  • A detailed best practices checklist

By the end, you’ll have a concrete toolkit for writing race-free concurrent Java code.


⚙️ 1. Synchronization — The Core Defense

The most direct way to prevent race conditions is by using synchronization — controlling access to shared resources so that only one thread can execute a critical section at a time.

The synchronized Keyword

In Java, synchronized provides mutual exclusion and visibility guarantees. When a thread enters a synchronized block or method, it acquires a monitor lock; when it exits, it releases that lock.

All variables written inside the synchronized block become visible to any thread that subsequently acquires the same lock — establishing a happens-before relationship.

Example: Preventing Race in Counter

java
class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public synchronized int getCount() {
        return count;
    }
}

Now, only one thread can execute increment() at a time. Each write to count is visible to all subsequent threads that enter a synchronized block on the same object.

Internal Mechanics

Each Java object has an intrinsic lock (monitor). When a synchronized block is entered, the JVM:

  1. Acquires the monitor of the specified object.
  2. Ensures visibility of all prior writes.
  3. Executes the critical section.
  4. Releases the lock (flushing changes to main memory).

Synchronized Block Example

java
class BankAccount {
    private int balance = 100;

    public void deposit(int amount) {
        synchronized (this) {
            balance += amount;
        }
    }

    public void withdraw(int amount) {
        synchronized (this) {
            balance -= amount;
        }
    }
}

Both methods synchronize on the same lock (this), ensuring mutual exclusion. Without synchronization, simultaneous deposits and withdrawals could lead to inconsistent balances.

Static Synchronization

Static synchronized methods lock on the class object rather than an instance.

java
class Logger {
    public static synchronized void log(String msg) {
        System.out.println(msg);
    }
}

Only one thread can call any static synchronized method of the class at a time.


🔒 2. Advanced Control with ReentrantLock

While synchronized is simple and safe, Java provides a more flexible mechanism: ReentrantLock (from java.util.concurrent.locks).

Why Use ReentrantLock?

  • It supports fairness (FIFO thread access).
  • You can try acquiring the lock without blocking indefinitely.
  • You can interrupt threads waiting for the lock.
  • It offers better diagnostic control (e.g., isHeldByCurrentThread()).

Example: Using ReentrantLock

java
import java.util.concurrent.locks.ReentrantLock;

class SafeCounter {
    private final ReentrantLock lock = new ReentrantLock();
    private int count = 0;

    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }

    public int getCount() {
        return count;
    }
}

The lock() and unlock() calls wrap the critical section. Using a finally block ensures the lock is always released — even if exceptions occur.

Fair Locks

java
ReentrantLock fairLock = new ReentrantLock(true); // Fair mode

Fair locks ensure threads acquire locks in the order they requested them. This can reduce throughput but improves predictability in high-contention environments.

tryLock()

Unlike synchronized, ReentrantLock allows non-blocking attempts:

java
if (lock.tryLock()) {
    try {
        // critical section
    } finally {
        lock.unlock();
    }
} else {
    // Could not acquire lock — handle gracefully
}

Interruptible Locks

java
try {
    lock.lockInterruptibly();
    // do work
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
}

This allows a thread to stop waiting for a lock when interrupted — useful for responsive applications.


⚡ 3. The volatile Keyword Explained

While locks ensure both mutual exclusion and visibility, sometimes you only need visibility — ensuring that updates made by one thread are seen by others, without necessarily locking.

That’s where the volatile keyword comes in.

What volatile Does

Declaring a variable volatile tells the JVM:

  1. Writes to the variable are immediately visible to all threads.
  2. Reads always fetch the most recent value from main memory.
  3. Prevents reordering of reads/writes around it.

However, it does not guarantee atomicity for compound actions (like increment).

Example: Visibility Without Volatile

java
class FlagExample {
    private boolean flag = false;

    public void writer() {
        flag = true;
    }

    public void reader() {
        while (!flag) {
            // may never exit!
        }
    }
}

Without volatile, the reader thread might cache the value of flag in a register, never seeing the update from another thread.

Example: Fix with Volatile

java
class FlagExample {
    private volatile boolean flag = false;

    public void writer() {
        flag = true;
    }

    public void reader() {
        while (!flag) {
            // now guaranteed to see latest value
        }
    }
}

Now, changes to flag are immediately visible across threads.

Volatile Does Not Make Compound Operations Atomic

java
class Counter {
    private volatile int count = 0;

    public void increment() {
        count++; // still NOT atomic!
    }
}

Even though count is volatile, count++ performs a read-modify-write, which can still interleave between threads.

Fix: Use AtomicInteger or synchronization.

Happens-Before Guarantee of Volatile

A write to a volatile variable happens-before every subsequent read of that variable by any thread.

This ensures visibility but not mutual exclusion.


🔢 4. Atomic Variables and Concurrent Data Structures

Java provides lock-free thread safety through atomic variables in java.util.concurrent.atomic. These classes rely on hardware-level Compare-And-Swap (CAS) operations to perform updates atomically without using locks.

Common Atomic Classes

ClassTypeUse Case
AtomicIntegerintCounters, indexes
AtomicLonglongHigh-precision counters
AtomicBooleanbooleanShared flags
AtomicReference<T>referenceManaging shared objects safely

Example: Atomic Counter

java
import java.util.concurrent.atomic.AtomicInteger;

class AtomicCounter {
    private final AtomicInteger count = new AtomicInteger(0);

    public void increment() {
        count.incrementAndGet(); // Atomic operation
    }

    public int getCount() {
        return count.get();
    }
}

This approach is lock-free and non-blocking. Each increment uses CAS — retrying internally until successful.

Compare: AtomicInteger vs Synchronized

FeatureAtomicIntegersynchronized
Mutual exclusion❌ No✅ Yes
Lock overhead❌ None✅ Context switch possible
Visibility✅ Guaranteed✅ Guaranteed
Performance (low contention)🚀 Fast⚙️ Slower
Performance (high contention)⚖️ Moderate⚖️ Similar
Reentrancy❌ No✅ Yes

When to Use

  • Use AtomicInteger for independent variables where only atomic updates are needed.
  • Use synchronized/ReentrantLock when operations involve multiple shared variables or complex invariants.

Example: AtomicReference for Safe Object Swaps

java
import java.util.concurrent.atomic.AtomicReference;

class ConfigManager {
    private final AtomicReference<String> config = new AtomicReference<>("default");

    public void update(String newConfig) {
        config.set(newConfig);
    }

    public String getConfig() {
        return config.get();
    }
}

Atomic references are great for maintaining shared objects safely without locks.

Concurrent Collections

Java’s java.util.concurrent package includes data structures that handle synchronization internally:

ClassDescription
ConcurrentHashMapThread-safe, high-performance map with segmented locks.
CopyOnWriteArrayListIdeal for mostly-read scenarios. Writes create new copies.
ConcurrentLinkedQueueNon-blocking FIFO queue.
BlockingQueueSupports producer-consumer pattern.
ConcurrentSkipListMapSorted concurrent map (lock-free).

Example: Using ConcurrentHashMap

java
import java.util.concurrent.ConcurrentHashMap;

class ConcurrentCache {
    private final ConcurrentHashMap<String, Integer> cache = new ConcurrentHashMap<>();

    public void put(String key, int value) {
        cache.put(key, value);
    }

    public Integer get(String key) {
        return cache.get(key);
    }
}

Concurrent collections eliminate the need for manual synchronization for most common operations.


🧩 5. Happens-Before Guarantees (Revisited)

Let’s reinforce the happens-before guarantees that Java provides — these relationships form the backbone of race condition prevention.

RelationshipGuarantee
Program OrderEach action in a thread happens-before later actions in the same thread.
Monitor LockUnlock on a monitor happens-before every subsequent lock on the same monitor.
Volatile VariableWrite to a volatile field happens-before every subsequent read of that field.
Thread StartThread.start() happens-before any action in the started thread.
Thread JoinAny action in a thread happens-before another thread’s successful join() on it.
Final Field RuleWrites to final fields in a constructor happen-before any subsequent read of the object reference.

Proper synchronization ensures these relationships hold, guaranteeing visibility and ordering.


✅ 6. Best Practices Checklist

Let’s summarize actionable best practices to prevent race conditions effectively in Java.

✅ General Principles

  • Avoid shared mutable state whenever possible.
  • Prefer immutability — immutable objects are naturally thread-safe.
  • Keep critical sections small — synchronize only what’s necessary.
  • Never block inside synchronized blocks (e.g., avoid I/O).
  • Use concurrent collections instead of manually synchronized structures.

✅ Using Locks

  • Always release locks in finally blocks.
  • Avoid nested locks (can cause deadlocks).
  • Use tryLock() to avoid indefinite blocking.
  • Prefer ReentrantLock only when advanced features are needed.

✅ Volatile Usage

  • Use volatile for simple flags or state signals.
  • Don’t use volatile for compound actions (like increment).
  • Remember: volatile guarantees visibility, not atomicity.

✅ Atomic Variables

  • Use AtomicInteger, AtomicLong, or AtomicReference for lock-free updates.
  • Choose atomic classes when you only need atomicity for a single variable.

✅ Thread Safety Patterns

  • Thread confinement: Restrict objects to one thread.
  • Immutable objects: Prefer final fields, no setters.
  • Safe publication: Use volatile, final, or proper synchronization to publish objects safely.

✅ Testing & Debugging

  • Use Thread Sanitizers and tools like jstack, VisualVM, or IntelliJ Concurrency Visualizer.
  • Introduce artificial delays in tests to expose timing-related bugs.
  • Use ExecutorService instead of manually managing threads.

🔍 Example: Combining Techniques

Here’s a real-world example combining multiple techniques:

java
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

class TaskQueue {
    private final AtomicInteger processedCount = new AtomicInteger(0);
    private final BlockingQueue<String> queue = new LinkedBlockingQueue<>();
    private volatile boolean running = true;

    public void submit(String task) {
        queue.offer(task);
    }

    public void process() {
        while (running || !queue.isEmpty()) {
            try {
                String task = queue.poll(100, TimeUnit.MILLISECONDS);
                if (task != null) {
                    System.out.println("Processing: " + task);
                    processedCount.incrementAndGet();
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }

    public void stop() {
        running = false;
    }

    public int getProcessedCount() {
        return processedCount.get();
    }
}

public class TaskProcessorDemo {
    public static void main(String[] args) throws InterruptedException {
        TaskQueue taskQueue = new TaskQueue();

        Thread producer = new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                taskQueue.submit("Task-" + i);
            }
            taskQueue.stop();
        });

        Thread consumer = new Thread(taskQueue::process);

        producer.start();
        consumer.start();

        producer.join();
        consumer.join();

        System.out.println("Total processed: " + taskQueue.getProcessedCount());
    }
}

This design avoids race conditions using:

  • BlockingQueue for safe inter-thread communication
  • AtomicInteger for atomic counting
  • volatile flag for visibility

🧠 Conclusion

Race conditions are not inevitable — they’re preventable when you understand how threads interact through memory and how to enforce happens-before relationships.

We covered:

  • Using synchronized and ReentrantLock for mutual exclusion.
  • Leveraging volatile for visibility-only updates.
  • Employing atomic variables for lock-free concurrency.
  • Relying on concurrent collections for scalability.
  • Following best practices to maintain clarity and safety.

Remember this rule of thumb:

Use the simplest tool that enforces the synchronization you need — and no more.

In the next part, we’ll explore advanced debugging techniques for identifying and profiling race conditions in live systems, using JVM tools and concurrency profilers.