Skip to content
Java advanced 4 min read

Multithreading

Multithreading lets a single program run multiple paths of execution concurrently, exploiting multi-core CPUs and keeping applications responsive during blocking work. Java has first-class thread support built into the language and runtime — along with the hazards (race conditions, deadlocks) that come with shared mutable state.

Thread vs Runnable

There are two ways to define work for a thread: extend Thread, or implement Runnable and hand it to a thread. Prefer Runnable — it decouples the task from the threading mechanism and works with executors and lambdas.

// Implementing Runnable (preferred)
Runnable task = () -> System.out.println("running on " + Thread.currentThread().getName());
Thread t = new Thread(task, "worker-1");
t.start();

// Extending Thread (rarely needed)
class Printer extends Thread {
    public void run() {
        System.out.println("printer thread");
    }
}
new Printer().start();

Tip: A class can implement multiple interfaces but extend only one class. Implementing Runnable keeps your inheritance slot free and matches the ExecutorService API.

start vs run

This is the most common beginner mistake. Calling run() directly executes the body on the current thread — no new thread is created. Only start() spawns a new thread and then invokes run() on it.

Thread t = new Thread(() -> System.out.println(Thread.currentThread().getName()));

t.run();   // prints "main"   — no new thread
t.start(); // prints "Thread-0" — new thread

Output:

main
Thread-0

The Thread Lifecycle

A thread moves through a fixed set of states, exposed by Thread.State.

StateMeaning
NEWCreated but start() not yet called
RUNNABLEEligible to run (running or ready)
BLOCKEDWaiting to acquire a monitor lock
WAITINGWaiting indefinitely (join(), wait())
TIMED_WAITINGWaiting with a timeout (sleep, timed join)
TERMINATEDrun() has completed

sleep and join

Thread.sleep pauses the current thread for a duration without releasing locks. join makes one thread wait for another to finish.

Thread worker = new Thread(() -> {
    try {
        Thread.sleep(100); // TIMED_WAITING for 100ms
        System.out.println("work done");
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt(); // restore the flag
    }
});

worker.start();
worker.join(); // main waits here until worker terminates
System.out.println("main continues");

Output:

work done
main continues

Warning: When you catch InterruptedException, restore the interrupt status with Thread.currentThread().interrupt() (or rethrow). Swallowing it hides cancellation requests from upstream code.

Race Conditions and synchronized

When multiple threads read and write shared mutable state without coordination, the result depends on timing — a race condition. The classic example is a non-atomic increment.

class Counter {
    private int count = 0;

    public void increment() { count++; }       // NOT atomic: read-modify-write
    public synchronized void safeIncrement() { count++; }
    public int get() { return count; }
}

count++ is three operations (read, add, write). Two threads can interleave and lose updates. The synchronized keyword serializes access by acquiring the object’s monitor, ensuring only one thread executes the block at a time and that changes are visible to others.

Counter c = new Counter();
Runnable job = () -> { for (int i = 0; i < 10_000; i++) c.safeIncrement(); };

Thread a = new Thread(job), b = new Thread(job);
a.start(); b.start();
a.join();  b.join();

System.out.println(c.get()); // reliably 20000 with synchronized

Output:

20000

Without synchronized, the final count would be unpredictably less than 20000.

Virtual Threads (Java 21)

Java 21 made virtual threads a standard feature (Project Loom). They are lightweight threads managed by the JVM, not the OS — you can run millions of them. Blocking a virtual thread (e.g. on I/O) does not block an OS thread, making the simple thread-per-request model scale.

// Java 21+
try (var executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 1_000_000; i++) {
        executor.submit(() -> {
            Thread.sleep(java.time.Duration.ofSeconds(1));
            return null;
        });
    }
}

Note: Virtual threads shine for I/O-bound workloads. For CPU-bound work, the number of useful threads is still bounded by your cores — use a bounded pool of platform threads.

Best Practices

  • Prefer Runnable/ExecutorService over manually managing Thread objects.
  • Always call start(), never run(), to actually create a thread.
  • Minimize shared mutable state; favor immutability and message passing.
  • Guard every read and write of shared state, not just writes.
  • Restore the interrupt flag when catching InterruptedException.
  • Use virtual threads for high-concurrency I/O; bounded pools for CPU-bound work.

Interview Questions

Q: What is the difference between start() and run()? start() creates a new thread and schedules run() to execute on it. Calling run() directly executes the body synchronously on the current thread with no concurrency.

Q: Why prefer implementing Runnable over extending Thread? Runnable separates the task from the execution mechanism, leaves the single inheritance slot free, and works with executors, thread pools, and lambdas. Extending Thread couples your logic to a specific threading approach.

Q: What is a race condition and how do you prevent it? A race condition occurs when the outcome depends on the non-deterministic interleaving of threads accessing shared mutable state. Prevent it with synchronization (synchronized, locks), atomic classes, or by eliminating shared state through immutability.

Q: How do virtual threads differ from platform threads? Virtual threads are JVM-managed and extremely cheap, so millions can exist. Blocking one does not tie up an OS thread, so a simple blocking thread-per-task style scales for I/O-bound work. Platform threads map 1:1 to OS threads and are a limited resource.

Last updated June 1, 2026
Was this helpful?