Nobody Tells You What Sun Got Wrong About Go. Here's the Proof.

Bottom line: Sun Microsystems tried Go's core idea — cheap, user-space "green" threads — in Java 1.0 back in 1996, and it failed so badly that Sun ripped it out for native OS threads by Java 1.2 in 1998.

That failure became industry gospel for over a decade: "green threads don't scale, period." Go's runtime team proved the gospel wrong not by rejecting Sun's idea, but by fixing the two things Sun never solved — an N:1 scheduler that stalled on any blocking syscall, and fixed 512KB-plus stacks that made spawning threads expensive.

Go's M:N scheduler, growable 2KB stacks, and integrated netpoller are the direct technical answer to why Sun's version died.

Java itself admitted this in 2023, when Project Loom shipped virtual threads — the same idea, finally built right.

I spent an evening this week falling down a JDK mailing list archive because of an HN thread arguing about whether goroutines are "just green threads with a marketing budget." Half the comments were confidently wrong.

The other half were confidently wrong in the opposite direction.

So I went and read the actual bug reports from Sun's original green thread implementation, and it turns out both sides are missing the real story: Sun didn't get the concept wrong, they got the execution wrong, in two very specific, very fixable ways — and Go's designers fixed exactly those two things.

If you've ever explained goroutines to a junior dev as "lightweight threads" and gotten the follow-up question "wait, didn't Java try that and it sucked?" — this is the answer you didn't have.

The Gospel That Got Baked Into the Industry

Here's the conventional wisdom that hardened sometime around 2001 and stuck for the next fifteen years: user-space threading is a dead end. Real concurrency means real OS threads.

Anyone proposing otherwise got pointed at Java's green threads as the cautionary tale.

Article illustration

I believed this too, for years. It's in half the systems design books on my shelf.

It's why the JVM world spent a decade building elaborate NIO event-loop frameworks (Netty, Vert.x) instead of just fixing the scheduler — because "fixing the scheduler" was considered a settled, closed question.

It wasn't settled. It was abandoned.

What Sun Actually Shipped in 1996

Java 1.0's original threading model on Solaris was what's called N:1 green threads — every Java thread you spawned was a user-space construct, and all of them got multiplexed onto a single underlying OS thread.

The JVM's own scheduler decided which green thread ran next, entirely in userspace, with no help from the kernel.

This had one real advantage at the time: thread creation was cheap, because you weren't asking the OS kernel to do anything.

On 1996-era hardware, spinning up an OS thread was genuinely expensive — page tables, kernel scheduling structures, the works.

But N:1 has a structural flaw that no amount of scheduler cleverness fixes: if any single green thread makes a blocking syscall — a file read, a socket accept, a DNS lookup — the entire OS thread blocks, and every other green thread on the machine freezes with it. You don't get concurrency.

You get one thread's I/O wait held hostage by the whole runtime.

Sun also gave green threads a fixed-size stack, allocated up front. Multiply a few hundred KB by a few thousand threads and you've burned through your address space before you've done any real work.

The Retreat That Became Doctrine

By Java 1.1, this was already causing real production pain — the JVM had no way to run true parallel work across multiple CPUs, because everything funneled through one OS thread no matter how many cores the box had.

Solaris JDK abandoned green threads entirely and switched to native (1:1) threads by Java 1.2, released in 1998.

That switch fixed the blocking-syscall problem and unlocked real multicore parallelism. It also introduced the cost Java developers have lived with ever since: native OS threads are heavy.

Default stack sizes around 512KB–1MB per thread on 64-bit Linux JVMs (tunable via `-Xss`, but rarely tuned down safely).

Spin up 10,000 of them and you're reserving several gigabytes of address space before a single request gets handled — which is exactly why the Java ecosystem built an entire cottage industry of reactive frameworks and thread-pool tuning guides to avoid ever creating that many threads in the first place.

That workaround became the water everyone swam in. "Don't spawn threads, use an event loop" wasn't a preference — it was damage control for a scheduler decision made in 1998.

What Go's Runtime Actually Fixed

Go's designers — Rob Pike, Robert Griesemer, and Ian Lance Taylor, drawing on CSP concurrency ideas going back to Pike's work on Plan 9 and Limbo — didn't invent user-space threading.

They inherited Sun's exact idea and fixed both of the specific bugs that killed it.

Fix one: M:N scheduling instead of N:1. Go multiplexes goroutines across a pool of OS threads (governed by `GOMAXPROCS`, defaulting to your core count), not onto a single one.

This is the "M:N" in what Go's runtime docs call the GMP model — G (goroutines), M (machine/OS threads), P (logical processors). Real parallelism, not just interleaved concurrency.

Fix two: an integrated netpoller. When a goroutine hits a blocking network call, the Go runtime intercepts it, parks the goroutine, and frees the underlying OS thread to run other goroutines — then resumes it when the I/O completes, using epoll/kqueue under the hood.

Blocking code looks blocking to the developer but never actually parks a whole OS thread. Sun's model had no equivalent; a blocking call was just a blocking call, full stop.

Fix three: growable stacks. A new goroutine starts with a tiny stack — documented in the Go runtime source as 2KB — that grows and shrinks dynamically as needed, instead of Sun's fixed multi-hundred-KB allocation per thread.

This is the detail that makes "spawn a million goroutines" a genuinely reasonable thing to do in a Go program, something you'd never attempt with a million native OS threads.

Here's the shape of the difference in code — this is the pattern that would've deadlocked under Sun's model but is completely unremarkable in Go:

```go func fetchAll(urls []string) []string { results := make([]string, len(urls))

var wg sync.WaitGroup for i, u := range urls { wg.Add(1)

go func(i int, u string) { defer wg.Done() resp, _ := http.Get(u) // blocks the goroutine, not the OS thread

results[i] = resp.Status }(i, u) }

wg.Wait() return results } ```

Spawn a few thousand of these goroutines and the netpoller quietly handles the scheduling underneath.

Try the naive equivalent with a few thousand native Java or pthread threads before virtual threads existed, and you're watching your process eat gigabytes of stack space for threads that spend 99% of their life parked waiting on a socket.

The Receipts, Side by Side

Sun Green Threads (1996)Native OS Threads (Java 1.3+, pthreads)Go Goroutines
Scheduling modelN:1 (many-to-one OS thread)1:1M:N
Blocks on syscallFreezes entire runtimeOnly that threadOnly that goroutine; OS thread freed
True multicore parallelismNoYesYes
Starting stack sizeFixed, several hundred KBFixed, 512KB–1MB typical~2KB, grows dynamically
Practical max concurrent unitsLow thousandsThousands (memory-bound)Millions (documented in Go concurrency talks)

Sun wasn't wrong that user-space scheduling could work.

They shipped half the design — the cheap-thread half — without the two structural pieces that make it safe: real multiplexing across cores and non-blocking I/O integration underneath.

Go shipped all three pieces together, which is the entire difference.

What This Means For You

If you're running a Go service and someone tells you "goroutines are basically what Java tried and abandoned," you now have the precise, citable rebuttal instead of a vibe.

It's not the same thing — it's the fixed version of the same thing.

If you're on the JVM side, you don't need to feel behind.

Java 21's virtual threads (Project Loom, shipped 2023) are Java's own M:N retreat back toward this model — cheap, schedulable-across-cores, non-blocking-under-the-hood user threads, twenty-three years after Java abandoned the N:1 version.

The JVM ecosystem didn't reject the idea forever. It took until modern hardware and modern engineering effort made the harder version buildable.

And if you're picking a language for a new high-concurrency service today, the lesson isn't "Go's model is definitively superior" — it's that the old heuristic ("user-space threads don't scale") was an artifact of one incomplete 1996 implementation, not a law of computing. Judge each runtime's actual scheduler, not folklore from a scheduler that got scrapped a quarter-century ago.

The Twist

The part that actually got me was realizing Sun wasn't some outside cautionary tale Go's designers were reacting against from a safe distance — Ken Thompson and Rob Pike had spent decades at Bell Labs building exactly the kind of lightweight-process, CSP-flavored systems that make M:N scheduling work, well before Go existed.

They weren't avoiding Sun's mistake. They already knew, from prior work, precisely which two bolts Sun had left loose.

Article illustration

Have you run into a piece of "settled" engineering wisdom that turned out to be one company's specific, fixable failure wearing a general rule's clothes?

I'd bet you have one from your own stack — what was it?

Story Sources