Threads in the same process share memory, so two threads can read and change the same variable at the same time. Without coordination, this produces wrong results with no crash and no error. This class of bug is called a race condition.
This chapter covers what a race condition is, the common patterns that cause them, what a critical section is, and how to protect shared state in real code.
Let's start with a simple example. You have a shared counter that starts at zero. You create two threads, and each thread increments the counter one million times. When both threads finish, you print the counter.
You would expect 2,000,000. But if you run this without any synchronization, you will often get a smaller number, something like 1,300,000. If you run it again, you get a different number. On a machine with more cores, the difference can be even larger.
The program does not crash or throw an exception. It simply returns a wrong result, and the result changes from one run to the next.
So what went wrong? The code that updates the counter is a single line: count++.
count++ looks like one operation, but the CPU actually performs it in three steps:
count from memory into a registerWith one thread, this always gives the correct result. With two threads, the operating system can pause a thread between any of these steps, or the two threads can run these steps at the same time on different cores. When that happens, one thread can overwrite the other thread's update.
Let's go through one such case step by step. The counter is at 5.
Thread A reads count and gets 5. Before it writes anything back, thread B also reads count, and it also gets 5. Thread A adds one and writes 6. Thread B adds one and also writes 6.
Two increments ran, but the counter only moved from 5 to 6. This is called a lost update.
Across two million increments, this can happen many times. How many updates are lost depends on how the threads were scheduled in that particular run, which is why the final count is different every time.
The counter bug is a race condition. Here is the precise definition.
A race condition happens when the correctness of a program depends on the timing or ordering of threads, and some of those orderings produce the wrong result.
A race condition needs two things:
If the data is not shared, or if it is shared but never changes after it is created, a race condition cannot happen. This is why the fixes later in this chapter either control access to shared state or remove one of these two ingredients.
Race conditions are hard to find because they are non-deterministic. The wrong ordering might happen only once in a million runs. Your unit tests can pass, and the code can work fine on your laptop. Then it fails in production, under real load, on a machine with many cores.
Debugging them is also difficult. Adding a print statement or stepping through the code in a debugger changes the timing between threads, and the bug may stop showing up.
So testing alone cannot prove that concurrent code is correct. You also need to reason about the code. The next section covers the patterns that most commonly lead to race conditions, so you know what to look for.
The first pattern is the one we just saw: read-modify-write. A thread reads a value, computes a new value from it, and writes the result back.
Take a bank balance of $100. Two deposits of $50 run at the same time.
Both threads read 100, both compute 150, and both write 150. The account should hold $200, but it holds $150.
Whenever the new value depends on the old value, and two threads can update it at the same time, you have a possible race condition.
The second pattern is check-then-act. A thread checks a condition, and then takes an action based on the result of that check. The problem is that the condition can change between the check and the action.
Consider a ticket booking system with one seat left.
User A's request checks if a seat is available, and the answer is yes. Before it books the seat, user B's request runs the same check, and it also sees one seat available. Both requests then book the seat. Now two people hold the same seat, and the seat count may even become -1.
The same pattern shows up in lazy initialization. Two threads both check if an object is null, both see null, and both create it. You end up with two instances of something that should exist only once, like a connection pool or a singleton.
Two threads call getInstance() simultaneously. Both see instance == null, both create a new object. Now you have two instances of what should be a singleton, and one is orphaned.
The third pattern appears when you combine operations that are individually thread-safe.
Suppose you use a concurrent hash map to count word frequencies. A call to get is thread-safe, and a call to put is thread-safe. So you might read the current count, add one, and write it back. But the sequence is not thread-safe.
Two threads can both read a count of 3, both increment it to 4, and both write back 4. One update is lost. This is another example of the read-modify-write race condition.
The same problem can happen when one operation updates multiple values. For example, transferring money between two accounts requires subtracting from one account and adding to another. Even if each update is thread-safe on its own, another thread could read the balances between those two steps and see an inconsistent state, with the money missing from both accounts.
Thread-safe operations do not automatically make a sequence of operations thread-safe. If multiple steps need to behave as one operation, they must be protected together. That is why concurrent collections provide combined operations such as putIfAbsent, compute, and merge, which perform the read and the update atomically.
Before we look at solutions, there is a related term you should know: a data race. It is often confused with a race condition, but the two are different.
A data race is a low-level memory problem. It happens when two threads access the same memory location concurrently, at least one of them writes, and there is no synchronization between them. In C++, a data race is undefined behavior.
A race condition is a logic problem. The program produces a wrong result because of the order in which threads ran.
You can have one without the other. The concurrent hash map example has no data race, because every individual access is synchronized. But it still has a race condition, because the get and the put from two threads can interleave. The unsynchronized count++ from the start of the chapter has both.
So removing every data race does not, by itself, make your program correct.
So how do you fix a race condition? To answer that, we need the idea of a critical section.
A critical section is a piece of code that accesses shared state and must not be executed by more than one thread at a time.
In the counter example, the critical section is the read, the add, and the write. In the booking example, it is the seat availability check together with the booking.
The goal is to make the critical section behave as if it were a single atomic step. Other threads should see the state either before the section ran or after it finished, never in between.
To do that, you make sure only one thread can be inside the critical section at any time. Other threads that want to enter have to wait until it is free.
In operating systems, there are three requirements for a correct solution to the critical section problem:
In practice, there are three common ways to handle a critical section.
The first is using a lock, also called a mutex. A thread acquires the lock before entering the critical section and releases it when it is done. If another thread already holds the lock, the new thread has to wait.
In Java, you can use a synchronized block or a ReentrantLock. With ReentrantLock, make sure the lock is always released, even if an exception occurs, usually by releasing it inside a finally block.
The second option is using atomic operations. For a simple case like a counter, you can use an atomic integer and call increment-and-get. The increment happens atomically, so two threads cannot interfere with each other during the update.
Under the hood, this is typically implemented using hardware instructions such as compare-and-swap.
Atomics work well for a single variable. When several values must change together, like the two balances in a transfer, use a lock instead.
The third option is to avoid sharing mutable data in the first place. Remember that a race condition needs both shared state and a thread that modifies it. Remove either one and the race is gone.
Here is the counter from the start of the chapter, rewritten so each thread counts into its own local variable:
No lock is needed, and this version is usually faster too, because the threads never compete for the same memory.
Once you use a lock, you need to decide how much code goes inside the critical section.
If the critical section is too large, performance suffers. For example, if you hold a lock during a network call, every other thread that needs the lock has to wait for that call to finish.
If the critical section is too small, the race condition can come back. A common mistake is to lock the check, release the lock, and then lock the action separately. Another thread can still run between the check and the action, so this is the same check-then-act problem as before.
The critical section should contain everything that must happen together to keep the data correct, and slow work like I/O should stay outside of it when possible.
A critical section also solves another problem: visibility.
CPUs cache values, and compilers may reorder instructions for performance. Because of this, when one thread updates a value, another thread is not always guaranteed to see that update unless the threads synchronize.
For example, suppose one thread keeps running while a running flag is true, and another thread sets running to false. Without proper synchronization, the first thread may keep seeing the old value and continue looping.
Locks solve this problem as well. When a thread releases a lock, the changes it made become visible to the next thread that acquires the same lock.
In Java, you can also mark a variable as volatile. This ensures that when one thread updates the variable, other threads see the latest value.
Making a counter volatile doesn't make count++ thread-safe. volatile guarantees visibility, but it does not make compound operations atomic. count++ still involves reading the current value, incrementing it, and writing it back. Two threads can read the same value at the same time and one update can still be lost.
When you are given a piece of concurrent code in an interview, you can use a simple process to find race conditions.
When you explain your answer, describe the specific ordering of threads that causes the bug. For example: "Thread A reads 5, thread B reads 5, both write 6, so one increment is lost." This makes your reasoning clear and easy to follow.
count++ is not one step. It is a read, an add, and a write, and another thread can run between any of them.volatile gives visibility but not atomicity.10 quizzes