Actually, I distinctly remember sitting in that conference room in 2019, watching my colleague demo a high-throughput service written in Go. He typed go func(), spun up a hundred thousand routine things, and just grinned. Meanwhile, I was staring at my IDE, dreading the CompletableFuture chain I had to debug later. It felt heavy. It felt like we were bringing a tank to a knife fight.

But, fast forward to February 2026. I haven’t touched that CompletableFuture mess in months. The “Go envy” that plagued the Java ecosystem for a decade? It’s pretty much evaporated. If you’ve been tracking the OpenJDK builds lately—specifically the release candidates for JDK 26—you know exactly what I’m talking about. We didn’t just catch up; we built something safer.

The “Virtual” Reality Check

We all know Project Loom hit the mainstream back in JDK 21. Virtual threads aren’t new anymore. But the way we use them has shifted drastically over the last two years. The novelty wore off, and the best practices settled in.

Golang logo - Golang Logo Lapel Pin – Ardan Labs Swag Store
Golang logo – Golang Logo Lapel Pin – Ardan Labs Swag Store

In the early days (around Java 19/20), everyone just wanted to replace their thread pools with Executors.newVirtualThreadPerTaskExecutor() and call it a day. And yeah, that worked. But that’s boring. The real shift—the thing that makes Go developers actually look over the fence now—is Structured Concurrency. It’s the answer to the “goroutine leak” problem that Go still struggles with structurally.

Structured Concurrency: Go’s Missing Piece?

In Go, you spawn a goroutine, and it floats off into the void. If the parent function dies, the child might keep running unless you manually manage contexts and cancellations. It’s flexible, sure, but it’s also a foot-gun factory.

Java took the longer road. We waited through previews in JDK 19, 20, 21… all the way to the polished API we have now. The core idea is simple: Threads should have a hierarchy. And I’m using this pattern extensively in my day-to-day work.

import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.ExecutionException;
import java.time.Duration;

public class UserDashboard {
    // Code block omitted for brevity
}

Channels? We Have Queues.

Golang logo - Go - Golang Logo Png - Free Transparent PNG Clipart Images Download
Golang logo – Go – Golang Logo Png – Free Transparent PNG Clipart Images Download

Okay, fair. We don’t have a chan keyword. But with Virtual Threads, blocking is cheap. In the old platform thread days, blocking a thread on a queue was a sin. Now? It’s practically free. You can treat a BlockingQueue exactly like a buffered channel.

import java.util.concurrent.LinkedTransferQueue;
import java.util.concurrent.Executors;

public class ChannelPattern {
    // Code block omitted for brevity
}

The “Select” Statement Equivalent

The one thing Java still doesn’t have a direct 1:1 syntax for is Go’s select statement (waiting on multiple channels). But we have StructuredTaskScope.ShutdownOnSuccess(), which handles the “race” pattern perfectly. I used this pattern recently for a redundant service call.

Golang logo - Go Gopher - Go Programming Language Logo - Free Transparent PNG ...
Golang logo – Go Gopher – Go Programming Language Logo – Free Transparent PNG …
public User getUserFast(String id) throws ExecutionException, InterruptedException {
    // Code block omitted for brevity
}

Why I’m Not Looking Back

I’ll be honest—I flirted with Go for a few years. But looking at the state of Java in 2026, the trade-off isn’t there anymore. We now have the lightweight threading model of Go, but with better observability (thanks to JFR), better tooling, and a structured concurrency model that prevents the exact kind of bugs that keep SREs awake at night. The verbosity is still there—it’s Java, after all—but it’s useful verbosity.

If you’re still running on Java 11 or 17, you are fighting a war that ended three years ago. Upgrade. The grass isn’t greener on the other side anymore; it’s greener right here, inside the try-with-resources block.

Questions readers ask

How does Java structured concurrency in JDK 26 fix goroutine leaks in Go?

Structured concurrency in Java enforces a parent-child hierarchy for threads, so if a parent function exits, its children are automatically cancelled. This prevents the goroutine leak problem Go still struggles with structurally, where spawned goroutines float off unless developers manually manage contexts and cancellations. Java evolved this pattern through previews in JDK 19, 20, and 21 before finalizing the polished API available today.

Can you use a Java BlockingQueue like a Go buffered channel with virtual threads?

Yes, you can treat a BlockingQueue as a buffered channel once virtual threads make blocking cheap. In the old platform-thread era, blocking a thread on a queue was considered a sin because threads were expensive. With virtual threads from Project Loom, blocking costs are practically free, so using LinkedTransferQueue or similar queue types replicates Go’s chan keyword functionality without new syntax.

What is the Java equivalent of Go’s select statement for racing multiple calls?

Java does not have a direct 1:1 syntax match for Go’s select statement, but StructuredTaskScope.ShutdownOnSuccess() handles the race pattern perfectly. It lets you fire multiple concurrent operations and return as soon as the first one succeeds, shutting down the rest. This works well for redundant service calls where you want the fastest response from several competing sources.

Should I upgrade from Java 11 or 17 to take advantage of virtual threads?

Upgrading is strongly recommended because staying on Java 11 or 17 means fighting a war that ended three years ago. JDK 21 brought virtual threads mainstream through Project Loom, and JDK 26 release candidates refine the model further. Modern Java now offers Go’s lightweight threading plus better observability via JFR, superior tooling, and structured concurrency that prevents entire classes of concurrency bugs.