Explain the check-then-act race condition with a concrete Java example, and show why it is unsafe even with thread-safe collections.
answer
- Check and act = two steps, stale gap between
- Lazy init if(x==null) is the poster child
- Thread-safe collection != atomic compound use
- Fix: putIfAbsent / computeIfAbsent / compareAndSet / lock the pair
- Each call atomic, the sequence is not
basics
~20 sCheck-then-act is when you test a condition and then act on it as separate steps. Another thread can change things between the check and the act, so your action is based on stale information. Example: "if absent, put" can let two threads both put.
solid answer
~40 sA check-then-act race happens when a thread observes some state (the check) and then does something assuming that state still holds (the act), but another thread changes the state in the gap between them. The classic case is `if (!map.containsKey(k)) map.put(k, v);` or lazy init `if (instance == null) instance = new Foo();`. Even if `map` is a thread-safe `ConcurrentHashMap`, each individual call is atomic but the *combination* is not — two threads can both pass the check and both act. The fix is to make the whole check-and-act one atomic operation: use a compound atomic API like `putIfAbsent`/`computeIfAbsent`, or hold a lock spanning both steps. Thread-safety of the container guarantees each method call won't corrupt internal structure, but it never makes your multi-call sequence atomic.
code
java · 8 lines// RACY: containsKey + put is a non-atomic compound action
ConcurrentHashMap<String, User> cache = new ConcurrentHashMap<>();
if (!cache.containsKey(id)) { // two threads can both pass
cache.put(id, loadUser(id)); // ...and both load + put
}
// SAFE: one atomic operation, loadUser runs at most once per key
cache.computeIfAbsent(id, key -> loadUser(key));go deeper
Recognizes that 'if not present, then put' is two steps and another thread can slip in between.
Gives a concrete example, explains why a thread-safe collection doesn't make the compound action atomic, and reaches for putIfAbsent/computeIfAbsent or a lock.
Connects check-then-act to lazy init and double-checked locking, knows the volatile requirement, and chooses the holder idiom/enum for singletons.
Treats compound-action atomicity as an API design concern, prefers atomic compound operations or immutability so callers can't construct races, and reasons about the cost/contention of the chosen primitive.
## The shape of the bug **Check-then-act** is any code that (1) *checks* a condition about shared state, then (2) *acts* on the result of that check — as two separate operations. The danger is the **window** between them: another thread can change the state in that gap, making your check **stale** by the time you act. ### A minimal example: lazy initialization ```java private Connection conn; public Connection get() { if (conn == null) { // CHECK conn = new Connection(); // ACT } return conn; } ``` Two threads call `get()` at the same time when `conn` is null: - Thread A sees `conn == null` (check passes). - Thread B sees `conn == null` too (A hasn't assigned yet). - Both run the act: two `Connection` objects are created; one leaks, and the two callers may use *different* connections. If `Connection` was meant to be a singleton, the invariant is broken. ### Why "use a thread-safe collection" does NOT fix it A common misconception: "I'll store it in a `ConcurrentHashMap`, that's thread-safe, so I'm fine." ```java ConcurrentHashMap<String, User> cache = new ConcurrentHashMap<>(); if (!cache.containsKey(id)) { // CHECK (atomic by itself) cache.put(id, loadUser(id)); // ACT (atomic by itself) } ``` `containsKey` is atomic. `put` is atomic. But the **pair** is not. Thread A and Thread B can both run `containsKey` (both get false) before either runs `put`, so both call the expensive `loadUser` and both `put`. You did the work twice and may have stored two different objects. **Thread-safety of a collection means each individual method won't corrupt the collection's internal state or leave it inconsistent — it says nothing about your multi-step *use* of the collection.** Compound actions you build out of several thread-safe calls are not automatically atomic. ### The general structure Check-then-act covers many idioms: - **Lazy init**: `if (x == null) x = create();` - **Put-if-absent**: `if (!map.containsKey(k)) map.put(k, v);` - **Get-then-update**: `int n = map.get(k); map.put(k, n + 1);` (this is also read-modify-write) - **Conditional remove**: `if (list.size() > 0) list.remove(0);` — size can drop to 0 in the gap, throwing. In every case the *decision* (check) is computed from a snapshot of shared state that may no longer be true when the *action* runs. ### How to fix it The rule: **make the check and the act a single atomic step.** Options: 1. **Use a compound atomic API the container already provides:** - `map.putIfAbsent(k, v)` — atomically checks absence and puts. - `map.computeIfAbsent(k, key -> loadUser(key))` — atomically checks and, only if absent, computes and inserts (and `loadUser` runs at most once per key). - `AtomicReference.compareAndSet(expected, new)` — atomic check-then-set. 2. **Hold a lock across both steps** so no other thread can observe or change the state in between: ```java synchronized (lock) { if (conn == null) conn = new Connection(); } ``` For lazy init of a static singleton, the **holder idiom** (a nested static class) or an `enum` gives lazy, thread-safe initialization without explicit locking, because class initialization is itself atomic by the JLS. 3. **Don't share mutable state at all** — confine or use immutable data. ### Why testing rarely catches it The window between check and act is usually tiny, so two threads collide only occasionally and only under load (data-dependent, nondeterministic). The code passes unit tests and single-threaded reasoning, then fails in production. That's the signature of a race.
- How does computeIfAbsent improve on putIfAbsent for an expensive value?putIfAbsent requires you to construct the value first, so the expensive work may run even when the key already exists (and may run on losing threads). computeIfAbsent runs the mapping function atomically and only when the key is truly absent, so the expensive computation happens at most once per key and isn't wasted.
- Is double-checked locking a valid fix for lazy-init check-then-act?Yes, if done correctly: the field must be volatile, and you re-check inside the synchronized block. Without volatile it's broken under the Java Memory Model (a thread can see a partially constructed object). The holder idiom or an enum is usually simpler and safer.
saying these in an interview costs you the question
- Assuming a ConcurrentHashMap makes containsKey+put atomic together
- Fixing lazy init by making only the field volatile (still two threads can create)
- Thinking a tiny window means it's not a real bug
- Using get()+put() to increment a counter in a concurrent map
- Believing thread-safe means your whole method is atomic