Goroutines vs. Virtual Threads: Two Routes to Lightweight Concurrency, a Decade Apart
Go built its language around message passing from day one. Java spent thirty years on shared memory, then had to retrofit structure onto both.
Go shipped goroutines complete in its first public release in November 2009. Java’s answer, virtual threads, finalized fourteen years later in JDK 21 via JEP 444, in September 2023. Both exist to solve the same complaint: an operating system thread costs roughly a megabyte of stack and a trip through the kernel to create, which caps a server at a few thousand concurrent connections no matter how idle each one actually is. The destination looks similar from a distance, millions of cheap, schedulable units of concurrency instead of thousands of expensive ones. The routes there could hardly be more different, and Java’s own coverage of Loom has mostly focused on what virtual threads do rather than how that compares to the model Go had already proven out a decade earlier.
Same Problem, Same Order of Magnitude, Different Decade
A goroutine starts with a 2 kilobyte stack, a figure fixed in the Go runtime’s source as its minimum stack size since Go 1.4 in 2014, down from 8 kilobytes before that. A platform thread’s stack defaults to roughly a megabyte on 64-bit HotSpot, and a typical Linux pthread defaults to 8 megabytes unless something lowers it. The gap between an OS thread and a goroutine’s minimum footprint is the same gap Project Loom set out to close for Java, just fourteen years and one language later.

_StackMin, unchanged since the Go 1.4 stack-size reduction). JVM default and Linux pthread default are common platform defaults, not values fixed by a single spec.CSP by Design, Shared Memory by Inheritance
Go’s concurrency model is not an accident of implementation, it is the language’s founding idea. Goroutines and channels descend directly from Tony Hoare’s 1978 paper on Communicating Sequential Processes, and channels have been a built-in type since Go’s first release, not a library added later. The language’s own proverb, “don’t communicate by sharing memory, share memory by communicating,” is stated as design philosophy, not just a style preference.
Virtual threads took the opposite path, on purpose. JEP 444 names backward compatibility as an explicit goal: existing code using the java.lang.Thread API should adopt virtual threads with minimal change. A virtual thread is, deliberately, still just a Thread object. It still synchronizes with the same locks, blocks on the same java.util.concurrent primitives, and shares mutable state the same way a platform thread always did. Nothing like a channel shipped alongside it, because the entire point was to make thirty years of existing shared-memory Java code scale without being rewritten, not to introduce a new way of thinking about concurrency.
Structured Concurrency: Go Never Had It, Java Is Still Previewing It
Here the roles reverse in a way that is easy to miss. A bare go f() statement has no relationship to the lifetime of the function that launched it. Nothing in the language stops a goroutine from outliving its parent, leaking, or running past the point anyone still cares about its result. Go’s ecosystem works around this by convention, with sync.WaitGroup, the context.Context cancellation type added in Go 1.7 in 2016, and the external errgroup package, none of which the compiler enforces.
Java went the other way: it built a dedicated API, StructuredTaskScope, specifically to bind a child thread’s lifetime to its parent’s scope and propagate cancellation automatically, so a forgotten subtask becomes structurally impossible rather than a convention someone can skip. That feature has been in preview since JEP 428 in JDK 19, back in 2022, and as of JEP 525 in JDK 26 it is still previewing, its sixth round, with a seventh, JEP 533, already targeted for JDK 27. Four years and seven preview iterations in, Java’s built-for-purpose answer to the problem Go never formally solved still isn’t finished.
| Go | Java (StructuredTaskScope) | |
|---|---|---|
| Parent-child lifetime binding | Not enforced by the language | Enforced: a scope cannot outlive its owner |
| Cancellation propagation | Opt-in via context.Context (2016) or errgroup | Built into the API by default |
| Status | Stable since 2009, unstructured by default | Preview since 2022, still previewing (JEP 525, JDK 26) |
What the Scheduler Looks Like Underneath
Mechanically, the two converge more than the philosophy suggests. Go multiplexes many goroutines onto a smaller number of OS threads using the GMP model, goroutines, machine threads, and logical processors, with a work-stealing scheduler added in Go 1.1 in 2013 and a processor count controlled by GOMAXPROCS. Virtual threads are scheduled the same way in spirit: by default onto a ForkJoinPool of carrier threads sized to the number of available processors. Both are M:N schedulers multiplexing many lightweight units onto few kernel threads. The starting philosophies differ; the plumbing that makes either one fast converges on the same idea.

The Surprise in the Benchmark Data
That result runs against the usual folk wisdom. Goroutines are supposed to be the featherweight option and the JVM the memory hog, yet in this 2023 benchmark Go used more than double the memory Java’s early-access virtual threads did at 1 million concurrent tasks, and by 100,000 tasks Go had already fallen behind Java, C#, and Node.js. Worth being precise about what this does and doesn’t show: it is one benchmark, on one machine, against a pre-release build of Java 21, and a goroutine here is also carrying a sleep timer and WaitGroup bookkeeping, not a bare stack. It’s a genuine data point, not a verdict, but it is a useful corrective to assuming goroutines automatically win on memory just because they started smaller.
Which model fits, in practice
- An existing large Java codebase full of blocking
java.util.concurrentcode: virtual threads exist specifically for this, no rewrite required - Building message-passing concurrency from scratch: Go’s channels are first-class from the start, not retrofitted onto a shared-memory language
- Guaranteed cancellation and lifetime binding matter more than convention:
StructuredTaskScopeenforces it, but budget for it still being a preview API - A team comfortable coordinating goroutines by hand with
contextanderrgroup: Go’s unstructured default has shipped stable and well-understood since 2009
What We Learned
Go and Java arrived at lightweight concurrency from opposite starting points and, in a few ways, ended up swapping expectations along the way. Go built message-passing channels into the language from its first release but left goroutine lifetimes structurally unmanaged, patched since only by convention. Java spent three decades in a shared-memory, lock-based model, then designed a purpose-built structured concurrency API to enforce exactly the lifetime binding Go never formalized, only to still be previewing that API seven rounds and four years in. The scheduler internals of both converge on the same M:N idea despite the different philosophies above them, and a real 2023 benchmark found Go’s actual memory use at scale trailing Java’s virtual threads rather than beating them, a reminder that the two systems are worth comparing on evidence, not just on which one talks about lightweight threads first.

