Tampilan
Kenapa penting
Server Java tradisional memakai model thread-per-request: satu request ditangani satu thread dari pool, biasanya berisi sekitar 200 thread. Sebagian besar waktu thread itu habis untuk menunggu — query database, panggilan HTTP ke layanan lain, membaca file. Selama menunggu, thread tetap memakan memori dan slot pool. Begitu 200 thread sibuk menunggu, request ke-201 harus antre walaupun CPU menganggur. Dulu solusinya adalah pemrograman reaktif yang rumit. Virtual thread (final sejak Java 21, JEP 444) memberi jalan lain: tetap menulis kode blocking yang mudah dibaca, tetapi bisa menjalankan puluhan ribu tugas yang menunggu secara bersamaan. Di Java 25 dan Spring Boot 4, mengaktifkannya cukup satu baris konfigurasi — tetapi kamu perlu paham batasnya agar tidak memindahkan masalah ke tempat lain.
Konsep inti
Platform thread vs virtual thread
| Aspek | Platform thread | Virtual thread |
|---|---|---|
| Dipetakan ke | Satu thread sistem operasi (1:1) | Banyak virtual thread berbagi sedikit carrier thread |
| Biaya pembuatan | Mahal (stack besar, panggilan ke OS) | Murah, seperti membuat objek biasa |
| Jumlah wajar | Ratusan sampai beberapa ribu | Ratusan ribu sampai jutaan |
| Saat blocking I/O | Thread OS ikut diam menunggu | Dilepas dari carrier; carrier menjalankan tugas lain |
| Daemon | Bisa dipilih | Selalu daemon |
| Perlu di-pool? | Ya | Tidak — buat satu per tugas |
Kuncinya ada di baris "Saat blocking I/O". Ketika virtual thread memanggil Thread.sleep, membaca socket, atau menunggu lock dari java.util.concurrent, JVM menyimpan stack-nya di heap dan melepaskannya dari carrier thread. Carrier itu langsung dipakai virtual thread lain. Saat I/O selesai, virtual thread dilanjutkan di carrier mana pun yang sedang bebas.
I/O-bound vs CPU-bound
Virtual thread bukan thread yang lebih cepat. Ia menaikkan throughput (jumlah tugas yang bisa berjalan bersamaan), bukan kecepatan per tugas.
- I/O-bound (memanggil database, HTTP, antrean pesan): sangat diuntungkan. Ribuan tugas bisa menunggu bersamaan.
- CPU-bound (kompresi, hashing, kalkulasi berat): tidak ada untungnya. Pekerjaan dibatasi jumlah core; tetap pakai
newFixedThreadPoolseukuran jumlah core seperti di bab sebelumnya.
Membuat virtual thread
Ada tiga cara langsung, ditambah executor per tugas yang paling sering dipakai:
java
public class VirtualBasics {
public static void main(String[] args) throws InterruptedException {
Thread vt = Thread.ofVirtual().name("order-worker").start(() -> {
Thread t = Thread.currentThread();
System.out.println(t.getName() + " virtual=" + t.isVirtual() + " daemon=" + t.isDaemon());
});
vt.join();
Thread pt = Thread.ofPlatform().name("legacy-worker").start(() ->
System.out.println(Thread.currentThread().getName()
+ " virtual=" + Thread.currentThread().isVirtual()));
pt.join();
Thread quick = Thread.startVirtualThread(() -> System.out.println("startVirtualThread jalan"));
quick.join();
}
}text
order-worker virtual=true daemon=true
legacy-worker virtual=false
startVirtualThread jalanKarena virtual thread selalu daemon, JVM tidak menunggunya saat main selesai — itulah sebabnya contoh di atas memanggil join().
Demo: 10.000 tugas yang menunggu
Program berikut menjalankan 10.000 tugas yang masing-masing "menunggu I/O" selama 100 ms, pertama di pool 200 platform thread, lalu dengan satu virtual thread per tugas.
java
import java.time.Duration;
import java.util.concurrent.*;
import java.util.stream.IntStream;
public class TenThousandTasks {
static long run(ExecutorService executor) {
long start = System.nanoTime();
try (executor) {
IntStream.range(0, 10_000).forEach(i -> executor.submit(() -> {
Thread.sleep(Duration.ofMillis(100)); // simulasi panggilan I/O
return i;
}));
} // close() menunggu 10.000 tugas selesai
return (System.nanoTime() - start) / 1_000_000;
}
public static void main(String[] args) {
System.out.println("platform (200 thread): " + run(Executors.newFixedThreadPool(200)) + " ms");
System.out.println("virtual per task : " + run(Executors.newVirtualThreadPerTaskExecutor()) + " ms");
}
}Hasil nyata di laptop 12 core (angkamu bisa sedikit berbeda):
text
platform (200 thread): 5029 ms
virtual per task : 147 msPool 200 thread memproses 50 gelombang × 100 ms ≈ 5 detik. Virtual thread menjalankan semuanya bersamaan, jadi total waktunya mendekati satu kali tunggu. Perhatikan juga bahwa try (executor) boleh dipakai pada variabel yang sudah ada (efektif final) — fitur try-with-resources yang sama dengan bab sebelumnya.
Pinning dan perbaikan synchronized (JEP 491)
Pinning terjadi saat virtual thread tidak bisa dilepas dari carrier ketika blocking, sehingga carrier ikut diam. Di Java 21–23, penyebab paling umum adalah blocking di dalam blok/method synchronized. Saran lama berbunyi "ganti synchronized dengan ReentrantLock".
Sejak Java 24 (JEP 491), synchronized tidak lagi mem-pin virtual thread. Di Java 25 kamu boleh memakai synchronized seperti biasa. Pinning masih bisa terjadi pada kasus yang jauh lebih jarang: saat kode native (method native atau Foreign Function & Memory API) memanggil balik kode Java yang blocking, dan saat blocking di dalam class initializer. Untuk mendeteksinya, JDK Flight Recorder mencatat event jdk.VirtualThreadPinned. Properti lama jdk.tracePinnedThreads sudah tidak diperlukan.
ThreadLocal dan ScopedValue
ThreadLocal tetap bekerja di virtual thread, tetapi pola lama "simpan objek mahal di ThreadLocal agar dipakai ulang" tidak lagi masuk akal: virtual thread tidak di-pool, jadi setiap tugas membuat salinan baru. Dengan jutaan virtual thread, memori bisa membengkak.
Untuk meneruskan konteks seperti ID request atau user yang sedang login, Java 25 menyediakan ScopedValue, yang final sejak Java 25 (JEP 506). Nilainya tidak bisa diubah, hanya terlihat di dalam scope run, dan otomatis hilang setelahnya — tanpa perlu remove().
java
public class ScopedValueDemo {
static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
static void chargePayment() {
System.out.println("[" + REQUEST_ID.get() + "] tagih pembayaran");
}
static void handle(String requestId) {
ScopedValue.where(REQUEST_ID, requestId).run(() -> {
System.out.println("[" + REQUEST_ID.get() + "] mulai checkout");
chargePayment();
});
}
public static void main(String[] args) throws InterruptedException {
Thread a = Thread.ofVirtual().start(() -> handle("req-001"));
Thread b = Thread.ofVirtual().start(() -> handle("req-002"));
a.join();
b.join();
System.out.println("di luar scope, isBound=" + REQUEST_ID.isBound());
}
}text
[req-002] mulai checkout
[req-002] tagih pembayaran
[req-001] mulai checkout
[req-001] tagih pembayaran
di luar scope, isBound=falseUrutan antar-request bisa berbeda setiap kali dijalankan, tetapi setiap request selalu melihat ID miliknya sendiri.
Preview
Structured concurrency (StructuredTaskScope, JEP 505) masih berstatus preview di Java 25, jadi hanya bisa dipakai dengan --enable-preview. Idenya: sub-tugas yang di-fork di dalam satu scope diperlakukan sebagai satu unit — scope.join() menunggu semuanya, dan bila satu gagal, sisanya dibatalkan. Ini alternatif yang lebih rapi untuk pola "panggil 3 layanan paralel" di bab sebelumnya. Jangan dipakai di kode produksi sampai statusnya final.
java
// Butuh: java --enable-preview --source 25 StructuredDemo.java
try (var scope = StructuredTaskScope.open()) {
Subtask<String> product = scope.fork(() -> fetchProduct());
Subtask<Integer> price = scope.fork(() -> fetchPrice());
scope.join(); // tunggu keduanya; bila satu gagal, yang lain dibatalkan
System.out.println(product.get() + " " + price.get());
}Contoh di dunia backend
Mengaktifkan di Spring Boot. Satu properti membuat Tomcat menangani setiap request di virtual thread, dan executor bawaan untuk @Async serta penjadwalan ikut memakai virtual thread:
yaml
# application.yaml — Potongan
spring:
threads:
virtual:
enabled: trueMembatasi akses ke sumber daya terbatas. Masalah yang sering muncul setelah migrasi: thread tidak lagi membatasi konkurensi, jadi sumber daya di belakangnya yang kewalahan — pool koneksi database, atau gateway pembayaran yang hanya mengizinkan 50 koneksi bersamaan. Jangan kembali ke pool thread; batasi aksesnya dengan Semaphore. Contoh berikut menagih 1.000 pesanan sekaligus dengan virtual thread, tetapi paling banyak 50 panggilan ke gateway yang berjalan bersamaan.
java
import java.time.Duration;
import java.util.List;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.IntStream;
public class PaymentBatch {
// Gateway pembayaran hanya mengizinkan 50 koneksi bersamaan
private static final Semaphore GATEWAY_PERMITS = new Semaphore(50);
private static final AtomicInteger inFlight = new AtomicInteger();
private static final AtomicInteger maxInFlight = new AtomicInteger();
record ChargeResult(String orderId, boolean success) {}
static ChargeResult charge(String orderId) throws InterruptedException {
GATEWAY_PERMITS.acquire();
try {
int now = inFlight.incrementAndGet();
maxInFlight.accumulateAndGet(now, Math::max);
Thread.sleep(Duration.ofMillis(50)); // simulasi HTTP ke gateway
return new ChargeResult(orderId, true);
} finally {
inFlight.decrementAndGet();
GATEWAY_PERMITS.release();
}
}
public static void main(String[] args) throws Exception {
List<String> orderIds = IntStream.rangeClosed(1, 1_000).mapToObj(i -> "ORD-" + i).toList();
long start = System.nanoTime();
int ok = 0;
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<ChargeResult>> futures = orderIds.stream()
.map(id -> executor.submit(() -> charge(id)))
.toList();
for (Future<ChargeResult> f : futures) {
if (f.get().success()) ok++;
}
}
long ms = (System.nanoTime() - start) / 1_000_000;
System.out.println("berhasil=" + ok + ", maks bersamaan=" + maxInFlight.get());
System.out.println("waktu ~" + (ms / 100 * 100) + " ms");
}
}text
berhasil=1000, maks bersamaan=50
waktu ~1000 ms1.000 pesanan / 50 slot × 50 ms ≈ 1 detik, dan gateway tidak pernah menerima lebih dari 50 koneksi. Semaphore memblokir dengan cara yang ramah virtual thread: yang menunggu izin dilepas dari carrier.
Pertanyaan interview
1. Apakah virtual thread membuat kode lebih cepat?
Tidak per tugas. Virtual thread menaikkan throughput untuk beban I/O-bound karena ribuan tugas bisa menunggu bersamaan tanpa menahan thread OS. Untuk beban CPU-bound tidak ada keuntungan, karena batasnya jumlah core.
2. Kenapa virtual thread tidak perlu di-pool?
Pool dibuat karena platform thread mahal. Virtual thread murah, jadi pola yang dianjurkan adalah satu virtual thread per tugas (newVirtualThreadPerTaskExecutor). Bila tujuan pool sebenarnya adalah membatasi akses ke sumber daya, gunakan Semaphore atau batas pool koneksi, bukan pool thread.
3. Apa itu pinning, dan apakah synchronized masih bermasalah di Java 25?
Pinning adalah kondisi virtual thread tidak bisa dilepas dari carrier saat blocking, sehingga carrier ikut diam. Sejak Java 24 (JEP 491) synchronized tidak lagi menyebabkan pinning. Kasus tersisa antara lain blocking lewat kode native yang memanggil balik Java dan blocking di class initializer; deteksi dengan event JFR jdk.VirtualThreadPinned.
Jebakan umum
- Mem-pool virtual thread.
newFixedThreadPoolberisi virtual thread menghilangkan manfaatnya. Pakai satu virtual thread per tugas. - Mengira semua jadi lebih cepat. Beban CPU-bound tidak membaik; ukur dulu sebelum dan sesudah migrasi.
- Lupa sumber daya di hilir. Pool koneksi database, rate limit API pihak ketiga, dan batas file descriptor tetap ada. Batasi dengan
Semaphoreatau ukuran pool, plus timeout. - Cache objek mahal di
ThreadLocal. Tanpa pool, setiap virtual thread membuat salinan baru. PakaiScopedValueuntuk konteks, dan objek bersama yang thread-safe untuk cache. - Saran usang soal
synchronized. Artikel era Java 21 menyuruh mengganti semuasynchronized— di Java 24+ itu tidak perlu lagi. - Memakai
StructuredTaskScopetanpa sadar statusnya. Di Java 25 masih preview; kode produksi jangan bergantung padanya.
Ringkasan
- Virtual thread murah dan dilepas dari carrier saat blocking, sehingga cocok untuk ribuan tugas I/O-bound.
- Buat dengan
Thread.ofVirtual(),Thread.startVirtualThread, atau — paling umum —Executors.newVirtualThreadPerTaskExecutor(); jangan di-pool. - Sejak Java 24
synchronizedtidak lagi mem-pin virtual thread; pantau sisa pinning dengan event JFR. ScopedValue(final di Java 25) adalah cara aman meneruskan konteks request; hati-hati memakaiThreadLocalsebagai cache.- Di Spring Boot cukup
spring.threads.virtual.enabled=true, lalu batasi akses ke database dan layanan luar. - Structured concurrency masih preview di Java 25 (JEP 505).