Java • Java-Specific Topics

Race Conditions — Recognize race conditions and shared-state issues (conceptual)

Understand why multiple threads accessing shared data can produce unpredictable, buggy results in Java programs.

Java programming emblem with a coffee cup, code braces, and terminal
Inside this lesson 10 sections 5 questions 6 figures

SECTION 1

Historical Context & Motivation

As computers evolved from running one task at a time to juggling many tasks simultaneously, a sneaky new category of bugs appeared. A race condition happens when two or more parts of a program compete to read or write the same piece of data at the same time, and the final result depends on which one finishes first. These bugs are notoriously difficult to find because they do not happen every time you run the program — they depend on timing and luck.

The concept of concurrency — doing multiple things at once — dates back to the earliest operating systems. Engineers quickly realized that when two processes share memory or files, unpredictable ordering can corrupt data. Java, released in 1995, was one of the first mainstream languages to build multithreading directly into the language. That decision made concurrent programming more accessible, but it also meant every Java developer needed to understand race conditions.

1965 • Dijkstra's Mutual Exclusion

Edsger Dijkstra published the first algorithm for preventing two processes from entering a critical section at the same time, formally identifying the shared-state problem.

1971 • Semaphore Concept

Dijkstra introduced semaphores — signaling mechanisms that let threads coordinate access to shared resources without stepping on each other.

1995 • Java Launches with Built-in Threads

Java 1.0 included the Thread class and the synchronized keyword, making multithreading a core language feature rather than an OS-level add-on.

2004 • java.util.concurrent Package

Java 5 introduced a rich concurrency library with locks, atomic variables, and thread-safe collections, giving developers more powerful tools to prevent race conditions.

2010s+ • Multi-core Processors Everywhere

Modern CPUs contain multiple cores executing threads truly in parallel, making race conditions more common and harder to reproduce than ever before.

The central question this lesson addresses is: How can two threads working with the same variable produce a wrong answer, and how do we recognize when this is happening? Understanding this question is the first step toward writing correct, reliable concurrent Java programs.

SECTION 2

Core Principles & Definitions

Before diving into examples, you need to know a handful of key terms. A thread is an independent path of execution inside a program. Think of it like a separate worker who can carry out instructions at the same time as other workers. When multiple threads access the same variable or object, that data is called shared state. The danger zone — the chunk of code where shared state is read or modified — is known as a critical section.

1

Thread

A lightweight unit of execution within a Java program. Every Java application has at least one thread (the main thread), and you can create more to perform tasks concurrently.
2

Shared State

Any variable, field, or object that can be accessed by more than one thread. Instance variables of an object passed to multiple threads are a common example.
3

Race Condition

A defect where the program's outcome depends on the unpredictable timing of thread execution. Two threads 'race' to access shared data, and the winner determines the result.
4

Critical Section

The region of code that reads or writes shared state. If two threads enter this region simultaneously without protection, a race condition can occur.
5

Atomicity

An operation is atomic if it completes in a single, uninterruptible step. The statement count++ looks atomic but is actually three steps: read, add, write.
✦

KEY TAKEAWAY

Imagine two friends sharing one notebook to keep a running score. Friend A reads the score (10), plans to add 5, and writes 15. But Friend B reads the score at the same moment (still 10), adds 3, and writes 13 — erasing A's update entirely. The final score says 13 instead of the correct 18. That is a race condition — the result depends on who writes last, not on correct math.

SECTION 3

Visual Explanation — Two Threads, One Variable

The diagram below shows the classic race condition scenario. Two threads both try to increment the same count variable. Each increment consists of three micro-steps: READ the current value, ADD one to it, and WRITE the new value back. If the threads interleave these steps, one update gets lost.

Race Condition: Two Threads Incrementing countThread AShared: countThread Bcount = 0READ count → 0READ count → 0ADD 0 + 1 = 1ADD 0 + 1 = 1WRITE count ← 1count = 1WRITE count ← 1count = 1 ✗Expected 2 — Got 1 (Lost Update!)
Both threads read count as 0, independently compute 1, and both write 1. The expected result after two increments is 2, but only 1 is stored. Thread B's write overwrites Thread A's, causing a lost update.

Notice that neither thread made a logical error. Each one correctly read the current value and added one. The bug is entirely about timing. If Thread A had finished writing before Thread B started reading, the result would have been correct. This is what makes race conditions so dangerous — the code looks perfectly fine, and the bug only appears under certain scheduling conditions.

SECTION 4

How It Works — The Read-Modify-Write Problem

The root cause of most race conditions is the read-modify-write pattern. Any time your code reads a value, computes something based on it, and writes the result back, there is a window of time during which another thread could interfere. In Java, even a simple statement like count++ is not a single operation at the machine level. The JVM breaks it into three separate steps.

DECOMPOSITION OF count++

Step1:temp←count(READ)Step2:temp←temp+1(MODIFY)Step3:count←temp(WRITE)Step 1: temp ← count (READ) Step 2: temp ← temp + 1 (MODIFY) Step 3: count ← temp (WRITE)

Between any two of these steps, the JVM's thread scheduler can pause the current thread and let another one run. This gap is the window of vulnerability.

A race condition requires three ingredients: (1) shared state that multiple threads can access, (2) at least one thread that writes to that state, and (3) no synchronization mechanism protecting the critical section. Remove any one of these three ingredients, and the race condition disappears.

THREE INGREDIENTS FOR A RACE CONDITION

RaceCondition=SharedState+ConcurrentWrite+NoSynchronizationRace Condition = Shared State + Concurrent Write + No Synchronization

If data is not shared (each thread has its own copy), or if all threads only read (no writes), or if access is synchronized (only one thread enters the critical section at a time), the race condition cannot occur.

⚠

Check-Then-Act: Another Common Pattern

Besides read-modify-write, the check-then-act pattern is another frequent source of race conditions. For example: if (map.containsKey(key)) { map.get(key).doSomething(); }. Between the check and the action, another thread could remove the key, causing a NullPointerException.

SECTION 5

Types of Race Conditions & How to Spot Them

Race conditions come in several flavors, and each one leaves different clues. The diagram below categorizes the most common types you will encounter in Java programs, along with the symptoms that suggest a race condition is present.

Common Race Condition Types in JavaRace ConditionRead-Modify-Writecount++, balance -= amtNon-atomic compound opsCheck-Then-Actif (x != null) x.use()Stale condition checkCompound ActionputIfAbsent, transfer()Multi-step must be atomicSymptoms That Suggest a Race Condition🎲Inconsistent ResultsDifferent output each run👻HeisenbugsBug vanishes in debugger📉Lost UpdatesCounters too low💥Corrupt DataObject in invalid stateKey Clue: Bug appears only under load or on multi-core machinesIf adding Thread.sleep() or print statements changes behavior → likely a race condition
Three major race condition patterns branch from a shared root cause. The symptoms at the bottom — inconsistent results, heisenbugs (bugs that disappear when you try to observe them), lost updates, and corrupt data — are the red flags that should trigger your suspicion of a race condition.

One of the trickiest aspects of race conditions is the Heisenbug phenomenon (named after Heisenberg's uncertainty principle in physics). When you add debugging statements like System.out.println() or step through the code in a debugger, you slow the threads down just enough that they no longer collide. The bug disappears, making you think you imagined it. If a bug only shows up in production or under heavy load, race conditions should be your first suspect.

  • ✓ Red flag 1: A shared variable that multiple threads write to without synchronized blocks or locks.
  • ✓ Red flag 2: A conditional check followed by an action on the same shared data, without both being inside the same lock.
  • ✓ Red flag 3: Unit tests that pass 99% of the time but randomly fail.
  • ✓ Red flag 4: Counter values that are lower than expected after concurrent operations.

SECTION 6

Worked Example — Bank Account Race Condition

Let's walk through a concrete Java scenario. Imagine a simplified bank account class where two threads each try to withdraw money at the same time. We will trace the execution step-by-step to see how a race condition leads to an overdraft that should have been prevented.

ℹ

The Code

Consider this Java class: class BankAccount { int balance = 100; void withdraw(int amount) { if (balance >= amount) { // CHECK balance = balance - amount; // ACT } } } Thread A calls withdraw(80) and Thread B calls withdraw(70) simultaneously.

Tracing the Race Condition

  1. 1

    Step 1 — Thread A checks the condition

    Thread A executes if (balance >= amount). Since balance is 100 and amount is 80, the condition is true. Thread A is about to subtract, but the scheduler pauses it here.
    Thread A: condition = true, balance still = 100
  2. 2

    Step 2 — Thread B checks the condition

    Before Thread A can write, Thread B runs the same check. It also reads balance as 100 (because Thread A hasn't modified it yet). Since 100 ≥ 70, Thread B's condition is also true.
    Thread B: condition = true, balance still = 100
  3. 3

    Step 3 — Thread A performs the withdrawal

    Thread A resumes and executes balance = 100 - 80. The balance is now 20.
    balance = 20
  4. 4

    Step 4 — Thread B performs the withdrawal

    Thread B resumes. It already passed the check (back in Step 2), so it proceeds to execute balance = 100 - 70. Wait — Thread B still uses the value 100 that it read earlier! The balance becomes 30. But this is wrong: both withdrawals totaling 150 from a 100-balance account should have been blocked.
    balance = 30 (or 20, depending on timing) — both withdrawals succeeded!
  5. 5

    Step 5 — Identify the bug

    The total withdrawn is 80 + 70 = 150, but the account only had 100. This is a classic check-then-act race condition. The check (is there enough money?) and the action (subtract the money) were not performed as a single atomic operation. Another thread slipped in between the check and the act, using stale information.
    Fix: wrap the entire if-block in a synchronized block to make check + act atomic.

SECTION 7

Prevention Strategies — Strengths & Limitations

Java provides several tools to prevent race conditions. Each approach has trade-offs between simplicity, performance, and flexibility. The table below compares the most common strategies at a conceptual level.

StrategyHow It WorksStrengthsLimitations
synchronizedOnly one thread can execute the synchronized block at a time. Other threads wait until the lock is released.Simple to use; built into the language; prevents both read-modify-write and check-then-act bugs.Can reduce performance if many threads are waiting; risk of deadlock if locks are acquired in the wrong order.
Atomic Variables (AtomicInteger)Uses special CPU instructions (compare-and-swap) to perform read-modify-write in a single uninterruptible step.Very fast for simple operations like incrementing counters; no blocking or waiting.Only works for single-variable operations; cannot protect multi-step compound actions.
Immutable ObjectsOnce created, the object's state never changes. No writes means no race conditions.Completely thread-safe by design; no synchronization overhead.Not practical when you genuinely need to update state (e.g., counters, balances).
Thread-Local DataEach thread gets its own private copy of the variable. No sharing means no racing.Eliminates shared state entirely for that variable; zero contention.Only useful when threads don't need to communicate through that variable.

Comparison of race condition prevention strategies in Java

✦

KEY TAKEAWAY

Think of a synchronized block like a bathroom door with a lock. Only one person (thread) can be inside at a time. Everyone else waits in line. This guarantees nobody sees you mid-action, but if the line gets long, things slow down. The art of concurrent programming is choosing the right lock granularity — lock as little as necessary, but enough to keep your data safe.

SECTION 8

Connection to Advanced Concurrency Concepts

Understanding race conditions is the foundation for several more advanced topics in Java concurrency. As you continue learning, you will encounter concepts that build directly on what you have learned here.

This LessonAdvanced ConceptConnection
Race condition (two threads, one variable)DeadlockDeadlock occurs when two threads each hold a lock the other needs. It is the flip side of over-synchronizing to prevent races.
synchronized keywordReentrantLock & ConditionMore flexible locking with features like try-lock, timed waits, and the ability to interrupt a waiting thread.
Shared mutable stateVolatile keyword & Memory ModelThe Java Memory Model defines when changes by one thread become visible to others. The volatile keyword ensures visibility but does not guarantee atomicity.
Atomic counter incrementsLock-Free Data StructuresAdvanced collections (like ConcurrentHashMap) use compare-and-swap internally to avoid locks entirely, achieving high throughput.

The key insight to carry forward is that concurrency bugs are design problems, not coding typos. You cannot reliably test your way out of a race condition because the bug depends on timing. Instead, you must reason about which data is shared, which threads access it, and whether proper synchronization is in place. This kind of careful reasoning becomes even more important as you work with multi-threaded servers, mobile applications, and distributed systems.

SECTION 9

Practice Problems

Question 1: CONCEPTUAL

In your own words, explain why the statement count++ is not safe to use in a multithreaded Java program without synchronization. What are the hidden steps?
Show answer
The statement count++ appears to be a single operation, but it is actually three steps: read the current value of count, add 1 to it, and write the result back. Between any of these steps, the thread scheduler can pause the current thread and let another thread run. If a second thread reads count before the first thread writes its updated value, both threads will compute the same result and one increment will be lost. This is a read-modify-write race condition.

Question 2: BASIC

Two threads each run count++ exactly 1,000 times on a shared variable count that starts at 0. If there is no synchronization, what is the range of possible final values for count?
Show answer
The expected (correct) value would be 2,000. However, without synchronization, the minimum possible value is 2. This extreme occurs if every pair of increments collides: both threads read the same value and write the same result, so 1,999 updates are lost. The maximum is 2,000 if, by luck, the threads happen to never interleave. Therefore the range is 2 to 2,000. In practice, you will typically see a value somewhere between these extremes, such as 1,500 or 1,800, depending on how often the threads collide.

Question 3: INTERMEDIATE

Consider this code: if (!list.contains(item)) { list.add(item); } Two threads execute this code simultaneously with the same item on a shared ArrayList. What race condition pattern is this, and what could go wrong?
Show answer
This is a check-then-act race condition. Thread A checks list.contains(item) and gets false. Before Thread A can add the item, Thread B also checks and gets false. Both threads then add the item, resulting in a duplicate entry in the list. The check and the add are not performed atomically, so the check becomes stale by the time the add executes. To fix this, both the check and the add should be inside the same synchronized block.

Question 4: APPLIED

You are building a ticket-selling system. The variable ticketsRemaining is shared by 50 threads (one per cashier). Each thread does: check if ticketsRemaining > 0, and if so, decrements it and prints a confirmation. Without synchronization, describe a specific scenario where the system sells more tickets than actually exist. Then propose a fix.
Show answer
Suppose ticketsRemaining = 1. Cashier thread #12 checks and sees 1 > 0, so it enters the if-block. Before it decrements, Cashier thread #37 also checks and sees 1 > 0 (the decrement hasn't happened yet). Both threads decrement the counter: thread #12 sets it to 0, thread #37 also sets it to 0 (or even −1 depending on timing). Two customers receive confirmations but only one ticket existed. This is a check-then-act race. The fix is to wrap the entire check-and-decrement operation in a synchronized block, or use AtomicInteger.decrementAndGet() with a subsequent check on the returned value.

Question 5: CRITICAL THINKING

A classmate argues: 'I tested my multithreaded counter program 100 times and always got the correct result. Therefore, there is no race condition.' Explain why this reasoning is flawed, and suggest a strategy to better detect potential race conditions in the code.
Show answer
This reasoning is flawed because race conditions are timing-dependent. On a single-core machine or when the JVM's thread scheduler happens to run threads sequentially, the race may never manifest. Getting the correct answer 100 times does not prove correctness — it only proves that the bug's timing window was not hit. On a different machine, under heavier load, or with a different JVM version, the same code could fail. To better detect race conditions, you should: (1) reason about the code by identifying shared state and checking whether access is properly synchronized, (2) run stress tests with many threads and many iterations to increase the chance of triggering the race, and (3) use static analysis tools like FindBugs or IntelliJ's concurrency inspections that can flag unsynchronized access to shared fields. The most reliable approach is prevention through correct design, not testing.

SUMMARY

Lesson Summary

Summary

A race condition occurs when two or more threads access shared state concurrently, and at least one thread writes to it, without proper synchronization. The two most common patterns are read-modify-write (like count++) and check-then-act (like checking a balance before withdrawing). Both patterns involve a gap between two operations that should be atomic but are not.

The symptoms of a race condition include inconsistent results across runs, bugs that vanish under a debugger (Heisenbugs), and counters or balances that are lower than expected. Java provides tools to prevent these issues, including the synchronized keyword, atomic variables, and immutable objects. Remember: you cannot reliably test away a race condition. The key is to reason about your code — identify shared data, check for concurrent writes, and verify that critical sections are properly protected.

Varsity Tutors • Java • Race Conditions — Recognize race conditions and shared-state issues (conceptual)