Threads

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

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:

  • volatile
  • synchronized
  • Lock
  • 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

or:

Flux

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.

Total
0
Shares
Leave a Reply

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

Previous Post

Nvidia’s $3.5B MediaTek bet reveals its plan for tackling Big Tech’s AI chip buildout

Next Post

LUCID Triton Smart Camera

Related Posts