• Skip to secondary menu
  • Skip to main content
  • Skip to primary sidebar
  • Home
  • Projects
  • Products
  • Themes
  • Tools
  • Request for Quote

Vengala Vinay

Having 12+ Years of Experience in Software Development

  • Home
  • WordPress
  • PHP
    • Codeigniter
  • Django
  • Magento
  • Selenium
  • Server
Home » Java Loom Virtual Threads vs. Go Goroutines: Under-the-Hood Scheduler and Thread Overhead Comparison

Java Loom Virtual Threads vs. Go Goroutines: Under-the-Hood Scheduler and Thread Overhead Comparison

Java Loom Virtual Threads: The ForkJoinPool Scheduler Deep Dive

Java’s Project Loom introduces virtual threads, a lightweight concurrency primitive that promises to dramatically improve the scalability of I/O-bound applications without requiring developers to rewrite their code for reactive paradigms. At its core, the virtual thread scheduler relies on a managed `ForkJoinPool` (or a custom `ScheduledExecutorService`) to orchestrate the execution of these virtual threads onto a limited number of operating system threads, known as carrier threads. Understanding this mechanism is crucial for optimizing performance and diagnosing potential bottlenecks.

By default, a virtual thread is mounted on a carrier thread from a `ForkJoinPool`. This pool is configured with a parallelism level typically set to the number of available processors. When a virtual thread performs a blocking operation (like I/O), it is “unmounted” from its carrier thread, allowing the carrier thread to pick up another ready virtual thread. This unmounting and remounting is a key differentiator from traditional OS threads, which would block the entire carrier thread. The `ForkJoinPool`’s work-stealing algorithm is instrumental here, ensuring that carrier threads remain busy by stealing tasks from other threads that might have a backlog.

Configuring the Virtual Thread Scheduler

While the default configuration is often sufficient, advanced users can customize the scheduler. This is primarily achieved by providing a custom `ExecutorService` when creating a `Thread.Builder` for virtual threads. The most common scenario involves configuring the underlying `ForkJoinPool`’s parallelism or using a different executor altogether.

To illustrate, let’s consider setting a specific parallelism for the default `ForkJoinPool` that backs virtual threads. This is typically done via system properties at JVM startup.

System Properties for ForkJoinPool Configuration

The parallelism of the default `ForkJoinPool` used for virtual threads can be controlled using the following JVM system properties:

  • jdk.virtualThreadScheduler.parallelism: Sets the number of worker threads in the scheduler pool.
  • jdk.virtualThreadScheduler.maxPoolSize: Sets the maximum number of worker threads.
  • jdk.virtualThreadScheduler.minRunnable: Controls the minimum number of threads that should be kept alive and running.

For example, to set the parallelism to 16, you would start your JVM with:

Example JVM Startup Command

java -Djdk.virtualThreadScheduler.parallelism=16 -jar myapp.jar

Alternatively, you can programmatically provide a custom `ExecutorService`.

Programmatic ExecutorService Configuration

This approach offers finer-grained control, allowing the use of different executor types or custom `ForkJoinPool` configurations.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ThreadFactory;

// ...

// Using a custom ForkJoinPool with specific parallelism
int customParallelism = 32;
ForkJoinPool customPool = new ForkJoinPool(customParallelism,
    ForkJoinPool.defaultForkJoinWorkerThreadFactory,
    null, // Default handler
    true); // Async mode

Thread.Builder builder = Thread.ofVirtual().;
// If you want to use a specific executor for virtual threads:
// Thread.Builder builder = Thread.ofVirtual().; // This is not directly supported for specifying the executor for *all* virtual threads.
// Instead, you typically use the executor to *submit* tasks that run as virtual threads.

// A more direct way to influence the *default* scheduler is via system properties.
// If you need to manage a specific set of virtual threads with a custom executor:
ExecutorService customExecutor = Executors.newFixedThreadPool(customParallelism);
Runnable task = () -> {
    // Your virtual thread logic
    System.out.println("Running in virtual thread on carrier: " + Thread.currentThread().getName());
};

// Submit tasks to the custom executor, which will run them as virtual threads
// Note: This doesn't change the *default* scheduler for Thread.ofVirtual().start()
// but rather manages a pool of tasks that *could* be virtual threads.
// To truly replace the default scheduler, you'd need to intercept Thread.ofVirtual()
// or use a custom ThreadFactory with a custom ExecutorService, which is more complex.

// The primary mechanism for influencing the *default* virtual thread scheduler
// remains system properties or ensuring the JVM's default ForkJoinPool is configured
// appropriately if you're not using the default scheduler.

// For direct control over a pool of virtual threads:
Thread.Builder virtualThreadBuilder = Thread.ofVirtual();
Thread virtualThread = virtualThreadBuilder.unstarted(() -> {
    System.out.println("Task executed by virtual thread.");
});
// virtualThread.start(); // This will use the default scheduler.

// To use a custom executor for a specific set of virtual threads, you'd typically
// manage the lifecycle of those threads and their submission to the executor.
// The `Thread.ofVirtual()` builder itself doesn't take an ExecutorService argument
// to *replace* the scheduler. It relies on the JVM's managed scheduler.

// If you want to use a custom executor for tasks that *run* as virtual threads,
// you'd submit them to that executor.
// Example: Using a custom ForkJoinPool for tasks that *will be* virtual threads.
// This is more about how tasks are submitted *to* the virtual thread mechanism.
// The scheduler itself is managed by the JVM.

// Let's re-focus on how the *scheduler* is configured.
// The default scheduler is a ForkJoinPool.
// You can configure *that* pool via system properties.
// If you need a completely separate pool for virtual threads, you'd typically
// create a custom ThreadFactory that produces virtual threads and use that
// with a custom ExecutorService.

ThreadFactory virtualThreadFactory = Thread.ofVirtual().factory();
ExecutorService customVirtualThreadExecutor = Executors.newThreadPerTaskExecutor(virtualThreadFactory);

// Now, tasks submitted to customVirtualThreadExecutor will run as virtual threads
// managed by this specific executor, not the global default scheduler.
customVirtualThreadExecutor.submit(() -> {
    System.out.println("Running on custom virtual thread executor.");
});

// Remember to shut down custom executors
// customExecutor.shutdown();
// customVirtualThreadExecutor.shutdown();
customPool.shutdown(); // Shutdown the custom ForkJoinPool

It’s important to note that `Thread.ofVirtual().start()` uses the JVM’s default scheduler. To use a custom `ExecutorService` for virtual threads, you typically create a `ThreadFactory` that produces virtual threads (e.g., `Thread.ofVirtual().factory()`) and then use that factory with an `ExecutorService` like `Executors.newThreadPerTaskExecutor()`. This allows for managing a specific set of virtual threads independently of the global scheduler.

Go Goroutines: The M:N Scheduler and Scheduler Overhead

Go’s concurrency model is built around goroutines, which are multiplexed onto a smaller number of OS threads. This is often referred to as an M:N scheduler, where M goroutines are mapped to N OS threads. The Go runtime manages this mapping, scheduling goroutines onto available threads. Unlike Java’s virtual threads which are a newer addition, Go’s goroutine scheduler has been a core feature since the language’s inception and has undergone significant evolution.

The Go scheduler’s primary goal is to efficiently utilize CPU resources and minimize context-switching overhead. Each OS thread (often called a “P” or “processor” in Go’s internal terminology, representing a logical processor) has its own run queue of goroutines. When a goroutine performs a blocking I/O operation, the scheduler can detach it from its OS thread, allowing that thread to execute another goroutine from its local queue or even steal work from other threads’ queues (work-stealing). This is conceptually similar to Java’s virtual threads but implemented at a lower level within the Go runtime.

Go Scheduler Internals: P, M, and G

The Go runtime scheduler is composed of three main components:

  • G (Goroutine): The unit of execution. Each goroutine has its own stack and is managed by the scheduler.
  • M (Machine): An OS thread. Goroutines are executed by Ms.
  • P (Processor): A logical processor that represents the context needed to execute a goroutine. A P is required for a goroutine to run. It has a local run queue of goroutines. The number of Ps is typically set to the number of CPU cores available to the Go program (controlled by GOMAXPROCS).

The scheduler’s job is to map Gs to Ms, with each M requiring a P to execute a G. When an M performs a blocking system call, it can disassociate from its P, allowing the P to be used by another M. This is a key mechanism for achieving high concurrency with a limited number of OS threads.

Configuring GOMAXPROCS

The `GOMAXPROCS` environment variable (or `runtime.GOMAXPROCS()` function) controls the maximum number of operating system threads that can execute Go code simultaneously. Setting `GOMAXPROCS` to a value higher than the number of available CPU cores can lead to increased context-switching overhead and diminishing returns. Conversely, setting it too low can underutilize available CPU resources.

Example: Setting GOMAXPROCS

To set `GOMAXPROCS` to 4, you would typically do this at the start of your Go program or via an environment variable:

# Via environment variable
export GOMAXPROCS=4
go run main.go

# Programmatically in Go
package main

import (
	"fmt"
	"runtime"
	"sync"
)

func main() {
	// Set GOMAXPROCS to 4
	runtime.GOMAXPROCS(4)
	fmt.Printf("GOMAXPROCS set to: %d\n", runtime.GOMAXPROCS(0))

	var wg sync.WaitGroup
	numGoroutines := 1000

	wg.Add(numGoroutines)
	for i := 0; i < numGoroutines; i++ {
		go func(id int) {
			defer wg.Done()
			// Simulate some work
			fmt.Printf("Goroutine %d running\n", id)
			// time.Sleep(10 * time.Millisecond) // Uncomment to see more scheduling
		}(i)
	}

	wg.Wait()
	fmt.Println("All goroutines finished.")
}

The Go scheduler is highly optimized for I/O-bound workloads. When a goroutine makes a blocking I/O call, the Go runtime can often “preempt” the goroutine, unblock the OS thread, and schedule another goroutine. This is achieved through a combination of cooperative yielding (goroutines explicitly yielding or making calls that the runtime can hook into) and, in some cases, non-cooperative preemption for system calls.

Thread Overhead and Resource Consumption Comparison

The fundamental difference in overhead lies in how each concurrency model manages its execution units. This has direct implications for memory consumption and the sheer number of concurrent tasks an application can handle.

Java Virtual Threads Overhead

Virtual threads are designed to have minimal overhead. Each virtual thread has a small stack (typically a few kilobytes) that can grow as needed. When unmounted, the carrier thread is released, and the virtual thread’s state is saved. This contrasts sharply with traditional OS threads, which have a much larger fixed stack size (often 1MB or more by default) and consume significant kernel resources.

Memory Footprint: A virtual thread consumes significantly less memory than an OS thread. Estimates suggest that millions of virtual threads can be created within typical JVM heap sizes, whereas only thousands of OS threads are feasible.

Context Switching: Switching between virtual threads that are running on the same carrier thread is very fast, as it’s managed in user space by the JVM. When a virtual thread is unmounted and remounted, there’s a small overhead associated with saving and restoring its state, but this is still considerably less than an OS thread context switch, which involves the kernel and can flush CPU caches.

Go Goroutines Overhead

Goroutines also offer very low overhead compared to OS threads. Each goroutine starts with a small stack (typically 2KB) that grows automatically. The Go runtime manages the scheduling and multiplexing of goroutines onto OS threads.

Memory Footprint: Similar to virtual threads, goroutines are memory-efficient. Millions of goroutines can be active concurrently. The primary memory consumers are the goroutine stacks and the data they operate on.

Context Switching: Context switching between goroutines on the same OS thread is handled by the Go scheduler in user space and is very efficient. When a goroutine blocks on I/O or a system call, the Go runtime can swap it out, allowing the OS thread to execute another goroutine. This user-space scheduling minimizes the overhead compared to kernel-level thread context switches.

Direct Comparison: Virtual Threads vs. Goroutines

While both technologies achieve high concurrency with low overhead, there are subtle differences:

  • Implementation Level: Virtual threads are a feature of the JVM, built on top of existing OS threads via the `ForkJoinPool` or custom executors. Goroutines are a fundamental language construct managed by the Go runtime’s scheduler, which directly interacts with OS threads.
  • Stack Management: Both have small, growable stacks. Java’s virtual threads use a `StackTransfer` object to capture the stack state when unmounted, while Go uses its internal stack management.
  • Blocking Operations: Both models excel at handling blocking I/O. Java’s virtual threads unmount from carrier threads. Go’s scheduler swaps out goroutines when they block, often allowing the OS thread to continue with other goroutines.
  • Scheduler Complexity: The Go scheduler is a mature, built-in component of the language runtime. Java’s virtual thread scheduler is a newer addition, leveraging and extending existing Java concurrency utilities like `ForkJoinPool`.
  • Ecosystem Integration: Java’s virtual threads aim for seamless integration with existing Java libraries, including blocking I/O APIs. Go’s concurrency primitives are deeply ingrained in the language and its standard library.

In practice, both Java virtual threads and Go goroutines enable building highly scalable, responsive applications that can handle a massive number of concurrent operations with significantly less resource consumption than traditional OS threads. The choice between them often comes down to the existing technology stack, team expertise, and specific application requirements.

Primary Sidebar

A little about the Author

Having 12+ Years of Experience in Software Development, Vinay is a principal software architect, senior systems engineer, and elite technical consultant. He specializes in bespoke PHP/WordPress development, high-performance Magento 2 & Shopify architectures, custom plugin/theme development from scratch, and legacy code modernization (including VB6, VB.NET, PyQt, and Crystal Reports). Known for solving complex database bottlenecks, speed optimization (Core Web Vitals), and advanced security code auditing, Vinay engineers production-ready systems designed to scale under heavy concurrent load conditions.



Chat on WhatsApp

Recent Posts

  • Go Goroutines vs. Node.js Event Loop: Scaling I/O-Bound Microservices Under High Load
  • Elixir Phoenix vs. Go Gin: Concurrency Models and Fault Tolerance Under Peak Request Volume
  • Python Celery vs. Go Channels: Distributed Task Queue Overhead and Memory Reliability
  • Scala Pekko vs. Go Goroutines: Actor Model vs. CSP for Event-Driven Reactive Systems
  • Java Loom Virtual Threads vs. Go Goroutines: Under-the-Hood Scheduler and Thread Overhead Comparison

Categories

  • apache (1)
  • Business & Monetization (390)
  • Centos (4)
  • Comparisons & Decision Making (55)
  • Debian (2)
  • Debugging & Troubleshooting (584)
  • Desktop Applications (14)
  • DevOps (7)
  • DevOps & Cloud Scaling (962)
  • Django (1)
  • Laravel (4)
  • Migration & Architecture (192)
  • Mobile Applications (24)
  • MySQL (1)
  • Performance & Optimization (806)
  • PHP (5)
  • PHP Development (21)
  • Plugins & Themes (244)
  • Programming Languages (9)
  • Python (19)
  • Ruby on Rails (1)
  • Security & Compliance (543)
  • SEO & Growth (491)
  • Server (23)
  • Ubuntu (9)
  • VB6 & VB.NET (8)
  • Web Applications & Frontend (19)
  • Web Assembly (Wasm) (2)
  • WordPress (22)
  • WordPress Plugin Development (7)
  • WordPress Theme Development (357)

Recent Posts

  • Go Goroutines vs. Node.js Event Loop: Scaling I/O-Bound Microservices Under High Load
  • Elixir Phoenix vs. Go Gin: Concurrency Models and Fault Tolerance Under Peak Request Volume
  • Python Celery vs. Go Channels: Distributed Task Queue Overhead and Memory Reliability

Top Categories

  • DevOps & Cloud Scaling (962)
  • Performance & Optimization (806)
  • Debugging & Troubleshooting (584)
  • Security & Compliance (543)
  • SEO & Growth (491)
  • Business & Monetization (390)

Our Products

  • ERP & LMS Systems (4)
  • Directories & Marketplaces (4)
  • Healthcare Portals (3)
  • Point of Sale (POS) (2)
  • E-Commerce Engines (2)

Our Services

  • E-Commerce Development (10)
  • WordPress Development (8)
  • Python & Desktop GUI (7)
  • General Consulting (7)
  • Legacy Modernization (5)
  • Mobile App Development (4)

Copyright © 2026 · Vinay Vengala