Multithreading, Explained: From First Thread to Interview Favourites
A friendly tour of multithreading: threads, race conditions, synchronized, wait/notify — then the classic interview questions, odd-even printing, producer-consumer and deadlocks, solved and explained.
Right now, your phone is playing music, syncing photos, checking for messages, and rendering this very page — on a handful of cores, all at once. Nothing about that is magic. It's multithreading: many little workers sharing one machine, and a set of rules that keeps them from trampling each other.
Those rules are what this article is about. We'll build them up from a kitchen, watch the famous bugs happen on purpose, and then do the thing interviews love most: solve the classic multithreading coding questions — the odd-even printer, the producer-consumer, the deadlock — line by line, until you could whiteboard them cold.
Let's start nowhere near a computer
One cook in a kitchen is simple: one pair of hands, one dish at a time, nothing ever surprises you. Now business doubles, so you hire a second cook.
Suddenly the kitchen is faster — and weirder. Both cooks reach for the same frying pan at the same moment. One grabs the salt while the other is mid-pour. Two perfectly competent cooks, and dinner is on the floor.
Notice what went wrong: nothing, in either cook's recipe. Each followed their steps perfectly. The chaos lives between them — in the shared pan, the shared salt, the shared counter space. That's the whole subject in one sentence: multithreading is easy until two workers share something, and then the sharing is the entire problem.
Where the second cook shows up in your day
- Every web server. A hundred users hit the same endpoint at once; each request rides its own thread through the same objects.
- Your phone's apps. One thread draws the UI; others fetch data — which is why a frozen app means someone did slow work on the drawing thread.
- The rate limiters and caches we've built before. The hard part of the LRU cache was never the linked list — it was the second cook.
- Interviews. "Write a program where two threads…" is a rite of passage, and we'll clear it below.
What a thread actually is
A thread is one worker with their own to-do list: a path of execution through your code, with its own call stack. What makes threads tricky is that they all share the program's memory — the kitchen — with every other thread. Creating one is two lines:
Thread worker = new Thread(() -> System.out.println("hello from " + Thread.currentThread().getName()));
worker.start(); // start(), NOT run() — this is the interview trapCalling run() directly executes the task on the current thread, like
handing your colleague's to-do list to yourself. Only start() asks the OS to
spin up a real second worker. Interviewers ask this constantly.
A thread also has a life story, and being able to narrate it is a quiet interview signal:
start() makes the thread eligible; the scheduler decides when it actually runs. That tiny detail is the source of every headache to come: you don't control the order in which threads interleave. Ever.
The famous bug: watch an increment go missing
Here's the experiment that turns multithreading from theory into religion. Two threads, each incrementing a shared counter 100,000 times:
package dev.fiveyear.threads;
public class LostUpdates {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Runnable work = () -> {
for (int i = 0; i < 100_000; i++) {
count++; // looks like one step. it isn't.
}
};
Thread a = new Thread(work);
Thread b = new Thread(work);
a.start();
b.start();
a.join(); // wait for both to finish
b.join();
System.out.println(count); // 200000? almost never.
}
}Run it and you'll get 137,482. Run it again: 154,006. The reason is that count++ is secretly three steps — read, add, write — and the scheduler is free to interleave two threads' steps like shuffled cards:
Both cooks read "41", both computed "42", both wrote "42". One increment evaporated. This is a race condition, and it has the cruelest property in software: it disappears in tests and reappears under production load.
The fix: one cook at a time
The cure is to make read-add-write one indivisible step — only one thread may be in that section at a time:
private static final Object lock = new Object();
// inside the loop:
synchronized (lock) {
count++; // now the three steps happen as one — no interleaving
}synchronized is a door with a single key. A thread walks in, the door locks behind it; everyone else queues outside until the key is returned. (For a lone counter, AtomicInteger.incrementAndGet() does the same job without an explicit lock — keep both in your pocket.)
We've met this exact bug before, wearing a costume: the token bucket in the rate limiter's low-level design leaks admissions through the same read-modify-write gap. Once you've seen the shape — look, decide, change, with daylight in between — you'll spot it everywhere.
Waiting politely: wait() and notify()
Locks solve "don't touch this together." The other half of the puzzle is "wait until it's your turn" — a consumer waiting for items, a printer waiting for its number to come up. The naive answer is a spin loop (while (!myTurn) {}), which burns a CPU core doing nothing.
The civilized answer is the oldest coordination tool in the language:
lock.wait()— "I'm going to sleep; wake me when something changes." Releases the lock while sleeping.lock.notifyAll()— "Something changed; everyone re-check your condition."
And one iron rule that interviewers probe directly: always wait() inside a while loop, never an if — a woken thread must re-check its condition, because another thread may have raced in first (and the JVM permits spurious wakeups).
The textbook home for all of this is the producer-consumer pattern:
Hold that picture — in the questions below, we build the queue at its center from scratch.
Your toolbox, one shelf up
Production code rarely hand-rolls threads and wait(). Know what replaces them:
| You need… | Reach for | Hand-rolled equivalent |
|---|---|---|
| a safe counter / flag | AtomicInteger, volatile | synchronized block |
| a producer-consumer queue | BlockingQueue | wait() / notifyAll() |
| a pool of reusable workers | ExecutorService | new Thread(…) per task |
| "run this async, combine results" | CompletableFuture | threads + join() choreography |
In interviews, you'll often be asked for the hand-rolled column first — to prove you know what the library is saving you from. So let's do exactly that.
The interview questions, solved properly
Q1 — "Two threads, print 1 to 20: one prints odds, the other evens, in order."
The trap: this sounds parallel but is really a strict turn-taking exercise — exactly what wait/notify was born for. Share a counter; each thread waits until the number's parity is its own:
package dev.fiveyear.threads;
/** Two threads take strict turns printing 1..max — the wait/notify classic. */
public class OddEvenPrinter {
private final Object lock = new Object();
private int next = 1;
private void printMine(int parity, int max) {
while (true) {
synchronized (lock) {
while (next <= max && next % 2 != parity) {
try { lock.wait(); } // not my turn — sleep
catch (InterruptedException e) { Thread.currentThread().interrupt(); return; }
}
if (next > max) {
lock.notifyAll(); // wake the other so it can exit too
return;
}
System.out.println(Thread.currentThread().getName() + " → " + next);
next++;
lock.notifyAll(); // hand the turn over
}
}
}
public static void main(String[] args) {
OddEvenPrinter printer = new OddEvenPrinter();
new Thread(() -> printer.printMine(1, 20), "odd ").start();
new Thread(() -> printer.printMine(0, 20), "even").start();
}
}Walk the rhythm: the odd thread prints 1, bumps the counter to 2, and shouts "your turn!" — the even thread wakes, sees parity 0 matches, prints, hands it back. The while-not-if guard is doing real work on line 12, and the extra notifyAll() before returning prevents the subtle ending bug where one thread exits and the other sleeps forever.
Q2 — "Write producer-consumer with wait/notify."
The shape from the diagram, in code: a bounded buffer where a full buffer parks producers and an empty one parks consumers.
package dev.fiveyear.threads;
import java.util.ArrayDeque;
import java.util.Queue;
/** The classic bounded buffer — what BlockingQueue does for you. */
public class BoundedBuffer<T> {
private final Queue<T> items = new ArrayDeque<>();
private final int capacity;
public BoundedBuffer(int capacity) {
this.capacity = capacity;
}
public synchronized void put(T item) throws InterruptedException {
while (items.size() == capacity) {
wait(); // full — producer parks, releasing the lock
}
items.add(item);
notifyAll(); // a consumer may be waiting for exactly this
}
public synchronized T take() throws InterruptedException {
while (items.isEmpty()) {
wait(); // empty — consumer parks
}
T item = items.remove();
notifyAll(); // a producer may be waiting for the space
return item;
}
}Two beats to say out loud in the interview: wait() releases the lock while sleeping (otherwise the producer would sleep holding the door shut and the consumer could never get in to make space — instant deadlock); and in real code this whole class is just new ArrayBlockingQueue<>(capacity) — you're hand-rolling it here only so the lock and the two wait conditions are visible.
Q3 — "Three threads, print A B C A B C… in order."
Same melody as Q1, different key: with three players, parity becomes a turn number modulo 3.
package dev.fiveyear.threads;
/** N threads printing in fixed rotation — turn % 3 picks whose go it is. */
public class InOrderPrinter {
private final Object lock = new Object();
private int turn = 0;
private void print(String label, int myTurn, int rounds) {
for (int i = 0; i < rounds; i++) {
synchronized (lock) {
while (turn % 3 != myTurn) {
try { lock.wait(); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); return; }
}
System.out.print(label);
turn++;
lock.notifyAll();
}
}
}
public static void main(String[] args) throws InterruptedException {
InOrderPrinter p = new InOrderPrinter();
new Thread(() -> p.print("A", 0, 4)).start();
new Thread(() -> p.print("B", 1, 4)).start();
new Thread(() -> p.print("C", 2, 4)).start();
// prints: ABCABCABCABC
}
}If the follow-up is "now make it N threads," nothing changes but the modulus — which is the point of learning the shape instead of the snippet.
Q4 — "Write a program that deadlocks. Then fix it."
A deadlock is two cooks, each holding one utensil and refusing to let go until they get the other's:
package dev.fiveyear.threads;
public class DeadlockDemo {
private static final Object FORK = new Object();
private static final Object KNIFE = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (FORK) { // A: grabs the fork…
pause();
synchronized (KNIFE) { // …then wants the knife
System.out.println("A ate dinner");
}
}
}).start();
new Thread(() -> {
synchronized (KNIFE) { // B: grabs the knife… ← opposite order!
pause();
synchronized (FORK) { // …then wants the fork
System.out.println("B ate dinner");
}
}
}).start();
// neither line ever prints. nobody crashes. they just… wait. forever.
}
private static void pause() {
try { Thread.sleep(50); } catch (InterruptedException ignored) { }
}
}The fix is almost anticlimactic, which is why it's such a good answer: make every thread acquire locks in the same global order. If both threads take FORK first and KNIFE second, the second thread simply queues at the fork — annoyed, but alive. Say the general rule, then the bonus tools: tryLock with a timeout, and jstack, which prints "Found one Java-level deadlock" against a hung process.
Where to go from here
You've got the core: threads share memory, sharing needs locks, turn-taking needs wait/notify, and lock order prevents deadlock. Three directions worth your next evening:
ExecutorServiceand thread pools — how real services run ten thousand tasks on fifty threads without creating ten thousand threads. (Building one by hand is the natural sequel: it's the producer-consumer queue from above with workers bolted on.)CompletableFuture— composing async work ("fetch these three things, then combine") without manual choreography.- Virtual threads — the recent headline feature: threads cheap enough to give every request its own, which quietly rewrites a decade of pooling advice.
And next time your phone plays music while loading a page while syncing photos, you'll see the kitchen for what it is: many cooks, shared counters, and — when the code is right — not a single dish on the floor.