Lesson 26 • Advanced
Multithreading Basics
Run more than one thing at once. Learn how to start threads, wait for them, share data safely, and avoid the bugs that make concurrency famously hard.
What You'll Learn in This Lesson
- ✓You'll be able to define a task with Runnable and run it on a Thread
- ✓You'll be able to start threads and wait for them with join()
- ✓You'll understand the thread lifecycle (NEW → RUNNABLE → TERMINATED)
- ✓You'll spot race conditions on shared state and fix them with synchronized
- ✓You'll know when to use volatile and how wait()/notify() signal between threads
- ✓You'll understand why higher-level tools (Executors, atomics) are preferred
Before You Start
You should be comfortable with interfaces and lambdas (a Runnable is an interface, and you'll write tasks as lambdas) and with static methods and fields. If those feel shaky, revisit the earlier Java lessons first — multithreading is hard enough without fighting the basics.
1️⃣ What Is a Thread? (Plain English)
So far your programs have done one thing at a time, top to bottom. A thread is a separate line of execution — a second worker running through code at the same time as your main code. Running multiple threads at once is called multithreading or concurrency.
💡 Analogy: Think of a kitchen. With one chef (single thread), every order is cooked one after another. Hire more chefs (threads) and several dishes get made at once — but now they share one kitchen (memory). Two chefs reaching for the same knife, or both editing the same order ticket, causes chaos. That shared chaos is exactly what synchronization is for.
Why bother? Concurrency lets you keep an app responsive while it waits on slow things (network, disk), and it lets you use all of a modern CPU's cores. The cost is that shared data becomes dangerous — which is the whole second half of this lesson.
2️⃣ Starting a Thread: Runnable, start(), and join()
A Runnable is simply a task — an object with one method, run(). You usually write it as a lambda. To run that task on its own thread, hand it to a Thread and call start().
start(), never run(). start() spins up a new thread and runs the task there. Calling run() directly just runs the task on the current thread — no concurrency at all.join() means "wait here until that thread finishes." Without it, your main method might end before the other threads have done their work.
The thread lifecycle (the states a thread moves through):
NEW created, but start() not called yet ↓ start() RUNNABLE eligible to run (the OS decides when) ↓ BLOCKED / WAITING / TIMED_WAITING paused (e.g. waiting on a lock or sleep) ↓ TERMINATED the run() method finished — the thread is done
You can read a thread's current state with thread.getState(), as the worked example below shows.
extends Thread wastes your one superclass slot. A Runnable is just a task you can pass anywhere — to a Thread today, to a thread pool tomorrow. Prefer it.public class Main {
public static void main(String[] args) throws InterruptedException {
// A Runnable is just "a task to run" — one method, run().
// The lambda below IS the task; nothing runs yet.
Runnable greet = () -> System.out.println(" Hello from the new thread!");
// Wrap the task in a Thread and start it.
Thread t = new Thread(greet);
System.out.println("State before start: " + t.getState()); // NEW
t.start(); // start() spins up a NEW thread, then runs the task
System.out.println("Main thread keeps going...");
// join() makes main WAIT here until t has finished.
t.join();
System.out.println("State after join: " + t.getState()); // TERMINATED
System.out.println("Done.");
}
}State before start: NEW
Main thread keeps going...
Hello from the new thread!
State after join: TERMINATED
Done.The "Main thread keeps going..." line can print before or after the new thread's greeting — the OS decides the timing. Everything after join() is guaranteed to run once the thread is TERMINATED.
public class Main {
public static void main(String[] args) throws InterruptedException {
// 🎯 YOUR TURN — fill in the blanks marked with ___
// 1) Make a Runnable task that prints "Task running!"
Runnable task = () -> System.out.println("Task running!");
// 2) Wrap the task in a Thread called "t"
Thread t = new ___(task); // 👉 which class wraps a Runnable?
// 3) Actually START the thread (NOT run()!)
t.___(); // 👉 the method that spawns a new thread
// 4) Wait for the thread to finish before printing "Done"
t.___(); // 👉 the method that blocks until t ends
System.out.println("Done");
// ✅ Expected output:
// Task running!
// Done
}
}Task running!
Done3️⃣ Shared State & Race Conditions
Threads in the same program share memory. That's powerful — and dangerous. A race condition happens when two threads read and write the same data at the same time, so the final result depends on luck (who "wins the race").
The classic case is counter++. It looks like one step, but the CPU does three: read the current value, add one, write it back. Watch two threads collide:
Thread A reads counter -> 5 Thread B reads counter -> 5 // both saw 5! Thread A writes 5 + 1 -> 6 Thread B writes 5 + 1 -> 6 // one increment LOST
Run that millions of times and you lose thousands of increments. The fix is to make the read-modify-write atomic — indivisible — so no other thread can interleave with it.
4️⃣ Fixing It with synchronized
The synchronized keyword puts a lock (also called a mutex) around a method or block. Only one thread can hold the lock at a time; others wait their turn. That turns the three-step counter++ into a single, uninterruptible critical section.
// Whole method: one thread inside at a time
static synchronized void bump() { counter++; }
// Or just a block, locking on a specific object
synchronized (lock) {
counter++;
}A lock has a cost — threads queue up — so synchronize the smallest section that actually touches shared data, not your whole program.
AtomicInteger gives you a correct, lock-free incrementAndGet() using a hardware compare-and-swap. For a single shared number, prefer it over hand-writing synchronized.public class Main {
static int counter = 0; // shared state — both threads touch it
static int safeCounter = 0; // protected by synchronized below
// synchronized = only ONE thread inside this method at a time.
static synchronized void bumpSafe() { safeCounter++; }
public static void main(String[] args) throws InterruptedException {
// counter++ is really 3 steps: read, add 1, write back.
// Two threads can read the SAME value, so increments get lost.
Runnable unsafe = () -> {
for (int i = 0; i < 100_000; i++) counter++; // RACE CONDITION
};
Runnable safe = () -> {
for (int i = 0; i < 100_000; i++) bumpSafe(); // locked, correct
};
Thread a = new Thread(unsafe), b = new Thread(unsafe);
a.start(); b.start();
a.join(); b.join(); // wait for both before reading the result
Thread c = new Thread(safe), d = new Thread(safe);
c.start(); d.start();
c.join(); d.join();
// Unsafe is usually LESS than 200000 (and changes each run).
System.out.println("Unsafe counter: " + counter + " (wanted 200000)");
System.out.println("Safe counter: " + safeCounter + " (always 200000)");
}
}Unsafe counter: 138472 (wanted 200000)
Safe counter: 200000 (always 200000)public class Main {
static int total = 0;
// 🎯 YOUR TURN — fill in the blanks marked with ___
// 1) Add the keyword that lets only ONE thread in at a time
static ___ void add() { // 👉 the lock keyword
total++;
}
public static void main(String[] args) throws InterruptedException {
Runnable job = () -> {
for (int i = 0; i < 50_000; i++) add();
};
Thread a = new Thread(job), b = new Thread(job);
a.start(); b.start();
// 2) Wait for BOTH threads before reading total
a.___(); // 👉 block until a finishes
b.___(); // 👉 block until b finishes
System.out.println("Total: " + total);
// ✅ Expected output:
// Total: 100000
}
}Total: 1000005️⃣ Visibility (volatile) & Signalling (wait/notify)
There's a second, sneakier problem besides races: visibility. For speed, each thread may cache a copy of a field. Thread A can set ready = true while thread B keeps reading its own stale false forever.
Marking a field volatile fixes that: every read sees the most recent write from any thread, with no caching. Use it for a simple flag written by one thread and read by others. Note it does not make count++ safe — that still needs synchronized or an atomic, because volatile only guarantees visibility, not atomicity.
| Tool | Guarantees | Use for |
|---|---|---|
| volatile | Visibility only | A flag one thread sets, others read |
| synchronized | Visibility + mutual exclusion | Multi-step updates to shared data |
Sometimes one thread needs to wait until another is ready. The low-level tools are wait() and notify(), both called while holding a lock. wait() releases the lock and sleeps; notify() wakes one waiting thread. Always wait inside a while loop that re-checks the condition (to guard against spurious wake-ups).
public class Main {
// volatile = "always read/write the real value in main memory,
// never a stale per-thread cache". One writer, many readers.
static volatile boolean ready = false;
public static void main(String[] args) throws InterruptedException {
Object lock = new Object(); // the object we coordinate on
Thread worker = new Thread(() -> {
synchronized (lock) {
// wait() RELEASES the lock and sleeps until notified.
while (!ready) {
try { lock.wait(); } catch (InterruptedException e) { return; }
}
}
System.out.println(" Worker: got the signal, working now.");
});
worker.start();
Thread.sleep(100); // let the worker reach wait()
System.out.println("Main: sending the signal.");
synchronized (lock) {
ready = true;
lock.notify(); // wake one waiting thread
}
worker.join();
System.out.println("Main: worker finished.");
}
}Main: sending the signal.
Worker: got the signal, working now.
Main: worker finished.6️⃣ Why You'll Rarely Write This By Hand
Everything above is the foundation — and it's exactly the code you should avoid writing in real projects. Raw threads, manual locks, and wait()/notify() are powerful but very easy to get subtly wrong (forgotten locks, deadlocks, missed signals).
Modern Java gives you safer, higher-level tools that wrap these primitives correctly:
ExecutorService/ thread pools — reuse a fixed set of threads instead of spawning one per task.AtomicInteger,AtomicLong— lock-free counters and accumulators.ConcurrentHashMapand otherjava.util.concurrentcollections — thread-safe by design.CompletableFuture,CountDownLatch,Semaphore— coordination without rawwait()/notify().
java.util.concurrent in real code. You get correctness and readability for free, and far fewer 2am concurrency bugs.🧩 Mini-Challenge: Count to a Million, Safely
Time to fly without the scaffolding. The starter below has only a comment outline — you write the logic. Use everything from this lesson: a shared static field, a synchronized method, two threads, start() and join().
public class Main {
// 🎯 MINI-CHALLENGE: count to a million with two threads, safely
// 1. Make a shared "static int count = 0;"
// 2. Write a synchronized method that does count++
// 3. In main, create TWO threads; each calls that method 500_000 times in a loop
// 4. start() both, then join() both
// 5. Print: "Final count: " + count
//
// ✅ Expected output:
// Final count: 1000000
public static void main(String[] args) throws InterruptedException {
// your code here
}
}Common Errors (and the fix)
- ❌ Race condition on shared state: your counter ends up less than expected and changes every run. Fix: wrap the read-modify-write in
synchronized, or use anAtomicInteger. Never assumecount++is atomic. - ❌ Not synchronizing shared state at all: reads see stale values or partial updates. Fix: guard every access (reads and writes) to shared mutable data with the same lock, or use a
java.util.concurrentcollection likeConcurrentHashMap. - ❌ Calling run() instead of start():
thread.run()runs on the current thread — no concurrency happens. Fix: callthread.start()to actually spawn a new thread. - ❌ Deadlock: thread A holds lock 1 and waits for lock 2, while thread B holds lock 2 and waits for lock 1 — both freeze forever. Fix: always acquire multiple locks in the same order everywhere, or avoid nested locks entirely.
- ❌ Synchronizing on a fresh object each time:
synchronized (new Object())creates a new lock per call, so it protects nothing. Fix: lock on one shared, stable object (afinalfield) every time.
📋 Quick Reference
| Goal | Syntax | Notes |
|---|---|---|
| Define a task | Runnable r = () -> {...}; | A task, runs nowhere yet |
| Start a thread | new Thread(r).start(); | start(), NOT run() |
| Wait for it | t.join(); | Blocks until t ends |
| Check state | t.getState() | NEW … TERMINATED |
| Mutual exclusion | synchronized void m() | One thread at a time |
| Visibility flag | volatile boolean ready; | No stale caches |
| Signal / wait | lock.wait() / lock.notify() | Hold the lock; wait in a while loop |
| Safe counter | new AtomicInteger(0) | Lock-free, prefer for counters |
❓ Frequently Asked Questions
What is the difference between start() and run() on a Thread?
start() creates a brand-new thread and runs the task on it, so it executes in parallel with your current code. Calling run() directly does NOT create a thread — it just runs the task on the current thread, like a normal method call. Almost every beginner concurrency bug starts with accidentally calling run().
Should I extend Thread or implement Runnable?
Prefer Runnable (usually as a lambda). Java only allows single inheritance, so extending Thread burns your one superclass slot, and it ties the task to one execution mechanism. A Runnable is just 'a task'; you can hand it to a Thread, an ExecutorService, or a thread pool unchanged.
What exactly is a race condition?
A race condition is when two or more threads read and write the same shared data at the same time, and the final result depends on who happens to win the 'race'. The classic example is counter++, which is really three steps (read, add one, write back); two threads can both read the same value and one increment is silently lost.
What is the difference between synchronized and volatile?
synchronized gives you a lock so only one thread at a time runs a block or method — it provides mutual exclusion AND visibility. volatile only guarantees visibility: every read sees the latest value written by any thread, with no caching. Use volatile for a simple flag set by one thread and read by others; use synchronized when you need to make several steps atomic.
Why is higher-level concurrency (Executors, atomics) preferred over raw threads?
Creating a new Thread for every task is expensive (each one needs its own stack) and easy to get wrong. ExecutorService reuses a pool of threads and manages their lifecycle; classes like AtomicInteger and ConcurrentHashMap give you correct, lock-free thread safety without you hand-writing synchronized blocks. Raw threads, wait/notify, and manual locks are powerful but error-prone, so reach for the high-level tools first.
🎉 Lesson Complete!
Great work — you've met the hard part of Java head-on. You can now define tasks with Runnable, start threads and wait with join(), read the thread lifecycle, fix race conditions with synchronized, use volatile for visibility, and signal between threads with wait()/notify(). Most importantly, you know why production code reaches for higher-level tools instead.
Next up: Concurrency Utilities — Executors, locks, semaphores, and latches that make all of this safer and easier.
Sign up for free to track which lessons you've completed and get learning reminders.