BLOG POSTS
Multithreading in Java – Basics and Examples

Multithreading in Java – Basics and Examples

Multithreading is one of those Java features that can turn your application from a sluggish, single-lane road into a multi-lane highway. At its core, multithreading allows your Java programs to execute multiple tasks simultaneously, making better use of CPU resources and improving overall performance. Whether you’re building web servers, processing large datasets, or handling concurrent user requests, understanding Java’s threading model is crucial for any developer working with server applications. This post will walk you through the fundamentals of Java multithreading, practical implementation examples, common pitfalls you’ll inevitably encounter, and real-world scenarios where threading makes the difference between a responsive application and one that leaves users staring at loading screens.

How Java Multithreading Works

Java threading is built around the concept of lightweight processes that share memory space within a single JVM instance. Each thread maintains its own stack and program counter, but they all share the heap memory, which is where things get interesting (and occasionally frustrating).

The Java Virtual Machine manages threads through a combination of operating system threads and its own scheduling mechanisms. When you create a thread in Java, you’re essentially asking the JVM to create a new execution path that can run concurrently with your main program thread.

Here’s the basic thread lifecycle you need to understand:

  • New: Thread object created but not started
  • Runnable: Thread is executing or ready to execute
  • Blocked: Thread is blocked waiting for a monitor lock
  • Waiting: Thread is waiting indefinitely for another thread
  • Timed Waiting: Thread is waiting for a specified period
  • Terminated: Thread has completed execution

The real power comes from understanding how threads communicate and synchronize. Java provides several mechanisms including synchronized blocks, volatile keywords, and higher-level concurrency utilities in the java.util.concurrent package.

Step-by-Step Implementation Guide

Let’s start with the two primary ways to create threads in Java. First, extending the Thread class:

public class CustomThread extends Thread {
    private String threadName;
    
    public CustomThread(String name) {
        this.threadName = name;
    }
    
    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println(threadName + " - Count: " + i);
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                System.out.println(threadName + " interrupted");
                return;
            }
        }
        System.out.println(threadName + " completed");
    }
    
    public static void main(String[] args) {
        CustomThread thread1 = new CustomThread("Worker-1");
        CustomThread thread2 = new CustomThread("Worker-2");
        
        thread1.start();
        thread2.start();
        
        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            System.out.println("Main thread interrupted");
        }
        
        System.out.println("All threads completed");
    }
}

The second approach uses the Runnable interface, which is generally preferred because it allows your class to extend other classes:

public class TaskRunner implements Runnable {
    private String taskName;
    private int iterations;
    
    public TaskRunner(String taskName, int iterations) {
        this.taskName = taskName;
        this.iterations = iterations;
    }
    
    @Override
    public void run() {
        for (int i = 0; i < iterations; i++) {
            System.out.println(taskName + " executing iteration " + (i + 1));
            
            // Simulate some work
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                System.out.println(taskName + " was interrupted");
                return;
            }
        }
    }
    
    public static void main(String[] args) {
        Thread thread1 = new Thread(new TaskRunner("DatabaseTask", 3));
        Thread thread2 = new Thread(new TaskRunner("FileProcessor", 4));
        
        thread1.start();
        thread2.start();
    }
}

For more complex scenarios, you'll want to use the ExecutorService framework, which provides better thread management:

import java.util.concurrent.*;
import java.util.List;
import java.util.ArrayList;

public class ExecutorExample {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(3);
        List<Future<String>> futures = new ArrayList<>();
        
        // Submit multiple tasks
        for (int i = 1; i <= 5; i++) {
            final int taskId = i;
            Future<String> future = executor.submit(() -> {
                Thread.sleep(2000);
                return "Task " + taskId + " completed by " + Thread.currentThread().getName();
            });
            futures.add(future);
        }
        
        // Collect results
        for (Future<String> future : futures) {
            try {
                String result = future.get();
                System.out.println(result);
            } catch (InterruptedException | ExecutionException e) {
                System.err.println("Task failed: " + e.getMessage());
            }
        }
        
        executor.shutdown();
        try {
            if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
                executor.shutdownNow();
            }
        } catch (InterruptedException e) {
            executor.shutdownNow();
        }
    }
}

Real-World Examples and Use Cases

Here's a practical example of a web server request handler that demonstrates thread safety and concurrent processing:

import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class WebRequestHandler {
    private final ExecutorService requestPool;
    private final AtomicInteger requestCounter;
    private final Map<String, Integer> requestStats;
    
    public WebRequestHandler(int poolSize) {
        this.requestPool = Executors.newFixedThreadPool(poolSize);
        this.requestCounter = new AtomicInteger(0);
        this.requestStats = new ConcurrentHashMap<>();
    }
    
    public void handleRequest(String endpoint, String clientId) {
        requestPool.submit(() -> {
            int requestId = requestCounter.incrementAndGet();
            System.out.println("Processing request " + requestId + 
                             " for endpoint: " + endpoint + 
                             " from client: " + clientId);
            
            try {
                // Simulate request processing time
                Thread.sleep(1000 + (int)(Math.random() * 2000));
                
                // Update statistics atomically
                requestStats.merge(endpoint, 1, Integer::sum);
                
                System.out.println("Request " + requestId + " completed successfully");
                
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                System.err.println("Request " + requestId + " was interrupted");
            } catch (Exception e) {
                System.err.println("Request " + requestId + " failed: " + e.getMessage());
            }
        });
    }
    
    public void printStats() {
        System.out.println("Request Statistics:");
        requestStats.forEach((endpoint, count) -> 
            System.out.println(endpoint + ": " + count + " requests"));
        System.out.println("Total requests processed: " + requestCounter.get());
    }
    
    public void shutdown() {
        requestPool.shutdown();
        try {
            if (!requestPool.awaitTermination(30, TimeUnit.SECONDS)) {
                requestPool.shutdownNow();
            }
        } catch (InterruptedException e) {
            requestPool.shutdownNow();
        }
    }
    
    public static void main(String[] args) throws InterruptedException {
        WebRequestHandler handler = new WebRequestHandler(10);
        
        // Simulate concurrent requests
        for (int i = 0; i < 20; i++) {
            String endpoint = "/api/endpoint" + (i % 3);
            String clientId = "client" + (i % 5);
            handler.handleRequest(endpoint, clientId);
        }
        
        // Wait a bit and print stats
        Thread.sleep(5000);
        handler.printStats();
        handler.shutdown();
    }
}

Another common use case is parallel data processing. Here's an example that processes a large dataset using multiple threads:

import java.util.concurrent.*;
import java.util.List;
import java.util.ArrayList;
import java.util.stream.IntStream;

public class ParallelDataProcessor {
    
    public static class DataChunk {
        private final int startIndex;
        private final int endIndex;
        private final int[] data;
        
        public DataChunk(int[] data, int startIndex, int endIndex) {
            this.data = data;
            this.startIndex = startIndex;
            this.endIndex = endIndex;
        }
        
        public long process() {
            long sum = 0;
            for (int i = startIndex; i < endIndex; i++) {
                // Simulate complex processing
                sum += data[i] * data[i];
            }
            return sum;
        }
    }
    
    public static long processDataParallel(int[] data, int numThreads) 
            throws InterruptedException, ExecutionException {
        
        ExecutorService executor = Executors.newFixedThreadPool(numThreads);
        List<Future<Long>> futures = new ArrayList<>();
        
        int chunkSize = data.length / numThreads;
        
        for (int i = 0; i < numThreads; i++) {
            int startIndex = i * chunkSize;
            int endIndex = (i == numThreads - 1) ? data.length : (i + 1) * chunkSize;
            
            DataChunk chunk = new DataChunk(data, startIndex, endIndex);
            futures.add(executor.submit(chunk::process));
        }
        
        long totalResult = 0;
        for (Future<Long> future : futures) {
            totalResult += future.get();
        }
        
        executor.shutdown();
        return totalResult;
    }
    
    public static void main(String[] args) throws Exception {
        // Create large dataset
        int[] data = IntStream.range(1, 1000001).toArray();
        
        // Compare serial vs parallel processing
        long startTime = System.currentTimeMillis();
        long serialResult = 0;
        for (int value : data) {
            serialResult += value * value;
        }
        long serialTime = System.currentTimeMillis() - startTime;
        
        startTime = System.currentTimeMillis();
        long parallelResult = processDataParallel(data, 4);
        long parallelTime = System.currentTimeMillis() - startTime;
        
        System.out.println("Serial result: " + serialResult + " (Time: " + serialTime + "ms)");
        System.out.println("Parallel result: " + parallelResult + " (Time: " + parallelTime + "ms)");
        System.out.println("Speedup: " + (double)serialTime / parallelTime + "x");
    }
}

Comparison with Alternative Approaches

Approach Best For Memory Usage Complexity Performance
Traditional Threads Simple concurrent tasks High (1MB+ per thread) Medium Good for CPU-bound tasks
Thread Pools Many short-lived tasks Medium (controlled) Low Excellent for I/O operations
CompletableFuture Asynchronous processing Low Medium Great for chaining operations
Parallel Streams Data processing Low Very Low Excellent for large datasets
Virtual Threads (Java 19+) High-concurrency I/O Very Low (few KB) Low Outstanding for blocking operations

Here's a practical comparison showing different approaches to handle the same task:

import java.util.concurrent.*;
import java.util.stream.IntStream;
import java.time.Duration;
import java.time.Instant;

public class ThreadingComparison {
    
    private static final int TASK_COUNT = 1000;
    
    // Traditional thread approach
    public static void traditionalThreads() throws InterruptedException {
        Instant start = Instant.now();
        Thread[] threads = new Thread[TASK_COUNT];
        
        for (int i = 0; i < TASK_COUNT; i++) {
            final int taskId = i;
            threads[i] = new Thread(() -> simulateWork(taskId));
            threads[i].start();
        }
        
        for (Thread thread : threads) {
            thread.join();
        }
        
        System.out.println("Traditional threads: " + 
                         Duration.between(start, Instant.now()).toMillis() + "ms");
    }
    
    // Thread pool approach
    public static void threadPool() throws InterruptedException {
        Instant start = Instant.now();
        ExecutorService executor = Executors.newFixedThreadPool(50);
        
        for (int i = 0; i < TASK_COUNT; i++) {
            final int taskId = i;
            executor.submit(() -> simulateWork(taskId));
        }
        
        executor.shutdown();
        executor.awaitTermination(60, TimeUnit.SECONDS);
        
        System.out.println("Thread pool: " + 
                         Duration.between(start, Instant.now()).toMillis() + "ms");
    }
    
    // Parallel streams approach
    public static void parallelStreams() {
        Instant start = Instant.now();
        
        IntStream.range(0, TASK_COUNT)
                .parallel()
                .forEach(ThreadingComparison::simulateWork);
        
        System.out.println("Parallel streams: " + 
                         Duration.between(start, Instant.now()).toMillis() + "ms");
    }
    
    private static void simulateWork(int taskId) {
        try {
            Thread.sleep(10); // Simulate I/O operation
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
    
    public static void main(String[] args) throws InterruptedException {
        System.out.println("Comparing threading approaches with " + TASK_COUNT + " tasks:");
        
        threadPool();
        parallelStreams();
        // traditionalThreads(); // Commented out - would likely cause issues with 1000 threads
    }
}

Best Practices and Common Pitfalls

Thread safety is where most developers trip up. Here are the critical patterns you need to understand:

import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.atomic.AtomicInteger;

public class ThreadSafetyExamples {
    
    // BAD: Non-thread-safe counter
    public static class UnsafeCounter {
        private int count = 0;
        
        public void increment() {
            count++; // This is NOT atomic!
        }
        
        public int getCount() {
            return count;
        }
    }
    
    // GOOD: Thread-safe counter using synchronization
    public static class SafeCounter {
        private int count = 0;
        
        public synchronized void increment() {
            count++;
        }
        
        public synchronized int getCount() {
            return count;
        }
    }
    
    // BETTER: Thread-safe counter using atomic operations
    public static class AtomicCounter {
        private final AtomicInteger count = new AtomicInteger(0);
        
        public void increment() {
            count.incrementAndGet();
        }
        
        public int getCount() {
            return count.get();
        }
    }
    
    // ADVANCED: Using explicit locks for fine-grained control
    public static class LockBasedCounter {
        private int count = 0;
        private final ReentrantLock lock = new ReentrantLock();
        
        public void increment() {
            lock.lock();
            try {
                count++;
            } finally {
                lock.unlock(); // Always unlock in finally block
            }
        }
        
        public int getCount() {
            lock.lock();
            try {
                return count;
            } finally {
                lock.unlock();
            }
        }
    }
    
    public static void testCounters() throws InterruptedException {
        int numThreads = 10;
        int incrementsPerThread = 1000;
        
        // Test unsafe counter
        UnsafeCounter unsafeCounter = new UnsafeCounter();
        testCounter("Unsafe", unsafeCounter::increment, unsafeCounter::getCount, 
                   numThreads, incrementsPerThread);
        
        // Test safe counter
        SafeCounter safeCounter = new SafeCounter();
        testCounter("Safe", safeCounter::increment, safeCounter::getCount, 
                   numThreads, incrementsPerThread);
        
        // Test atomic counter
        AtomicCounter atomicCounter = new AtomicCounter();
        testCounter("Atomic", atomicCounter::increment, atomicCounter::getCount, 
                   numThreads, incrementsPerThread);
    }
    
    private static void testCounter(String name, Runnable incrementer, 
                                  java.util.function.Supplier<Integer> getter,
                                  int numThreads, int incrementsPerThread) 
                                  throws InterruptedException {
        
        Thread[] threads = new Thread[numThreads];
        long startTime = System.currentTimeMillis();
        
        for (int i = 0; i < numThreads; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < incrementsPerThread; j++) {
                    incrementer.run();
                }
            });
            threads[i].start();
        }
        
        for (Thread thread : threads) {
            thread.join();
        }
        
        long endTime = System.currentTimeMillis();
        int expectedCount = numThreads * incrementsPerThread;
        int actualCount = getter.get();
        
        System.out.printf("%s Counter: Expected=%d, Actual=%d, Time=%dms, Correct=%s%n",
                         name, expectedCount, actualCount, (endTime - startTime),
                         (expectedCount == actualCount));
    }
    
    public static void main(String[] args) throws InterruptedException {
        testCounters();
    }
}

Common pitfalls to watch out for:

  • Race conditions: Multiple threads accessing shared data without proper synchronization
  • Deadlocks: Threads waiting for each other indefinitely
  • Resource leaks: Not properly shutting down thread pools
  • Excessive context switching: Creating too many threads for your CPU cores
  • Shared mutable state: The root of most concurrency problems

Here's an example showing how to avoid deadlocks:

public class DeadlockPrevention {
    private final Object lock1 = new Object();
    private final Object lock2 = new Object();
    
    // BAD: Can cause deadlock
    public void badMethod1() {
        synchronized(lock1) {
            synchronized(lock2) {
                // Do work
            }
        }
    }
    
    public void badMethod2() {
        synchronized(lock2) {
            synchronized(lock1) {
                // Do work - DEADLOCK POTENTIAL!
            }
        }
    }
    
    // GOOD: Always acquire locks in the same order
    public void goodMethod1() {
        synchronized(lock1) {
            synchronized(lock2) {
                // Do work
            }
        }
    }
    
    public void goodMethod2() {
        synchronized(lock1) { // Same order as goodMethod1
            synchronized(lock2) {
                // Do work - No deadlock
            }
        }
    }
    
    // BETTER: Use timeout-based locking
    private final ReentrantLock reentrantLock1 = new ReentrantLock();
    private final ReentrantLock reentrantLock2 = new ReentrantLock();
    
    public boolean safeMethod() {
        boolean acquired1 = false;
        boolean acquired2 = false;
        
        try {
            acquired1 = reentrantLock1.tryLock(1, TimeUnit.SECONDS);
            if (acquired1) {
                acquired2 = reentrantLock2.tryLock(1, TimeUnit.SECONDS);
                if (acquired2) {
                    // Do work safely
                    return true;
                }
            }
            return false; // Could not acquire locks
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return false;
        } finally {
            if (acquired2) reentrantLock2.unlock();
            if (acquired1) reentrantLock1.unlock();
        }
    }
}

Performance considerations and monitoring are crucial for production systems. Always monitor thread pool metrics and adjust pool sizes based on your application's characteristics. For CPU-intensive tasks, use a pool size close to your CPU core count. For I/O-intensive tasks, you can safely use larger pools since threads will be blocked waiting for I/O operations.

The java.util.concurrent package documentation provides comprehensive details on all the concurrency utilities available in Java. For advanced threading concepts and patterns, the Oracle Java Concurrency Tutorial offers deep insights into best practices and advanced techniques.

Modern Java applications should also consider virtual threads introduced in Project Loom, which dramatically reduce the overhead of creating threads for I/O-bound applications. This technology allows you to create millions of lightweight threads without the traditional memory overhead, making highly concurrent applications much more feasible.



This article incorporates information and material from various online sources. We acknowledge and appreciate the work of all original authors, publishers, and websites. While every effort has been made to appropriately credit the source material, any unintentional oversight or omission does not constitute a copyright infringement. All trademarks, logos, and images mentioned are the property of their respective owners. If you believe that any content used in this article infringes upon your copyright, please contact us immediately for review and prompt action.

This article is intended for informational and educational purposes only and does not infringe on the rights of the copyright owners. If any copyrighted material has been used without proper credit or in violation of copyright laws, it is unintentional and we will rectify it promptly upon notification. Please note that the republishing, redistribution, or reproduction of part or all of the contents in any form is prohibited without express written permission from the author and website owner. For permissions or further inquiries, please contact us.

Leave a reply

Your email address will not be published. Required fields are marked