Skip to content
elephantoo

Multithreading basics

Lesson 35 of 43 15 min read

Threads vs processes, Runnable, start vs run, sleep, join, interrupts and thread states.


Your laptop's CPU has several cores, and a web server must handle thousands of requests at once. Multithreading lets a Java program do several things at the same time: download files while keeping the UI responsive, process many requests in parallel, or split a big computation across cores. This lesson covers the fundamentals: creating threads, their lifecycle, sleep, join and interruption.

Processes and threads#

  • A process is a running program with its own memory. Your Java application is one process (one JVM).
  • A thread is an independent path of execution inside a process. All threads in a process share the same heap (objects), but each has its own call stack (local variables).

Every Java program starts with one thread, called main. The JVM also runs background threads, such as the garbage collector.

Concurrency means making progress on multiple tasks over the same period (possibly by switching between them); parallelism means literally running at the same instant on different cores. Threads give you both.

Creating a thread#

The work a thread performs is a Runnable: a functional interface with a void run() method. Pass one to a Thread and call start():

FirstThread.java
public class FirstThread {
    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 1; i <= 3; i++) {
                System.out.println(Thread.currentThread().getName() + " step " + i);
            }
        };

        Thread worker = new Thread(task, "worker-1");
        worker.start();          // runs task on a NEW thread
        worker.join();           // wait for it to finish

        System.out.println(Thread.currentThread().getName() + " done");
    }
}
Output
worker-1 step 1
worker-1 step 2
worker-1 step 3
main done

Other ways to create threads:

Java
// 1. Subclass Thread (less flexible: uses up your one superclass)
class Downloader extends Thread {
    @Override public void run() { System.out.println("downloading..."); }
}
new Downloader().start();

// 2. The builder API (Java 21)
Thread t = Thread.ofPlatform().name("reporter").start(() -> System.out.println("report"));

// 3. A virtual thread (Java 21): see the executors and modern Java lessons
Thread v = Thread.ofVirtual().start(() -> System.out.println("lightweight!"));

Prefer passing a Runnable (or using an executor, next-but-one lesson) over subclassing Thread.

start() vs run()

StartVsRun.java
public class StartVsRun {
    public static void main(String[] args) throws InterruptedException {
        Runnable who = () -> System.out.println("running on " + Thread.currentThread().getName());

        Thread t = new Thread(who, "worker");
        t.run();      // a plain method call: runs on main!
        t.start();    // a real new thread
        t.join();
    }
}
Output
running on main
running on worker

Calling start() twice on the same thread throws IllegalThreadStateException. A thread runs once.

Threads run concurrently and unpredictably#

With several threads, the scheduler decides who runs when. The interleaving changes from run to run:

Interleaving.java
public class Interleaving {
    public static void main(String[] args) throws InterruptedException {
        Thread a = new Thread(() -> { for (int i = 0; i < 5; i++) System.out.print("A"); });
        Thread b = new Thread(() -> { for (int i = 0; i < 5; i++) System.out.print("B"); });
        a.start();
        b.start();
        a.join();
        b.join();
        System.out.println();
    }
}

Possible outputs:

Output
AAAAABBBBB

or AABBBABAAB, or anything else. Never assume an order between threads unless you enforce one with synchronisation tools.

Sleeping and waiting: sleep and join#

SleepJoin.java
public class SleepJoin {
    public static void main(String[] args) throws InterruptedException {
        long start = System.currentTimeMillis();

        Thread slow = new Thread(() -> {
            try {
                Thread.sleep(500);                  // pause this thread for ~500 ms
                System.out.println("slow task finished");
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
        Thread fast = new Thread(() -> System.out.println("fast task finished"));

        slow.start();
        fast.start();
        slow.join();                                // main waits for both
        fast.join();

        long elapsed = System.currentTimeMillis() - start;
        System.out.println("both done, took about 500 ms: " + (elapsed >= 500 && elapsed < 1500));
    }
}
Output
fast task finished
slow task finished
both done, took about 500 ms: true
  • Thread.sleep(ms) pauses the current thread. It throws the checked InterruptedException.
  • t.join() makes the current thread wait for t to finish. t.join(1000) waits at most one second.

Running the two tasks in parallel took ~500 ms rather than 500 ms + the fast task's time. With slow I/O (network calls, disk), threads let waiting overlap.

Thread states#

A thread moves through these states (thread.getState()):

Output
NEW ──start()──► RUNNABLE ◄──────────────┐
                    │                     │
      waiting for a lock: BLOCKED ────────┤
      wait()/join(): WAITING ─────────────┤
      sleep(ms)/join(ms): TIMED_WAITING ──┘
                    │
              run() ends
                    ▼
               TERMINATED
States.java
public class States {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            try { Thread.sleep(200); } catch (InterruptedException e) { }
        });
        System.out.println(t.getState());
        t.start();
        Thread.sleep(50);
        System.out.println(t.getState());
        t.join();
        System.out.println(t.getState());
    }
}
Output
NEW
TIMED_WAITING
TERMINATED

Interrupting a thread#

Java has no safe way to forcibly kill a thread. Instead, you ask it to stop with interrupt(), and the thread cooperates:

Interrupt.java
public class Interrupt {
    public static void main(String[] args) throws InterruptedException {
        Thread poller = new Thread(() -> {
            int checks = 0;
            while (!Thread.currentThread().isInterrupted()) {
                checks++;
                try {
                    Thread.sleep(100);                    // pretend to poll a server
                } catch (InterruptedException e) {
                    System.out.println("interrupted while sleeping, cleaning up");
                    Thread.currentThread().interrupt();   // restore the flag
                }
            }
            System.out.println("poller stopped after at least " + (checks > 0 ? "one" : "zero") + " check");
        });

        poller.start();
        Thread.sleep(350);
        poller.interrupt();                               // politely ask it to stop
        poller.join();
        System.out.println("main finished");
    }
}
Output
interrupted while sleeping, cleaning up
poller stopped after at least one check
main finished

How interruption works:

  • t.interrupt() sets the thread's interrupted flag.
  • If the thread is blocked in sleep, wait or join, it wakes up immediately with an InterruptedException, and the flag is cleared.
  • Long-running loops should check Thread.currentThread().isInterrupted().

Never swallow InterruptedException with an empty catch. Either propagate it (declare throws InterruptedException) or restore the flag with Thread.currentThread().interrupt() so code higher up knows a stop was requested.

Daemon threads#

By default the JVM keeps running until all non-daemon threads finish. A daemon thread is a background helper that doesn't keep the JVM alive:

Java
Thread heartbeat = new Thread(() -> { while (true) { /* ping */ } });
heartbeat.setDaemon(true);    // must be set before start()
heartbeat.start();
// when main ends, the JVM exits even though heartbeat is still looping

Use daemons for non-critical background work; never for tasks that must complete, like saving a file, because they are abandoned abruptly at exit.

Uncaught exceptions in threads#

An exception thrown inside a thread's run() kills that thread only, and prints a stack trace. It does not reach main's try/catch:

Uncaught.java
public class Uncaught {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> { throw new IllegalStateException("boom"); });
        t.setUncaughtExceptionHandler((thread, ex) ->
                System.out.println(thread.getName() + " died: " + ex.getMessage()));
        t.setName("risky");
        t.start();
        t.join();
        System.out.println("main is still alive");
    }
}
Output
risky died: boom
main is still alive

Executors (two lessons ahead) make getting results and exceptions back from threads much easier.

A practical example: parallel downloads#

ParallelWork.java
import java.util.ArrayList;
import java.util.List;

public class ParallelWork {
    static void fakeDownload(String file) {
        try {
            Thread.sleep(300);          // simulate network latency
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        List<String> files = List.of("a.pdf", "b.pdf", "c.pdf", "d.pdf");

        long t0 = System.currentTimeMillis();
        for (String f : files) fakeDownload(f);
        long sequential = System.currentTimeMillis() - t0;

        t0 = System.currentTimeMillis();
        List<Thread> threads = new ArrayList<>();
        for (String f : files) {
            Thread t = new Thread(() -> fakeDownload(f));
            threads.add(t);
            t.start();
        }
        for (Thread t : threads) t.join();
        long parallel = System.currentTimeMillis() - t0;

        System.out.println("Sequential ~1200 ms: " + (sequential >= 1200));
        System.out.println("Parallel ~300 ms: " + (parallel < 800));
    }
}
Output
Sequential ~1200 ms: true
Parallel ~300 ms: true

Creating a raw thread per task works for a handful of tasks but doesn't scale to thousands. Platform threads are expensive (each reserves a stack, typically around 1 MB). That's why real applications use thread pools or, in Java 21, virtual threads.

The big catch: shared data#

All threads share the heap. When two threads read and write the same variable at the same time, results can be wrong in surprising ways. That's a race condition, and it's the subject of the next lesson.

Common mistakes#

  • Calling run() instead of start().
  • Forgetting join(), so main reads results before workers have finished.
  • Swallowing InterruptedException.
  • Assuming threads execute in a particular order.
  • Creating thousands of platform threads directly; use executors.
  • Using deprecated Thread.stop(), suspend() or resume().

What's next#

Next, synchronisation and thread safety: why count++ breaks with two threads, and the tools (synchronized, volatile, atomics, locks and concurrent collections) that fix it.

Check your understanding

Quick quiz

0/3 answered
  1. 1.What is the difference between calling thread.start() and thread.run()?

  2. 2.What does t.join() do?

  3. 3.What is the recommended way to ask a thread to stop?

Finished reading?

Mark this lesson complete to track your progress.