Why Do We Need Threads?
Imagine you are running a restaurant with only one waiter.
A customer places an order.
The waiter takes the order to the kitchen and then stands there doing nothing while waiting for the food.
Only after the food is ready can the waiter serve that customer and move to the next one.
That is similar to what happens when a program executes everything sequentially:
Task A → wait → finish
Task B → wait → finish
Task C → wait → finish
But what if Task A is waiting for:
- A database response
- A network API
- A file operation
- A message from another service
The CPU may have other useful work to do while Task A is waiting.
So we naturally arrive at a question:
Why should one task block the progress of everything else?
This is where concurrency comes into the picture.
Instead of making one task wait before starting another, we can have multiple tasks making progress:
┌── Task A → waiting for DB
│
Application ─────┼── Task B → running
│
└── Task C → running
And this is one of the fundamental reasons we have threads.
What Is a Thread?
A thread is an independent path of execution inside a process.
A Java application can have:
Java Process
│
├── Thread 1
├── Thread 2
├── Thread 3
└── Thread 4
Each thread can execute work independently while sharing resources such as:
- Heap memory
- Objects
- Static variables
- Other application resources
For example:
Thread thread = new Thread(() -> {
System.out.println("Running in another thread");
});
thread.start();
Now the JVM can execute this work concurrently with other work.
The First Big Win: Concurrency
Threads can improve application responsiveness and throughput, especially when tasks spend significant time waiting for I/O.
But there is a catch.
Threads solved one problem and introduced a whole new class of problems.
The Dark Side of Threads (Dark Side of Shared Memory)
Once multiple threads start executing at the same time and sharing memory, our program becomes much harder to reason about.
With a single thread, execution is relatively predictable:
A → B → C → D
With multiple threads:
Thread 1: A → B → → D
Thread 2: X → Y → Z
The exact order can change from one execution to another.
This is where concurrency bugs begin.
1. Race Conditions
Consider:
count++;
It looks like one operation.
But conceptually it involves:
read count
↓
add 1
↓
write count
Now imagine two threads execute it at the same time.
Suppose:
count = 5
Both threads may read 5:
Thread 1 → reads 5
Thread 2 → reads 5
Thread 1 → writes 6
Thread 2 → writes 6
Expected:
7
Actual:
6
One update has been lost.
This is a race condition.
The result depends on the timing and interleaving of threads.
2. Visibility Problems
Another problem is visibility.
Modern CPUs use multiple levels of caches to improve performance.
A simplified view looks like:
Main Memory
│
┌───────┴───────┐
↓ ↓
CPU Core 1 CPU Core 2
│ │
Cache Cache
│ │
Thread A Thread B
If one thread updates shared data, another thread needs the appropriate Java Memory Model guarantees to reliably observe that update.
This is why Java provides mechanisms such as:
volatilesynchronizedLock- Atomic classes
For example:
private volatile boolean running = true;
volatile provides visibility and ordering guarantees for that variable.
But it does not make every compound operation atomic.
For example:
count++;
is still not made thread-safe merely by declaring count as volatile.
3. Deadlocks
Now introduce locks.
Imagine:
Thread 1
│
├── holds Lock A
│
└── waits for Lock B
Thread 2
│
├── holds Lock B
│
└── waits for Lock A
Neither thread can continue.
Thread 1 → waiting for Thread 2
Thread 2 → waiting for Thread 1
Forever.
This is a deadlock.
The application may appear completely frozen even though the process itself is still alive.
4. Livelock
A livelock is different.
The threads are not blocked.
They are actively doing something—but they aren't making progress.
Think about two people walking toward each other in a narrow hallway.
One moves left.
The other also moves left.
Then both move right.
Then both move right again.
They are active, but nobody gets anywhere.
That's roughly what a livelock looks like:
Thread A → changes state
Thread B → reacts
Thread A → reacts
Thread B → reacts
↓
No actual progress
5. Starvation
Another problem is starvation.
A thread may continuously fail to get the CPU time or lock access it needs because other threads keep getting priority.
For example:
Thread A → continuously gets access
Thread B → waits
Thread C → waits
Thread D → waits
Thread B may technically be runnable but rarely gets a chance to make progress.
Threads Also Have a Cost
Concurrency sounds great.
So why not simply create thousands or millions of threads?
Because traditional platform threads are relatively expensive resources.
A platform thread is associated with an operating-system thread, and each thread requires memory and scheduling resources.
For example:
for (int i = 0; i < 10_000; i++) {
new Thread(() -> process()).start();
}
This is generally not a good design.
The exact memory cost varies by JVM, operating system, architecture, and configuration, but thousands of platform threads can consume substantial memory.
Context Switching
There is another cost.
Suppose the CPU is executing:
Thread A
The operating system may need to switch to:
Thread B
The system has to preserve and restore execution state.
Simplified:
Thread A running
↓
Save A's state
↓
Load B's state
↓
Thread B running
This is called context switching.
Context switching is necessary, but excessive switching adds overhead.
The CPU can end up spending more time managing execution than doing useful application work.
The Real Problem
We now have an interesting dilemma.
One thread
Simple
Safe
Easy to understand
BUT
Poor concurrency
Waiting blocks progress
Many platform threads
High concurrency
Better utilization
BUT
More memory
More scheduling overhead
Race conditions
Deadlocks
Visibility problems
Harder debugging
So we arrive at:
How can we get the benefits of concurrency without drowning in its complexity and resource cost?
And this question drives much of Java's concurrency evolution.
Java's Concurrency Evolution
Java didn't solve everything with one feature.
Instead, concurrency evolved step by step.
Threads
↓
Synchronization
↓
java.util.concurrent
↓
ExecutorService
↓
Thread Pools
↓
Future
↓
CompletableFuture
↓
Reactive Programming
↓
Virtual Threads
↓
Structured Concurrency
Each step addressed problems introduced by the previous approach.
Java 1.0 — Threads
The fundamental building block was the Thread API.
Thread thread = new Thread(() -> {
doWork();
});
thread.start();
This gave developers the ability to execute work concurrently.
But manually creating and managing threads doesn't scale very well.
That led to the next question:
Instead of creating threads ourselves, can Java manage them for us?
Java 5 — Executors and java.util.concurrent
Java 5 introduced a major concurrency upgrade through:
java.util.concurrent
One of the most important additions was ExecutorService.
Instead of thinking:
"Create a thread."
we could think:
"Submit a task."
ExecutorService executor =
Executors.newFixedThreadPool(10);
executor.submit(() -> {
processOrder();
});
Now the application could reuse a limited number of threads.
Tasks
┌──────┼──────┐
↓ ↓ ↓
Task Task Task
│ │ │
└──────┼──────┘
↓
Thread Pool
┌────┬────┬────┐
│ T1 │ T2 │ T3 │
└────┴────┴────┘
This reduced the need to constantly create new platform threads.
Thread Pools: Better, But Not Perfect
Thread pools solved the thread-creation problem.
But they introduced a new limitation.
Suppose we have:
100 platform threads
and all 100 are waiting for database or network responses.
Now we have:
100 threads
↓
100 blocked operations
↓
No thread available for new work
We can increase the pool size.
Maybe:
100 → 500 → 1000
But eventually we hit resource limits.
This is particularly important for modern applications where thousands of requests may spend most of their time waiting for I/O.
Java 8 — CompletableFuture
Java 8 introduced CompletableFuture.
It provided a way to compose asynchronous operations.
For example:
CompletableFuture
.supplyAsync(() -> getUser())
.thenApply(user -> getOrders(user))
.thenApply(orders -> calculateTotal(orders));
Independent operations could also be executed concurrently:
CompletableFuture<User> user =
getUserAsync();
CompletableFuture<List<Order>> orders =
getOrdersAsync();
CompletableFuture.allOf(user, orders);
This helped applications avoid some unnecessary blocking and compose asynchronous workflows.
But there was a trade-off.
As asynchronous workflows became more complicated, the code could become harder to read and reason about.
Reactive Programming
The next major approach was reactive programming.
The basic idea was:
Don't keep a thread blocked while waiting for I/O.
Instead of returning a value directly:
User getUser();
you might return something like:
Mono<User>
or:
Flux<Order>
This allowed applications to handle large numbers of concurrent I/O operations using non-blocking execution.
Reactive programming can be extremely powerful.
But it introduces a different programming model and concepts such as:
- Publishers
- Subscribers
- Operators
- Schedulers
- Backpressure
- Reactive pipelines
For some applications, this complexity is worthwhile.
For others, developers wanted something simpler.
And this is where Project Loom changed the conversation.
Project Loom
Project Loom asked a fascinating question:
Can we make threads cheap enough that developers can use a simple synchronous programming model even when handling huge numbers of concurrent tasks?
The answer was:
Virtual Threads.
Java 21 — Virtual Threads
Virtual Threads became a standard feature in Java 21.
Instead of requiring one heavyweight platform thread for every concurrent task, the JVM can manage a very large number of lightweight virtual threads over a smaller number of platform threads.
Conceptually:
Thousands of Tasks
↓
Virtual Threads
↓
┌──────────┼──────────┐
↓ ↓ ↓
Carrier 1 Carrier 2 Carrier 3
│ │ │
└──────────┼──────────┘
↓
CPU
Creating a virtual thread is much cheaper than creating a platform thread.
For example:
Thread.startVirtualThread(() -> {
callDatabase();
});
Or:
try (var executor =
Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> callService());
}
Why Are Virtual Threads Important?
The interesting part isn't simply:
"Virtual threads are faster."
That's not the right way to think about them.
The real idea is:
Virtual threads make concurrency cheaper.
With platform threads, we often had to think carefully about:
How big should my thread pool be?
How many concurrent requests can I handle?
Will these threads consume too much memory?
Virtual threads change the economics of that decision.
You can often model application work more naturally:
One task
↓
One virtual thread
↓
Perform blocking-style I/O
↓
Virtual thread can be suspended
↓
Platform thread can execute other work
The JVM manages the underlying scheduling.
From "How Many Threads?" to "How Many Tasks?"
This is perhaps the biggest conceptual change.
Traditional approach
Limited platform threads
↓
Thread pool
↓
Tasks wait in queue
Virtual-thread approach
Many concurrent tasks
↓
Virtual threads
↓
JVM manages execution
The developer can focus more on:
What work needs to happen?
rather than:
How do I manually manage a scarce thread resource?
But Virtual Threads Don't Remove Concurrency Problems
This is extremely important.
Virtual threads do not magically eliminate:
- Race conditions
- Deadlocks
- Incorrect synchronization
- Shared mutable state
- Poor database design
- External service bottlenecks
- CPU limitations
If you write:
count++;
from multiple concurrent threads, it can still be unsafe.
Virtual threads make threads cheaper.
They don't make shared mutable state safe.
The Next Step: Structured Concurrency
Once concurrency becomes cheap, another question appears:
How do we manage thousands of concurrent tasks safely?
Consider:
Request
│
├── User Service
├── Order Service
└── Payment Service
These tasks belong to the same request.
If the request is cancelled, it often makes sense for its child tasks to be cancelled too.
This is the idea behind Structured Concurrency.
Conceptually:
Parent Task
│
├── Child Task A
├── Child Task B
└── Child Task C
The lifetime of the child tasks is tied to the parent.
This makes concurrent code easier to reason about and manage.
The Bigger Picture
Java concurrency isn't a random collection of APIs.
It is an evolution.
Each generation tried to solve a problem created by the previous generation.
WHY?
Sequential execution
↓
"Why wait for everything?"
↓
Threads
↓
"Threads are difficult to manage."
↓
Executors / Thread Pools
↓
"Threads are still expensive when blocked."
↓
Async / CompletableFuture
↓
"Async code is becoming difficult to reason about."
↓
Reactive Programming
↓
"Can we get scalability with simpler code?"
↓
Virtual Threads
↓
"How do we structure thousands of concurrent tasks?"
↓
Structured Concurrency
The Core Lesson
The history of Java concurrency can be understood through one question:
How can we make more progress at the same time without making our applications too expensive or too complicated?
Threads gave us concurrency.
Executors gave us thread management.
Thread pools gave us controlled resource usage.
CompletableFuture gave us composable asynchronous workflows.
Reactive programming gave us highly scalable non-blocking pipelines.
Virtual threads made large-scale concurrency much cheaper while preserving a familiar programming model.
Structured concurrency aims to make that concurrency easier to manage.
Final Mental Model
Think of the evolution like this:
Threads
│
│ "Run things concurrently"
↓
Executors
│
│ "Manage threads"
↓
Thread Pools
│
│ "Reuse limited resources"
↓
CompletableFuture
│
│ "Compose async work"
↓
Reactive
│
│ "Handle massive non-blocking I/O"
↓
Virtual Threads
│
│ "Make concurrency lightweight"
↓
Structured Concurrency
"Make concurrency manageable"
And that is the story of Java concurrency:
We started by creating threads.Then we learned how difficult threads could be.So we built abstractions around them.Then we made concurrency asynchronous.Then reactive.And eventually, Java made threads cheap again.
From Thread to Virtual Threads — Java's concurrency story is really the story of making concurrent programming easier, cheaper, and more scalable.
Top comments (0)