Scheduler internals in Go ↔ Java: how your code actually executes

When you write go func() or create a Thread in Java, it seems like you are managing concurrency. But in reality, you are delegating the task to the scheduler. And this is where the real show begins.

Go and Java use fundamentally different models: Go — M:N scheduler (many goroutines on fewer threads), Java — 1:1 (thread = OS thread). This affects everything: latency, scalability, behavior under locks.

Let's break down the key mechanisms: parking/unparking, work stealing, queues, netpoller, and preemption. This is not just theory — this is why some code "flies," while others suddenly freeze.


Goroutine parking / unparking

Goroutine parking / unparking - sleep and wake-up of tasks 😴⚡, the goroutine falls asleep and starts again

Parking is when a goroutine temporarily "falls asleep" and frees the thread (OS thread). Unparking is when it is returned to execution.

In Go this happens constantly: when waiting for a channel, mutex, syscalls. The scheduler removes the goroutine from execution and places it in wait structures. The thread (M) can then take another goroutine (G).

In Java, blocking a thread means blocking the OS thread. Yes, there is LockSupport.park(), but more often blocking is a real sleep at the OS level.


package main

import "time"

func main() {
    ch := make(chan int)

    go func() {
        // this goroutine "parks" here
        val := <-ch
        println(val)
    }()

    time.Sleep(time.Second)

    // will wake up the goroutine
    ch <- 42
}

import java.util.concurrent.ArrayBlockingQueue;

public class Main {
    public static void main(String[] args) throws Exception {
        ArrayBlockingQueue<Integer> queue = new ArrayBlockingQueue<>(1);

        new Thread(() -> {
            try {
                // the thread is blocked (OS-level)
                Integer val = queue.take();
                System.out.println(val);
            } catch (Exception e) {}
        }).start();

        Thread.sleep(1000);

        queue.put(42); // unblocks the thread
    }
}
Go parks lightweight goroutines, not heavy threads. This is key to scalability. Under the hood, the scheduler simply changes pointers and queues, not interacting with the OS. In Java, blocking often goes to kernel → more expensive. Therefore, in Go, you can hold tens of thousands of goroutines, while in Java, thousands of threads are already a problem.
Used everywhere: channels, mutex, IO. In Go, this is a cheap operation → you can write "naive" code. In Java, you need to think about thread pools and limiting threads. The advantage of Go is its lightweight nature. The disadvantage is a more complex debugging of the scheduler. Java, on the other hand: simpler model, but more expensive blocking.

Work stealing

Work stealing - stealing tasks for speed 🏴‍☠️⚡, threads take work from others

Work stealing is when one thread "steals" tasks from another.

In Go, each P (processor) has its own queue of goroutines. If the queue is empty, it steals tasks from other Ps.

In Java, ForkJoinPool uses a similar mechanism.


// ASCII scheme

P1: [G1 G2 G3]
P2: []

P2 -> steal -> G2

for i := 0; i < 100; i++ {
    go func(i int) {
        println(i)
    }(i)
}

import java.util.concurrent.ForkJoinPool;

public class Main {
    public static void main(String[] args) {
        ForkJoinPool pool = new ForkJoinPool();

        for (int i = 0; i < 100; i++) {
            int x = i;
            pool.submit(() -> System.out.println(x));
        }
    }
}
Work stealing reduces CPU idle time. Under the hood, it's load balancing. But it is not free: there are synchronization and contention. In Go, this is built into the runtime, in Java - it depends on the executor.
Ideal for CPU-bound tasks. Plus - even load. Minus - overhead. In Go, you get this "for free". In Java, you need to choose the right pool.

Syscall blocking behavior

Syscall blocking behavior - falling into kernel 🧱⛓️, system call blocks execution

When a goroutine makes a syscall (for example, reading a file), the thread may block. Go handles this by creating a new thread to avoid blocking the execution of other goroutines.

In Java, the thread is completely blocked.


package main

import "os"

func main() {
    go func() {
        f, _ := os.Open("file.txt")
        buf := make([]byte, 100)
        f.Read(buf) // syscall
    }()
}

import java.io.FileInputStream;

public class Main {
    public static void main(String[] args) throws Exception {
        new Thread(() -> {
            try {
                FileInputStream f = new FileInputStream("file.txt");
                byte[] buf = new byte[100];
                f.read(buf); // blocks the thread
            } catch (Exception e) {}
        }).start();
    }
}
Syscall is the boundary between user space ↔ kernel. Go tries to hide this and not block the scheduler. Java leaves this to you. If you ignore this, you get thread starvation.
Go is great for IO-heavy applications. Java requires async (NIO). The plus of Go is its simplicity. The minus is more threads under the hood. Java offers control but the code is more complex.

Local run queue

Local run queue - personal task queue 🎯🧵, the thread executes its goroutines

Each P in Go has a local goroutine queue. This reduces contention.


// P1: [G1 G2]
// P2: [G3 G4]

// goroutine first goes into the local queue
go func() { println("hi") }()

// the analog — worker thread queue
Local queues are an optimization for cache locality. Under the hood, this reduces lock contention. In Java, the analog is thread-local queues in ForkJoinPool.
Used in high-concurrency systems. Plus — speed. Minus — balancing complexity.

Global run queue

Global run queue - global task storage 🏭⚡, queue for load balancing

Global queue — fallback. If local ones are empty — we take from there.


// Global: [G5 G6]

// runtime puts tasks in global queue under overload

// global queue executor
Global queue — contention point. Under the hood this is a shared structure. The less you use it — the better.
Used during burst load. Plus — balancing. Minus — locks.

Network poller (netpoller)

Network poller (netpoller) - network notification watchdog 🌐🔔, monitors the readiness of I/O events

Go uses epoll/kqueue through netpoller. This allows for non-blocking threads on IO.

Java does this through NIO.


// netpoller manages IO events

// Selector in NIO
Netpoller is the heart of IO in Go. It allows you to write synchronous code that is actually async. It’s a deception, but very useful.
Used in HTTP servers, gRPC. Plus — simplicity of the API. Minus — complexity of the runtime.

Cooperative vs async preemption

Cooperative vs async preemption - politeness vs coercion 🤝⚡, the scheduler either waits or interrupts

Previously, Go used cooperative preemption — a goroutine had to "yield" itself.

Now there is async preemption — the runtime can interrupt it.

Java has always had preemptive scheduling.


// long loop can be interrupted by the runtime

// thread can be interrupted by the OS scheduler
Async preemption makes Go safer. Without it, one could "get stuck" in an infinite loop. Under the hood, it's signals and safe points.
Important for fairness. Plus — stability. Minus — a small overhead.

Goroutine blocking on IO

Goroutine blocking on IO - blocking on input-output ⏳🌐, goroutine is waiting for data from the network or disk

In Go, goroutines are lightweight threads managed by the runtime rather than the operating system. When a goroutine performs a blocking IO operation (for example, reading from a socket), it does NOT block the OS-thread (unlike the classical Java model). Instead, the Go runtime "parks" the goroutine, saves its state, and frees the thread to execute other goroutines.

Under the hood, the following happens: when an IO operation is attempted, Go uses non-blocking system calls. If the data is not ready, the goroutine is registered in the netpoller and put into a waiting state. The OS-thread (M) is freed and can execute other goroutines (G). When the data is ready, the netpoller notifies the scheduler, and the goroutine returns to the execution queue.

In Java, the classical model (before Loom) consists of blocking threads: if a thread does socket.read(), it blocks the OS-thread. This is expensive, as a thread is a heavy structure. Therefore, NIO (Selector), CompletableFuture, or reactive frameworks are used.

Code example (Go)


package main

import (
    "fmt"
    "net"
)

func main() {
    // create a TCP connection
    conn, _ := net.Dial("tcp", "example.com:80")

    // this operation may "block" the goroutine,
    // but NOT the OS thread
    buffer := make([]byte, 1024)
    n, _ := conn.Read(buffer)

    fmt.Println("Read bytes:", n)
}

Code example (Java)


import java.io.InputStream;
import java.net.Socket;

public class Main {
    public static void main(String[] args) throws Exception {
        Socket socket = new Socket("example.com", 80);

        InputStream in = socket.getInputStream();

        byte[] buffer = new byte[1024];

        // blocks the OS thread
        int n = in.read(buffer);

        System.out.println("Read bytes: " + n);
    }
}

// Diagram (Go):

G (goroutine)
   |
   v
IO call ---> no data
   |
   v
park goroutine ----> netpoller
   |
   v
OS thread free

// Diagram (Java):

Thread
   |
   v
IO call ---> blocking
   |
   v
Thread busy (waiting)
Go automatically makes IO non-blocking at the runtime level, so the developer does not need to think about Selector/epoll. But it is important to understand: if you use C libraries or syscall directly, you may inadvertently block the thread. In Java, on the contrary, everything is blocking by default, and one needs to consciously switch to NIO/async. The reason lies in the architecture: Go runtime controls execution, JVM does not; it delegates to the OS.
This mechanism is actively used in high-load services: HTTP servers, proxies, streaming. In Go, tens of thousands of connections can be handled with a minimal number of threads. In Java, Netty or a reactive stack (Spring WebFlux) is typically used for this purpose. The advantage of Go is the simplicity of the code (synchronous style). The downside is less control. Java offers more flexibility but is more complex to implement. Under the hood, it all comes down to the cost of OS-thread vs goroutine.

Scheduler + IO interaction

Scheduler + IO interaction - the dance of the scheduler and I/O 💃⚙️, management of threads and waiting for operations

Go uses the GMP model (Goroutine, Machine, Processor). The scheduler distributes goroutines (G) across logical processors (P), which are executed on threads (M). When a goroutine is blocked on IO, it is removed from execution, and P switches to another goroutine.

The key idea: the scheduler is tightly integrated with IO. When the netpoller receives an event (data is ready), it puts the goroutine back in the run queue. This allows Go to effectively balance CPU-bound and IO-bound tasks.

In Java, the scheduler is the OS scheduler. The JVM does not manage thread scheduling directly. Asynchronous IO operations are implemented through Selector, ForkJoinPool, virtual threads (Loom).

Code example (Go)


package main

import (
    "fmt"
    "net/http"
)

func fetch(url string) {
    // each call - a separate goroutine
    resp, _ := http.Get(url)
    fmt.Println("Fetched:", url, resp.Status)
}

func main() {
    urls := []string{"http://example.com", "http://google.com"}

    for _, url := range urls {
        go fetch(url) // scheduler distributes tasks
    }

    select {} // to prevent the program from finishing
}

Code example (Java)


import java.net.http.*;
import java.net.URI;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        HttpClient client = HttpClient.newHttpClient();

        List<String> urls = List.of("http://example.com", "http://google.com");

        for (String url : urls) {
            client.sendAsync(
                HttpRequest.newBuilder(URI.create(url)).build(),
                HttpResponse.BodyHandlers.ofString()
            ).thenAccept(resp -> {
                System.out.println("Fetched: " + url + " " + resp.statusCode());
            });
        }
    }
}

// GMP scheme:

G1  G2  G3
 |   |   |
 v   v   v
 P1 ----> M1 (OS thread)
 P2 ----> M2

IO event:
netpoller -> scheduler -> G queue
It is important to understand that the scheduler in Go is cooperative + partially preemptive. If a goroutine does not perform a syscall or yield the CPU for a long time, it can block others. Therefore, use the runtime.Gosched() function or break up tasks. In Java, this is resolved at the OS scheduler level, but the cost is greater overhead.
This mechanism is critical in microservices and systems with a large number of concurrent requests. Go is well-suited for API gateways, proxies, and batch processing. Java is for complex enterprise systems with thread control. The plus of Go is automatic scaling of tasks. The minus is less control over scheduling. Java provides fine-grained control through thread pools.

Netpoller (epoll + kqueue)

Netpoller (epoll + kqueue) - the kernel monitors sockets 👁️📡, epoll/kqueue notify about events

Netpoller is a component of the Go runtime that uses OS mechanisms (epoll in Linux, kqueue in BSD/macOS) to monitor IO readiness. It allows for efficient handling of thousands of connections without blocking threads.

When a goroutine performs IO, the descriptor is registered with netpoller. The OS then informs (via epoll/kqueue) when the socket is ready. Netpoller wakes up the corresponding goroutine through the scheduler.

In Java, the equivalent is Selector (NIO). But the developer has to write the select() loop and manage events themselves. Go hides this behind the standard library.

Code example (Go)


package main

import (
    "net"
)

func main() {
    // net.Listener under the hood uses netpoller
    ln, _ := net.Listen("tcp", ":8080")

    for {
        conn, _ := ln.Accept() // does not block the thread
        go handle(conn)
    }
}

func handle(conn net.Conn) {
    buffer := make([]byte, 1024)
    conn.Read(buffer)
}

Code example (Java)


import java.nio.channels.*;
import java.net.InetSocketAddress;
import java.util.Iterator;

public class Main {
    public static void main(String[] args) throws Exception {
        Selector selector = Selector.open();

        ServerSocketChannel server = ServerSocketChannel.open();
        server.bind(new InetSocketAddress(8080));
        server.configureBlocking(false);

        server.register(selector, SelectionKey.OP_ACCEPT);

        while (true) {
            selector.select(); // block on events

            Iterator<SelectionKey> keys = selector.selectedKeys().iterator();

            while (keys.hasNext()) {
                SelectionKey key = keys.next();
                keys.remove();

                if (key.isAcceptable()) {
                    // handle connection
                }
            }
        }
    }
}

// netpoller scheme:

goroutine
   |
   v
register fd -> netpoller
   |
   v
epoll_wait()
   |
   v
event ready
   |
   v
scheduler -> goroutine runnable
Although Go hides netpoller, it's important to remember: a large number of open connections requires tuning ulimit and file descriptors. It's also important to avoid long operations inside a goroutine after IO, otherwise, the advantage of the non-blocking model is lost. In Java, you control the Selector directly — this is more complex but gives more control.
Netpoller is used in all network services: HTTP servers, gRPC, WebSocket. Go provides a simple API and scalability. Java (through Netty) achieves the same efficiency but requires more code. Advantages of Go — development speed and readability. Disadvantages — less transparency. Java — better for custom network protocols and fine-tuning.

General comparison table

Term Go Java Comment
Parking goroutine thread Go is easier and cheaper
Work stealing built-in ForkJoinPool in Go by default
Syscall does not block scheduler blocks thread important for IO
Local queue exists partially optimization
Global queue fallback main contention point
Netpoller built-in NIO different levels of abstraction
Preemption cooperative + async preemptive Go has caught up with Java

Output / Result

Scheduler is the hidden conductor of your program. And in Go, it is a much more active participant than in Java.

Go builds an illusion: you write synchronous code, but under the hood, everything is asynchronous. The scheduler parks, steals tasks, balances the load, manages IO. It’s powerful, but it requires understanding.

Java is more honest: a thread is a thread. If it is blocked, it is blocked. Yes, there is ForkJoinPool, NIO, but they are not built in as deeply.

If you understand the scheduler, you start to see the invisible: why the code is lagging, where contention is, where the blocks are. This is the level where a developer stops being a user of the language and becomes its explorer.

Go is about massive parallelism. Java is about control and maturity. And the best result comes when you understand both worlds.


🌐 На русском
Total Likes:0

Оставить комментарий

My social media channel
By sending an email, you agree to the terms of the privacy policy

Useful Articles:

Java v25: Choosing the Right Multithreading for Any Task
Introduction The Java world is rapidly evolving, and with each version, new tools are emerging for effectively working with multithreading, collections, and asynchrony. Java 25 brings powerful feature...
Java under the Microscope: Stack, Heap, and GC using Code Examples
Diagram - Java Memory Model - Heap / Non-Heap / Stack Heap (memory for objects) Creates objects via new. Young Generation: Eden + Survivor. Old Generation: objects that have survived several GC c...
Let's look at: Trace, Profiling, Integration Testing, Code Coverage, Mocking, Deadlock Detection in Go vs Java | Testing, Debugging and Profiling
Series: Go for Java Developers — analysis of trace, profiling and testing In this article we will analyze tools and practices for testing, debugging and profiling in Go. For a Java developer this wil...

New Articles:

Scheduler internals in Go ↔ Java: how your code actually executes
When you write go func() or create a Thread in Java, it seems like you are managing concurrency. But in reality, you are delegating the task to the scheduler. And this is where the real show begins. ...
Low-Level Mechanisms - Part 2 | Go ↔ Java
In this article, we have gathered key low-level mechanisms of Go that most often raise questions for developers coming from Java. We will discuss: unsafe.Pointer, struct alignment, pointer arithmetic,...
Compiler, Build, and Tooling in Go and Java: how assembly, initialization, analysis, and diagnostics are organized in two ecosystems
This article is dedicated to a general overview of how the compiler, build, and tooling practices are arranged in Go, and how to better understand them through comparison with Java. We will not delve ...
Fullscreen image