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.

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.

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.

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.

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.

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++
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
Race 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.

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.

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.

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
Step 1 — Thread A checks the conditionThread 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
Step 2 — Thread B checks the conditionBefore 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
Step 3 — Thread A performs the withdrawalThread A resumes and executes balance = 100 - 80. The balance is now 20.
balance = 20
4
Step 4 — Thread B performs the withdrawalThread 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
Step 5 — Identify the bugThe 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.

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.

Comparison of race condition prevention strategies in Java
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.
✦ 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.

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.

Practice Problems

PROBLEM 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?
PROBLEM 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?
PROBLEM 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?
PROBLEM 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.
PROBLEM 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.

Lesson 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)