Computer scienceProgramming languagesJavaInterview preparationTech interviewConcurrency

Java multithreading

16 minutes read

Multithreading is a fundamental concept in Java that allows applications to perform multiple tasks concurrently. Mastering this area is key to building efficient, responsive, and scalable software. In this article, we’ll break down the core ideas of multithreading and explain how they help you develop more robust and high-performing Java applications.

Multithreading

Threads are a fundamental concept for achieving concurrency by running multiple sequences of instructions in parallel within a single program.

JVM Threads

A Thread is a JVM object mapped one-to-one to an OS thread, with its own call stack and scheduler context:

// Create and start a thread
Thread thread = new Thread(() -> {
    for (int i = 1; i <= 5; i++) {
        System.out.println("Thread prints: " + i + " on " + Thread.currentThread().getName());
        try {
            Thread.sleep(200); // blocks this thread only
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
});

thread.setName("MyWorker");
thread.start();

try {
    thread.join(); // wait until it finishes
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
}

System.out.println("Main thread resumes");

In this example, the lambda defines the work that will be executed by the thread named “MyWorker.” When start() is called, the thread is registered with the OS scheduler and begins execution independently. The sleep(200) call pauses only that specific thread for 200 milliseconds without affecting others. Finally, join() blocks the main thread, making it wait until “MyWorker” finishes its execution.

Runnable Interface

A Runnable represents a unit of work, allowing you to define a task independently of the thread that will run it (separates the definition of what to do (the task) from how it runs (the thread itself)):

Runnable runnable = () -> {
    System.out.println("Runnable executed by " + Thread.currentThread().getName());
};

Thread thread = new Thread(runnable, "RunnableThread");
thread.start();

Here, implementing Runnable as a lambda specifies the action — printing the current thread’s name. The Thread(runnable, "RunnableThread") constructor creates a new thread, assigns it the name "RunnableThread", and associates it with the specified action. Finally, calling start() registers the thread with the OS scheduler, allowing it to run the Runnable's code independently.

Synchronization

Synchronization guarantees that only one thread can access a critical section at a time, preventing race conditions when multiple threads interact with shared mutable data. In Java, this is commonly done using the synchronized keyword or explicit lock objects:

public class Counter {
    private int value = 0;

    // Synchronized method - only one thread can run this at a time
    public synchronized void increment() {
        value++;
        System.out.println(Thread.currentThread().getName() + " incremented value to " + value);
    }

    public static void main(String[] args) {
        Counter counter = new Counter();

        Runnable task = () -> counter.increment();

        Thread t1 = new Thread(task, "Thread-A");
        Thread t2 = new Thread(task, "Thread-B");

        t1.start();
        t2.start();
    }
}

In this snippet, The synchronized keyword ensures that only one thread at a time can execute this method for the same Counter object. This prevents multiple threads from updating value at the same time, avoiding race conditions and incorrect results.

Thread states

Every thread in Java goes through a well-defined lifecycle, transitioning between different states managed by the JVM and the underlying OS. These states help you understand how threads behave during execution and are essential for writing correct concurrent programs. The main thread states are: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, and TERMINATED:

Thread worker = ... // new worker to make a difficult task
System.out.println(worker.getState()); // NEW
         
worker.start(); // start the worker
System.out.println(worker.getState()); // RUNNABLE

worker.join();  // waiting for completing the worker
System.out.println(worker.getState()); // TERMINATED

A thread starts in the NEW state after creation. Once start() is called, it moves to RUNNABLE, ready for the OS to schedule it. If the thread tries to enter a synchronized block that’s already locked, it becomes BLOCKED. Methods like wait() or join() can place a thread in WAITING or TIMED_WAITING, depending on whether there’s a timeout. Finally, after finishing its task or being stopped, the thread enters the TERMINATED state.

Thread interruption

In multithreaded applications, it's often necessary to stop a running thread gracefully. Java provides the interrupt() method for this purpose. Interrupting a thread is a cooperative mechanism—it doesn't forcibly terminate the thread but sets an internal flag, signaling that the thread should stop its work if appropriate. The thread can check this flag using Thread.interrupted() or isInterrupted(), and many blocking methods like sleep() or join() automatically throw an InterruptedException when a thread is interrupted during execution. Properly handling interruptions is essential for building responsive and well-behaved concurrent programs:

public class CustomThread extends Thread {
    @Override
    public void run() {
        while (!isInterrupted()) {
            try {
                doAction();
                Thread.sleep(1000); // it may throw InterruptedException
            } catch (InterruptedException e) {
                System.out.println("sleeping was interrupted");
                Thread.currentThread().interrupt();

                break; // stop the loop
            }
        }
        System.out.printf("%s finished%n", getName());
    }

    private void doAction() {
        for (int i = 1; i <= 10; i++) {
            System.out.println("Worker thread: Working... step " + i);
        }
    }
}

In this example, the CustomThread repeatedly performs a simulated action by printing ten messages, then sleeps for one second. The thread keeps running this cycle until it is interrupted. If the thread gets interrupted during the sleep() call, it catches the InterruptedException, prints a message, re-interrupts itself to preserve the interrupt flag, and exits the loop. This ensures the thread stops its work gracefully when requested.

Executors & thread pools

An ExecutorService allows you to manage a pool of reusable threads while controlling the maximum level of concurrency. Thread pools are generally preferred over creating a new thread for each task, as they minimize the overhead of thread creation and provide more efficient resource management:

public class ThreadPoolExample {
    public static void main(String[] args) throws InterruptedException {
        ExecutorService pool = Executors.newFixedThreadPool(3);

        for (int i = 0; i < 10; i++) {
            final int index = i;
            pool.submit(() -> {
                System.out.println("Task " + index + " on " + Thread.currentThread().getName());
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });
        }

        pool.shutdown();
        pool.awaitTermination(1, TimeUnit.MINUTES);
    }
}

In this example, a fixed thread pool with three threads is created to run ten tasks. Instead of creating a new thread for each task, the same three threads are reused. After all tasks are submitted, calling shutdown() prevents new tasks from being added, and awaitTermination(...) blocks until either all tasks finish or the specified timeout expires.

Callable and Future

While Runnable allows you to define tasks for threads, it cannot return results or throw checked exceptions. To handle tasks that produce a result or may fail, Java provides the Callable interface, which works together with Future. A Callable represents a task that returns a value, and the Future provides a way to retrieve that value asynchronously once the task completes:

public class CallableExample {
    public static void main(String[] args) throws Exception {
        ExecutorService executor = Executors.newSingleThreadExecutor();

        Callable<String> task = () -> {
            Thread.sleep(500); // Simulate some work
            return "Task completed by " + Thread.currentThread().getName();
        };

        Future<String> future = executor.submit(task);

        System.out.println("Waiting for result...");
        String result = future.get(); // Blocks until task completes
        System.out.println("Result: " + result);

        executor.shutdown();
    }
}

In this example, a Callable is submitted to a single-threaded executor. The Future returned by submit() represents the pending result. Calling future.get() blocks until the task finishes and returns its result. This pattern is useful when tasks need to produce results without blocking the main thread unnecessarily.

ThreadLocal class

In multithreaded applications, it's often useful to store data that is isolated to each individual thread. Java provides the ThreadLocal class for this purpose. A ThreadLocal variable maintains a separate copy of a value for each thread that accesses it, preventing accidental data sharing between threads. This is commonly used for things like user session data, per-thread configurations, or date formatters:

public class ThreadLocalExample {
    private static final ThreadLocal<String> threadLocal = new ThreadLocal<>();

    public static void main(String[] args) {
        Runnable task = () -> {
            threadLocal.set("Value for " + Thread.currentThread().getName());
            try {
                Thread.sleep(100);
                System.out.println(threadLocal.get());
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        };

        Thread t1 = new Thread(task, "Thread-A");
        Thread t2 = new Thread(task, "Thread-B");

        t1.start();
        t2.start();
    }
}

In this example, each thread sets its own value in the ThreadLocal variable. When threadLocal.get() is called, each thread retrieves only its own value, independent of the other threads. This provides a simple and safe way to store per-thread data without requiring explicit synchronization.

CompletableFuture class

CompletableFuture is a class in Java that simplifies asynchronous programming by allowing tasks to run in the background and providing a flexible API to handle their results once they complete. It supports non-blocking operations, result chaining, and easy error handling, making it ideal for writing clean and efficient asynchronous code:

public class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        System.out.println("Main thread: " + Thread.currentThread().getName());
        CompletableFuture<Void> voidFuture = CompletableFuture.runAsync(() -> {
            try {
                int ONE_SECOND = 1000;
                Thread.sleep(ONE_SECOND);
                System.out.println("Void future thread: " + Thread.currentThread().getName());
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                throw new RuntimeException(e);
            }
        });

        CompletableFuture<String> futureWithValue = CompletableFuture.supplyAsync(() -> {
            try {
                int ONE_SECOND = 1000;
                Thread.sleep(ONE_SECOND);
                System.out.println("Future with value thread: " + Thread.currentThread().getName());
                return "Value from future after 1 second";
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                throw new RuntimeException(e);
            }
        });

        System.out.println("Some info from main thread");
        voidFuture.get();
        String valueFromFuture = futureWithValue.get();
        System.out.println(valueFromFuture);
    }
}

In this example, two asynchronous tasks are created using CompletableFuture that run in separate threads. The first task, defined with runAsync(), performs a background action that doesn’t return a result. The second task, created with supplyAsync(), returns a String result after a short delay.

The get() method is used to block the main thread until each task completes, ensuring their output is printed before the program exits. In real-world scenarios, chaining methods like thenApply(), thenAccept(), or exceptionally() allow you to handle results or errors without blocking, making CompletableFuture a powerful tool for writing non-blocking, efficient, and readable asynchronous code.

Reentrant lock

While the synchronized keyword is often sufficient for basic thread safety, Java provides the ReentrantLock class for more advanced and flexible locking. Part of the java.util.concurrent.locks package, ReentrantLock allows explicit control over locking, including features like attempting to acquire a lock without blocking, interruptible locking, and fair ordering. It is called "reentrant" because the same thread can acquire the lock multiple times without causing a deadlock.

Here’s a simple example demonstrating how to use ReentrantLock:

public class SimpleLockExample {
    private static final ReentrantLock lock = new ReentrantLock();
    private static int counter = 0;
    
    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            lock.lock();
            try {
                counter++;
                System.out.println(Thread.currentThread().getName() + ": " + counter);
            } finally {
                lock.unlock();
            }
        };
        
        Thread t1 = new Thread(task, "A");
        Thread t2 = new Thread(task, "B");
        
        t1.start();
        t2.start();
        
        t1.join();
        t2.join();
    }
}

In this example, both threads attempt to increment a shared counter. The lock.lock() call ensures that only one thread can enter the critical section at a time, similar to using synchronized. The finally block guarantees that the lock is always released, even if an exception occurs within the critical section, preventing potential deadlocks or hangs.

This approach gives you more control compared to synchronized and is recommended when you need advanced locking behavior or more complex thread coordination.

Final Thoughts

Mastering Java multithreading and asynchronous programming is key to developing efficient, responsive, and reliable applications — and it's a common focus in technical interviews. To solidify your understanding, try implementing each example and solving the practice tasks. Good luck!

3 learners liked this piece of theory. 0 didn't like it. What about you?
Report a typo