Praktik terbaik multithreading di lapangan: edisi Java — pola mapan di era virtual thread
· Diperbarui pada: · Go Komura · Multithreading, Java, Aplikasi bisnis, Investigasi bug, Desain
Riwayat revisi (1 pembaruan, terakhir pada 31 Aug 2026)
Catatan perubahan yang dilakukan pada artikel ini. Jika versi sebelumnya telah diarsipkan, versi itu tetap dapat dibaca melalui tautan permanen dengan DOI.
- Diterjemahkan ulang sebagai terjemahan lengkap dari naskah Jepang. Versi bahasa Indonesia sebelumnya adalah ringkasan yang hanya memindahkan sebagian naskah, sehingga bagian, tabel, gambar Mermaid, keterangan gambar, dan FAQ tidak ada. Semuanya dipulihkan sesuai naskah Jepang, dan klaim teknisnya sama dengan versi Jepang.
- Publikasi pertama
Mengutip artikel ini(DOI (arsip terdaftar): 10.5281/zenodo.22175934)
DOI di bawah mengarah ke versi yang telah diarsipkan sebelumnya dan mungkin berbeda dari teks saat ini. Gunakan URL halaman ini untuk merujuk teks saat ini.
Go Komura (2026). Praktik terbaik multithreading di lapangan: edisi Java — pola mapan di era virtual thread. KomuraSoft LLC. https://comcomponent.com/id/blog/multithreading-best-practices-java/
- DOI (arsip terdaftar)
- 10.5281/zenodo.22175934
- DOI (versi terakhir yang didaftarkan)
- 10.5281/zenodo.22175935
“Kami ingin memparalelkan batch sistem bisnis di Java.” “Cache bersama di aplikasi web Spring kadang rusak.” “Kami mewarisi aplikasi Swing lama yang penuh new Thread.” — Java sudah menanamkan multithreading ke dalam bahasa sejak JDK 1.0, memiliki kotak perkakas java.util.concurrent yang matang, dan dengan virtual thread di JDK 21 menulis ulang lagi kebiasaan pemrograman konkuren. Justru karena perkakasnya kaya, pilihan “mana yang dipakai” menjadi mutu desain itu sendiri.
Artikel ini adalah edisi Java dari seri multithreading di lapangan. Ditujukan kepada pengembang yang menulis sistem bisnis, batch, dan aplikasi server di Java, artikel ini menurunkan prinsip desain multithread — jangan membuat thread secara langsung, kurangi state mutable bersama, terapkan disiplin kunci, rancang cara berhenti sejak awal — ke perkakas Java (sasaran utama adalah LTS, JDK 21 dan setelahnya), lalu merangkum pemilahan di era virtual thread serta jebakan khas Java, berdasarkan sumber primer per Agustus 2026. Ditulis agar dapat dibaca sendiri. Prinsip yang sama, dijabarkan untuk bahasa lain, ada di “edisi .NET”, “edisi C++”, dan “edisi C”.
1. Kesimpulan dulu
- Jangan menulis
new Threaddi kode bisnis — aturan yang sama berlaku di Java. Serahkan tugas keExecutorServicedan biarkan pustaka mengelola siklus hidup thread.1 - Arahkan tugas yang didominasi menunggu I/O ke virtual thread. Virtual thread, yang resmi di JDK 21, dipakai dengan “satu virtual thread per tugas” dan tidak boleh pernah di-pool. Batasi jumlah eksekusi bersamaan dengan
Semaphore, bukan dengan ukuran pool.23 - Virtual thread adalah alat throughput, bukan alat untuk mempercepat komputasi. Paralelisasi CPU-bound tetap pekerjaan thread platform yang berukuran kira-kira sebanyak jumlah inti — pool tetap atau parallel stream, seperti sebelumnya.3
- Kunci pada objek kunci
private final, atau padaReentrantLockkhusus.synchronized(this)dan mengunci objek yang diekspos publik dapat bertabrakan dengan kode luar. Jika perolehan berbatas waktu (tryLock) diperlukan, pakaiReentrantLock. - Di JDK 21–23, pemblokiran di dalam
synchronizedmenempelkan virtual thread; JDK 24 (JEP 491) menyelesaikan itu. Bedakan peringatan lama dari kenyataan sekarang.34 volatilemenjamin visibilitas dan pengurutan, bukan keatomikan. PakaiAtomicInteger/LongAdderuntuk penghitung, dan kunci untuk state majemuk.5- Penghentian kooperatif lewat interruption adalah satu-satunya cara yang benar untuk berhenti.
Thread.stop/suspend/resumesekarang melemparUnsupportedOperationException. Jangan telanInterruptedException— pulihkan status atau lempar ulang.6 - Hentikan
ExecutorServicedengan pola dua tahap: shutdown → awaitTermination → shutdownNow.shutdownNowbersifat best-effort (implementasi standar bekerja lewat interruption), jadi ia mengandaikan tugas merespons interruption.1 - UI Swing milik eksklusif EDT (Event Dispatch Thread). Dari thread lain, minta pembaruan lewat
SwingUtilities.invokeLater.7
Pada diagram, garis utuh menunjukkan relasi yang selalu berlaku dan garis putus-putus menunjukkan relasi bersyarat (syaratnya ada pada penjelasan masing-masing relasi di halaman rincian). Daftar lengkap relasi (total 28, beserta bukti dan tingkat kepastian) serta definisi konsep utama dikumpulkan di halaman rincian peta pengetahuan (dalam bahasa Jepang). Data: JSON-LD / Turtle
2. Mengapa multithreading sulit — kondisi balapan, deadlock, dan model memori
Dipadatkan, masalah yang dibawa multithreading ada dua jenis, terlepas dari bahasanya.
Kondisi balapan (race condition) adalah bug di mana hasil berubah tergantung urutan beberapa thread mencapai potongan kode tertentu. Contoh klasiknya adalah penghitung bersama: satu ekspresi count++ sebenarnya terurai menjadi tiga langkah — baca, tambah, tulis kembali. Jika dua thread masuk ke tiga langkah itu pada saat yang sama, penambahan satu thread tertimpa dan hilang oleh penulisan kembali yang lain. Hasil berubah setiap kali dijalankan, dan hasil mana yang muncul tidak dapat diprediksi.
sequenceDiagram
participant A as Thread A
participant M as Variabel bersama count
participant B as Thread B
Note over M: count = 10
A->>M: Baca (10)
B->>M: Baca (10)
A->>A: Tambah lokal (11)
B->>B: Tambah lokal (11)
A->>M: Tulis kembali (11)
B->>M: Tulis kembali (11)
Note over M: Dua kali ditambah, tetapi count = 11<br/>penambahan Thread A hilang
Gambar 1: Kondisi balapan klasik di mana penambahan pada penghitung bersama hilang. Jika thread lain menyisip di antara tiga langkah count++, yang menulis kembali belakangan menimpa yang lain
Deadlock adalah keadaan di mana dua thread saling menunggu kunci yang dipegang pihak lain, sehingga keduanya tidak bisa maju. Thread A memegang kunci 1 dan menunggu kunci 2, thread B memegang kunci 2 dan menunggu kunci 1 — hanya itu, dan keduanya berhenti selamanya.
flowchart LR
A["Thread A<br/>sedang memegang kunci 1"] -->|"menunggu pelepasan kunci 2"| B["Thread B<br/>sedang memegang kunci 2"]
B -->|"menunggu pelepasan kunci 1"| A
Gambar 2: Tunggu sirkular pada deadlock. Begitu panah tunggu membentuk lingkaran, semua thread di dalam lingkaran itu berhenti selamanya
Keduanya bergantung pada timing. Kombinasi urutan eksekusi yang di mesin pengembangan hanya mengenai sekali dalam puluhan ribu kali, di server produksi dengan jumlah inti dan beban yang berbeda terjadi setiap hari. “Tidak mereproduksi begitu debugger dipasang” atau “hilang setelah log ditambah” juga karena pengamatan mengubah timing — itu perilaku khas bug persaingan. Karena itu, semua prinsip artikel ini mengarah ke mengurangi tempat yang perlu disinkronkan, sebelum menyinkronkannya dengan benar.
2.1. Premis khas Java — model memori dan happens-before
Di atas itu, yang khas Java adalah bahwa cara data bersama terlihat didefinisikan oleh relasi happens-before pada Java Memory Model (JMM).
Jika variabel bersama dibaca-tulis tanpa sinkronisasi, itu tidak menjadi “perilaku tak terdefinisi” seperti di C++, tetapi nilai lama terus terlihat, atau urutan penulisan tampak tertukar, adalah perilaku yang sah. Kesalahan konsistensi memori seperti “bendera boolean dilihat di dalam loop, tetapi nilai yang diubah thread lain tidak pernah terlihat” adalah perilaku yang diizinkan JMM, bukan bug JVM.5 Perkakas untuk mencegahnya adalah mekanisme yang membentuk happens-before — synchronized, volatile, dan kelas-kelas java.util.concurrent. Jika koleksi konkuren dipakai dengan benar, “happens-before antara operasi pembaruan dan pengambilan berikutnya” dijamin pustaka.8
Jadi pedoman praktis Java dapat diringkas begini. Jangan mengutak-atik variabel bersama mentah. Untuk berbagi, pakai perkakas java.util.concurrent, dan biarkan pustaka yang membentuk happens-before.
3. Cara membuat thread — ExecutorService dan virtual thread
3.1. Memisahkan tugas dari eksekusi
Di Java, prinsip “jangan membuat thread sendiri” diemban ExecutorService. Ia memisahkan pekerjaan (Runnable / Callable) dari cara menjalankannya (berapa thread, antrean mana), dan menyerahkan pembuatan, pemakaian ulang, serta pembuangan thread kepada pustaka.1
Mulai JDK 21, pemilihan sarana eksekusi menjadi dua pilihan yang sederhana.23
flowchart TB
S["Ada tugas yang ingin dijalankan secara konkuren"] --> Q1{"Apa yang mendominasi tugas?"}
Q1 -->|"Didominasi menunggu I/O<br/>panggilan HTTP, DB, berkas"| VT["Virtual thread<br/>Executors.newVirtualThreadPerTaskExecutor()<br/>Satu per tugas. Jangan di-pool"]
Q1 -->|"Komputasi yang memakai CPU"| PT["Pool tetap thread platform<br/>Executors.newFixedThreadPool(kira-kira jumlah inti)<br/>atau parallel stream"]
VT --> LIMIT["Batasi jumlah bersamaan ke layanan eksternal<br/>dengan Semaphore, bukan dengan pool"]
Gambar 3: Pemilihan sarana eksekusi mulai JDK 21. Tarik dulu garis “I/O mengubah cara menunggu, CPU diparalelkan”; I/O-bound ditangani virtual thread, CPU-bound ditangani pool konvensional
Ada satu catatan di sisi CPU-bound. Executors.newFixedThreadPool memang membatasi jumlah thread, tetapi antrean tunggunya tidak terbatas. Pada layanan yang selalu hidup di mana masukan terus mengungguli pemrosesan, yang dibatasi ke jumlah inti hanyalah thread; tugas dan datanya yang menumpuk di antrean terus memakan memori. Dalam susunan itu, pakai ThreadPoolExecutor secara langsung dengan antrean berkapasitas plus kebijakan penolakan, atau letakkan pembatas masuk seperti Semaphore di sisi pengirim, agar backpressure dapat diterapkan (prinsip yang sama dengan pembahasan antrean di bab 4).
3.2. Jangan salah memakai virtual thread
Virtual thread adalah thread ringan yang terpisah dari thread OS. Karena ia melepaskan thread OS selama operasi pemblokiran JDK (I/O, kunci, sleep, dan sejenisnya di pustaka standar), jutaan buah dapat dijalankan dalam satu JVM. Namun bukan berarti “setiap pemblokiran bisa melepaskan”. Jika memblokir saat menjalankan kode native (JNI) atau foreign function, virtual thread tetap menempel (pin) pada carrier thread. Yang diselesaikan JDK 24 (JEP 491, dibahas kemudian) adalah pinning oleh synchronized; pinning di batas native tetap ada. Jika pemrosesan yang memblokir lama lewat driver JNI atau API perangkat dimuat ke banyak virtual thread, carrier thread dapat habis. Namun, seperti ditekankan panduan resmi, ia bukan “thread yang cepat”. Kecepatan eksekusi kode tidak berubah; yang diberikan adalah skala (throughput).3
Ada tiga disiplin pemakaian.3
- Jangan di-pool. Virtual thread adalah barang sekali pakai yang murah; keadaan yang benar adalah “jumlah tugas = jumlah virtual thread”. Memasukkan virtual thread ke
newFixedThreadPooladalah kesalahan; pakailah dalam bentuktry (var executor = Executors.newVirtualThreadPerTaskExecutor()). - Batasi jumlah eksekusi bersamaan dengan
Semaphore. Batasan seperti “paling banyak 10 koneksi bersamaan ke API eksternal” diekspresikan dengan semaphore, bukan ukuran pool. - Jangan dipakai untuk pekerjaan CPU-bound. Paralelisasi komputasi tetap lebih tepat pada thread platform sebanyak kira-kira jumlah inti, seperti sebelumnya.
Perhatikan bahwa yang berjalan di dalam virtual thread adalah kode sinkron biasa. Bukan menulis ulang kode seperti async/await di .NET; gagasan desain virtual thread adalah menjalankan “kode lurus satu permintaan satu thread” dalam jumlah besar apa adanya.2
3.3. Jangan disalahpahami — “jangan di-pool” hanya berlaku untuk virtual thread
Dari disiplin “jangan di-pool”, jangan dibaca bahwa “Java tidak punya mekanisme pool thread (atau pool itu tidak efisien)”. Kenyataannya terbalik: pool Java adalah perkakas matang yang ada di pustaka standar sejak JDK 5 (2004). Ada ThreadPoolExecutor sebagai pool umum yang dapat dikonfigurasi rinci (yang dibuat pabrik-pabrik Executors), ForkJoinPool bertipe work-stealing (instans bersama commonPool() adalah tujuan eksekusi default parallel stream dan CompletableFuture), dan ScheduledThreadPoolExecutor untuk eksekusi berkala — pada pekerjaan CPU-bound, mereka tetap pemain utama.
Pool pada asalnya adalah optimasi “ membuat dan menahan thread OS mahal, jadi dipakai ulang “. Virtual thread hampir meniadakan biaya pembuatan, sehingga premis itu hilang — alasan untuk memakai ulang pun hilang. Bukan pool yang tidak efisien, melainkan virtual thread sudah begitu ringan sehingga optimasi berupa pool tidak diperlukan. Itulah pemahaman yang tepat. Di kaki virtual thread, penjadwal JDK tetap mengoperasikan kira-kira sebanyak jumlah inti carrier thread (thread OS) sebagai ForkJoinPool work-stealing.2 Jadi kerangka “menangani konkurensi besar dengan pool kecil thread OS” tetap ada; yang berpindah hanyalah pengelolaan pool itu, dari tangan pengembang ke JVM. Dapat dikatakan bahwa jawaban Java adalah mencapai titik yang sama dengan async/await .NET — yang mengembalikan thread ke pool pada await — tanpa mengubah bentuk kode.
4. Mengurangi state mutable bersama — partisi, immutability, koleksi konkuren, antrean
Persaingan hanya terjadi ketika “beberapa thread” dan “data mutable yang dibagi” hadir bersamaan. Jumlah thread ditentukan persyaratan, jadi yang dapat dipotong lewat desain adalah berbagi. Sarananya ada tiga jalur — “partisi”, “immutability”, “penyerahan” — dan di Java ditulis begini.
Partisi. Pada agregasi paralel, jangan biarkan setiap thread menulis ke variabel total bersama; buat hasil parsial per thread lalu gabungkan di akhir. reduce / collect pada parallel stream menyediakan kerangka itu, dan LongAdder yang dibahas kemudian juga implementasi strategi partisi: “memecah sel di dalam, menyebar persaingan, lalu menjumlahkan saat dibaca”. Mengurangi jumlah penulisan ke data bersama datang sebelum menulis sinkronisasi dengan benar.
Jadikan immutable. Dengan record dan koleksi immutable (List.copyOf / Map.copyOf), data yang “tidak diubah setelah dibangun” dapat dibagi tanpa sinkronisasi. Untuk konfigurasi dan data master, pola mapannya adalah “saat diganti, buat objek baru, lalu tukar referensi volatile”. Namun “tampak hanya-baca” dan “immutable” adalah hal yang berbeda. Aksesor record mengembalikan referensi komponen apa adanya, dan salinan List.copyOf pun dangkal (objek elemen tidak diduplikasi), jadi jika elemennya mutable, siapa pun yang memegang alias dapat mengubah isinya, dan persaingan tetap ada. Yang boleh dibagi tanpa sinkronisasi hanyalah ketika seluruh graf objek, termasuk elemen, immutable. Jika ada elemen mutable, berikan salinan dalam, atau dekatkan elemen itu juga ke record / tipe immutable.
Pakai operasi majemuk koleksi konkuren. “Buat dan masukkan jika belum ada” pada ConcurrentHashMap memakai computeIfAbsent. Metode ini dieksekusi secara atomik untuk keseluruhan panggilan; jika kunci tidak ada, fungsi pemetaan dipanggil tepat sekali di dalam panggilan itu.8 Perbedaan jaminan dibanding ConcurrentDictionary.GetOrAdd di .NET (pabrik dapat berjalan beberapa kali saat persaingan) mudah dikacaukan orang yang bolak-balik antara kedua bahasa. Namun itu bukan “tepat sekali sepanjang umur kunci”. Jika fungsi mengembalikan null atau melempar pengecualian, pemetaan tidak didaftarkan, dan panggilan berikutnya menjalankan fungsi lagi (sama jika entri dihapus setelah terdaftar). Inisialisasi yang tidak boleh menggandakan efek samping harus dirancang sampai fungsi berhasil dengan nilai non-null. Sebagai imbalan keatomikan, sebagian pembaruan thread lain diblokir selama perhitungan, jadi jaga fungsi pemetaan tetap singkat, dan jangan memperbarui peta itu sendiri dari dalam fungsi (pembaruan rekursif dapat menjadi IllegalStateException).8
// Pola mapan penghitung frekuensi: computeIfAbsent + LongAdder
ConcurrentHashMap<String, LongAdder> freqs = new ConcurrentHashMap<>();
freqs.computeIfAbsent(key, k -> new LongAdder()).increment();
Serahkan lewat antrean. Aliran data antar-thread diarahkan ke BlockingQueue. Dengan ArrayBlockingQueue yang kapasitasnya ditentukan, put memblokir saat penuh dan menjadi backpressure alami — kerangka yang sama dengan channel berbatas di edisi .NET. Desain ini tetap berguna di era virtual thread, karena memperjelas batas produsen/konsumen.
5. Disiplin kunci — synchronized dan ReentrantLock
5.1. Apa yang dikunci, dan apa yang tidak dilakukan sambil memegang kunci
Satuan kunci dipikirkan sebagai “data”, bukan “rentang kode”. Pasangkan satu objek kunci ke setiap kumpulan data mutable yang ingin dilindungi, dan ambil kunci yang sama di semua tempat yang menyentuh data itu — tabel korespondensi itu yang runtuh adalah wujud bug persaingan. synchronized(this) dan synchronized(SomeClass.class) memungkinkan kode luar mengunci objek yang sama, jadi hindari; pasangkan private final Object lock = new Object(); yang tidak diekspos ke luar satu lawan satu dengan data yang dilindungi.
Tambahkan dua disiplin pemakaian. Pertama, jangan melakukan hal yang memakan waktu atau menyentuh dunia luar sambil memegang kunci. I/O, pemanggilan listener, atau mengeksekusi kode tak dikenal sambil memegang kunci memperpanjang waktu pegang, dan pihak yang dipanggil dapat mengambil kunci lain lalu membentuk tunggu sirkular di Gambar 2. Kedua, seragamkan urutan perolehan beberapa kunci. Di tempat yang mengambil dua kunci atau lebih, jadikan aturan bahwa semua thread mengambil dalam urutan yang sama; di tempat urutan tidak dapat dijamin, sediakan jalur “lepaskan dan coba lagi jika tidak didapat” dengan tryLock(timeout) yang dibahas kemudian.
synchronized cukup untuk “eksklusi singkat dan sederhana”. Maju ke ReentrantLock ketika yang berikut diperlukan.
- Perolehan berbatas waktu lewat
tryLock(timeout)(mengubah hang abadi menjadi kegagalan yang dapat dicatat dan ditangani) - Kebijakan keadilan, beberapa
Condition, atau ingin memisahkan perolehan dan pelepasan kunci ke metode yang berbeda
Saat memakai ReentrantLock, jangan rusak bentuk try segera setelah lock(), lalu unlock() di finally (karena tidak ada sintaksis setara RAII di C++, bentuk ini adalah seluruh disiplinnya).
5.2. Virtual thread dan pinning — catatan yang berubah di JDK 24
Pada awal pengenalan virtual thread (JDK 21–23), ada batasan bahwa memblokir di dalam blok synchronized menempelkan virtual thread ke thread OS (thread OS tidak dilepaskan, sehingga keuntungan skala hilang), dan penggantian situs yang sering atau lama memblokir dengan ReentrantLock dianjurkan.3 Batasan itu diselesaikan di JEP 491 pada JDK 24 dengan penulisan ulang implementasi monitor, dan synchronized tidak lagi menempelkan virtual thread.4 Jika memakai JDK 24 atau setelahnya, penggantian mekanis sebagai penanggulangan pinning tidak diperlukan. Ada nilai untuk memeriksa apakah pedoman internal masih tertinggal pada catatan era JDK 21.
5.3. Posisi kelas Atomic dan volatile
Pembaruan atomik variabel tunggal diemban AtomicInteger / AtomicLong / AtomicReference (untuk statistik yang hanya ditambah pada frekuensi tinggi, LongAdder yang tahan persaingan). volatile adalah jaminan visibilitas dan pengurutan (happens-before), bukan keatomikan operasi majemuk.5 Kesimpulan yang sama dengan edisi .NET dan C++ berlaku di Java: bendera dan nilai tunggal memakai kelas Atomic, state majemuk memakai kunci, jangan dipaksakan dengan volatile sendirian.
6. Merancang cara berhenti — interruption sebagai bahasa bersama
6.1. Tata cara interrupt
Berhenti dan pembatalan di Java disatukan pada interruption. t.interrupt() menandai status interrupt thread sasaran; jika sasaran sedang memblokir di sleep / wait / join dan sejenisnya, ia melempar InterruptedException dan membangunkan segera (saat itu status interrupt dibersihkan).6 Sarana paksa dulu, Thread.stop / suspend / resume, pada dasarnya tidak aman, jadi memanggilnya sekarang menjadi UnsupportedOperationException.6
flowchart TB
OWNER["Pihak yang menghentikan: t.interrupt()"] --> ST["Status interrupt ditandai"]
ST --> A["Thread yang sedang menghitung:<br/>periksa Thread.interrupted() di loop"]
ST --> B["Sedang memblokir di sleep / wait / join:<br/>InterruptedException terbang dan bangun segera<br/>(status dibersihkan)"]
A --> E["Rapikan, lalu selesai sendiri"]
B --> C{"Apa yang dilakukan di catch?"}
C -->|"Dapat selesai sendiri"| E
C -->|"Tidak dapat selesai (misalnya di dalam pustaka)"| R["Pulihkan status dengan<br/>Thread.currentThread().interrupt()<br/>agar sinyal tetap ada"]
R --> E
Gambar 4: Penghentian kooperatif lewat interruption. Menelan InterruptedException menghilangkan sinyal berhenti — setelah catch, pilihannya hanya “selesai” atau “pulihkan”
Cukup ingat satu disiplin praktis. Jangan menulis kode yang menangkap InterruptedException lalu tidak melakukan apa pun. Jika dapat berakhir dalam tanggung jawab sendiri, berakhir di situ; jika tidak, pulihkan status dengan Thread.currentThread().interrupt() dan teruskan sinyal ke pemanggil (lihat FAQ).
6.2. Shutdown dua tahap ExecutorService
API berhenti ExecutorService menumpang pada model interruption. shutdown() berhenti menerima tugas baru dan membiarkan tugas yang sudah masuk selesai, shutdownNow() mencoba menghentikan tugas yang sedang berjalan. Sebagai spesifikasi antarmuka ini best-effort, dan implementasi standar (ThreadPoolExecutor dan sejenisnya) secara eksplisit menyatakan bahwa pembatalan biasanya lewat Thread.interrupt() — artinya tugas yang tidak merespons interruption tidak berhenti meski dengan shutdownNow. Jika memakai Executor implementasi sendiri, cara pembatalannya (apakah mengirim interrupt atau tidak) perlu dikonfirmasi di dokumentasi implementasi.1 Pola mapan berhenti yang ditunjukkan dokumentasi resmi adalah pola dua tahap berikut.1
/** Kembalikan true jika berhenti selesai. Jangan lanjut ke pelepasan sumber daya bersama selama masih false. */
boolean shutdownAndAwaitTermination(ExecutorService pool) {
pool.shutdown(); // tahap 1: berhenti menerima yang baru, tunggu selesai
try {
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
// tahap 2: minta pembatalan. Tugas yang diturunkan tanpa dijalankan dikembalikan,
// jadi selesaikan Future itu sebagai dibatalkan agar pemanggil yang menunggu get() bangun
pool.shutdownNow().forEach(r -> {
if (r instanceof Future<?> f) f.cancel(false);
});
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
System.err.println("Pool did not terminate");
return false; // berhenti belum selesai. sampaikan dalam bentuk yang dapat dibedakan dari sukses
}
}
return true;
} catch (InterruptedException ex) {
pool.shutdownNow().forEach(r -> {
if (r instanceof Future<?> f) f.cancel(false);
});
Thread.currentThread().interrupt(); // pulihkan juga status interrupt sendiri
return false; // jalur ini pun mungkin belum selesai berhenti
}
}
flowchart TB
S["shutdown()<br/>berhenti menerima tugas baru"] --> W1{"tunggu selesai dengan<br/>awaitTermination"}
W1 -->|"selesai dalam batas waktu"| DONE["Berhenti selesai"]
W1 -->|"habis waktu"| NOW["shutdownNow()<br/>kirim interrupt ke tugas yang berjalan<br/>(apakah merespons terserah tugas)"]
NOW --> W2{"tunggu lagi dengan<br/>awaitTermination"}
W2 -->|"selesai"| DONE
W2 -->|"masih belum selesai"| LOG["catat sebagai anomali<br/>(tersangka: tugas yang tidak merespons interruption)"]
Gambar 5: Shutdown dua tahap ExecutorService. Desain bertahap “tunggu dengan tenang → minta lewat interrupt → jika masih belum selesai, amati sebagai anomali”
Ada satu batasan pada pembatalan Runnable yang dikembalikan shutdownNow(). Yang dikembalikan adalah objek yang ada di antrean eksekusi; pada submit mentah, itu adalah FutureTask yang sama yang diberikan ke pemakai, tetapi pada tugas yang dimasukkan lewat pembungkus seperti ExecutorCompletionService, itu pembungkus di dalam antrean, bukan Future pemakai. Dalam susunan itu, pembatalan di atas tidak menyelesaikan Future sisi pemakai, jadi simpan sendiri daftar Future saat pengiriman, lalu batalkan yang itu saat shutdown (atau kembalikan tugas yang diturunkan kepada pemiliknya).
close() (AutoCloseable) mulai JDK 19 adalah bentuk “lakukan shutdown lalu tunggu sampai selesai” yang dapat ditulis dengan try-with-resources; try (var executor = ...) yang dipasangkan dengan newVirtualThreadPerTaskExecutor virtual thread adalah bentuk dasar modern.1 Namun close() bukan pengganti pola dua tahap di atas. Karena menunggu selesai tanpa batas waktu, satu tugas yang tidak merespons interruption atau tidak pernah berakhir pun membuat thread yang mencoba menutup memblokir selamanya. Ia perkakas yang cocok untuk situasi di mana tugas dalam cakupan berhingga dan penyelesaiannya dijamin (pola kirim di tempat, tunggu di tempat). Untuk jalur shutdown aplikasi yang “harus selesai dalam waktu berhingga”, pakai pola dua tahap berbatas waktu. Pembatalan tugas individual dilakukan Future.cancel(true), juga lewat interruption.
7. Thread UI — EDT Swing
Pada aplikasi desktop, terlepas dari bahasa atau kerangka, ada aturan “UI milik eksklusif thread yang mengelolanya”. Di Swing, thread eksklusif itu adalah Event Dispatch Thread (EDT). Metode komponen Swing pada prinsipnya tidak thread-safe; menyentuhnya dari beberapa thread menimbulkan interferensi thread dan kesalahan konsistensi memori. Pembaruan layar dari thread lain diminta ke EDT lewat SwingUtilities.invokeLater; sebaliknya, pemrosesan panjang di EDT membekukan UI, jadi pekerjaan berat dikeluarkan ke thread pekerja dengan SwingWorker dan sejenisnya.7 Kerangka di JavaFX sama: pembaruan UI diminta ke application thread lewat Platform.runLater.
8. Verifikasi dan debugging — thread dump sebagai senjata
Bug persaingan tidak boleh diharapkan ketemu di pengujian. Pengujian biasa menghitung sebagai sukses eksekusi yang “kebetulan tidak bersaing”. Siapkan tiga lapisan.
Garis pertahanan pertama adalah desain. Di tinjauan, konfirmasi dengan tabel: “data mutable mana yang dibagi”, “kunci mana yang melindungi masing-masing (tabel korespondensi di 5.1)”, “apakah urutan perolehan kunci unik”, “apakah ada catch yang menelan InterruptedException”, “apakah jalur berhenti (shutdown/interruption) sampai ke semua tugas”.
Kedua, kuasai thread dump. Java punya perkakas standar untuk mengambil “keadaan thread pada saat macet ini”: jstack (atau jcmd <pid> Thread.print) mengeluarkan jejak tumpukan, dan opsi -l menambahkan informasi kunci.9 Catatannya: dump format lama ini untuk thread platform; virtual thread aplikasi tidak termasuk. Saat menelusuri permintaan yang memblokir pada susunan yang memakai virtual thread (bab 3), pakai jcmd <pid> Thread.dump_to_file -format=json <berkas> yang dapat mendump termasuk virtual thread.2 Investigasi hang dasarnya: ambil dump 2–3 kali selang beberapa detik, lalu cocokkan thread yang tidak bergerak menunggu kunci mana, dan siapa yang memegang kunci itu. Jika habis waktu tryLock(timeout) dicatat di log (5.2), pemicu pengambilan dump juga dapat diotomatiskan.
Ketiga, goyang dengan beban. Uji stres yang berjalan lama dengan derajat paralelisme di atas jumlah inti, mengacak urutan pemrosesan, atau menyisipkan jeda buatan adalah sarana realistis untuk lebih mudah “mengenai” persaingan di mesin pengembangan. Jalankan sekali sebelum rilis pengujian dengan volume data dan jumlah thread setara produksi.
9. Konkurensi Java ke depan — structured concurrency
Terakhir, setengah langkah ke depan. Structured Concurrency (StructuredTaskScope), yang mengandaikan virtual thread dan “memperlakukan beberapa subtugas sebagai satu satuan kerja, lalu menstrukturkan propagasi kegagalan dan pembatalan”, sedang dikembangkan; per Agustus 2026 masih fitur pratinjau. Di pratinjau kelima JDK 25 (JEP 505) bentuk API diubah ke StructuredTaskScope.open(), dan di JDK 26 yang berlaku sekarang ia berlanjut sebagai pratinjau keenam (JEP 525).1011 Sementara itu, Scoped Values, berbagi konteks immutable yang menyelesaikan masalah ThreadLocal, diresmikan di JDK 25.12 Prinsip artikel ini (perjelas batas tugas, bagi yang immutable, berhenti secara kooperatif) selaras dengan arah yang dituju API baru itu.
10. Ringkasan — daftar periksa edisi Java
- Apakah
new Threadsudah tidak tersisa di kode bisnis (sudah bertumpu padaExecutorService/ virtual thread)? - Apakah sarana eksekusi dipisah antara I/O-bound dan CPU-bound (percabangan Gambar 3)?
- Apakah virtual thread tidak di-pool, dan batasan jumlah bersamaan diekspresikan dengan
Semaphore? - Apakah data bersama immutable (
record/List.copyOf) atau bertumpu pada perkakasjava.util.concurrent? - Apakah tidak ada kunci pada
synchronized(this)/ objek publik? - Apakah operasi majemuk
ConcurrentHashMap(computeIfAbsentdan sejenisnya) dipakai, dan fungsi pemetaan dijaga singkat? - Apakah keatomikan tidak diharapkan dari
volatile(penghitung sudah kelas Atomic/LongAdder)? - Apakah tidak ada satu pun catch yang menelan
InterruptedException? - Apakah berhenti
ExecutorServicememakai pola dua tahap berbatas waktu (apakah tempat yang memakaiclose()terbatas pada cakupan di mana penyelesaian tugas dijamin)? - Apakah pembaruan UI Swing/JavaFX terkumpul di EDT/application thread?
Java adalah salah satu bahasa dengan perkakas konkurensi yang paling lengkap, dan kehadiran virtual thread membuka jalan “menskalakan kode sinkron yang lurus apa adanya”. Justru karena itu, menempatkan dengan benar pembagian peran perkakas — mana alat throughput, mana alat eksklusi, apa sinyal berhenti — adalah substansi desain multithread di Java.
Artikel terkait
- Praktik terbaik multithreading di lapangan: edisi .NET
- Praktik terbaik multithreading di lapangan: edisi C++
- Praktik terbaik multithreading di lapangan: edisi C
- Tabel keputusan praktis C# async/await — Task.Run dan ConfigureAwait
Area konsultasi terkait
KomuraSoft LLC menangani tinjauan desain multithread untuk sistem bisnis dan pemrosesan batch Java, investigasi bug yang berasal dari konkurensi seperti kerusakan state bersama atau “kadang tidak berhenti / macet” (analisis thread dump), dan konsultasi teknis pengenalan virtual thread.
Tautan referensi
-
Oracle, ExecutorService (Java SE 21 & JDK 21 API). Tentang shutdown() yang membiarkan tugas yang sudah masuk selesai sambil berhenti menerima yang baru; tentang shutdownNow() yang mencoba menghentikan tugas yang berjalan dan mengembalikan daftar tugas yang menunggu, tetapi implementasi khas membatalkan lewat Thread.interrupt(), tanpa jaminan di luar best-effort, sehingga tugas yang tidak merespons interruption tidak berakhir; tentang menunggu selesai dengan awaitTermination; tentang close() (Java 19 dan setelahnya, AutoCloseable) yang melakukan shutdown lalu menunggu selesai dan dapat dipakai dengan try-with-resources; dan tentang shutdown dua tahap shutdown → awaitTermination → shutdownNow yang ditunjukkan sebagai contoh pemakaian. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
OpenJDK, JEP 444: Virtual Threads. Tentang virtual thread yang menjadi fitur resmi di JDK 21; tentang ia sebagai thread ringan yang secara substansial mengurangi tenaga untuk menulis, merawat, dan mengamati aplikasi konkuren ber-throughput tinggi; tentang gagasan desain menskalakan kode sinkron lurus “satu permintaan satu thread” apa adanya; tentang penjadwal virtual thread JDK yang berupa ForkJoinPool work-stealing yang beroperasi dalam mode FIFO, dengan derajat paralelisme default sama dengan jumlah prosesor yang tersedia; dan tentang format baru thread dump yang mencakup virtual thread, ditambahkan sebagai jcmd Thread.dump_to_file (teks polos dan JSON), serta thread dump lama yang tidak mencakup virtual thread. ↩ ↩2 ↩3 ↩4 ↩5
-
Oracle Java SE Core Libraries, Virtual Threads. Tentang virtual thread yang diimplementasikan runtime Java dan melepaskan thread OS saat I/O pemblokiran; tentang ia sebagai fitur untuk skala (throughput) bukan kecepatan (latensi), dan tidak cocok untuk pemrosesan intensif CPU; tentang virtual thread yang tidak pernah di-pool dan dipakai satu per tugas (newVirtualThreadPerTaskExecutor); tentang pembatasan jumlah eksekusi bersamaan yang memakai Semaphore, bukan pool thread; tentang, pada titik JDK 21, pemblokiran di dalam synchronized yang menimbulkan pinning ke thread OS, sehingga penggantian dengan ReentrantLock dianjurkan jika sering atau lama; dan tentang deteksi pinning dengan -Djdk.tracePinnedThreads. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
OpenJDK, JEP 491: Synchronize Virtual Threads without Pinning. Tentang implementasi monitor JVM yang ditulis ulang untuk virtual thread di JDK 24, sehingga memblokir di dalam blok atau metode synchronized tidak lagi menempelkan virtual thread ke carrier thread; dan tentang penanggulangan era JDK 21–23 berupa “ganti synchronized dengan ReentrantLock” yang pada prinsipnya tidak lagi diperlukan. ↩ ↩2
-
Oracle, The Java Tutorials, Memory Consistency Errors. Tentang kesalahan konsistensi memori yang timbul ketika beberapa thread melihat data yang sama secara tidak konsisten; tentang kunci menghindarinya yaitu relasi happens-before (jaminan bahwa penulisan memori oleh suatu pernyataan terlihat dari pernyataan lain); dan tentang synchronized, volatile, Thread.start / join, dan sejenisnya yang membentuk happens-before. ↩ ↩2 ↩3
-
Oracle, Thread (Java SE 21 & JDK 21 API). Tentang Thread.stop / suspend / resume yang pada dasarnya tidak aman (kunci dilepas dalam keadaan tidak konsisten sehingga objek rusak terlihat; suspend mengundang deadlock), sehingga deprecated dan dijadwalkan dihapus, dan memanggilnya sekarang melempar UnsupportedOperationException; tentang interrupt() yang menandai status interrupt, dan pada thread yang memblokir di sleep / wait / join melempar InterruptedException untuk membangunkan (saat itu status interrupt dibersihkan); dan tentang perbedaan penanganan status antara interrupted() dan isInterrupted(). ↩ ↩2 ↩3
-
Oracle, The Java Tutorials, The Event Dispatch Thread. Tentang kode pemrosesan event Swing yang berjalan di Event Dispatch Thread (EDT); tentang sebagian besar metode objek Swing yang tidak thread-safe, sehingga pemanggilan dari beberapa thread menimbulkan interferensi thread dan kesalahan konsistensi memori, dan akses ke komponen Swing pada prinsipnya harus dilakukan di EDT; tentang dari thread lain, tugas diminta ke EDT dengan SwingUtilities.invokeLater / invokeAndWait; dan tentang tugas di EDT yang harus diselesaikan dalam waktu singkat. ↩ ↩2
-
Oracle, ConcurrentHashMap (Java SE 21 & JDK 21 API). Tentang keseluruhan panggilan metode computeIfAbsent yang dieksekusi secara atomik, dan fungsi pemetaan yang dipanggil tepat sekali ketika kunci tidak ada; tentang sebagian operasi pembaruan thread lain yang diblokir selama perhitungan, sehingga perhitungan harus dijaga singkat dan sederhana; tentang peta itu sendiri yang tidak boleh diubah dari dalam fungsi pemetaan, dan pembaruan rekursif yang terdeteksi yang menjadi IllegalStateException; dan tentang operasi pengambilan (get) yang tidak memblokir, serta relasi happens-before yang berlaku antara pembaruan per kunci dan pengambilan berikutnya. ↩ ↩2 ↩3
-
Oracle, The jstack Command (Java SE 21 Tools Reference). Tentang jstack yang mengeluarkan jejak tumpukan semua thread proses Java yang ditentukan (nama kelas, nama metode, nomor baris); tentang opsi -l yang menampilkan rincian termasuk informasi tambahan terkait kunci; dan tentang pemakaian bersama perkakas diagnostik lain seperti jcmd. ↩
-
OpenJDK, JEP 505: Structured Concurrency (Fifth Preview). Tentang API structured concurrency yang memperlakukan kumpulan subtugas terkait sebagai satu satuan kerja dan menstrukturkan propagasi galat serta pembatalan; tentang StructuredTaskScope yang diubah ke bentuk dibuka dengan metode pabrik statis (open); dan tentang ia sebagai pratinjau kelima pada titik JDK 25, belum fitur resmi. ↩
-
OpenJDK, JEP 525: Structured Concurrency (Sixth Preview). Tentang structured concurrency yang berlanjut sebagai pratinjau keenam juga di JDK 26; yaitu, pada JDK yang berlaku per Agustus 2026 ia tetap fitur pratinjau, dan pemakaiannya memerlukan pengaktifan fitur pratinjau. ↩
-
OpenJDK, JEP 506: Scoped Values. Tentang Scoped Values yang diresmikan di JDK 25; tentang ia sebagai mekanisme untuk membagi data konteks immutable secara aman dan efisien di dalam thread maupun antar-thread; dan tentang ia sebagai solusi atas masalah ThreadLocal (mutabilitas, pengelolaan masa hidup, biaya pewarisan). ↩
Artikel terkait
Artikel terbaru dengan tag yang sama untuk mendalami topik-topik terdekat.
Praktik terbaik multithreading di lapangan: edisi C — menulis aman mengikuti cara Win32 API
Pola mapan multithreading C × Win32 adalah pembuatan thread dengan _beginthreadex, kunci SRW dan variabel kondisi, Interlocked, serta des...
Praktik terbaik multithreading di lapangan: edisi C++ — menghilangkan kecelakaan secara struktural dengan RAII dan jthread
Multithreading di C++ adalah dunia di mana data race menjadi perilaku tak terdefinisi. Artikel ini merapikan jebakan destruktor std::thre...
Praktik terbaik multithreading di lapangan: edisi .NET — yang harus diputuskan sebelum menambah thread
Merangkum pola desain .NET/C# agar aplikasi multithread tidak sesekali crash atau macet: jangan membuat thread sendiri, bertumpu pada Tas...
Spurious wakeup — mengapa condition variable "bangun tanpa notifikasi" dan cara menunggu yang benar di Windows
wait pada condition variable dapat bangun meskipun notifikasi tidak datang (spurious wakeup). Artikel ini mengurai alasan spesifikasi men...
Mekanisme dan praktik Volume Shadow Copy (VSS) — mengapa file yang sedang dipakai bisa dicadangkan
File yang sedang dipakai biasanya tidak bisa disalin karena pelanggaran berbagi, tetapi perangkat lunak cadangan bisa mengambilnya. Artik...
Topik terkait
Halaman-halaman ini menempatkan topik dalam konteks layanan dan keputusan yang lebih luas.
Topik teknis Windows
Portal tentang pengembangan Windows, investigasi bug, dan pemanfaatan aset yang ada.
Pertanyaan yang sering diajukan
Pertanyaan yang sering muncul dalam konsultasi tentang topik artikel ini.
- Kalau sudah ada virtual thread, apakah pool thread (ExecutorService) tidak lagi diperlukan?
- Tergantung kasus pemakaian. Virtual thread adalah mekanisme untuk menjalankan banyak tugas yang didominasi menunggu I/O; ia tidak mempercepat kode, melainkan menaikkan throughput. Untuk pekerjaan I/O-bound, pakai satu virtual thread per tugas (Executors.newVirtualThreadPerTaskExecutor), dan jangan mem-pool virtual thread. Sebaliknya, untuk memparalelkan komputasi yang menghabiskan CPU, pool thread platform yang dibatasi kira-kira sebanyak jumlah inti (atau parallel stream) tetap yang tepat, seperti sebelumnya. Jika jumlah akses bersamaan ke layanan eksternal ingin dibatasi, rekomendasi era virtual thread adalah membatasinya dengan Semaphore, bukan dengan ukuran pool.
- Mana yang harus dipakai, synchronized atau ReentrantLock?
- Untuk eksklusi singkat dan sederhana, synchronized sudah cukup, dan kodenya tetap ringkas. Pilih ReentrantLock ketika fitur seperti perolehan berbatas waktu lewat tryLock, kebijakan keadilan, atau beberapa Condition diperlukan. Ada catatan historis saat menggabungkannya dengan virtual thread: di JDK 21–23, pemblokiran di dalam blok synchronized menempelkan (pin) virtual thread ke thread OS, sehingga penggantian situs yang sering atau lama memblokir dengan ReentrantLock dianjurkan. JDK 24 (JEP 491) menulis ulang implementasi monitor dan menyelesaikan batasan itu. Mulai JDK 24, penggantian synchronized dengan alasan pinning tidak diperlukan.
- Apakah menambahkan volatile membuat sesuatu menjadi thread-safe?
- Tidak. volatile di Java membentuk relasi happens-before antara penulisan ke variabel itu dan pembacaan darinya, menjamin visibilitas (penulisan terbaru terlihat oleh thread lain) dan pengurutan, tetapi tidak menjamin keatomikan operasi majemuk seperti "baca, hitung, tulis kembali". Jika penghitung volatile int dinaikkan dengan ++ dari beberapa thread, penambahan hilang. Pakai AtomicInteger / AtomicLong (atau LongAdder untuk agregasi berfrekuensi tinggi) untuk penghitung, dan kunci jika beberapa variabel harus dilindungi bersama. volatile hampir hanya cocok pada kasus seperti bendera status sederhana — satu thread menulis, yang lain hanya membaca.
- Bolehkah menangkap InterruptedException lalu mengabaikannya?
- Tidak. Interruption adalah sinyal standar Java untuk berhenti dan pembatalan; menelannya menciptakan thread yang tidak akan berhenti. Pada saat InterruptedException dilempar, status interrupt sudah dibersihkan, jadi jika pekerjaan tidak dapat diselesaikan sendiri, pulihkan status dengan Thread.currentThread().interrupt() agar sinyal tetap ada bagi pemanggil, atau lempar ulang pengecualian apa adanya. Blok catch kosong yang tidak melakukan apa pun adalah penyebab klasik bug di mana shutdown tidak berlaku atau shutdownNow diabaikan.
- Tidak bisakah thread dihentikan dengan Thread.stop?
- Tidak. Thread.stop pada dasarnya tidak aman (melepaskan kunci sambil meninggalkannya dalam keadaan tidak konsisten, sehingga objek rusak terlihat oleh thread lain), jadi sudah lama deprecated, dan memanggilnya di Java sekarang melempar UnsupportedOperationException. Hal yang sama berlaku untuk Thread.suspend / resume. Satu-satunya cara sah untuk menghentikan thread adalah penghentian kooperatif lewat interruption. Jika memakai ExecutorService, shutdown hanya berhenti menerima tugas baru dan menunggu penyelesaian — ia tidak mengirim interrupt ke tugas yang sedang berjalan. Yang mencoba menghentikan tugas yang berjalan adalah shutdownNow, dan itu bersifat best-effort (implementasi standar bekerja lewat interruption).
Profil penulis
Halaman perkenalan penulis artikel.
Go Komura
Direktur KomuraSoft LLC
Berspesialisasi dalam pengembangan perangkat lunak Windows, konsultasi teknis, dan investigasi bug, terutama pada proyek dengan sistem yang sudah ada dan bug yang sulit direproduksi.