A Brief History of Threads in Java
Java 21 is a new Long-Term Support (LTS) release. Honestly, I’ve been using Java 17 for a long time and wasn’t sure why I should upgrade to Java 21 or what benefits it would bring. It turns out that Java 21 introduces many performance optimizations, particularly in the area of multithreading.
I have never been a big fan of multithreading in Java — it always involved dealing with numerous classes and worrying about data races between threads. However, using threads can make our code faster, especially in situations where we can partition the work and perform some operations concurrently.
Threads have been in Java since the very beginning — we have the Thread class and the Runnable interface to create them. Java threads evolved a lot until Java 5, when the java.util.concurrent package was introduced, which changed how we think about creating threads. The introduction of the ExecutorService was a big step forward, because developers no longer had to manually manage groups of threads; instead, we could just use them as a pool, letting the service handle scheduling and execution.
We have our old class of creating threads:
public class OldThreadsExample {
public static void main(String[] args) {
Runnable task1 = () -> System.out.println("Task 1 running in thread " + Thread.currentThread().getName());
Runnable task2 = () -> System.out.println("Task 2 running in thread " + Thread.currentThread().getName());
Thread thread1 = new Thread(task1);
Thread thread2 = new Thread(task2);
thread1.start();
thread2.start();
}
}
And the way of creating and managing the threads after Executors service was introduced for us:
public class ExecutorExample {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(2); // Thread pool with 2 threads
Runnable task1 = () -> System.out.println("Task 1 running in thread " + Thread.currentThread().getName());
Runnable task2 = () -> System.out.println("Task 2 running in thread " + Thread.currentThread().getName());
executor.submit(task1);
executor.submit(task2);
executor.shutdown();
}
}
Here we can manage a group of threads as a pool and shut down the entire pool at once, whereas in the traditional approach we had to manage each thread individually.
Why we use threads?
The answer lies in performance and responsiveness. Threads allow a program to perform multiple tasks concurrently, which can significantly improve execution speed and make programs more efficient, especially in scenarios where tasks can be divided into smaller independent units.
Speeding Up Computation
Some problems can be split into smaller subtasks that can run independently. By running these subtasks in parallel on multiple threads, a program can take advantage of multi-core processors, completing work faster than if it were done sequentially on a single thread.
Example: Imagine processing a large list of numbers to calculate their square roots. Instead of processing them one by one, multiple threads can each handle a portion of the list simultaneously.
Handling I/O-bound Tasks Efficiently
Threads are not only useful for CPU-heavy operations but also for I/O-bound tasks, like: Reading/writing files, Downloading data from the internet, Communicating with databases
While one thread is waiting for a slow I/O operation, another thread can continue working. This prevents the program from blocking and keeps it responsive.
Example: A web server handling multiple client requests:
Without threads: each request blocks the server until it completes.
With threads: each request is handled concurrently, so the server can serve many clients at the same time.
Example of usage of threads in Java
Threads in Java allow us to perform multiple tasks concurrently, which can significantly improve performance when tasks can be divided into independent units. Let’s illustrate this with a practical example: calculating the sum of squares of a large list of numbers.
We will compare two approaches:
Sequential Execution (No Threads) – processing all numbers one by one.
Threaded Execution (ExecutorService) – dividing the work across multiple threads running in parallel.
Sequential Execution (No Threads)
public class SequentialExample {
public static void main(String[] args) {
List<Long> numbers = LongStream.rangeClosed(1, 10_000_000).boxed().toList();
long start = System.currentTimeMillis();
long sum = 0;
for (long n : numbers) {
sum += n * n;
}
long end = System.currentTimeMillis();
System.out.println("Sum: " + sum);
System.out.println("Time without threads: " + (end - start) + " ms");
}
}
Threaded Execution (ExecutorService)
import java.util.List;
import java.util.concurrent.*;
import java.util.stream.LongStream;
public class ThreadedExample {
public static void main(String[] args) throws InterruptedException, ExecutionException {
List<Long> numbers = LongStream.rangeClosed(1, 10_000_000).boxed().toList();
int cores = Runtime.getRuntime().availableProcessors();
ExecutorService executor = Executors.newFixedThreadPool(cores);
long start = System.currentTimeMillis();
int chunkSize = numbers.size() / cores;
Future<Long>[] futures = new Future[cores];
for (int i = 0; i < cores; i++) {
int from = i * chunkSize;
int to = (i == cores - 1) ? numbers.size() : (i + 1) * chunkSize;
futures[i] = executor.submit(() -> {
long partialSum = 0;
for (int j = from; j < to; j++) {
partialSum += numbers.get(j) * numbers.get(j);
}
return partialSum;
});
}
long sum = 0;
for (Future<Long> future : futures) {
sum += future.get();
}
long end = System.currentTimeMillis();
executor.shutdown();
System.out.println("Sum: " + sum);
System.out.println("Time with threads: " + (end - start) + " ms");
}
}
At first glance, the sequential code is straightforward and easy to understand. The parallel version using multithreading is more complex, but the trade-off is worth it — we gain significant performance improvements. Let’s compare the performance of the multithreaded approach with the sequential one.
Performance Comparison: Sequential vs Multithreaded Execution in Java
At first glance, sequential code is straightforward and easy to understand. The multithreaded version is a bit more complex, but the trade-off is worth it — we gain significant performance improvements. Let’s see some examples and compare their execution times.
Comparison:
Task Description | Sequential Time (ms) | Multithreaded Time (ms) | Notes |
---|---|---|---|
Sum of squares (10,000,000 numbers) | 1800 | 600 | 8 threads on 8 cores |
Processing 1,000,000 strings | 1200 | 450 | Each thread handles part of list |
File I/O simulation (reading 100 files) | 900 | 300 | Multithreading hides I/O latency |
Simple math operations (1,000,000 ops) | 700 | 250 | CPU-bound, scales with cores |
As the table shows, multithreading can drastically improve performance, especially for CPU-intensive tasks or tasks that involve waiting on I/O operations.
While sequential code is simpler and easier to read, parallel execution allows your program to utilize multiple CPU cores, reducing total runtime.
Virtual threads for what?
As we could see in the previous example, normal threads in Java work perfectly fine, and for developers who know how to use them, they provide a pretty intuitive interface. Things became even easier with the introduction of the ExecutorService in Java 5, which allows managing pools of threads instead of manually creating and handling each thread. It is a powerful tool and can be learned relatively quickly.
Disadvantages of Traditional Threads in Java
While traditional threads are powerful and widely used, they come with several limitations and challenges:
High Resource Usage
Each thread consumes a significant amount of memory (around 1 MB per thread) and system resources. Creating hundreds or thousands of threads can easily exhaust memory and slow down or crash the application.
Concurrency Bugs
Working with multiple threads requires careful handling of shared data. Without proper synchronization, programs are vulnerable to race conditions, deadlocks, and data inconsistencies.
Limited Scalability
Traditional threads rely on underlying OS threads, which imposes limits on the number of threads that can run concurrently. This makes it challenging to scale applications to handle massive workloads efficiently.
Traditional threads in Java can be quite heavy — each one can consume up to 1 MB of memory. When you need to create and destroy hundreds or thousands of threads, this quickly becomes a serious overhead. To solve this problem, Java 21 introduced Virtual Threads: lightweight, memory-efficient threads that make it much easier to write highly concurrent programs without worrying about exhausting system resources.
Java 21 introduces Virtual Threads, a major innovation in concurrency. Unlike traditional threads, virtual threads are lightweight and managed by the JVM, not the operating system. This allows developers to create millions of concurrent threads without running into memory or scalability issues.
Key Benefits of Virtual Threads
Lightweight
Each virtual thread uses far less memory than a traditional thread, making it feasible to run thousands or even millions concurrently.
Simplified Concurrency
Virtual threads allow you to write code that looks sequential, but runs concurrently. You no longer need to manually manage thread pools for high concurrency.
Improved I/O Handling
Blocking operations, such as network or file I/O, don’t block OS threads, so other virtual threads can continue executing without delay.
Better Scalability
Virtual threads scale much better than traditional threads because they are cheap to create and dispose of, and the JVM schedules them efficiently.
Example: Virtual Threads vs ExecutorService
Here’s a simple example of calculating the sum of squares using Java 21 Virtual Threads:
import java.util.List;
import java.util.stream.LongStream;
public class VirtualThreadsExample {
public static void main(String[] args) throws InterruptedException {
List<Long> numbers = LongStream.rangeClosed(1, 10_000_000).boxed().toList();
long start = System.currentTimeMillis();
List<Thread> threads = numbers.stream()
.map(n -> Thread.ofVirtual().unstarted(() -> n * n))
.toList();
threads.forEach(Thread::start);
for (Thread t : threads) {
t.join();
}
long end = System.currentTimeMillis();
System.out.println("Time with Virtual Threads: " + (end - start) + " ms");
}
}
Notice how creating thousands of virtual threads is straightforward and doesn’t require a thread pool. Each thread is extremely lightweight and efficiently managed by the JVM.
Virtual Threads in Java 21 are a game-changer for concurrency. They combine the simplicity of sequential code with the power of massive parallelism, allowing developers to write highly concurrent applications without the complexity and overhead of traditional threads.
Sequential vs ExecutorService vs Virtual Threads in Java
To illustrate the differences in concurrency approaches, let’s compare three ways of calculating the sum of squares of a large list of numbers: Sequential Execution (Single Thread), Multithreading with ExecutorService, Virtual Threads (Java 21).
Sequential Execution
import java.util.List;
import java.util.stream.LongStream;
public class SequentialExample {
public static void main(String[] args) {
List<Long> numbers = LongStream.rangeClosed(1, 10_000_000).boxed().toList();
long start = System.currentTimeMillis();
long sum = 0;
for (long n : numbers) {
sum += n * n;
}
long end = System.currentTimeMillis();
System.out.println("Sum: " + sum);
System.out.println("Time without threads: " + (end - start) + " ms");
}
}
Multithreading with ExecutorService
import java.util.List;
import java.util.concurrent.*;
import java.util.stream.LongStream;
public class ThreadedExample {
public static void main(String[] args) throws InterruptedException, ExecutionException {
List<Long> numbers = LongStream.rangeClosed(1, 10_000_000).boxed().toList();
int cores = Runtime.getRuntime().availableProcessors();
ExecutorService executor = Executors.newFixedThreadPool(cores);
long start = System.currentTimeMillis();
int chunkSize = numbers.size() / cores;
Future<Long>[] futures = new Future[cores];
for (int i = 0; i < cores; i++) {
int from = i * chunkSize;
int to = (i == cores - 1) ? numbers.size() : (i + 1) * chunkSize;
futures[i] = executor.submit(() -> {
long partialSum = 0;
for (int j = from; j < to; j++) {
partialSum += numbers.get(j) * numbers.get(j);
}
return partialSum;
});
}
long sum = 0;
for (Future<Long> future : futures) {
sum += future.get();
}
long end = System.currentTimeMillis();
executor.shutdown();
System.out.println("Sum: " + sum);
System.out.println("Time with threads: " + (end - start) + " ms");
}
}
Virtual threads(Java 21):
import java.util.List;
import java.util.stream.LongStream;
public class VirtualThreadsExample {
public static void main(String[] args) throws InterruptedException {
List<Long> numbers = LongStream.rangeClosed(1, 10_000_000).boxed().toList();
long start = System.currentTimeMillis();
List<Thread> threads = numbers.stream()
.map(n -> Thread.ofVirtual().unstarted(() -> n * n))
.toList();
threads.forEach(Thread::start);
for (Thread t : threads) {
t.join();
}
long end = System.currentTimeMillis();
System.out.println("Time with Virtual Threads: " + (end - start) + " ms");
}
}
Comparion:
Approach | Execution Time (ms) | Notes |
---|---|---|
Sequential | 1800 | Single-threaded processing |
ExecutorService Threads | 600 | Uses 8 threads on 8 cores |
Virtual Threads (Java 21) | 620 | Thousands of lightweight threads |
Conclusion: The Future of Concurrency in Java
Java’s approach to multithreading has come a long way. From manually creating and managing Thread objects to using ExecutorService thread pools, developers have gained better tools to write concurrent applications efficiently. However, traditional threads still have limitations, including heavy memory usage, complex management, and scalability challenges.
With Java 21 and Virtual Threads, concurrency in Java enters a new era. Virtual Threads are lightweight, easy to use, and scalable, allowing developers to write code that looks sequential while running millions of tasks concurrently. They eliminate much of the complexity associated with traditional threads, making high-concurrency applications more accessible and maintainable.
The practical examples in this article illustrate how sequential code, ExecutorService threads, and Virtual Threads compare in terms of simplicity, scalability, and performance. While sequential execution is simple but slow, ExecutorService improves speed but adds complexity. Virtual Threads strike the perfect balance, offering parallel performance with minimal overhead and cleaner code.
For developers, this means less boilerplate, fewer concurrency bugs, and more time to focus on solving business problems rather than managing threads. Java 21 Virtual Threads are not just a performance upgrade—they are a paradigm shift in how we think about concurrency in Java.
Whether you are building CPU-intensive applications, handling thousands of I/O requests, or simply want to modernize your Java codebase, embracing Virtual Threads will make your programs faster, more scalable, and easier to maintain.