Back to Race Conditions
Q3Question 3 of 3

What is data race vs race condition?

Key Concepts
  • A data race is a JMM violation: two threads access the same variable, at least one writes, and there is no happens-before ordering between them.
  • A race condition is a logic bug: the program's correctness depends on the relative timing of operations, such as check-then-act or read-modify-write.
  • Data races and race conditions can exist independently of each other.
  • Data race without race condition: two threads write to a non-volatile variable but the program does not care which value wins.
  • Race condition without data race: each individual operation is synchronized but the compound operation is not atomic.
  • Most real-world bugs have both: unsynchronized read-modify-write like counter++ is both a data race and a race condition.
  • Fix data races with volatile, synchronized, or Atomic classes. Fix race conditions by making the compound operation atomic.

Definition

A data race is a low-level JMM violation where threads access shared memory without happens-before ordering, while a race condition is a high-level logic bug where correctness depends on thread timing; both can exist independently.
  • Data race – two threads access the same variable concurrently, at least one writes, and there is no happens-before relationship (no synchronized, no volatile, no Lock). The JMM makes no guarantees about what values are seen: torn reads, stale values, and out-of-thin-air values are all legal.
  • Race condition – a correctness bug where the result depends on thread scheduling order. Classic patterns include check-then-act (check a condition, then act on it, but the condition changes between the check and the act) and read-modify-write (read a value, compute a new value, write it back, but another thread modifies the value between read and write).
  • Independent existence – a data race can exist without a race condition (two threads write to an unprotected variable but the program is correct regardless of which write wins). A race condition can exist without a data race (each operation is individually synchronized but the compound operation is not atomic).

At National Bank of India, a data race is like two tellers writing on the same ledger line simultaneously without any coordination: the ink blurs and the characters overlap, making the entry physically unreadable. A race condition is like two tellers each checking the vault balance (Rs 100,000), each approving a Rs 70,000 withdrawal, and both approving because they checked separately: the numbers on paper are perfectly readable (no data race), but the logic is wrong because both should not have approved.

Understanding the Concepts

What is a data race?

A data race occurs when two threads access the same memory location concurrently, at least one of them writes, and there is no happens-before relationship between the accesses. Under the Java Memory Model, a program with a data race has undefined behavior for that variable: the reading thread may see stale values from its CPU cache, partially written values (torn reads for long and double), or values that were never written by any thread.

JAVA
package com.bank.concurrency;

public class DataRaceDemo {
    private static int sharedBalance = 100000;  // NOT volatile, NOT synchronized

    public static void main(String[] args) throws InterruptedException {
        Thread writer = new Thread(() -> {
            sharedBalance = 50000;  // write without synchronization
        }, "Writer");

        Thread reader = new Thread(() -> {
            // DATA RACE: no happens-before between write and read
            // May see 100000 (stale), 50000, or even 0 (default)
            System.out.println("Balance: " + sharedBalance);
        }, "Reader");

        writer.start();
        reader.start();
        writer.join();
        reader.join();
    }
}

What is a race condition?

A race condition is a logic bug where the program produces incorrect results because the outcome depends on the relative timing of thread operations. The most common pattern is check-then-act: you check a condition and then act on it, but another thread changes the condition between your check and your act.

JAVA
package com.bank.concurrency;

import java.math.BigDecimal;

public class RaceConditionDemo {
    private static volatile BigDecimal balance = new BigDecimal("100000.00");
    // volatile prevents data race, but NOT race condition!

    static void withdraw(BigDecimal amount) {
        if (balance.compareTo(amount) >= 0) {   // CHECK
            // Another thread can withdraw between check and act!
            balance = balance.subtract(amount);   // ACT
        }
    }

    public static void main(String[] args) throws InterruptedException {
        BigDecimal amount = new BigDecimal("70000.00");
        Thread t1 = new Thread(() -> withdraw(amount), "Rajesh");
        Thread t2 = new Thread(() -> withdraw(amount), "Priya");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        // Both read 100000, both pass check, both subtract 70000
        // Final balance: -40000 (overdraft!)
        System.out.println("Balance: " + balance);
    }
}

Can a data race exist without a race condition?

Yes. If two threads write to the same non-volatile variable but the program does not care which value wins, the logic is correct regardless of thread timing, even though the JMM violation exists.

JAVA
package com.bank.concurrency;

public class DataRaceWithoutRaceCondition {
    private static int lastUpdater = 0;  // NOT volatile: DATA RACE

    public static void main(String[] args) throws InterruptedException {
        // Both threads set the value; we do not care who writes last
        Thread t1 = new Thread(() -> { lastUpdater = 1; }, "Rajesh");
        Thread t2 = new Thread(() -> { lastUpdater = 2; }, "Priya");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        // Data race: no happens-before between writes and read
        // But NOT a race condition: either 1 or 2 is correct
        System.out.println("Last updater: " + lastUpdater);
    }
}

Note that even though the logic is correct, this is still technically undefined behavior under the JMM. A strict reading of the specification says you should not rely on this.

Can a race condition exist without a data race?

Yes. When each individual operation is properly synchronized but the compound operation is not atomic, you have a race condition without a data race.

JAVA
package com.bank.concurrency;

import java.util.HashMap;
import java.util.Map;

public class RaceConditionWithoutDataRace {
    private static final Map<String, Integer> accounts = new HashMap<>();
    private static final Object lock = new Object();
    private static final int MAX_ACCOUNTS = 100;

    static void addAccountIfRoom(String accountId) {
        int size;
        synchronized (lock) {
            size = accounts.size();  // individually synchronized: no data race
        }
        // Gap: another thread may add an account here!
        synchronized (lock) {
            if (size < MAX_ACCOUNTS) {
                accounts.put(accountId, 0);  // individually synchronized: no data race
            }
        }
        // RACE CONDITION: size was checked in one sync block,
        // put happens in another. Between them, another thread adds.
    }

    // CORRECT: compound operation in one synchronized block
    static void addAccountIfRoomFixed(String accountId) {
        synchronized (lock) {
            if (accounts.size() < MAX_ACCOUNTS) {
                accounts.put(accountId, 0);
            }
        }
    }
}

How is this different from a data race in C/C++?

In C and C++, a data race is truly undefined behavior: the compiler may optimize based on the assumption that no data race exists, which can lead to arbitrary program behavior including crashes, security vulnerabilities, or time-travel optimizations. In Java, the JMM is more constrained. A data race in Java does not produce arbitrary behavior; it only means that the reading thread may see any value that was previously written to the variable (or the default value), but the program will not crash or corrupt unrelated memory.

| Aspect | Java data race | C/C++ data race |
|---|---|---|
| Consequence | Stale or torn read | Fully undefined behavior |
| Memory safety | Preserved | Compromised |
| Compiler assumption | JMM constrains | Assumes no races |
| Common fix | volatile, synchronized | atomic, mutex |

What are the common patterns that combine both?

Most real-world concurrency bugs involve both a data race and a race condition simultaneously. The classic example is counter++ without synchronization.

JAVA
package com.bank.concurrency;

public class BothBugDemo {
    private static int transactionCount = 0;  // DATA RACE: no volatile

    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 0; i < 100_000; i++) {
                transactionCount++;  // BOTH bugs:
                // Data race: no happens-before
                // Race condition: read-modify-write is not atomic
                // Thread-1 reads 42, Thread-2 reads 42
                // Both write 43, one increment LOST
            }
        };
        Thread t1 = new Thread(task, "Counter-1");
        Thread t2 = new Thread(task, "Counter-2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("Count: " + transactionCount);
        // Expected: 200000. Actual: often less due to lost updates.
    }
}

What are the correct fixes?

For a data race: add volatile, synchronized, or use an Atomic class to establish a happens-before relationship. For a race condition: make the compound operation atomic by putting the entire check-then-act or read-modify-write inside a single synchronized block or using an atomic CAS operation.

JAVA
package com.bank.concurrency;

import java.util.concurrent.atomic.AtomicInteger;

public class FixedCounterDemo {
    // Fixes BOTH data race AND race condition
    private static final AtomicInteger transactionCount = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 0; i < 100_000; i++) {
                transactionCount.incrementAndGet();
                // Atomic CAS: read + increment + write in one operation
                // No data race (volatile semantics)
                // No race condition (atomic compound operation)
            }
        };
        Thread t1 = new Thread(task, "Counter-1");
        Thread t2 = new Thread(task, "Counter-2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("Count: " + transactionCount.get());
        // Always prints: 200000
    }
}

What should a senior engineer know?

Tools like the Java thread sanitizer (jcstress) can detect data races systematically. In production, race conditions are harder to find because they depend on timing and may only manifest under specific load patterns. Code review for race conditions should focus on compound operations that span multiple statements, even if each statement is individually thread-safe.

What are common interview follow-up questions?

  • Is volatile enough to fix counter++? No. Volatile fixes the data race (visibility) but not the race condition (atomicity of read-modify-write). You need AtomicInteger or synchronized.
  • Can a single-threaded program have a race condition? Not in the traditional sense, but asynchronous callbacks or signal handlers can create similar timing-dependent bugs.

Visual Overview

DIAGRAM
  Data Race vs Race Condition
  ═══════════════════════════

  DATA RACE (JMM violation):
    Thread-1: write(balance = 50000)  ──→ no volatile, no sync
    Thread-2: read(balance)           ──→ may see stale value
    Fix: volatile, synchronized, or Atomic

  RACE CONDITION (logic bug):
    Thread-1: if (balance >= 70000) ──→ TRUE (balance = 100000)
    Thread-2: if (balance >= 70000) ──→ TRUE (balance = 100000)
    Thread-1: balance -= 70000      ──→ balance = 30000
    Thread-2: balance -= 70000      ──→ balance = -40000 (overdraft!)
    Fix: make check-then-act atomic

  BOTH TOGETHER (counter++):
    read(42) → compute(43) → write(43)   ← Thread-1
    read(42) → compute(43) → write(43)   ← Thread-2
    Expected: 44. Actual: 43. One increment LOST.

Key Takeaway

A data race is a low-level JMM violation: threads access shared memory without a happens-before relationship, allowing stale reads, torn values, and undefined behavior. A race condition is a high-level logic bug: correctness depends on thread timing, typically in check-then-act or read-modify-write patterns. The two are independent. A data race can exist without a race condition when the program does not care which value wins. A race condition can exist without a data race when individual operations are synchronized but the compound operation is not atomic.

Most real-world bugs have both: counter++ on a non-volatile int is a data race (no visibility guarantee) and a race condition (read-modify-write is not atomic). The fix addresses both: AtomicInteger.incrementAndGet() provides volatile semantics for visibility and CAS for atomicity. In banking, a 64-bit transaction ID written without volatile is a data race that can produce a torn read. A balance check followed by a debit in separate synchronized blocks is a race condition that can cause an overdraft.

Remember one sentence: a data race is about memory visibility (fix with volatile or synchronized) and a race condition is about operation atomicity (fix by making the compound action a single atomic step), and they can occur independently or together.

---

Share
Use ← → arrow keys to navigate