Servers, UIs and batch jobs all run code concurrently, and race conditions are among the hardest bugs to find.
A thread is an independent path of execution within the JVM; all threads share the same heap. You can start one with Thread.ofPlatform().start(runnable) or new Thread(runnable).start(), and wait for it with join(). In practice you'll usually submit tasks to an executor (next lesson), but the safety rules are the same.
When threads share mutable state, you get race conditions. count++ looks atomic but is three steps — read, add, write — and two threads can interleave them so an increment is lost. Worse, without synchronization one thread may never see another's writes (more on that in the memory-model lesson).
Tools, from simplest to most specialised:
- Don't share mutable state: confine data to one thread, or use immutable objects (records,
List.copyOf). - synchronized — a method or block that only one thread at a time can run per lock object; it also makes writes visible to the next thread that takes the same lock.
- java.util.concurrent.atomic —
AtomicInteger,AtomicLong,LongAdderfor counters and lock-free updates. - Concurrent collections —
ConcurrentHashMap(with atomicmerge,compute),CopyOnWriteArrayList,BlockingQueuefor producer-consumer. - ReentrantLock and
ReadWriteLockwhen you need timeouts, try-lock or fairness.
Guard compound actions too: if (!map.containsKey(k)) map.put(k, v) is racy even on a ConcurrentHashMap — use putIfAbsent or computeIfAbsent.
Keep critical sections short, never call unknown code while holding a lock, and always acquire multiple locks in the same order to avoid deadlock.
Example
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
public class Races {
static int unsafeCount = 0;
static final AtomicInteger safeCount = new AtomicInteger();
static final ConcurrentHashMap<String, Integer> hits = new ConcurrentHashMap<>();
public static void main(String[] args) throws InterruptedException {
List<Thread> threads = new ArrayList<>();
for (int t = 0; t < 4; t++) {
threads.add(Thread.ofPlatform().start(() -> {
for (int i = 0; i < 100_000; i++) {
unsafeCount++; // data race: may lose updates
safeCount.incrementAndGet(); // atomic
hits.merge("home", 1, Integer::sum); // atomic per key
}
}));
}
for (Thread th : threads) th.join();
// unsafeCount is unpredictable (often < 400000); the other two are always 400000
System.out.println(unsafeCount + " " + safeCount.get() + " " + hits.get("home"));
}
}Remember
- Shared mutable state + no synchronization = race condition
- Prefer immutability and confinement; then atomics and concurrent collections; then locks
- Make compound actions atomic (
merge,computeIfAbsent), not check-then-act
Watch out for
- Assuming
count++orHashMapis safe to use from several threads. - Acquiring two locks in different orders in different places, causing deadlock.