DEV Community

Cover image for Java Concurrency: Threads
AnkitDevCode
AnkitDevCode

Posted on

Java Concurrency: 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

With multiple threads:

Thread 1: A → B →      → D
Thread 2:      X → Y → Z
Enter fullscreen mode Exit fullscreen mode

The exact order can change from one execution to another.

This is where concurrency bugs begin.


1. Race Conditions

Consider:

count++;
Enter fullscreen mode Exit fullscreen mode

It looks like one operation.

But conceptually it involves:

read count
    ↓
add 1
    ↓
write count
Enter fullscreen mode Exit fullscreen mode

Now imagine two threads execute it at the same time.

Suppose:

count = 5
Enter fullscreen mode Exit fullscreen mode

Both threads may read 5:

Thread 1 → reads 5
Thread 2 → reads 5

Thread 1 → writes 6
Thread 2 → writes 6
Enter fullscreen mode Exit fullscreen mode

Expected:

7
Enter fullscreen mode Exit fullscreen mode

Actual:

6
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

volatile provides visibility and ordering guarantees for that variable.

But it does not make every compound operation atomic.

For example:

count++;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Neither thread can continue.

Thread 1 → waiting for Thread 2
Thread 2 → waiting for Thread 1
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The operating system may need to switch to:

Thread B
Enter fullscreen mode Exit fullscreen mode

The system has to preserve and restore execution state.

Simplified:

Thread A running
      ↓
Save A's state
      ↓
Load B's state
      ↓
Thread B running
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Many platform threads

High concurrency
Better utilization

       BUT

More memory
More scheduling overhead
Race conditions
Deadlocks
Visibility problems
Harder debugging
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

Now the application could reuse a limited number of threads.

             Tasks
        ┌──────┼──────┐
        ↓      ↓      ↓
      Task   Task   Task
        │      │      │
        └──────┼──────┘
               ↓
         Thread Pool
        ┌────┬────┬────┐
        │ T1 │ T2 │ T3 │
        └────┴────┴────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

and all 100 are waiting for database or network responses.

Now we have:

100 threads
      ↓
100 blocked operations
      ↓
No thread available for new work
Enter fullscreen mode Exit fullscreen mode

We can increase the pool size.

Maybe:

100 → 500 → 1000
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

Independent operations could also be executed concurrently:

CompletableFuture<User> user =
        getUserAsync();

CompletableFuture<List<Order>> orders =
        getOrdersAsync();

CompletableFuture.allOf(user, orders);
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

you might return something like:

Mono<User>
Enter fullscreen mode Exit fullscreen mode

or:

Flux<Order>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Creating a virtual thread is much cheaper than creating a platform thread.

For example:

Thread.startVirtualThread(() -> {
    callDatabase();
});
Enter fullscreen mode Exit fullscreen mode

Or:

try (var executor =
         Executors.newVirtualThreadPerTaskExecutor()) {

    executor.submit(() -> callService());
}
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Virtual-thread approach

Many concurrent tasks
        ↓
Virtual threads
        ↓
JVM manages execution
Enter fullscreen mode Exit fullscreen mode

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++;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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)