The labs on this site need JavaScript to run. The same content is written out below.
Learn › Java › Threads and concurrency
Race condition
Why does count++ lose increments?
Several threads increment the same variable at the same time. count++ consists of three steps (read, add, and write), so when the steps of different threads are interleaved, increments are lost. A situation like this, where the result depends on the order in which the threads run, is called a race condition.
Checked against real execution · The same code was run on JDK 21
Concepts in this lab
- Race condition
- This is a situation in which two or more threads read and write the same data at the same time, and the result depends on the order of execution. count++ consists of three steps: read the value, add 1, and write it back. If the steps of two threads are interleaved, one of the increments is lost. It typically stays hidden in tests and appears under load.
- Critical section and mutual exclusion
- A critical section is a section of code that causes problems if several threads run it at the same time. Mutual exclusion guarantees that only one thread at a time runs that section, and it is implemented with a lock. While one thread holds the lock, the other threads wait.
- synchronized
- This Java keyword marks a critical section. Only one thread at a time can run a method or block marked with it, and a thread that wants to run it must first acquire the monitor lock of the object involved. It is simple, but a thread that waits for the lock is stopped, so keep the scope only as large as needed.
- volatile
- This keyword, when placed on a variable, guarantees that a value written by one thread is always read as the latest value by other threads (a visibility guarantee). However, it does not combine a read-modify-write compound operation into one, so it does not make an operation such as count++ safe. It suits a status flag that one thread writes and several threads read.
- Visibility
- This refers to whether a value changed by one thread can be seen by other threads. Without synchronization, CPU caches and compiler optimizations can let another thread keep seeing the value from before the change. synchronized, volatile, and the tools in java.util.concurrent guarantee it.
- Atomicity and CAS (compare-and-swap)
- Atomicity is the property that an operation made of several steps runs as one, with no gap for anything to cut in. CAS (compare-and-swap) performs "change to the new value only if the current value equals the value I read" as a single atomic operation, which makes safe updates possible without locks. AtomicInteger and similar classes use it.
- Thread safety
- This is the property of working correctly even when several threads use something at the same time. Code is thread-safe if there is no shared state, if the state is immutable, or if access is properly synchronized. A Spring singleton bean is used by many request threads together, so it must be made thread-safe (usually by keeping no state).
Try these
- Default settings — Alternating execution with no synchronization. Increments are lost.
- Adding only volatile — The latest value is visible, but volatile cannot keep the steps from being interleaved.
- Guarding with synchronized — The other threads wait until one thread finishes count++.
- Using AtomicInteger — Without a lock, a thread writes only if the value it read is unchanged (CAS). Otherwise it tries again.
- A lucky run with no overlap — The result is correct even though there is no synchronization. This is why tests do not catch the bug.
- 4 threads at random — Run it several times with different pattern numbers. The result is different each time.
Running with the default settings
- Thread A: It read count. The value read is 0.
- Thread B: It read count. The value read is 0.
- Thread A: It added 1 to 0, the value it read, and got 1. It has not written the result to count yet.
- Thread B: It added 1 to 0, the value it read, and got 1. It has not written the result to count yet.
- Thread A: It wrote 1 to count.
- Thread B: It wrote 1 to count. But since this thread read the value, other threads had raised count to 1. The thread overwrote that value with 1. Increments lost by this write: 1.
- Thread A: It read count. The value read is 1.
- Thread B: It read count. The value read is 1.
- Thread A: It added 1 to 1, the value it read, and got 2. It has not written the result to count yet.
- Thread B: It added 1 to 1, the value it read, and got 2. It has not written the result to count yet.
- Thread A: It wrote 2 to count.
- Thread B: It wrote 2 to count. But since this thread read the value, other threads had raised count to 2. The thread overwrote that value with 2. Increments lost by this write: 1.
- Thread A: It read count. The value read is 2.
- Thread B: It read count. The value read is 2.
- Thread A: It added 1 to 2, the value it read, and got 3. It has not written the result to count yet.
- Thread B: It added 1 to 2, the value it read, and got 3. It has not written the result to count yet.
- Thread A: It wrote 3 to count.
- Thread B: It wrote 3 to count. But since this thread read the value, other threads had raised count to 3. The thread overwrote that value with 3. Increments lost by this write: 1.
Result of this run
- Increments lost: 3. Another thread cut in between the read and the write of one thread, so a result computed from a stale value overwrote the newer one. This is a race condition: the result depends on the order in which the threads happen to run.
Code
static int count = 0;
Runnable task = () -> {
for (int i = 0; i < 3; i++) {
count++; // read -> add -> write
}
};
// 2 threads run the same task at the same time
for (int t = 0; t < 2; t++) {
new Thread(task).start();
}
Assumptions of this model
The model is simplified so that only one step of one thread runs at a time. On a real machine with multiple cores, several threads truly run at the same time. The model also assumes that a read always sees the latest value, but with a real ordinary variable, a value written by another thread may not be visible (a visibility problem). When real code in which 2 threads each increment 1 million times was run with JDK 21 on this PC, count++ without synchronization gave a different result every time, from 1,017,250 to 1,892,129, the result still fell short with volatile, and synchronized and AtomicInteger always gave 2,000,000. AtomicInteger is drawn in the order read → CAS → retry on failure, but depending on the CPU, a real JVM sometimes completes this increment with a single instruction, so retries may not be noticeable.
Previous lab: Creating threads and thread states · Next lab: Deadlock