Java Academy

Master Java Development

Menu

Core Java

Servlets

JDBC

Microservices

Build Tools

APIs

Core Java · Lesson 10

Multithreading

Concurrency: Running Code in Parallel

Multithreading allows multiple tasks to run concurrently (seemingly at the same time) within a single program. A thread is a lightweight process. On multi-core processors, threads truly run in parallel; on single cores, the OS switches between threads rapidly. Multithreading is essential for responsive applications, server handling multiple clients, and performing time-consuming tasks without blocking the UI.

Creating Threads: Two Approaches

Method 1: Extend the Thread class and override the run() method. Method 2: Implement the Runnable interface and pass it to a Thread constructor. The Runnable approach is preferred because Java doesn't support multiple inheritance — implementing Runnable leaves room for extending another class.

// Method 1: Extend Thread
public class MyThread extends Thread {
    private String name;
    
    public MyThread(String name) {
        this.name = name;
    }
    
    @Override
    public void run() {
        for (int i = 1; i <= 5; i++) {
            System.out.println(name + " - Count: " + i);
            try {
                Thread.sleep(1000);  // Pause for 1 second
            } catch (InterruptedException e) {
                System.out.println("Thread interrupted");
            }
        }
        System.out.println(name + " finished");
    }
}

// Method 2: Implement Runnable (preferred)
public class MyRunnable implements Runnable {
    private String name;
    
    public MyRunnable(String name) {
        this.name = name;
    }
    
    @Override
    public void run() {
        for (int i = 1; i <= 5; i++) {
            System.out.println(name + " - Count: " + i);
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                System.out.println("Thread interrupted");
            }
        }
        System.out.println(name + " finished");
    }
}

public class ThreadDemo {
    public static void main(String[] args) {
        // Method 1: Using Thread subclass
        MyThread thread1 = new MyThread("Thread-1");
        thread1.start();  // Call start(), not run()!
        
        // Method 2: Using Runnable
        Thread thread2 = new Thread(new MyRunnable("Thread-2"));
        thread2.start();
        
        // Both threads run concurrently
        System.out.println("Main thread continues");
    }
}

Thread States and Lifecycle

Threads go through several states: New (created, not started), Runnable (running or waiting to run), Blocked (waiting for a resource), Waiting (waiting for another thread), and Terminated (finished). Understanding these states helps you debug concurrent issues and control thread behavior.

public class ThreadLifecycle {
    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            System.out.println("Worker thread started");
            try {
                Thread.sleep(2000);  // Simulating work
            } catch (InterruptedException e) {
                System.out.println("Worker interrupted");
            }
            System.out.println("Worker thread finished");
        });
        
        System.out.println("State before start: " + worker.getState());  // NEW
        
        worker.start();
        System.out.println("State after start: " + worker.getState());   // RUNNABLE
        
        Thread.sleep(1000);
        System.out.println("State during sleep: " + worker.getState());  // TIMED_WAITING
        
        worker.join();  // Main thread waits for worker to finish
        System.out.println("State after finish: " + worker.getState());  // TERMINATED
    }
}

Thread Synchronization: Managing Shared Data

When multiple threads access the same data, race conditions occur — unpredictable results from interleaved access. Use the synchronized keyword to ensure only one thread accesses a method or block at a time. This protects shared state but can impact performance.

public class Counter {
    private int count = 0;
    
    // Synchronized method: only one thread at a time
    public synchronized void increment() {
        count++;
    }
    
    public synchronized int getCount() {
        return count;
    }
    
    // Alternative: synchronized block (more fine-grained)
    public void incrementWithBlock() {
        synchronized (this) {
            count++;
        }
    }
}

public class SynchronizationDemo {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        
        // Create 10 threads, each incrementing 1000 times
        Thread[] threads = new Thread[10];
        for (int i = 0; i < 10; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    counter.increment();
                }
            });
            threads[i].start();
        }
        
        // Wait for all threads to finish
        for (Thread thread : threads) {
            thread.join();
        }
        
        System.out.println("Final count: " + counter.getCount());  // 10000
    }
}

Thread Communication: wait() and notify()

Threads often need to coordinate — one thread waits while another prepares data. Use wait() to pause a thread until notified, and notify() or notifyAll() to wake waiting threads. This avoids busy-waiting (constantly checking) and improves efficiency.

public class Producer implements Runnable {
    private DataBuffer buffer;
    
    public Producer(DataBuffer buffer) {
        this.buffer = buffer;
    }
    
    @Override
    public void run() {
        for (int i = 1; i <= 5; i++) {
            buffer.produce("Item-" + i);
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }
}

public class Consumer implements Runnable {
    private DataBuffer buffer;
    
    public Consumer(DataBuffer buffer) {
        this.buffer = buffer;
    }
    
    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            buffer.consume();
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }
}

public class DataBuffer {
    private String data = null;
    private boolean hasData = false;
    
    public synchronized void produce(String item) {
        while (hasData) {
            try {
                wait();  // Wait until consumer reads
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
        this.data = item;
        this.hasData = true;
        System.out.println("Produced: " + item);
        notify();  // Wake consumer
    }
    
    public synchronized String consume() {
        while (!hasData) {
            try {
                wait();  // Wait until producer writes
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
        String item = this.data;
        this.hasData = false;
        System.out.println("Consumed: " + item);
        notify();  // Wake producer
        return item;
    }
}

Common Mistake: Calling run() instead of start(). thread.run() executes immediately in the current thread (not concurrent). Always call thread.start(), which spawns a new thread. Also, modifying shared data without synchronization causes unpredictable bugs — if multiple threads touch the same object, protect it with synchronized.

Best Practices for Multithreading

  • Prefer Runnable over extending Thread: leaves room for inheritance
  • Use Thread.sleep() for delays, not busy loops: saves CPU
  • Synchronize access to shared mutable state: prevents race conditions
  • Use synchronized blocks (not methods) for better performance
  • Use join() to wait for threads to finish before continuing
  • Handle InterruptedException gracefully: stop threads cleanly
  • Avoid deadlocks: acquire locks in consistent order across threads
  • Consider higher-level concurrency utilities (ExecutorService, Locks) for complex scenarios

Real-World Example: Web Server Handling Multiple Clients

A Java web server creates a new thread for each client connection. The main thread runs a loop accepting connections, spawning a ClientHandler thread for each. Multiple threads run concurrently, handling requests independently — if one client is slow, others aren't blocked. Shared resources like a thread-safe connection pool use synchronized access. When a request requires data from a database, threads wait for the response without blocking others. This architecture allows a single server to serve hundreds or thousands of clients efficiently.

Quiz Check

Quick Knowledge Test

Loading quiz questions...