The short version
- Two threads changing the same data can lose updates: count++ is three steps. On 8 threads, a plain counter lost thousands of increments every run.
- Changes made by one thread aren’t guaranteed to be seen by another without synchronization. A loop waiting on a plain boolean flag never stopped; with volatile, it stopped at once.
- Thread-safe collections don’t make sequences of calls safe. containsKey-then-put on a ConcurrentHashMap created 376 extra sessions; computeIfAbsent created none.
- Deadlock happens when threads take the same locks in different orders. Taking locks in one fixed order makes it impossible.
- Hand work between threads with a bounded BlockingQueue, run tasks on a thread pool, and prefer immutable objects and one-lock-per-object designs.
Before we start
A thread is one path of execution through your program. A server handling many requests runs many threads at once, and they share the same objects in memory. Most of the time that’s fine. Trouble starts when two threads read and change the same data at the same time.
This chapter covers the problems and the tools, in the order you’ll need them:
| Problem | Symptom | Tool |
|---|---|---|
| Lost updates | totals come out too low | locks, atomics |
| Visibility | one thread never sees another’s change | volatile, locks |
| Check-then-act | duplicates, double bookings | one atomic operation |
| Contention | correct but slow | finer-grained locks, LongAdder |
| Deadlock | everything stops | lock ordering |
| Handing off work | producers outrun consumers | bounded BlockingQueue |
| Too many threads | slow, memory-hungry | thread pools |
Every example here is complete Java that compiles and runs, and every number is from a real run. Your numbers will differ, because thread timing differs between machines and between runs. That unpredictability is the problem.
Lost updates: the race, in code
The bug
final class Counters {
long plain;
long locked;
synchronized void incLocked() { locked++; } // (1)
}
class RaceDemo {
public static void main(String[] args) throws Exception {
for (int run = 1; run <= 3; run++) {
Counters c = new Counters();
Thread[] ts = new Thread[8];
for (int t = 0; t < 8; t++) {
ts[t] = new Thread(() -> {
for (int i = 0; i < 1_000_000; i++) { c.plain++; c.incLocked(); }
});
ts[t].start();
}
for (Thread t : ts) t.join();
System.out.println("run " + run + ": expected 8000000, plain " + c.plain
+ ", synchronized " + c.locked);
}
}
}- The same increment, but only one thread at a time may run it.
run 1: expected 8000000, plain 7985587, synchronized 8000000
run 2: expected 8000000, plain 7998612, synchronized 8000000
run 3: expected 8000000, plain 7990719, synchronized 8000000The plain counter lost between about 1,400 and 14,400 increments, a different number every run, with no error.
Why
plain++ looks like one step. It’s three: read the value, add one, write it back. Two threads can both read 41, both add one, and both write 42. One increment vanishes.
| Time | Thread A | Thread B | Value |
|---|---|---|---|
| 1 | reads 41 | 41 | |
| 2 | reads 41 | 41 | |
| 3 | writes 42 | 42 | |
| 4 | writes 42 | 42 (should be 43) |
The part of the code where this can happen (read, then write based on what was read) is called a critical section. A race condition is when the result depends on the timing of threads inside one.
Locks: one thread at a time
A lock lets only one thread into a critical section at once. Java has two kinds you’ll use.
synchronized
Every Java object can act as a lock. A synchronized method locks the object it’s called on; a synchronized (x) { … } block locks x.
final class Wallet {
private long balance;
synchronized boolean spend(long paise) { // (1)
if (paise > balance) return false;
balance -= paise;
return true;
}
synchronized void add(long paise) { balance += paise; }
synchronized long balance() { return balance; } 2
}- 2reads too
- Checking and changing happen as one step, so two spends can’t both pass the check.
- Reads are synchronized too, for visibility (next section).
ReentrantLock
java.util.concurrent.locks.ReentrantLock does the same job with more options: tryLock (give up if you can’t get it, or wait at most a set time), fairness (first come, first served), and locking in one method and unlocking in another. Always unlock in a finally:
final class SeatLock {
private final ReentrantLock lock = new ReentrantLock();
private String holder;
boolean tryHold(String user, long waitMs) throws InterruptedException {
if (!lock.tryLock(waitMs, TimeUnit.MILLISECONDS)) return false; // (1)
try {
if (holder != null) return false;
holder = user;
return true;
} finally {
lock.unlock(); // (2)
}
}
}- Instead of waiting forever, wait a bounded time and report failure.
- Forgetting to unlock (an exception skips the line) leaves the lock held forever.
finallyprevents that.
Use synchronized by default; it’s shorter and can’t be left locked by mistake. Reach for ReentrantLock when you need tryLock or fairness.
What to lock
The rule that matters most: lock the smallest thing that the conflicting operations actually share. One lock for the whole application is correct but makes every thread wait for every other. In the problem walkthroughs:
| Problem | What’s shared | Lock |
|---|---|---|
| Parking lot | one spot | an atomic claim per spot |
| Tic-tac-toe | one game’s turn | one lock per game |
| Splitwise | one group’s balances | one lock per group |
| Rate limiter | one key’s counter | one lock per key |
| Movie booking | one show’s seats | one lock per show |
Visibility: when one thread never sees another’s change
The bug
class VisibilityDemo {
static boolean stop; // (1)
static volatile boolean stopV; // (2)
public static void main(String[] args) throws Exception {
Thread plain = new Thread(() -> { long n = 0; while (!stop) n++; });
Thread vol = new Thread(() -> { long n = 0; while (!stopV) n++; });
plain.setDaemon(true); // lets the program exit anyway
plain.start();
vol.start();
Thread.sleep(500);
stop = true;
stopV = true;
plain.join(2000);
vol.join(2000);
System.out.println("2 s after setting the flag: plain loop still running = "
+ plain.isAlive() + ", volatile loop still running = " + vol.isAlive());
}
}- A plain flag.
- The same flag, marked
volatile.
2 s after setting the flag: plain loop still running = true, volatile loop still running = falseThe main thread set stop = true, and two seconds later the first loop was still running. It never saw the change.
Why
For speed, the JVM and the CPU may keep a value in a register or a core’s cache, and the compiler may decide that since nothing in the loop changes stop, it only needs to read it once. Without synchronization, Java makes no promise that one thread’s write is ever seen by another.
volatile makes that promise for one variable: every read sees the latest write. Locks make it for everything inside them: what one thread wrote before releasing a lock is visible to the next thread that takes the same lock. Java’s rules for this are called the happens-before relationship.
Two practical rules:
- A flag or a reference read by one thread and written by another:
volatile. - Anything more than a single read or write (check-then-change, two related fields): a lock or an atomic class.
volatiledoes not makecount++safe.
Atomic variables: lock-free single updates
The java.util.concurrent.atomic classes do one operation on one value safely, without a lock, using a CPU instruction called compare-and-set (CAS): “set this to the new value, but only if it’s still the old value I read”. If another thread got there first, the CAS fails and the update retries.
final class Spot {
private final AtomicReference occupant = new AtomicReference<>();
boolean tryOccupy(String car) { return occupant.compareAndSet(null, car); } // (1)
void release() { occupant.set(null); }
} - The whole parking-lot race from the parking lot walkthrough in one line: claim the spot only if it’s still empty. Exactly one of two racing gates wins.
AtomicInteger, AtomicLong, AtomicReference and AtomicBoolean cover most cases.
Atomics aren’t always faster
When many threads hammer the same atomic, CAS retries pile up. We counted to 16 million on 8 threads three ways:
class CounterSpeedDemo {
public static void main(String[] args) throws Exception {
for (String mode : new String[] { "synchronized", "AtomicLong", "LongAdder" }) {
long best = Long.MAX_VALUE;
for (int rep = 0; rep < 3; rep++) {
Object lock = new Object();
long[] box = new long[1];
AtomicLong atomic = new AtomicLong();
LongAdder adder = new LongAdder();
Thread[] ts = new Thread[8];
long t0 = System.nanoTime();
for (int t = 0; t < 8; t++) {
ts[t] = new Thread(() -> {
for (int i = 0; i < 2_000_000; i++) {
switch (mode) {
case "synchronized" -> {
synchronized (lock) { box[0]++; }
}
case "AtomicLong" -> atomic.incrementAndGet();
default -> adder.increment();
}
}
});
ts[t].start();
}
for (Thread t : ts) t.join();
best = Math.min(best, System.nanoTime() - t0);
}
System.out.println(mode + ": 16M increments on 8 threads in "
+ best / 1_000_000 + " ms");
}
}
}synchronized: 16M increments on 8 threads in 380 ms
AtomicLong: 16M increments on 8 threads in 531 ms
LongAdder: 16M increments on 8 threads in 15 msUnder this much contention, the atomic was slower than a plain lock, and LongAdder was about 25 times faster than the lock. LongAdder keeps several counters internally, so threads usually update different ones, and adds them up when you ask for the total. Use it for statistics and counters that are written far more often than read. Use AtomicLong when you need the exact current value on every update (like generating sequence numbers).
Concurrent collections, and the check-then-act trap
HashMap and ArrayList aren’t safe to change from several threads; they can lose entries or corrupt their insides. The concurrent versions are:
| Use | Instead of | Notes |
|---|---|---|
ConcurrentHashMap | HashMap | safe, and fast: different keys rarely block each other |
CopyOnWriteArrayList | ArrayList for listeners | copies on every write; great when reads vastly outnumber writes |
ConcurrentLinkedQueue | LinkedList as a queue | non-blocking |
ArrayBlockingQueue, LinkedBlockingQueue | a queue with wait/notify | put and take wait when full or empty |
Safe collections, unsafe sequences
A thread-safe map makes each call safe. It doesn’t make a sequence of calls safe:
class CheckThenActDemo {
public static void main(String[] args) throws Exception {
for (String mode : new String[] { "containsKey then put", "computeIfAbsent" }) {
AtomicInteger created = new AtomicInteger();
for (int trial = 0; trial < 2000; trial++) {
ConcurrentHashMap sessions = new ConcurrentHashMap<>();
CountDownLatch go = new CountDownLatch(1);
Thread[] ts = new Thread[4];
for (int t = 0; t < 4; t++) {
ts[t] = new Thread(() -> {
try { go.await(); } catch (InterruptedException e) { return; }
if (mode.startsWith("contains")) {
if (!sessions.containsKey("asha")) { 1
created.incrementAndGet();
sessions.put("asha", new Object()); // ...act
}
} else {
sessions.computeIfAbsent("asha", k -> { 2
created.incrementAndGet();
return new Object();
});
}
});
ts[t].start();
}
go.countDown();
for (Thread t : ts) t.join();
}
System.out.println(mode + ": 2000 trials of 4 threads, sessions created "
+ created.get() + " (should be 2000)");
}
}
} - 1check…
- 2one step
- Two calls, each safe on its own. Between them, another thread can do the same check.
- One call that checks and creates atomically.
containsKey then put: 2000 trials of 4 threads, sessions created 2376 (should be 2000)
computeIfAbsent: 2000 trials of 4 threads, sessions created 2000 (should be 2000)376 extra sessions created and thrown away. If creating one meant charging a card or sending an OTP, that’s 376 extra charges or messages. The concurrent map’s atomic methods (putIfAbsent, computeIfAbsent, compute, merge, remove(key, value), replace(key, old, new)) exist exactly for this. Movie booking uses replace(id, HELD, PAYING) so a double click can only start one payment.
Deadlock: everyone waiting forever
The bug
Transferring money locks both accounts. If one thread transfers A to B while another transfers B to A, and each locks the account it’s sending from first:
final class BankAccount {
final int id;
long balance;
BankAccount(int id, long balance) { this.id = id; this.balance = balance; }
}
class DeadlockDemo {
static void transferNaive(BankAccount from, BankAccount to, long amt) {
synchronized (from) {
synchronized (to) { from.balance -= amt; to.balance += amt; }
}
}
static void transferOrdered(BankAccount from, BankAccount to, long amt) {
BankAccount first = from.id < to.id ? from : to; // (1)
BankAccount second = first == from ? to : from;
synchronized (first) {
synchronized (second) { from.balance -= amt; to.balance += amt; }
}
}
public static void main(String[] args) throws Exception {
boolean ordered = args.length > 0 && args[0].equals("ordered");
BankAccount a = new BankAccount(1, 1_000_000), b = new BankAccount(2, 1_000_000);
Runnable ab = () -> {
for (int i = 0; i < 1_000_000; i++)
if (ordered) transferOrdered(a, b, 1); else transferNaive(a, b, 1);
};
Runnable ba = () -> {
for (int i = 0; i < 1_000_000; i++)
if (ordered) transferOrdered(b, a, 1); else transferNaive(b, a, 1);
};
Thread t1 = new Thread(ab), t2 = new Thread(ba);
t1.setDaemon(true);
t2.setDaemon(true);
t1.start();
t2.start();
t1.join(3000);
t2.join(3000);
long[] stuck = // (2)
ManagementFactory.getThreadMXBean().findDeadlockedThreads();
System.out.println((ordered ? "locks in id order" : "locks in argument order")
+ ": finished = " + (!t1.isAlive() && !t2.isAlive())
+ ", deadlocked threads = " + (stuck == null ? 0 : stuck.length)
+ (ordered ? ", total money = " + (a.balance + b.balance) : ""));
}
}- Both threads lock the account with the lower id first, whichever direction the money goes.
- The JVM can find deadlocked threads itself; monitoring tools use the same call.
Run once each way:
locks in argument order: finished = false, deadlocked threads = 2
locks in id order: finished = true, deadlocked threads = 0, total money = 2000000Why
| Time | Thread 1: A → B | Thread 2: B → A |
|---|---|---|
| 1 | locks A | locks B |
| 2 | waits for B (thread 2 has it) | waits for A (thread 1 has it) |
| 3 | waits forever | waits forever |
Each holds what the other needs. A deadlock needs all four of these at once: locks that only one thread can hold, threads holding one lock while waiting for another, no way to take a lock away, and a cycle of waiting. Break any one and deadlock is impossible. The practical fixes:
- Lock ordering (as above): if every thread takes locks in the same global order, a cycle can’t form. The usual choice.
- *
tryLockwith a timeout:* give up, release what you hold, and retry. Prevents permanent deadlock, but can make threads keep retrying (“livelock”). - One lock instead of several: often the simplest. The movie booking walkthrough measured per-seat locks in tap order deadlocking 849 times in 4,000 tries, and settled on one short lock per show.
Producer-consumer: handing work between threads
The idea
One set of threads produces work (orders arriving), another consumes it (chefs cooking). Put a bounded BlockingQueue between them. put waits when the queue is full; take waits when it’s empty. That waiting is a feature: when consumers fall behind, producers are slowed down automatically instead of filling memory. This is called back-pressure.
class ProducerConsumerDemo {
static final String DONE = new String("DONE"); // (1)
public static void main(String[] args) throws Exception {
BlockingQueue orders = new ArrayBlockingQueue<>(100);
AtomicInteger cooked = new AtomicInteger(), deepest = new AtomicInteger();
List chefs = new ArrayList<>();
for (int c = 0; c < 3; c++) {
Thread chef = new Thread(() -> {
try {
while (true) {
String o = orders.take(); 2
if (o == DONE) return;
deepest.accumulateAndGet(orders.size(), Math::max);
Thread.sleep(1); // cooking takes time
cooked.incrementAndGet();
}
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
chef.start();
chefs.add(chef);
}
long t0 = System.nanoTime();
for (int i = 0; i < 1500; i++) orders.put("order-" + i); 3
long heldBack = (System.nanoTime() - t0) / 1_000_000;
for (int c = 0; c < 3; c++) orders.put(DONE);
for (Thread chef : chefs) chef.join();
System.out.println("1500 orders, 3 chefs, capacity 100: cooked " + cooked.get()
+ ", queue never above " + deepest.get() + ", producer held back ~"
+ heldBack + " ms");
}
} - 2waits if empty
- 3waits if full
- A poison pill: one per consumer, put after the real work, tells each consumer to stop.
- Consumers sleep while there’s nothing to do; no busy loops.
- The producer could create orders far faster than chefs cook them. The full queue makes it wait.
1500 orders, 3 chefs, capacity 100: cooked 1500, queue never above 100, producer held back ~599 msAll 1,500 orders were cooked, the queue never grew past its limit, and the producer spent about 600 ms waiting for chefs to catch up. With an unbounded queue, it would have finished instantly and left the backlog in memory, which in a real server means running out of memory under load.
The elevator walkthrough uses the same idea: button presses from many threads go into a queue, and one controller thread processes them in order.
Thread pools: don’t create a thread per task
Creating a thread is expensive: each one reserves memory for its stack and takes work from the operating system. A thread pool keeps a fixed set of threads and feeds them tasks from a queue.
class PoolDemo {
public static void main(String[] args) throws Exception {
int tasks = 20_000;
long threadMs = 0, poolMs = 0;
for (int rep = 0; rep < 2; rep++) { // (1)
AtomicLong sum = new AtomicLong();
long t0 = System.nanoTime();
List ts = new ArrayList<>();
for (int i = 0; i < tasks; i++) {
final int n = i;
Thread t = new Thread(() -> sum.addAndGet(n));
t.start();
ts.add(t);
}
for (Thread t : ts) t.join();
threadMs = (System.nanoTime() - t0) / 1_000_000;
ExecutorService pool = Executors.newFixedThreadPool(8);
t0 = System.nanoTime();
List> fs = new ArrayList<>();
for (int i = 0; i < tasks; i++) {
final int n = i;
fs.add(pool.submit(() -> sum.addAndGet(n)));
}
for (Future> f : fs) f.get();
poolMs = (System.nanoTime() - t0) / 1_000_000;
pool.shutdown();
}
System.out.println(tasks + " tiny tasks: a new thread each " + threadMs
+ " ms, a pool of 8 threads " + poolMs + " ms");
}
} - The first round warms up the JVM; the second is the one reported.
20000 tiny tasks: a new thread each 575 ms, a pool of 8 threads 9 msAbout 60 times faster for tiny tasks. Pools also cap how many threads exist at once, so a burst of 100,000 requests doesn’t create 100,000 threads. Rules of thumb: about one thread per CPU core for CPU-heavy work; more for work that mostly waits on the network or disk. Always shutdown() a pool you created. (Since Java 21, virtual threads make “one thread per task” cheap again for tasks that mostly wait; the pool advice still applies to CPU-heavy work.)
Read-write locks: many readers, few writers
A ReentrantReadWriteLock lets any number of readers in at once, but a writer gets exclusive access. It helps only when reads are much more common than writes and each read does real work while holding the lock. We ran 160,000 operations on a shared map from 8 threads, 99% of them reads that do some computing:
one lock: 160k operations (99% reads, each read does some work) in 48 ms
read-write lock: 160k operations (99% reads, each read does some work) in 30 msA real but modest gain. And it can make things worse: if “reads” actually change things, they need the write lock anyway. The LRU cache is the classic case: every get moves an entry to the front of the list, so a read-write lock doesn’t help at all there.
Making objects safe without locks
The cheapest lock is the one you don’t need:
- Immutable objects (records with final fields and copied collections, from chapter 1) can be shared by any number of threads with no locking. The board in snakes and ladders is shared by a million simulated games for this reason.
- Thread confinement: data used by only one thread needs no lock. Give each thread its own
Randomor its own running total, and combine results at the end. That’s how the snakes and ladders simulation went from losing a quarter of its totals (and being 65 times slower with a sharedRandom) to exact and fast. - One owner thread: let a single thread own some state and send it requests through a queue, as the elevator controller does.
Testing concurrent code
Concurrency bugs hide. A race that happens once in 10,000 runs passes every test you write by hand. Make tests that give bugs a chance to show:
- Start threads together. Hold them at a
CountDownLatchand release them at once, as several examples above do. - Repeat many times. Thousands of trials, not one.
- Check invariants, not just results. “No seat sold twice”, “balances add up to zero”, “every move alternates turns”.
- Make timing controllable. Pass in clocks and fakes so a test can create the exact interleaving it wants.
The problem walkthroughs in this track follow this recipe in every “make it safe” step: 100 cars racing for 50 spots, 2,000 double-clicks on Pay, 10,000 expenses each sent twice.
How this shows up in interviews
The question is always some form of “what if two people do this at once?” A calm, structured answer:
- List the shared state that changes, in a table: what it is, who changes it, what could go wrong.
- For each, pick the smallest correct tool: an atomic for one value, a concurrent map’s atomic method for a check-then-act, a lock per object for several related fields.
- Never hold a lock while calling something slow or external (a payment gateway, a listener, the network).
- If you take several locks, say the order.
- Explain how you’d test it: threads released together, many repetitions, invariants checked.
Interviewer: Two users book the last seat at the same moment. Walk me through it.
You: The shared state is that seat’s status for that show. “Check it’s free, then mark it held” is a check-then-act, so the check and the change must be one step. I’d lock per show and do the check and claim inside it, or use a single conditional update if it’s in a database. One request wins; the other sees “taken” and picks again. I wouldn’t hold that lock during payment; the hold has an expiry instead. And I’d test it with a latch releasing a few hundred threads at one seat, checking that exactly one booking exists afterwards.
Stay in the loop
Get new problems and walkthroughs by email
One short email when something new is published: a deep-dive walkthrough, an LLD problem, or a real outage retold. No spam, unsubscribe any time.