DEV Community

Rob D
Rob D

Posted on • Originally published at robd.tech

Why Does Concurrency Have to Be So Hard in Java After 20 Years?

Java has been around for nearly three decades, and we’ve had threads since day one. We got java.util.concurrent in 2004, lambdas in 2014, CompletableFuture improvements, reactive streams, and virtual threads in 2023…

And yet, writing correct concurrent code in Java still feels like navigating a minefield.

Why is this still so hard?


The Problem: Too Many Half-Solutions

Let’s take a simple case:

Fetch data from three APIs concurrently, process results, handle errors, and respect a timeout.

1. Threads (1995)

public List<String> fetchData() {
    List<String> results = Collections.synchronizedList(new ArrayList<>());
    CountDownLatch latch = new CountDownLatch(3);

    Thread t1 = new Thread(() -> {
        try {
            results.add(callApi("api1"));
        } catch (Exception e) {
            // What do we do here?
        } finally {
            latch.countDown();
        }
    });

    // … repeat for t2, t3 …

    t1.start(); t2.start(); t3.start();

    try {
        latch.await(10, TimeUnit.SECONDS); // What if it times out?
    } catch (InterruptedException e) {
        // Now what? Cancel the threads?
    }

    return results; // Hope for the best
}
Problems:

Manual lifecycle management

No consistent error propagation

Cancellation is basically impossible

2. ExecutorService (2004)
java
Copy code
public List<String> fetchData() throws Exception {
    ExecutorService executor = Executors.newFixedThreadPool(3);
    try {
        List<Future<String>> futures = List.of(
            executor.submit(() -> callApi("api1")),
            executor.submit(() -> callApi("api2")),
            executor.submit(() -> callApi("api3"))
        );

        List<String> results = new ArrayList<>();
        for (Future<String> f : futures) {
            try {
                results.add(f.get(10, TimeUnit.SECONDS));
            } catch (TimeoutException e) {
                f.cancel(true);
            }
        }
        return results;
    } finally {
        executor.shutdown();
        executor.awaitTermination(5, TimeUnit.SECONDS);
        executor.shutdownNow(); // Fingers crossed
    }
}
Better, but still verbose and error-prone.

3. CompletableFuture (2014)
java
Copy code
public CompletableFuture<List<String>> fetchData() {
    var f1 = CompletableFuture.supplyAsync(() -> callApi("api1"));
    var f2 = CompletableFuture.supplyAsync(() -> callApi("api2"));
    var f3 = CompletableFuture.supplyAsync(() -> callApi("api3"));

    return CompletableFuture.allOf(f1, f2, f3)
        .thenApply(v -> List.of(f1.join(), f2.join(), f3.join()))
        .orTimeout(10, TimeUnit.SECONDS)
        .exceptionally(ex -> List.of());
}
Cleaner, but:

No structured concurrency

Cancellation is awkward

Error handling is ad-hoc

4. Virtual Threads (2023)
java
Copy code
public List<String> fetchData() throws Exception {
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        var futures = List.of(
            executor.submit(() -> callApi("api1")),
            executor.submit(() -> callApi("api2")),
            executor.submit(() -> callApi("api3"))
        );

        return futures.stream()
            .map(f -> {
                try {
                    return f.get(10, TimeUnit.SECONDS);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }
            })
            .toList();
    }
}
Virtual threads help performance, but the core problems remain:

No automatic cancellation

No clear error boundaries

Timeouts are still manual

# Whats Wrong Here?
After 20+ years, Javas concurrency is still missing:

Structured Concurrency  no parent/child lifetimes, leading to leaks.

Reliable Cancellation  interruption is unreliable and inconsistent.

Consistent Error Handling  failures dont cleanly propagate.

Resource Safety  Executors and threads must be closed manually.

Context  no standard way to pass cancellation tokens, timeouts, or tracing.

Other languages got this right:

Go has context.WithTimeout for group cancellation.

Kotlin has coroutines with structured scopes.

C# has Tasks with CancellationToken.

# What We Actually Want

Enter fullscreen mode Exit fullscreen mode

try (var scope = new CoroutineScope()) {
var results = List.of("api1", "api2", "api3").stream()
.map(api -> scope.async(suspend -> callApi(suspend, api)))
.map(handle -> handle.join())
.toList();
return results;
} // Automatic cleanup, cancellation, and error propagation



This is:

Structured — parent/child relationships are explicit

Cancellable — cooperative and consistent

Safe — resources cleaned up automatically

Transparent — errors bubble naturally

# Enter JCoroutines 🚀
Concurrency should feel like an elevator: press a few clear buttons and trust the machinery.

That’s what I set out to build with JCoroutines:

Structured concurrency by default

Explicit context passing (cancellation, timeouts, schedulers)

No compiler magic — just clean Java APIs on top of virtual threads

It’s small, explicit, and available today.

Try It Out
On Maven Central:

maven xml
<dependency>
  <groupId>tech.robd</groupId>
  <artifactId>jcoroutines</artifactId>
  <version>0.1.0</version>
</dependency>
Or Gradle:

kotlin gradle.build.kts

implementation("tech.robd:jcoroutines:0.1.0")

The Path Forward
Java itself is heading this way (see JEP 428 on structured concurrency), but it will take years before that’s fully stable.

Meanwhile, JCoroutines gives you these patterns now — using just Java 21+ and virtual threads.

[📦 Maven Central](https://central.sonatype.com/artifact/tech.robd/jcoroutines)

[💻 GitHub repo](https://github.com/robdeas/jcoroutines)


Enter fullscreen mode Exit fullscreen mode

Top comments (0)