Virtual thread diperkenalkan Java 21 sebagai thread yang dijadwalkan JVM sendiri, bukan oleh sistem operasi, sehingga biaya pembuatannya mendekati biaya membuat objek biasa. Konsekuensinya besar, satu program dapat menjalankan ribuan sampai ratusan ribu tugas konkuren dengan jumlah thread sistem operasi yang tetap kecil. Topik ini memperdalam pengenalan singkatnya, yaitu cara kerja carrier thread, cacat pinning yang ditimbulkan synchronized pada JDK 21, serta penilaian kapan platform thread justru masih lebih tepat dipakai.
Konkurensi masif dengan virtual thread#
Gaya kerja virtual thread adalah satu tugas satu thread. Alih-alih mengelola antrian tangan sendiri, tugas setiap permintaan cukup diluncurkan sebagai virtual thread dan dibiarkan JVM mengaturnya. Demo berikut meluncurkan sepuluh ribu virtual thread sekaligus, masing-masing menghitung jumlah 1 sampai 100 dan menulis hasilnya ke slot array miliknya, lalu menunggu semuanya selesai dengan join().
public class DemoRibuanThread {
static final int JUMLAH = 10_000;
public static void main(String[] args) throws InterruptedException {
long[] hasil = new long[JUMLAH];
Thread[] tugas = new Thread[JUMLAH];
for (int i = 0; i < JUMLAH; i++) {
int indeks = i;
tugas[i] = Thread.ofVirtual().start(() -> {
long total = 0;
for (int j = 1; j <= 100; j++) {
total += j;
}
hasil[indeks] = total;
});
}
for (Thread t : tugas) {
t.join();
}
boolean semuaBenar = true;
for (long h : hasil) {
if (h != 5050) {
semuaBenar = false;
}
}
System.out.println("Virtual thread yang diluncurkan : " + JUMLAH);
System.out.println("Semua hasil benar : " + semuaBenar);
}
}Virtual thread yang diluncurkan : 10000
Semua hasil benar : trueSepuluh ribu thread selesai dalam waktu yang singkat karena virtual thread tidak memakai satu thread sistem operasi per tugas. Objeknya ringan, stack-nya disimpan di heap dan diperkecil bila tidak dipakai, sehingga membuat sepuluh ribu bukan hal yang istimewa. Tetap saja, semua thread itu bersaing mengakses data bersama seperti thread lain, sehingga aturan sinkronisasi dari topik Sinkronisasi berlaku penuh dan array hasil di atas sengaja diberi slot terpisah agar bebas tabrakan.
Carrier thread dan pemindahan tugas#
Virtual thread tidak berjalan sendiri, melainkan dipasang pada carrier thread, yaitu platform thread milik pool ForkJoinPool internal JVM. Ketika tugasnya melakukan operasi yang menunggu, misalnya sleep, pembacaan data dari jaringan, atau menunggu ReentrantLock, virtual thread diturunkan dari carrier-nya sehingga carrier bebas menjalankan tugas lain. Perpindahan ini disebut mount dan unmount, dan terjadi tanpa diketahui kode tugasnya.
Mekanisme inilah yang membuat sepuluh ribu tugas menunggu hanya membutuhkan beberapa carrier. Selama tugas yang sedang jalan terus berpindah saat menunggu, jumlah platform thread tidak perlu tumbuh mengikuti jumlah tugasnya. Persoalan muncul tepat ketika sebuah virtual thread tidak dapat diturunkan, karena carrier-nya terpaksa ikut menunggu dan cadangan carrier itu terbatas jumlahnya.
Pinning oleh synchronized#
Virtual thread yang memblokir di dalam blok atau metode synchronized tidak dapat diturunkan dari carrier-nya pada JDK 21, dan keadaan itu disebut pinning. Demonya dijaga tetap adil dengan memakai monitor pribadi untuk setiap tugas, sehingga tidak ada persaingan antar-tugas sama sekali, dan satu-satunya perbedaan kedua versi adalah keberadaan blok synchronized di sekitar tidurnya.
import java.time.Duration;
public class DemoPinning {
static final int JUMLAH = 10;
static void tidurSingkat() {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
static long jalankan(Runnable[] tugas) throws InterruptedException {
Thread[] daftar = new Thread[JUMLAH];
long mulai = System.nanoTime();
for (int i = 0; i < JUMLAH; i++) {
daftar[i] = Thread.ofVirtual().start(tugas[i]);
}
for (Thread t : daftar) {
t.join();
}
return Duration.ofNanos(System.nanoTime() - mulai).toMillis();
}
public static void main(String[] args) throws InterruptedException {
final Object[] kunciPribadi = new Object[JUMLAH];
Runnable[] denganMonitor = new Runnable[JUMLAH];
Runnable[] tanpaMonitor = new Runnable[JUMLAH];
for (int i = 0; i < JUMLAH; i++) {
kunciPribadi[i] = new Object();
final Object kunci = kunciPribadi[i];
denganMonitor[i] = () -> {
synchronized (kunci) {
tidurSingkat();
}
};
tanpaMonitor[i] = DemoPinning::tidurSingkat;
}
long berpinning = jalankan(denganMonitor);
long normal = jalankan(tanpaMonitor);
System.out.println("Dengan blok synchronized : berjalan berurutan? " + (berpinning > 600));
System.out.println("Tanpa blok synchronized : berjalan serentak? " + (normal < 600));
}
}Dengan blok synchronized : berjalan berurutan? true
Tanpa blok synchronized : berjalan serentak? trueVersi tanpa monitor berakhir sekitar 200 ms, yaitu durasi satu tidur, karena sepuluh tugas diturunkan saat menunggu dan hanya singkat menumpang carrier. Versi bermonitor berakhir sekitar lima kali lipat lebih lama, karena setiap tugas yang terpaku menyita satu carrier, sementara cadangan carrier hanya mengikuti jumlah inti, yaitu dua pada mesin pengujian. Sepuluh tugas pun berjalan bergelombang dua-dua, bukan serentak. Angka pastinya berubah antar mesin, tetapi selisih berlipatnya tidak, dan itulah yang dibaca keluaran di atas.
Sebagai catatan masa depan, JDK 24 melonggarkan batasan ini lewat JEP 491 sehingga virtual thread dapat diturunkan meski sedang memegang monitor. Pada JDK 21 dan 22, kehati-hatian terhadap synchronized di jalur yang sering menunggu tetap perlu.
Kapan platform thread masih unggul#
Pilihan berpindah ke platform thread tetap masuk akal pada tiga keadaan. Pertama, tugas yang berat pada prosesor tidak dibantu oleh virtual thread, karena jumlah tenaga komputasi tetap dibatasi jumlah inti, dan melebihi jumlah inti hanya menambah biaya pemindahan konteks. Kedua, kode yang terpaku atau memanggil fungsi native lewat JNI tetap menyita carrier, sehingga jumlah tugas serupa sebaiknya tidak diluncurkan beramai-ramai. Ketiga, fitur yang mengendalikan thread sistem operasi secara langsung, misalnya prioritas, hanya berlaku pada platform thread.
Sebaliknya, bila kebutuhannya adalah banyak tugas yang banyak menunggu, yaitu memanggil layanan, membaca berkas, atau menunggu antrean, virtual thread adalah pilihan yang paling hemat. Perbandingan berikut memperlihatkan mengapa versi platformnya tidak layak dijalankan untuk skala yang sama.
// tidak-dijalankan
Thread[] tugas = new Thread[10_000];
for (int i = 0; i < tugas.length; i++) {
tugas[i] = Thread.ofPlatform().unstarted(() -> {
// pekerjaan yang sama dengan versi virtual thread
});
}
for (Thread t : tugas) {
t.start();
}
for (Thread t : tugas) {
t.join();
}Setiap platform thread menyita memori untuk stack-nya sejak dibuat, sehingga sepuluh ribu thread sekaligus memakan memori hingga mencapai gigabait dan sering berakhir OutOfMemoryError sebelum semua tugas sempat berjalan. Virtual thread mengalokasikan stack-nya di heap dan tumbuh sesuai kebutuhan, sehingga skala yang sama terasa ringan. Perbedaan inilah yang membuat gaya satu tugas satu thread praktis hanya setelah virtual thread ada.
Kesalahan umum#
- Memasukkan virtual thread ke dalam pool. Thread pool dirancang untuk thread yang mahal, sedangkan virtual thread murah untuk dibuat dan dibuang. Gunakan satu virtual thread per tugas atau
Executors.newVirtualThreadPerTaskExecutor(). - Mengira virtual thread mempercepat perhitungan. Yang bertambah adalah jumlah tugas yang dapat menunggu bersamaan, bukan kekuatan prosesornya. Tugas berat CPU tetap dibatasi jumlah inti.
- Membiarkan
synchronizedpanas tanpa disadari. Blok yang jarang berebut tidak berbahaya, tetapi blok yang dipegang selama operasi lambat membuat virtual thread terpaku. Pindahkan penjagaan semacam itu keReentrantLock. - Mengira thread yang banyak menghapus kebutuhan sinkronisasi. Sepuluh ribu virtual thread yang menulis satu objek bersama tetap menghasilkan race condition, sehingga aturan dari topik sinkronisasi tetap berlaku.
Ringkasan#
- Virtual thread dijadwalkan JVM di atas carrier thread, disimpan di heap, dan murah dibuat hingga puluhan ribu per program.
- Operasi yang menunggu menurunkan virtual thread dari carrier-nya, sehingga sedikit platform thread melayani banyak tugas.
- Virtual thread yang memblokir di dalam
synchronizedterpaku pada carrier-nya di JDK 21, dan efeknya terukur langsung dengan demo monitor pribadi. - Platform thread tetap lebih tepat untuk tugas berat prosesor, kode yang terpaku atau native, dan kendali thread sistem operasi secara langsung.
Referensi#
- JEP 444: Virtual Threads — OpenJDK — proposal final virtual thread di JDK 21, termasuk aturan pinning dan penjadwalnya.
- JEP 491: Synchronize Virtual Threads without Pinning — OpenJDK — perbaikan pinning pada JDK 24 yang dirujuk sebagai catatan masa depan.
- Class Thread — Oracle
— dokumentasi resmi
ofVirtual,ofPlatform, dan pembangun thread-nya.