Java Multithreading

Multithreading lets a single Java program run multiple sequences of instructions, called threads, at the same time within one process. Instead of doing everything one step after another, your program can perform several tasks concurrently — downloading a file while updating a progress bar, or handling many client connections on a server. Every Java program already uses at least one thread (the main thread), and multithreading is simply the ability to spin up more of them, each running its own code path while sharing the same memory and resources. Understanding threads is essential for writing responsive applications, servers, and anything that needs to do real work in parallel.

Overview: What Multithreading Is and How It Works

A process is a running program with its own memory space; a thread is a unit of execution inside a process. When you start a Java program, the JVM creates a process and immediately starts one thread — the main thread — which runs your main method. Every additional Thread object you create and start runs concurrently with the main thread and with each other, but all threads inside the same process share the same heap memory (objects, static fields) while each thread gets its own private stack (local variables, method call frames) and its own program counter.

On modern hardware with multiple CPU cores, threads can genuinely execute in parallel, at the same physical instant. On a single core, the operating system’s scheduler rapidly switches between threads (a context switch), giving each a small slice of CPU time so that they appear to run simultaneously even though only one instruction stream executes at any given nanosecond. Either way, from the programmer’s point of view the effect is the same: you cannot assume any particular order in which statements from different threads will execute relative to each other, unless you explicitly synchronize them.

Because threads share the heap, two threads can read and write the same object’s fields at the same time. This is powerful (no need to copy data between threads) but dangerous: if two threads modify shared state without coordination, you get a race condition, where the final result depends on the unpredictable timing of the threads. Java’s synchronized keyword and related concurrency utilities exist specifically to make shared-state access safe.

Syntax: Creating and Starting Threads

There are two standard ways to define the work a thread should do:

  • Implement Runnable and pass it to a Thread constructor. This is the preferred approach because it does not force your class to extend Thread, leaving you free to extend something else, and it works naturally with lambdas.
  • Extend Thread and override its run() method. Simpler for quick demos, but less flexible since Java has no multiple inheritance of classes.

General form using Runnable:

Runnable task = () -> {
    // code the new thread will execute
};
Thread t = new Thread(task);
t.start();   // begins the new thread; run() executes concurrently
t.join();    // (optional) blocks the caller until t finishes

Key Thread methods you will use constantly:

Method Purpose
start() Creates a new OS-level thread and calls run() on it concurrently. Can only be called once per thread.
run() Contains the code to execute. Calling it directly (instead of start()) just runs it like a normal method on the current thread — no new thread is created.
join() Blocks the calling thread until the target thread finishes. Used to wait for results.
sleep(long ms) Static method that pauses the current thread for the given milliseconds.
interrupt() Signals a thread that it should stop what it’s doing (cooperative; the thread must check for this).
isAlive() Returns true if the thread has started but not yet terminated.
setDaemon(boolean) Marks a thread as a background/daemon thread that won’t keep the JVM alive by itself.
currentThread() Static method returning a reference to the thread executing this line of code.

Examples

Example 1: Creating and Running a Thread

This example creates one worker thread with a Runnable, starts it, and then uses join() so the main thread waits for it to finish before printing its own message.

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 1; i <= 3; i++) {
                System.out.println("Worker thread: count " + i);
            }
        };
        Thread worker = new Thread(task);
        worker.start();
        worker.join();
        System.out.println("Main thread: worker is done");
    }
}

Output:

Worker thread: count 1
Worker thread: count 2
Worker thread: count 3
Main thread: worker is done

Because main calls worker.join() immediately after start(), it blocks until the worker fully finishes, so this output is deterministic every time you run it. Without the join(), “Main thread: worker is done” could print before, during, or after the worker’s loop.

Example 2: Running Multiple Threads Concurrently

Here two threads run at the same time, each printing its own messages. Unlike Example 1, both are started before either is joined, so they genuinely race against each other.

public class Main {
    static class Greeter extends Thread {
        private final String name;
        Greeter(String name) {
            super(name);
            this.name = name;
        }
        public void run() {
            for (int i = 1; i <= 2; i++) {
                System.out.println(name + " says hello " + i);
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Greeter a = new Greeter("Thread-A");
        Greeter b = new Greeter("Thread-B");
        a.start();
        b.start();
        a.join();
        b.join();
        System.out.println("Main: both greeters finished");
    }
}

Output (one possible run):

Thread-A says hello 1
Thread-A says hello 2
Thread-B says hello 1
Thread-B says hello 2
Main: both greeters finished

The two join() calls guarantee “Main: both greeters finished” prints last, but the interleaving of the A and B lines above it is not guaranteed. On another run you might see them alternate, e.g. “Thread-A says hello 1”, “Thread-B says hello 1”, “Thread-A says hello 2”, “Thread-B says hello 2”. This is the essence of concurrency: order between independent threads is decided by the scheduler, not your source code.

Example 3: A Race Condition, and Fixing It with synchronized

When two threads modify the same shared variable without coordination, updates can be lost. Here two threads each increment a shared counter 100,000 times with no protection:

public class Main {
    static int counter = 0;
    static void increment() {
        counter++;
    }
    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 0; i < 100000; i++) {
                increment();
            }
        };
        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("Expected: 200000, Actual: " + counter);
    }
}

Output (varies by run — this is the point):

Expected: 200000, Actual: 193847

counter++ is not one atomic operation; it is really “read counter, add one, write counter back”. If both threads read the same value before either writes it back, one increment is silently lost. The exact final number is unpredictable and will differ each time you run the program — sometimes it may even happen to be 200000 by luck, which makes race conditions especially dangerous: they can hide in testing and appear in production.

The fix is to make the read-modify-write sequence atomic using synchronized, which only lets one thread execute the method at a time (it acquires the object’s monitor lock):

public class Main {
    static int counter = 0;
    static synchronized void increment() {
        counter++;
    }
    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 0; i < 100000; i++) {
                increment();
            }
        };
        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("Expected: 200000, Actual: " + counter);
    }
}

Output:

Expected: 200000, Actual: 200000

Now every call to increment() is serialized: a thread must acquire the lock before entering the method and releases it on exit, so no update can be lost. The trade-off is reduced parallelism for that critical section, since threads must wait their turn.

Under the Hood: Thread Lifecycle and the JVM

Every Thread object moves through a well-defined lifecycle, represented by the Thread.State enum:

  • NEW — the Thread object has been created but start() has not been called yet.
  • RUNNABLE — the thread is executing, or eligible to execute and waiting for CPU time from the OS scheduler.
  • BLOCKED — the thread is waiting to acquire a lock held by another thread (e.g. entering a synchronized block).
  • WAITING / TIMED_WAITING — the thread is paused, waiting to be notified or for a timeout, such as during join() or sleep().
  • TERMINATEDrun() has finished (normally or via an uncaught exception); the thread cannot be restarted.

When you call start(), the JVM asks the underlying operating system to create a genuine native thread, which the OS scheduler then manages alongside every other thread on the machine, from this program and others. This is why calling run() directly is so different: it is just an ordinary method call on the current thread, with no OS thread creation involved at all. Synchronized blocks and methods use a mechanism called a monitor lock (every Java object has one, whether or not it’s ever used): only one thread can hold a given object’s monitor at a time, and other threads calling a synchronized method or block on that same object are forced into the BLOCKED state until the lock is released. This locking also creates a happens-before relationship, which guarantees that changes to shared variables made inside a synchronized block become visible to the next thread that acquires the same lock — without this, the JVM’s optimizations and each CPU core’s local cache could let one thread never see another’s writes.

Common Mistakes

Mistake 1: Calling run() Instead of start()

It’s easy to accidentally call run(), which compiles fine but does not create a new thread at all — it just executes synchronously on whichever thread called it:

public class Main {
    public static void main(String[] args) {
        Thread worker = new Thread(() -> {
            System.out.println("Running on: " + Thread.currentThread().getName());
        });
        worker.run();
        System.out.println("Main thread: " + Thread.currentThread().getName());
    }
}

Output:

Running on: main
Main thread: main

Notice both lines say main — no new thread ever ran. The fix is to call start(), which actually spawns a new native thread:

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            System.out.println("Running on: " + Thread.currentThread().getName());
        });
        worker.start();
        worker.join();
        System.out.println("Main thread: " + Thread.currentThread().getName());
    }
}

Output:

Running on: Thread-0
Main thread: main

Now the worker really runs on its own thread (Thread-0), distinct from main.

Mistake 2: Sharing Mutable State Without Synchronization

As shown in Example 3 above, incrementing a shared field from multiple threads without synchronized (or an atomic class) silently loses updates. This mistake is dangerous precisely because it often works fine in casual testing and only misbehaves under real concurrent load, so always ask “can more than one thread touch this variable?” before writing shared mutable state.

Mistake 3: Assuming Thread Execution Order

Never write code that depends on threads printing or finishing in a particular order unless you enforce that order yourself with join(), locks, or higher-level coordination tools. The scheduler is free to run threads in almost any interleaving, and code that “happens to work” in testing can fail unpredictably later, including in production.

Best Practices

  • Prefer implementing Runnable (or using a lambda) over extending Thread, since it keeps your class free to extend something else and separates “what to run” from “how it runs”.
  • Always call start() to actually run code on a new thread; only call run() directly when you deliberately want normal, synchronous execution.
  • Protect every piece of mutable state that more than one thread can access, either with synchronized or with classes from java.util.concurrent (like AtomicInteger or ConcurrentHashMap).
  • Keep synchronized blocks as small as possible — lock only what needs protecting, not entire methods full of unrelated work, to minimize how long other threads wait.
  • Use join() when the main thread (or another thread) genuinely needs to wait for a task to finish before proceeding.
  • For real applications, prefer an ExecutorService thread pool over manually creating raw Thread objects one at a time — it reuses threads and manages their lifecycle for you.
  • Give threads meaningful names (via the Thread constructor) so logs and stack traces during debugging are easier to read.
  • Never assume a particular interleaving or order between independently started threads; if order matters, enforce it explicitly.

Practice Exercises

  • Exercise 1: Write a program that creates three threads, each printing its own name and the numbers 1 through 5, then have the main thread wait for all three (using join()) before printing “All done”.
  • Exercise 2: Reproduce the race condition from Example 3 using a shared ArrayList<Integer> (populate it via two threads, unsynchronized) and observe what goes wrong; then fix it using a synchronized block or Collections.synchronizedList.
  • Exercise 3: Write a program with two threads: one that increments a shared counter to 5 and one that only prints the counter’s value once it reaches 5. (Hint: this requires more than a plain while loop check — research wait()/notify() or a simple busy-wait with Thread.sleep() to see why coordination is tricky.)

Summary

  • A thread is an independent path of execution inside a process; all threads in a Java program share the same heap but have their own stacks.
  • Create threads by implementing Runnable (preferred) or extending Thread, then call start() — never call run() directly if you want real concurrency.
  • join() lets one thread wait for another to finish, which is essential for deterministic ordering.
  • Threads pass through NEW, RUNNABLE, BLOCKED, WAITING/TIMED_WAITING, and TERMINATED states, managed jointly by the JVM and the OS scheduler.
  • Shared mutable state accessed by multiple threads without coordination causes race conditions with unpredictable, often-wrong results.
  • The synchronized keyword uses an object’s monitor lock to serialize access to critical sections, fixing race conditions at the cost of some parallelism.
  • For real-world code, prefer higher-level tools like ExecutorService and java.util.concurrent classes over manually managed raw threads.