Seluruh bab sebelumnya berdiri di atas satu asumsi tak terucap: thread itu mahal. Thread platform — thread yang dikelola sistem operasi sejak Java versi pertama — memesan tumpukan (stack) berukuran ratusan kilobyte hingga satu megabyte per kepala dan mengikuti ritme penjadwal OS yang tidak murah. Akibatnya, server klasik yang memakai “satu thread per permintaan” mentok di ribuan klien, dan seluruh industri bergeser ke pool kecil ditambah asinkron non-blocking — gaya pemrograman yang kuat tetapi sukar dibaca. Virtual threads, difinalkan di Java 21 lewat JEP 444, mematahkan asumsi itu: thread yang dikelola JVM dengan biaya nyaris sepele, sehingga “satu thread per permintaan” kembali terjangkau pada skala jutaan. Bab ini membedah cara kerjanya, membuatnya langsung lewat Thread maupun lewat executor, lalu menandai batas kapan thread platform masih pilihan yang benar.

Thread Platform yang Mahal #

Mengapa thread platform terasa berat? Setiap thread platform berkorespondensi satu-satu dengan thread OS: registrasi di kernel, penjadwalan oleh OS, dan area tumpukan yang dipesan di muka. Ukuran tumpukan bawaan Java berada di sekitar 512 KB hingga 1 MB tergantung sistem; seribu thread berarti ratusan megabyte memori baru untuk tumpukan yang hampir seluruhnya tak terpakai. Penjadwalan konteks antar thread OS juga menuntut masuk-keluar kernel. Praktisnya, aplikasi biasa sanggup memelihara ribuan thread platform — bukan jutaan — dan setiap thread yang menunggu I/O memperparah pemborosan: thread mahal itu duduk menganggur penuh memori hanya untuk menunggu jawaban jaringan.

Diparkir di JVM, Bukan di Kernel #

Virtual thread memutus korespondensi dengan thread OS. Virtual thread adalah objek yang dikelola JVM: tumpukannya disimpan di heap sebagai rangkaian segmen yang tumbuh dan menyusut sesuai kebutuhan, dan penjadwalnya adalah ForkJoinPool khusus yang berbagi mesin work-stealing yang sudah kita kenal. Sebuah virtual thread berjalan dengan cara menempel (mounted) pada carrier thread — thread platform yang sebenarnya dieksekusi CPU. Saat virtual thread memanggil operasi pemblokir (Thread.sleep, baca socket, lock()), JVM tidak membiarkan carrier menganggur: virtual thread dilepas (unmounted), keadaannya diparkir sebagai tumpukan objek di heap, dan carrier langsung menjalankan virtual thread lain.

Hasil bersihnya: menunggu menjadi nyaris gratis. Sepuluh ribu virtual thread yang sama-sama menunggu jawaban layanan luar hanya menempati memori sebesar keadaan masing-masing — puluhan byte hingga kilobyte — tanpa satu pun thread OS yang terbuang. Semantik pemrograman tetap gaya lama yang mudah dibaca: kode berurutan, blok-blok try, exception biasa; tidak ada callback, tidak ada rantai reaktif.

Membuat dan Menamai Virtual Thread #

API pembuatannya menyatu pada class Thread yang sudah dikenal: builder Thread.ofVirtual() menggantikan konstruktor lama, dan isVirtual() melaporkan jenisnya:

public class VirtualThreadDasar {
    public static void main(String[] args) throws InterruptedException {
        String[] hasil = new String[5];

        // Bentuk 1: bangun dan mulai virtual thread dengan nama.
        Thread kurator1 = Thread.ofVirtual().name("kurator-1").start(() ->
                hasil[0] = "katalog diproses");
        Thread kurator2 = Thread.ofVirtual().name("kurator-2").start(() ->
                hasil[1] = "tag diperbarui");

        // Bentuk 2: builder dipakai ulang, nama berurutan dibuat otomatis.
        Thread.Builder.OfVirtual pembangun = Thread.ofVirtual().name("pekerja-", 0);
        Thread pekerja0 = pembangun.start(() -> hasil[2] = "berkas-1 diarsipkan");
        Thread pekerja1 = pembangun.start(() -> hasil[3] = "berkas-2 diarsipkan");

        // Pembanding: thread platform biasa (mahal, dikelola OS).
        Thread pengawas = Thread.ofPlatform().name("pengawas").start(() ->
                hasil[4] = "audit selesai");

        Thread[] semua = {kurator1, kurator2, pekerja0, pekerja1, pengawas};
        for (Thread t : semua) {
            t.join();
        }
        for (int i = 0; i < semua.length; i++) {
            System.out.println(semua[i].getName() + " -> " + hasil[i]
                    + " (virtual: " + semua[i].isVirtual() + ")");
        }
    }
}

Output yang Dihasilkan:

text
kurator-1 -> katalog diproses (virtual: true)
kurator-2 -> tag diperbarui (virtual: true)
pekerja-0 -> berkas-1 diarsipkan (virtual: true)
pekerja-1 -> berkas-2 diarsipkan (virtual: true)
pengawas -> audit selesai (virtual: false)

Tiga hal dari contoh tersebut:

  1. Thread.ofVirtual().name("kurator-1").start(...): builder mengembalikan thread yang belum hidup; start() memulainya seperti biasa. Seluruh fasilitas thread lama — join(), interrupt(), isAlive() — bekerja identik pada virtual thread.
  2. name("pekerja-", 0): awalan nama plus nomor awal; builder yang sama menghasilkan pekerja-0, pekerja-1, dan seterusnya tanpa kita menghitung sendiri.
  3. Thread.ofPlatform(): pembanding eksplisit — builder yang sama bentuknya untuk thread platform. Baris pengawas menegaskan perbedaan lewat isVirtual(): empat true, satu false.

Sepuluh Ribu Penunggu Sekaligus #

Bukti skala paling jujur adalah volume. Program berikut menyiapkan 10.000 task, masing-masing menunggu 1 milidetik lewat Thread.sleep — peniruan panggilan layanan luar — pada executor yang membuat satu virtual thread segar per task:

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class SepuluhRibuTugas {
    public static void main(String[] args) throws Exception {
        final int JUMLAH_TUGAS = 10_000;
        long total = 0;
        int selesai = 0;

        // Satu virtual thread per task: 10.000 penunggu berjalan bersamaan.
        try (ExecutorService eksekutor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<Integer>> futures = new ArrayList<>(JUMLAH_TUGAS);
            for (int i = 1; i <= JUMLAH_TUGAS; i++) {
                int nomor = i;
                // Setiap task meniru panggilan layanan luar: menunggu 1 ms.
                futures.add(eksekutor.submit(() -> {
                    Thread.sleep(1); // penungguan I/O tiruan
                    return nomor;
                }));
            }
            for (Future<Integer> f : futures) {
                total += f.get();
                selesai++;
            }
        } // close(): seluruh task ditunggu sampai tuntas

        System.out.println("task selesai : " + selesai);
        System.out.println("jumlah hasil : " + total);
    }
}

Output yang Dihasilkan:

text
task selesai : 10000
jumlah hasil : 50005000

Bedah singkatnya:

  1. Executors.newVirtualThreadPerTaskExecutor(): executor tanpa batas — setiap submit mendapat virtual thread baru, tidak ada antrean, tidak ada pool ukuran tetap yang perlu dihitung dengan rumus W/C dari Chapter Thread Pools. Batasnya datang dari sumber daya hulu (koneksi basis data, kuota layanan), bukan dari thread.
  2. 10.000 penunggu selesai sebentar: Thread.sleep(1) pada virtual thread memarkir diri di JVM tanpa menyentuh penjadwal OS; seluruh task menunggu bersamaan, bukan bergiliran. Dengan thread platform, jumlah sebanyak ini saja sudah menuntut beberapa gigabyte tumpukan dan jauh melampaui batas ulir per proses pada banyak sistem.
  3. Angka yang dicetak hanya hitungan pasti: 10000 task dan 50005000 total — durasi total sengaja tidak dicetak karena bergantung mesin; yang penting bagi determinisme adalah seluruh task tuntas dan hasilnya terkumpul penuh.

Pinning dan Batas Virtual Thread #

Ada situasi ketika virtual thread tidak bisa dilepas dari carrier-nya — keadaan bernama pinning: carrier terpaksa ikut menunggu bersama virtual thread yang menempel, dan bila kasusnya menumpuk, manfaat skala terkikis. Pemicu klasiknya adalah blok synchronized yang memanggil operasi pemblokir — JEP 491 (final di Java 24) merombak implementasi monitor sehingga kasus ini sudah hilang di Java 25. Sisa pemicu yang bertahan terutama panggilan native (JNI/FFM) yang memblokir di dalamnya. Bila dicurigai terjadi pinning, thread dump (jcmd ... Thread.dump -format=json memperlihatkan daftar virtual thread yang terpasang) menjadi alat pemeriksa pertama.

AspekThread platformVirtual thread
Dikelola olehSistem operasiJVM (carrier: ForkJoinPool khusus)
TumpukanDipesan di muka, ukuran tetapDi heap, tumbuh-menyusut
Biaya per threadRatusan KB - 1 MBRatusan byte
Jumlah wajarRibuanJutaan
Cocok untukKomputasi CPU lama dan padatMenunggu I/O
Kedalaman tumpukan terlihat jstackYaPerlu Thread.dump khusus

Kapan thread platform tetap pilihan benar? Untuk komputasi CPU-bound yang berdurasi panjang, virtual thread tidak memberi keuntungan — penungguan tidak ada yang bisa diparkir — sambil menambah lapisan penjadwalan; ForkJoinPool atau parallel stream tetap alatnya. Kedua dunia ini bukan pesaing melainkan berdampingan: virtual thread untuk jutaan penunggu, thread platform sebanyak inti untuk komputasi.

Jutaan thread yang hidup bebas membawa satu masalah baru: siapa yang menunggu, membatalkan, dan membersihkan semuanya bila satu saja di antaranya gagal? Jawaban arsitekturalnya — structured concurrency — menjadi Chapter berikutnya.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

SepuluhRibuTugas.java

Unduh berkas
java
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class SepuluhRibuTugas {
    public static void main(String[] args) throws Exception {
        final int JUMLAH_TUGAS = 10_000;
        long total = 0;
        int selesai = 0;

        // Satu virtual thread per task: 10.000 penunggu berjalan bersamaan.
        try (ExecutorService eksekutor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<Integer>> futures = new ArrayList<>(JUMLAH_TUGAS);
            for (int i = 1; i <= JUMLAH_TUGAS; i++) {
                int nomor = i;
                // Setiap task meniru panggilan layanan luar: menunggu 1 ms.
                futures.add(eksekutor.submit(() -> {
                    Thread.sleep(1); // penungguan I/O tiruan
                    return nomor;
                }));
            }
            for (Future<Integer> f : futures) {
                total += f.get();
                selesai++;
            }
        } // close(): seluruh task ditunggu sampai tuntas

        System.out.println("task selesai : " + selesai);
        System.out.println("jumlah hasil : " + total);
    }
}

VirtualThreadDasar.java

Unduh berkas
java
public class VirtualThreadDasar {
    public static void main(String[] args) throws InterruptedException {
        String[] hasil = new String[5];

        // Bentuk 1: bangun dan mulai virtual thread dengan nama.
        Thread kurator1 = Thread.ofVirtual().name("kurator-1").start(() ->
                hasil[0] = "katalog diproses");
        Thread kurator2 = Thread.ofVirtual().name("kurator-2").start(() ->
                hasil[1] = "tag diperbarui");

        // Bentuk 2: builder dipakai ulang, nama berurutan dibuat otomatis.
        Thread.Builder.OfVirtual pembangun = Thread.ofVirtual().name("pekerja-", 0);
        Thread pekerja0 = pembangun.start(() -> hasil[2] = "berkas-1 diarsipkan");
        Thread pekerja1 = pembangun.start(() -> hasil[3] = "berkas-2 diarsipkan");

        // Pembanding: thread platform biasa (mahal, dikelola OS).
        Thread pengawas = Thread.ofPlatform().name("pengawas").start(() ->
                hasil[4] = "audit selesai");

        Thread[] semua = {kurator1, kurator2, pekerja0, pekerja1, pengawas};
        for (Thread t : semua) {
            t.join();
        }
        for (int i = 0; i < semua.length; i++) {
            System.out.println(semua[i].getName() + " -> " + hasil[i]
                    + " (virtual: " + semua[i].isVirtual() + ")");
        }
    }
}