iTechGuides is reader-supported. When you buy through links on our site, we may earn an affiliate commission. As an Amazon Associate I earn from qualifying purchases. Learn more
Goroutines and Java virtual threads aim at the same practical goal: letting blocking-style code run in very large numbers without dedicating an operating-system thread to each task. Beyond that goal they differ. Goroutines are Go’s unit of concurrency. Virtual threads are ordinary java.lang.Thread instances that the JDK maps onto a smaller set of platform threads. Their memory rules also come from different specifications: Go’s from the Go Memory Model, Java’s from Chapter 17 of the Java Language Specification, which virtual threads do not change.
So the direct answer to “Are Java virtual threads as lightweight as goroutines?” is that they are designed for a similar purpose, but the primary documentation does not support a like-for-like claim about memory use or overhead. The sections below explain what each runtime documents, why task counts and stack sizes cannot predict process memory, and how to measure the difference for your own workload.
The short comparison
| Aspect | Go goroutines | Java virtual threads |
|---|---|---|
| Unit you create | A goroutine, started with the go statement |
A java.lang.Thread, created with Thread.ofVirtual() or an executor from Executors.newVirtualThreadPerTaskExecutor() (Java 21 onward, per JEP 444) |
| What it runs on | Threads managed by the Go runtime, with goroutines multiplexed onto them | Carrier platform threads chosen by the JDK scheduler in an M:N arrangement |
| Initial stack | “A few kilobytes” per the Go FAQ, resized automatically | Stored in heap-resident stack-chunk objects that grow and shrink, up to the platform-thread stack-size limit |
| Blocking behavior | The runtime runs other goroutines on available threads | For supported blocking I/O, the virtual thread can unmount and free its carrier |
| Rules for shared data | The Go Memory Model (dated June 6, 2022) | JLS Chapter 17 happens-before rules, unchanged |
| Memory guidance from the runtime’s own docs | The Go GC guide cautions against reading virtual-memory metrics such as VSS as a direct measure of useful memory | JEP 444 says heap and GC activity for virtual threads is generally hard to compare with asynchronous code |
How each runtime multiplexes tasks
Goroutines in the Go runtime
The Go FAQ describes goroutines as independently executing functions multiplexed onto a set of operating-system threads. When one blocks, the runtime can schedule other goroutines on threads that are free. The FAQ says a goroutine carries little overhead beyond its stack memory and that its stack is resizable and bounded. The scheduling policy itself is an implementation detail that can change between Go releases, so do not assume identical placement or ordering on every version or platform.
Virtual threads and carrier threads
OpenJDK JEP 444 delivered virtual threads in Java 21. A virtual thread runs Java code on a platform thread, called its carrier, only while it is mounted on that carrier. The JDK scheduler maps virtual threads onto platform threads. When a virtual thread performs a supported blocking operation through the relevant Java APIs, the runtime can unmount it and free the carrier for other work. The JEP states the intent plainly: “Virtual threads are a lightweight implementation of threads that is provided by the JDK rather than the OS.” It also lists goroutines as another example of user-mode threads, which is a fair summary of the relationship: analogous in purpose, not identical in API, implementation or operational behavior.
#1 Best Overall
Stack storage and what the figures do not tell you
Go: small, resizable stacks
The Go FAQ says a newly created goroutine starts with a stack of a few kilobytes, and that the runtime grows and shrinks that stack automatically. The same FAQ puts the average CPU overhead of a function call at about three cheap instructions. Both are high-level descriptions from a page that does not state a publication date. They are not a fixed stack size, a cross-language benchmark, or a guarantee for every architecture and Go version, and they do not translate into an end-to-end request cost.
Java: stack chunks on the heap
JEP 444 says a virtual thread’s stack is stored in heap stack-chunk objects. The stack grows and shrinks as execution proceeds, up to the stack-size limit configured for platform threads. Because those chunks live on the managed heap, the cost of many virtual threads appears in heap occupancy and garbage-collector work, not in a separate pool of stack memory.
Why neither figure predicts process memory
A count of goroutines or virtual threads says little about resident memory on its own. The factors that move the number are:
Free tools Windows power users keep installed
One-click scans. No signup required.
- Stack depth at the moment of measurement, since both runtimes size stacks by use.
- Live heap reachable from each task, including request buffers, caches and parsed payloads.
- Thread-local values. JEP 444 warns that virtual threads may be extremely numerous and that thread-local values can add memory cost at that scale.
- Allocation rate, which determines how often the garbage collector runs and how much heap headroom it needs.
The Go GC guide frames the same issue from the other direction. Goroutine stacks are often small relative to the live heap, but very large goroutine populations can affect garbage-collector behavior. Use resident-set size together with heap and GC data rather than a virtual-memory figure.
Memory models: what is shared and what is not
A memory model answers one question: when is a write made by one task guaranteed to be visible to a read in another? Scheduling changes how tasks run, not the answer to that question.
The Go Memory Model
The Go Memory Model, dated June 6, 2022, specifies when a read in one goroutine can observe a write made in another. Its advice is to serialize access to shared data that more than one goroutine modifies, using channel operations or the primitives in the sync and sync/atomic packages. In the absence of data races, Go programs have the documented sequential-consistency guarantee. The Advice section states: “Programs that modify data being simultaneously accessed by multiple goroutines must serialize such access.”
The Java Memory Model and virtual threads
Chapter 17 of the Java Language Specification defines the Java Memory Model. Visibility rests on the happens-before relation, formed from program order and synchronization edges. Two edges matter most in everyday code: an unlock of a monitor happens-before each later lock of that monitor, and a write to a volatile field happens-before subsequent reads of that field. Because JEP 444 defines a virtual thread as an instance of java.lang.Thread, these rules apply to it unchanged. The scheduler changes how Java code is multiplexed onto platform threads. It does not introduce a separate memory model for virtual threads.
The Tool Desk
Outbyte Driver Updater FREEScan for outdated or missing drivers - takes under a minuteDriver Scan →Outbyte PC Repair FREERepair Windows errors before they cause bigger problemsFix Now →The same handoff in each language
Both examples below publish a value written by one task and read it in another. Each is correct only because a synchronization edge connects the write to the read.
Rank #3
Go:
package main
import "fmt"
var data int
func main() {
done := make(chan bool)
go func() {
data = 42
done <- true
}()
<-done
fmt.Println(data)
}
The send on done happens before the receive completes, so main is guaranteed to print 42. Remove the channel and the program has a data race, so nothing is guaranteed about what it prints.
Java:
class Handoff {
static int data;
static volatile boolean ready;
// Runs in task A (platform or virtual thread)
static void publish() {
data = 42;
ready = true; // volatile write
}
// Runs in task B (platform or virtual thread)
static void consume() {
if (ready) { // volatile read that observes true
System.out.println(data); // guaranteed to print 42
}
}
}
The write to data precedes the volatile write to ready in program order, and the volatile read that sees true happens after it, so the value 42 is visible. If consume reads false, it prints nothing. If ready is made an ordinary field, the same code has a race, and that is true whether the tasks are virtual threads or platform threads. The scheduler does not repair a missing happens-before edge.
Concurrency overhead: what cheap tasks do and do not buy
Both runtimes make it practical to create one task per unit of work instead of pooling scarce threads. They do not remove the limits around that work:
- Creation and scheduling are not free. Goroutine creation, scheduling, synchronization, stack growth and garbage collection all cost something. The documents describe these costs qualitatively, and no source here quantifies them for a matched workload.
- The gain appears mainly when tasks block. A virtual thread that waits on supported blocking I/O can release its carrier. CPU-bound work still needs processor time, so cheap tasks do not add cores.
- Pinning can reduce the benefit. Oracle’s Java SE virtual-thread guide describes cases where a virtual thread that blocks inside a
synchronizedblock or a native method keeps its carrier. Whether this applies depends on the JDK version and code path. Oracle publishes versioned virtual-thread documentation, including pages for Java SE 25 and 26, so read the page that matches your build. The same guide covers diagnostics, including thejdk.tracePinnedThreadssystem property and thejdk.VirtualThreadPinnedFlight Recorder event. - Downstream capacity does not change. Database connections, rate limits, file descriptors, memory budgets and CPU cores bound throughput regardless of task count. More concurrent tasks mean more requests queued behind those limits.
- Per-thread data multiplies. Thread-local caches and context values are copied per task, so they scale with the number of virtual threads, as noted earlier.
Can virtual threads replace a thread pool?
Sometimes. The reason the pool exists matters more than the pool itself.
Rank #4
- The pool keeps blocking tasks from starving a fixed set of threads. A per-task virtual-thread executor removes that reason, because each task gets its own virtual thread.
- The pool caps concurrency against a database, API quota or licence limit. A virtual-thread executor does nothing to enforce that cap. Keep an explicit limit, such as a semaphore sized to the downstream capacity.
- The work is CPU-bound. Keep worker counts tied to available cores. Virtual threads do not add processor capacity.
The following Java 21 sketch replaces an unbounded pool of platform threads with one virtual thread per request while keeping an explicit limit of 20 concurrent database calls. query(r) stands in for your database call.
Semaphore dbPermits = new Semaphore(20);
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (Request r : requests) {
executor.submit(() -> {
dbPermits.acquire();
try {
return query(r);
} finally {
dbPermits.release();
}
});
}
}
In Go, the same cap is usually a buffered channel used as a semaphore. Goroutines do not require a pool to be created cheaply, but the downstream limit still needs an explicit bound.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Measuring overhead fairly
Any memory or throughput claim about your system has to come from your own measurements, taken on both sides under the same conditions. Pin these variables first:
- Exact Go toolchain version and JDK build.
- Hardware, CPU limits and container memory limits.
- Workload: blocking pattern, such as database waits or outbound HTTP calls, versus CPU-bound work.
- Stack depth at the point of blocking.
- Allocation rate and live-heap size.
- Thread-local and context-propagation use.
- Concurrency level and the downstream limits it reaches.
Record throughput, tail latency (p99 is a common choice), CPU use, resident-set size, heap occupancy and GC frequency and pause time. Then follow this procedure:
Best Value
- Build both implementations against the same downstream stub or test database with identical limits.
- Ramp concurrency in fixed steps and record every step, not only the peak.
- Run each configuration long enough for heap and GC behavior to settle, and repeat the runs.
- Capture memory and runtime data with each runtime’s tools:
# Go: microbenchmarks with allocation counts, repeated 10 times
go test -run=^$ -bench=. -benchmem -count=10 ./...
# Go: log each garbage-collection cycle while the service runs
GODEBUG=gctrace=1 ./yourapp
# Java: dump virtual-thread state to a JSON file
jcmd <pid> Thread.dump_to_file -format=json /tmp/threads.json
# Java: heap summary and GC logging
jcmd <pid> GC.heap_info
java -Xlog:gc -jar yourapp.jar
For Java microbenchmarks, use JMH rather than hand-written timing loops, since it handles warm-up and dead-code elimination. Keep in mind that microbenchmarks isolate one path; production-shaped load tests are what establish the overall difference.
Choosing between them
Treat the memory model as a correctness decision that applies in both languages. A missing synchronization edge is a bug whether it runs in a goroutine or a virtual thread, so review shared-state access before comparing runtimes. Then weigh the practical factors: your team’s depth in each memory model, what your observability stack exposes for each runtime, and where the real bottleneck sits.
In practice the two runtimes rarely compete for the same service. A Java team compares virtual threads with its existing platform-thread pools and downstream limits. A Go team compares its goroutine designs against its own limits. Where blocking I/O dominates and downstream capacity is explicit, both models can make thread-per-request code practical. How much that is worth in memory and throughput is a question for your measurements, not for a headline comparison.
Quick wins for a faster PC:
Scan for outdated or missing drivers - takes under a minuteDriver Scan →Repair Windows errors before they cause bigger problemsFix Now →Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

