Scheduler internals in Go ↔ Java: how your code is actually executed

When you write go func() or create a Thread in Java, it seems like you are managing concurrency. But in reality, you are passing 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 — it's why one code "flies" while another suddenly freezes.


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 releases the thread (OS thread). Unparking is when it is brought back 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)

    // wakes 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, rather than interacting with the OS. In Java, blocking often goes to the kernel → more expensive. Therefore, in Go, you can keep 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 plus of Go is simplicity. The downside is more complex scheduler debugging. Java is the opposite: simpler model, but more expensive blocking.

Work stealing

Work stealing - steal 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 P.

In Java, ForkJoinPool uses a similar mechanism.


// ASCII diagram

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's not free: there is 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 the kernel 🧱⛓️, the system call blocks execution

When a goroutine makes a syscall (for example, reading a file), the thread can be blocked. Go solves this by creating a new thread so as not to block 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 it — you get thread starvation.
Go is great for IO-heavy applications. Java requires async (NIO). The plus of Go — simplicity. The minus — more threads under the hood. Java — control, but more complex code.

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") }()

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

Global run queue

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

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


// Global: [G5 G6]

// runtime puts tasks in global queue during overload

// global executor queue
Global queue — point of contention. Under the hood, it 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 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 trick, but very useful.
Used in HTTP servers, gRPC. Plus — simplicity of the API. Minus — complexity of runtime.

Cooperative vs async preemption

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

Earlier, Go used cooperative preemption — 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, you could "hang" in an infinite loop. Under the hood, it's signals and safe points.
Important for fairness. Plus — stability. Minus — slight overhead.

Goroutine blocking on IO

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

In Go, goroutines are lightweight threads managed by the runtime, not 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 trying to perform an IO operation, Go uses non-blocking system calls. If the data is not ready, the goroutine is registered in the netpoller and put in 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 because 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() {
    // creating 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);
    }
}

// Scheme (Go):

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

// Scheme (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 accidentally block the thread. In Java, on the other hand, everything is blocking by default, and you need 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, you can handle tens of thousands of connections with a minimal number of threads. In Java, this is usually done using Netty or a reactive stack (Spring WebFlux). The plus 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 💃⚙️, managing threads and waiting for operations

Go uses the GMP model (Goroutine, Machine, Processor). The scheduler distributes goroutines (G) across logical processors (P), which run 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 closely 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 efficiently 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 exiting
}

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 make a syscall for a long time or does not yield the CPU, it can block others. Therefore, use runtime.Gosched() functions or break tasks down. In Java, this is handled at the OS scheduler level, but the cost is more 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 advantage of Go is automatic task scaling. The downside is less control over scheduling. Java gives fine-grained control through thread pools.

Netpoller (epoll + kqueue)

Netpoller (epoll + kqueue) - the core 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 track IO readiness. It allows efficient handling of thousands of connections without blocking threads.

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

The Java equivalent is Selector (NIO). But the developer must 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(); // blocks on events

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

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

                if (key.isAcceptable()) {
                    // handling the connection
                }
            }
        }
    }
}

// Netpoller Scheme:

goroutine
   |
   v
register fd -> netpoller
   |
   v
epoll_wait()
   |
   v
event ready
   |
   v
scheduler -> goroutine runnable
Although Go hides netpoller, it is important to remember: a large number of open connections requires tuning ulimit and file descriptors. It is also important to avoid long operations inside goroutines after IO, otherwise the benefit of the non-blocking model is lost. In Java, you control the Selector directly — it is more complex but gives more control.
Netpoller is used in all networking services: HTTP servers, gRPC, WebSocket. Go provides a simple API and scalability. Java (through Netty) achieves the same efficiency but requires more code. The pros of Go are speed of development and readability. The cons are less transparency. Java is better for custom networking 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 various 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. This is powerful, but 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 so deeply.

If you understand the scheduler, you start to see the invisible: why code is stuttering, where contention is, where locks 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:

Memory / Runtime / Allocator - Go vs Java
Memory management, pointers, and profiling are fundamental aspects of efficient code. Let s consider three key concepts: slice backing array, pointer, and profiling (pprof / trace), and compare Go wit...
Slice internals in Go ↔ Java: from header to hidden allocations
Slice in Go is one of those structures that looks simple, but under the hood behaves like a little clever beast. If you are a Java developer, you might think: "well, this is just an ArrayList." And th...
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...

New Articles:

Concurrency is not about “starting many threads”. It’s about agreements between them. Imagine a restaurant kitchen: — cooks (threads / goroutines) — orders (tasks) — and the main question: how do th...
When HashMap starts killing production: the engineering story of ConcurrentHashMap
Imagine a typical production service. 32 CPU hundreds of threads configuration / session / rate limits cache tens of thousands of operations per second And somewhere inside — a regular Map. At first...
Zero Allocation in Java: what it is and why it matters
Zero Allocation — is an approach to writing code in which no unnecessary objects are created in heap memory during runtime. The main idea: fewer objects → less GC → higher stability and performance. ...
Fullscreen image