Jika Anda pernah membandingkan eksperimen menjalankan 200 thread versus 10.000 thread di Java, inti pelajarannya bukan sekadar “Java sekarang bisa membuat thread jauh lebih banyak”. Pelajaran yang lebih berguna adalah: virtual threads membuat model one task per thread menjadi realistis kembali untuk beban kerja yang sering blocking, terutama pada worker internal, task runner, tooling developer, dan job CI.
Dalam konteks artikel rujukan eksperimen 10.000 thread, poin pentingnya bukan angka pastinya, melainkan perubahan karakteristik runtime. Dengan platform thread, menambah thread biasanya cepat memukul memori, context switching, dan overhead scheduling. Dengan virtual threads Java, ribuan task yang banyak menunggu I/O dapat dijalankan lebih efisien karena thread virtual tidak selalu mengikat satu thread OS selama ia sedang menunggu.
Apa masalah yang diselesaikan virtual threads?
Banyak tooling internal dan pipeline CI punya pola yang mirip:
- Memanggil banyak endpoint HTTP internal atau API eksternal.
- Membaca dan menulis file dalam jumlah besar.
- Menjalankan proses eksternal seperti compiler, linter, test runner, atau CLI lain.
- Mengakses database, object storage, message broker, atau artefak build.
- Menunggu hasil dari banyak task kecil secara paralel.
Pada pola seperti ini, bottleneck utamanya sering bukan CPU murni, melainkan waktu tunggu. Di sinilah virtual threads berguna: kode tetap terlihat sederhana seperti gaya blocking biasa, tetapi runtime bisa menjadwalkan task dengan jauh lebih padat dibanding platform thread tradisional.
Ringkasnya: jika worker atau job CI Anda banyak waiting, virtual threads berpotensi meningkatkan throughput dan menyederhanakan kode dibanding harus memaksa semua hal menjadi async callback atau reactive.
Platform thread vs virtual thread
Platform thread
Platform thread adalah thread Java tradisional yang dipetakan ke thread OS. Keunggulannya matang, perilakunya mudah dipahami, dan cocok untuk pekerjaan yang butuh benar-benar berjalan paralel pada CPU. Kekurangannya, membuat sangat banyak platform thread mahal karena setiap thread membawa overhead memori dan scheduling dari OS.
Virtual thread
Virtual thread adalah thread ringan yang dikelola runtime Java. Ia tetap terlihat seperti Thread dari sudut pandang developer, tetapi penjadwalannya tidak satu-banding-satu dengan thread OS. Saat task melakukan blocking pada operasi yang didukung, virtual thread dapat unmount dari carrier thread, sehingga carrier thread bisa dipakai task lain.
Carrier thread
Carrier thread adalah platform thread yang dipakai JVM untuk mengeksekusi virtual thread. Anda bisa membayangkannya sebagai “kendaraan” tempat virtual thread berjalan ketika sedang aktif. Saat virtual thread memanggil operasi blocking yang dapat dikelola dengan baik oleh runtime, virtual thread dapat berhenti menempel pada carrier thread. Ini inti efisiensinya.
Akibatnya, Anda bisa punya sangat banyak virtual thread aktif secara logis, tanpa harus punya jumlah thread OS yang sama banyak.
Kapan virtual threads Java cocok untuk worker dan CI?
Cocok untuk beban I/O blocking
Gunakan virtual threads jika mayoritas waktu task Anda dihabiskan untuk:
- HTTP call ke service lain.
- JDBC/database query yang bersifat menunggu respons.
- Akses file, artefak build, cache, atau object storage.
- Menunggu proses eksternal selesai.
- Task orchestration dengan banyak unit kerja independen.
Contoh nyata:
- Worker internal yang membaca daftar pekerjaan lalu memanggil API internal satu per satu secara paralel.
- Task runner yang harus mengunduh banyak dependensi, memverifikasi checksum, lalu menjalankan command.
- Job CI yang mengecek banyak service, menjalankan banyak test shard, atau mengumpulkan metadata dari banyak sumber.
Kurang cocok untuk beban CPU-bound
Virtual threads bukan solusi ajaib untuk pekerjaan yang benar-benar menghabiskan CPU, misalnya:
- Kompresi besar-besaran.
- Enkripsi berat.
- Image/video processing.
- Analisis data yang dominan komputasi.
Untuk workload seperti ini, menambah ribuan virtual thread tidak otomatis meningkatkan throughput. CPU fisik tetap batas utamanya. Untuk CPU-bound, batasi paralelisme sesuai jumlah core dan karakteristik mesin.
Heuristik cepat
- Jika task Anda lebih banyak menunggu daripada menghitung, virtual threads layak dicoba.
- Jika task Anda lebih banyak menghitung daripada menunggu, fokuslah ke pembatasan concurrency, profiling CPU, dan desain batch yang efisien.
Dampak pada throughput: kenapa bisa naik?
Peningkatan throughput biasanya datang dari dua hal:
- Lebih banyak task blocking bisa hidup bersamaan tanpa biaya setinggi platform thread.
- Kode tetap model sinkron, sehingga implementasi paralel sering lebih sederhana dan lebih sedikit overhead koordinasi dibanding style async yang kompleks.
Misalnya sebuah worker harus memproses 5.000 item, dan tiap item melakukan HTTP request lalu menulis hasil ke storage. Dengan thread pool tradisional berukuran kecil, banyak item harus antre. Dengan virtual threads, Anda bisa menjalankan satu item per thread secara lebih langsung, selama downstream system tetap dibatasi dengan benar.
Penting: throughput aplikasi belum tentu naik jika bottleneck justru berada di luar proses Java, misalnya database connection pool terlalu kecil, rate limit API ketat, disk lambat, atau service downstream sudah jenuh.
Contoh implementasi praktis
1. Worker internal dengan executor virtual thread
Contoh berikut menunjukkan pola yang umum: satu task per item kerja. Kodenya tetap mudah dibaca karena memakai gaya blocking biasa.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class WorkerApp {
private static final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
public static void main(String[] args) throws Exception {
List<String> jobIds = List.of("a1", "a2", "a3");
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = jobIds.stream()
.map(jobId -> executor.submit(processJob(jobId)))
.toList();
for (Future<String> future : futures) {
System.out.println(future.get());
}
}
}
static Callable<String> processJob(String jobId) {
return () -> {
var request = HttpRequest.newBuilder()
.uri(URI.create("https://internal-api.example/jobs/" + jobId))
.timeout(Duration.ofSeconds(10))
.GET()
.build();
var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 400) {
throw new IllegalStateException("HTTP " + response.statusCode() + " for job " + jobId);
}
// Simulasi write ke file/db/storage
storeResult(jobId, response.body());
return "done:" + jobId;
};
}
static void storeResult(String jobId, String body) throws InterruptedException {
Thread.sleep(50);
}
}Kenapa pendekatan ini menarik?
- Setiap item kerja bisa ditulis sebagai alur sinkron biasa.
- Anda tidak perlu memecah logika menjadi callback bertingkat.
- Task yang sedang menunggu I/O tidak harus memonopoli platform thread.
2. Batasi concurrency downstream, bukan hanya jumlah thread
Kesalahan umum saat beralih ke virtual threads adalah menganggap “kalau bisa 10.000 thread, berarti boleh 10.000 request paralel”. Itu berbahaya. Tetap pasang limit terhadap resource yang benar-benar terbatas.
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
public class BoundedWorker {
private static final Semaphore apiLimit = new Semaphore(100);
public static void main(String[] args) throws Exception {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
final int jobId = i;
executor.submit(() -> {
apiLimit.acquire();
try {
callRemoteApi(jobId);
} finally {
apiLimit.release();
}
});
}
}
}
static void callRemoteApi(int jobId) throws InterruptedException {
Thread.sleep(200);
}
}Yang dibatasi di sini bukan jumlah virtual thread, melainkan akses ke downstream API. Ini biasanya lebih realistis untuk production.
3. Menjalankan proses eksternal pada tooling/CI
Virtual threads juga cocok saat task Anda banyak menunggu proses eksternal, misalnya memanggil linter, compiler, scanner, atau command internal.
import java.io.IOException;
import java.util.List;
import java.util.concurrent.Executors;
public class CiRunner {
public static void main(String[] args) throws Exception {
List<List<String>> commands = List.of(
List.of("sh", "-c", "echo test-a && sleep 1"),
List.of("sh", "-c", "echo test-b && sleep 1")
);
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = commands.stream()
.map(cmd -> executor.submit(() -> runCommand(cmd)))
.toList();
for (var future : futures) {
int exitCode = future.get();
if (exitCode != 0) {
throw new IllegalStateException("Command failed with exit code " + exitCode);
}
}
}
}
static int runCommand(List<String> command) throws IOException, InterruptedException {
Process process = new ProcessBuilder(command)
.inheritIO()
.start();
return process.waitFor();
}
}Untuk task runner internal, pola ini sering jauh lebih sederhana dibanding membangun orchestrator async manual.
Blocking I/O dan mengapa virtual threads efektif
Efektivitas virtual threads terutama terasa pada blocking I/O. Saat thread menunggu respons jaringan, file system, atau operasi blocking lain yang didukung runtime, virtual thread dapat dilepas dari carrier thread. JVM lalu bisa memakai carrier thread itu untuk task lain.
Dengan model ini, operasi yang secara logis tampak “memblokir” dari sisi kode Java belum tentu memblokir resource OS dengan cara yang sama seperti platform thread klasik.
Karena itu, virtual threads memberi dua keuntungan sekaligus:
- Model pemrograman sederhana: tetap sinkron dan linear.
- Utilisasi runtime lebih baik pada beban kerja yang banyak menunggu.
Risiko pinning: batasan penting di production
Apa itu pinning?
Pinning terjadi ketika virtual thread tidak bisa dilepas dari carrier thread pada saat ia sedang blocking. Akibatnya, keunggulan elastisitasnya berkurang karena carrier thread tetap tertahan.
Penyebab umum
Secara praktis, risiko pinning sering muncul saat:
- Ada blok
synchronizedyang panjang lalu di dalamnya terjadi operasi blocking. - Kode native atau library tertentu menahan thread dengan cara yang tidak ramah terhadap virtual thread.
- Bagian kritis terlalu besar dan mencampur locking dengan I/O.
Contoh yang perlu dihindari
class BadExample {
private final Object lock = new Object();
void doWork() throws InterruptedException {
synchronized (lock) {
// Hindari blocking lama di dalam synchronized
Thread.sleep(1000);
}
}
}Lebih aman mengecilkan area lock dan memisahkan I/O dari bagian sinkronisasi:
class BetterExample {
private final Object lock = new Object();
private String state;
void doWork() throws InterruptedException {
String snapshot;
synchronized (lock) {
snapshot = state;
}
// Blocking dilakukan di luar lock
Thread.sleep(1000);
synchronized (lock) {
state = snapshot + ":updated";
}
}
}Dampak pinning
- Throughput tidak naik seperti yang diharapkan.
- Carrier thread bisa habis lebih cepat.
- Latensi task meningkat saat banyak thread tertahan di area sinkronisasi.
Jadi, migrasi ke virtual threads bukan hanya mengganti executor. Anda juga perlu meninjau pola locking dan library yang dipakai.
Kapan tidak sebaiknya memakai virtual threads?
- CPU-bound heavy jobs yang seharusnya dibatasi sesuai core.
- Hot path dengan locking intens yang sulit diubah.
- Library lama yang belum teruji pada virtual thread atau banyak memakai native blocking yang tidak jelas perilakunya.
- Workload yang sudah efisien dengan event loop/reactive dan tidak punya masalah kompleksitas maintenance.
Virtual threads bukan berarti semua model concurrency lama harus dibuang. Untuk beberapa sistem, thread pool platform biasa tetap lebih mudah diprediksi, terutama jika paralelisme memang kecil dan workload didominasi CPU.
Cara mengukur hasil dengan benchmark sederhana
Jangan mulai dari benchmark mikro yang tidak relevan. Untuk worker dan CI, benchmark yang berguna biasanya adalah benchmark beban kerja representatif.
Apa yang diukur
- Total waktu menyelesaikan N task.
- Throughput task per detik atau per menit.
- Latensi p50/p95 jika relevan.
- Penggunaan CPU dan memori.
- Jumlah error, timeout, atau retry.
- Dampak pada downstream: DB pool, API rate limit, file descriptor, network saturation.
Skenario benchmark yang realistis
- Buat task yang mewakili operasi nyata, misalnya HTTP + parse + write file kecil.
- Jalankan dengan platform thread pool terbatas, misalnya fixed pool.
- Jalankan dengan virtual thread per task.
- Tambahkan batas concurrency downstream yang sama pada kedua skenario.
- Bandingkan waktu total, error rate, dan resource mesin.
Contoh harness sederhana
import java.time.Duration;
import java.time.Instant;
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 SimpleBenchmark {
public static void main(String[] args) throws Exception {
int tasks = 5_000;
run("platform-fixed-200", Executors.newFixedThreadPool(200), tasks);
run("virtual-per-task", Executors.newVirtualThreadPerTaskExecutor(), tasks);
}
static void run(String name, ExecutorService executor, int tasks) throws Exception {
try (executor) {
Instant start = Instant.now();
List<Future<?>> futures = new ArrayList<>();
for (int i = 0; i < tasks; i++) {
futures.add(executor.submit(() -> {
simulatedBlockingTask();
return null;
}));
}
for (Future<?> f : futures) {
f.get();
}
Duration duration = Duration.between(start, Instant.now());
System.out.println(name + " took " + duration.toMillis() + " ms");
}
}
static void simulatedBlockingTask() {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}Benchmark ini sengaja sederhana, tetapi cukup untuk memvalidasi hipotesis dasar: apakah workload Anda memang didominasi waiting. Jangan menganggap hasil benchmark sintetis ini langsung mewakili production; pakai sebagai indikasi awal, lalu lanjutkan ke uji integrasi yang lebih nyata.
Kesalahan umum saat benchmarking
- Membandingkan jumlah thread berbeda tanpa menyamakan limit downstream.
- Mengukur task palsu yang hanya
sleeplalu menyimpulkan semua I/O nyata akan sama. - Tidak memonitor CPU, memori, GC, dan antrian connection pool.
- Tidak memisahkan throughput Java dari bottleneck service luar.
Integrasi logging dan observability
Salah satu keuntungan virtual threads adalah model request/task per thread kembali masuk akal. Ini membantu logging dan tracing karena konteks eksekusi tetap terasa linear.
Praktik logging
- Sertakan job ID, pipeline step, atau correlation ID di setiap log.
- Jangan bergantung pada nama thread sebagai identitas utama, karena jumlah virtual thread bisa sangat besar dan dinamis.
- Gunakan logging terstruktur agar mudah difilter di CI dan worker fleet.
Contoh sederhana dengan konteks task
import java.util.UUID;
import java.util.concurrent.Executors;
public class LoggingExample {
public static void main(String[] args) throws Exception {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 3; i++) {
String jobId = UUID.randomUUID().toString();
executor.submit(() -> {
log(jobId, "start");
Thread.sleep(100);
log(jobId, "done");
return null;
});
}
}
}
static void log(String jobId, String message) {
System.out.printf("jobId=%s message=%s%n", jobId, message);
}
}Apa yang perlu dipantau
- Jumlah task aktif dan task selesai.
- Durasi per tahap: fetch, process, store, external command.
- Timeout, retry, cancellation, dan rejection.
- Antrian di connection pool database atau HTTP client pool.
- Error yang berkaitan dengan file descriptor, socket, atau limit OS lain.
Untuk observability yang lebih matang, gabungkan metrik aplikasi, log terstruktur, dan tracing per job. Fokus utamanya adalah apakah virtual threads membantu menyelesaikan lebih banyak kerja dengan resource yang tetap aman.
Checklist adopsi untuk pipeline/build tool atau task runner internal
- Identifikasi task yang dominan blocking I/O.
- Petakan semua downstream limit: DB pool, rate limit API, koneksi jaringan, file descriptor, proses eksternal.
- Ganti executor lama dengan executor virtual thread pada jalur yang cocok.
- Tambahkan concurrency guard seperti semaphore untuk resource terbatas.
- Tinjau blok
synchronizeddan area lock yang mengelilingi I/O. - Uji library eksternal yang dipakai worker atau tool Anda.
- Tambahkan timeout yang eksplisit pada HTTP, DB, dan proses eksternal.
- Pastikan cancellation dan interrupt ditangani dengan benar.
- Tambahkan metrik throughput, latensi, error, dan saturation downstream.
- Lakukan benchmark representatif sebelum dan sesudah migrasi.
Panduan rollout aman di production
1. Mulai dari use case sempit
Jangan langsung memindahkan seluruh aplikasi. Pilih worker atau step CI yang:
- jelas I/O-bound,
- mudah diukur,
- punya blast radius kecil.
2. Pertahankan limit concurrency eksternal
Virtual threads mempermudah concurrency, tetapi bukan alasan untuk menghapus semua pembatas. Kalau sebelumnya API eksternal aman di 50 request paralel, tetap hormati batas itu.
3. Aktifkan fallback
Jika memungkinkan, simpan opsi konfigurasi untuk kembali ke executor lama. Ini memudahkan rollback saat ada masalah library, deadlock, timeout tak terduga, atau saturasi downstream.
4. Monitor gejala yang relevan
- Lonjakan timeout ke service lain.
- Peningkatan penggunaan koneksi DB.
- Antrian task yang tidak turun.
- Thread dump yang menunjukkan blocking di area sinkronisasi.
- Peningkatan latensi tanpa kenaikan throughput.
5. Audit cancellation dan error handling
Pada worker dan CI, task gagal harus mudah dihentikan dan dibersihkan. Pastikan interrupt tidak diabaikan, proses eksternal bisa diterminasi, dan resource ditutup dengan benar.
Kesalahan implementasi yang sering terjadi
- Menyamakan virtual threads dengan unlimited concurrency.
- Tidak memberi timeout pada operasi jaringan dan proses eksternal.
- Membiarkan blocking I/O di dalam synchronized sehingga memicu pinning.
- Tidak menguji library lama yang perilakunya kurang jelas pada model ini.
- Hanya melihat angka thread, bukan throughput end-to-end dan kesehatan downstream.
Kesimpulan
Virtual threads Java untuk worker dan CI yang lebih efisien paling masuk akal saat workload Anda didominasi operasi blocking: HTTP, database, file, storage, atau proses eksternal. Keuntungan utamanya bukan sekadar bisa membuat 10.000 thread, melainkan bisa mempertahankan model kode sinkron yang sederhana sambil meningkatkan pemanfaatan runtime untuk beban I/O-heavy.
Namun, adopsinya harus disiplin. Anda tetap perlu membatasi akses ke resource downstream, menghindari pinning akibat locking yang buruk, dan mengukur hasil dengan benchmark yang representatif. Jika dilakukan dengan benar, virtual threads bisa menjadi peningkatan praktis untuk tooling developer, worker internal, dan pipeline CI tanpa harus memaksa seluruh kode menjadi reactive atau callback-heavy.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!