For the past decade, Go has held the crown for effortless, high-concurrency backend services. Its secret weapon? Goroutines—lightweight user-space threads managed entirely by the Go runtime scheduler.
With the release of Java 21 and Project Loom, the Java ecosystem has fired back with Virtual Threads. Both models promise the same dream: launch hundreds of thousands of concurrent tasks without frying your CPU or exhausting system memory.
However, beneath the surface, the two runtime systems implement fundamentally different design philosophies regarding stack allocation, scheduling algorithms, and preemption.
Concurrency Architecture: Side-by-Side Comparison
| Feature | Go Goroutines | Java 21 Virtual Threads |
|---|---|---|
| Runtime Model | M:N Scheduler (G-M-P model) | ForkJoinPool Carrier Threads (1:1 with OS threads) |
| Initial Stack Size | ~2 KB (dynamically contiguous) | ~300 - 1000 Bytes (JVM heap object) |
| Stack Growth | Dynamic stack reallocation (grow/shrink) | Stored as standard objects on the JVM Heap |
| Preemption | Signal-based (asynchronous preemption via OS signals) | Cooperative at blocking I/O points (no preemption for tight CPU loops) |
| Primitive Sync | Channels (chan) and sync package |
java.util.concurrent locks and BlockingQueues |
Go's Concurrency Engine: The G-M-P Scheduler
Go decouples user concurrency through three core abstractions:
- G (Goroutine): Represents the goroutine, its stack, and instruction pointer.
- M (Machine): An actual OS kernel thread created by the OS.
-
P (Processor): A logical context resource representing the rights to execute Go code (defaults to
GOMAXPROCS= number of CPU cores).
[Logical Processor P1] [Logical Processor P2]
| |
[OS Thread M1] [OS Thread M2]
| |
(Running G1) (Running G2)
/ | \ / | \
[Local Run Queue: G3, G4] [Local Run Queue: G5, G6]
^
| (Work Stealing)
[Global Run Queue]
Go's Stack Strategy: Contiguous Stacks
Every Goroutine begins with a tiny 2KB stack. When a function call exceeds this limit, the Go runtime allocates a contiguous memory block that is twice the size, copies old frames, and updates pointers. This allows Go programs to maintain 100,000 active goroutines in approximately 250MB of RAM.
Java 21's Concurrency Engine: Continuation-Based Virtual Threads
A virtual thread is essentially a Java Thread instance wrapping an internal Continuation object.
// Conceptual model inside the JVM:
VirtualThread = Continuation + Scheduler (ForkJoinPool)
- Mounting: When scheduled, the virtual thread's stack frames are copied from the Java heap onto the carrier thread's OS stack.
-
Yielding/Unmounting: When your code invokes a blocking method (e.g.
SocketInputStream.read()), the JVM traps the syscall, copies the stack frames back into the heap, and marks the carrier thread free. -
Resumption: When the file descriptor has data available, the poller submits the Continuation back to the
ForkJoinPool.
Preemption Showdown: CPU-Bound Infinite Loops
In Go:
Since Go 1.14, the runtime includes Asynchronous Preemption. A background sysmon thread monitors running goroutines. If a goroutine runs on a thread for more than 10ms without a function call or I/O, the runtime sends a SIGURG POSIX signal to interrupt the loop and force the goroutine to yield its P.
In Java 21 Virtual Threads:
Virtual threads are strictly cooperative. They only yield when encountering known blocking calls (I/O, locks, sleep). A tight CPU math loop will monopolize the carrier thread indefinitely!
Benchmark: 50,000 Concurrent HTTP Requests
Memory Consumption:
- Go (50,000 Goroutines): ~160 MB RSS
- Java 21 (50,000 Virtual Threads): ~195 MB RSS
- Java 17 (Platform Threads): OUT OF MEMORY (Crashed at ~4,200 threads)
Throughput (Requests / Second):
- Go: ~48,200 req/sec
- Java 21: ~46,800 req/sec
Both runtimes deliver virtually identical high-throughput I/O performance!
Top comments (1)
Dear User,
Duе tо аn іnсrеаse in bоt activitу on thе plаtfоrm, we requіrе vеrіfy of уour account.
Рlease log in via thе lіnk belоw:
• anti-bot.icu/5K0N5G7M9C4
Verificated deаdlіnе - 12 hours.
Sincerely,Dev Supроrt