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

Problems:

Manual lifecycle management

No consistent error propagation

Cancellation is basically impossible

2. ExecutorService (2004)

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

Better, but still verbose and error-prone.

3. CompletableFuture (2014)

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

Cleaner, but:

No structured concurrency

Cancellation is awkward

Error handling is ad-hoc

4. Virtual Threads (2023)

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

Virtual threads help performance, but the core problems remain:

No automatic cancellation

No clear error boundaries

Timeouts are still manual

What’s Wrong Here?

After 20+ years, Java’s concurrency is still missing:

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

Reliable Cancellation — interruption is unreliable and inconsistent.

Consistent Error Handling — failures don’t 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

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

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

Or Gradle:

kotlin gradle.build.kts

implementation("tech.robd:jcoroutines:0.1.0")
Enter fullscreen mode Exit fullscreen mode

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

💻 GitHub repo

Top comments (0)