Core Java

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.

Default or minimum stack size per thread, log scale. Goroutine minimum per the Go runtime source (_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.

GoJava (StructuredTaskScope)
Parent-child lifetime bindingNot enforced by the languageEnforced: a scope cannot outlive its owner
Cancellation propagationOpt-in via context.Context (2016) or errgroupBuilt into the API by default
StatusStable since 2009, unstructured by defaultPreview 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.

Approximate relative peak memory at 1 million concurrent idle tasks, indexed to the lowest (Rust’s tokio runtime) as 1×. Derived from Piotr Kołaczkowski’s benchmark, which reported Go “loses over 12x to the winner” and “over 2x to Java” virtual threads at 1 million tasks; the Java bar here is calculated from those two stated ratios, not read directly off the original chart. Source: pkolaczk.github.io, “How Much Memory Do You Need to Run 1 Million Concurrent Tasks?”, tested on OpenJDK 21-ea and Go 1.18.1.

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.concurrent code: 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: StructuredTaskScope enforces it, but budget for it still being a preview API
  • A team comfortable coordinating goroutines by hand with context and errgroup: 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.

Eleftheria Drosopoulou

Eleftheria is an Experienced Business Analyst with a robust background in the computer software industry. Proficient in Computer Software Training, Digital Marketing, HTML Scripting, and Microsoft Office, they bring a wealth of technical skills to the table. Additionally, she has a love for writing articles on various tech subjects, showcasing a talent for translating complex concepts into accessible content.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button