Skip to main content

    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().

    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.

    Worked Example: start a thread, join it, watch its state
    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.");
        }
    }
    Output
    State before start: NEW
    Main thread keeps going...
      Hello from the new thread!
    State after join:  TERMINATED
    Done.
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    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.

    🎯 Your Turn #1: start and join a thread
    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
        }
    }
    Output
    Task running!
    Done
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    3️⃣ 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.

    Worked Example: race condition vs 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)");
        }
    }
    Output
    Unsafe counter: 138472 (wanted 200000)
    Safe counter:   200000 (always 200000)
    The unsafe number is different every run and almost never reaches 200000 — that's the lost updates. The synchronized counter is always exactly 200000. Run it a few times on onecompiler.com/java to watch the unsafe value change.
    🎯 Your Turn #2: protect shared state
    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
        }
    }
    Output
    Total: 100000
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    5️⃣ 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.

    ToolGuaranteesUse for
    volatileVisibility onlyA flag one thread sets, others read
    synchronizedVisibility + mutual exclusionMulti-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).

    Worked Example: volatile flag + wait/notify signalling
    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.");
        }
    }
    Output
    Main: sending the signal.
      Worker: got the signal, working now.
    Main: worker finished.
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    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.
    • ConcurrentHashMap and other java.util.concurrent collections — thread-safe by design.
    • CompletableFuture, CountDownLatch, Semaphore — coordination without raw wait()/notify().

    🧩 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().

    🧩 Mini-Challenge starter (write the logic yourself)
    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
        }
    }
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    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 an AtomicInteger. Never assume count++ 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.concurrent collection like ConcurrentHashMap.
    • Calling run() instead of start(): thread.run() runs on the current thread — no concurrency happens. Fix: call thread.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 (a final field) every time.

    📋 Quick Reference

    GoalSyntaxNotes
    Define a taskRunnable r = () -> {...};A task, runs nowhere yet
    Start a threadnew Thread(r).start();start(), NOT run()
    Wait for itt.join();Blocks until t ends
    Check statet.getState()NEW … TERMINATED
    Mutual exclusionsynchronized void m()One thread at a time
    Visibility flagvolatile boolean ready;No stale caches
    Signal / waitlock.wait() / lock.notify()Hold the lock; wait in a while loop
    Safe counternew 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.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service

    Install LearnCodingFast

    Learn faster with the app on your home screen.