Praktik terbaik multithreading di lapangan: edisi Java — konvensi era virtual thread

· · Multithreading, Java, Aplikasi bisnis, Investigasi bug, Desain

«Kami ingin memparalelkan pekerjaan batch bisnis di Java.» «Cache bersama di aplikasi web Spring kami kadang-kadang rusak.» «Kami mewarisi aplikasi Swing lama yang penuh new Thread.» — Java telah menanamkan multithreading ke dalam bahasa sejak JDK 1.0, matang menjadi kotak peralatan java.util.concurrent, dan, dengan virtual thread di JDK 21, menulis ulang lagi kebijaksanaan pemrograman konkuren. Justru karena perangkatnya begitu kaya, alat yang Anda pilih menjadi mutu desain itu sendiri.

Artikel ini adalah edisi Java dari seri multithreading di lapangan. Ditujukan kepada pengembang yang menulis sistem bisnis, pekerjaan batch, dan aplikasi server di Java, ia memetakan prinsip desain multithreading — jangan pernah membuat thread secara langsung, kurangi state bersama yang dapat berubah, disiplinkan penguncian, rancang cara menghentikan lebih dulu dari yang lain — ke perangkat Java (terutama rilis LTS, JDK 21 dan setelahnya), dan merangkum pilihan era virtual thread serta jebakan khas Java, berpijak pada sumber primer per Agustus 2026. Ditulis agar dapat dibaca sendiri. Prinsip yang sama yang diterapkan ke bahasa lain dibahas di artikel pendamping «Edisi .NET», «Edisi C++», dan «Edisi C».

1. Kesimpulan dulu

  • Jangan menulis new Thread di kode bisnis — aturan yang sama berlaku di Java. Serahkan tugas ke ExecutorService dan biarkan pustaka mengelola siklus hidup thread.1
  • Arahkan tugas yang sebagian besar menunggu I/O ke virtual thread. Virtual thread, yang resmi di JDK 21, dipakai satu per tugas dan tidak boleh pernah di-pool. Batasi konkurensi dengan Semaphore, bukan ukuran pool.23
  • Virtual thread adalah alat throughput, bukan alat untuk mempercepat komputasi. Paralelisme CPU-bound tetap pekerjaan thread platform yang berukuran kira-kira sebanyak inti — pool tetap atau parallel stream, seperti sebelumnya.3
  • Kunci pada objek kunci private final, atau ReentrantLock khusus. synchronized(this) dan mengunci objek yang diekspos publik dapat bertabrakan dengan kode luar. Jika Anda butuh akuisisi berbatas waktu (tryLock), pakai ReentrantLock.
  • Di JDK 21-23 ada masalah di mana pemblokiran di dalam blok synchronized menempelkan virtual thread; JDK 24 (JEP 491) memperbaikinya. Bedakan peringatan lama dari kenyataan sekarang.34
  • volatile menjamin visibilitas dan pengurutan, bukan atomisitas. Pakai AtomicInteger / LongAdder untuk penghitung, dan kunci untuk state majemuk.5
  • Penghentian kooperatif lewat interruption adalah satu-satunya cara benar untuk menghentikan thread. Thread.stop / suspend / resume sekarang melempar UnsupportedOperationException. Jangan telan InterruptedException — pulihkan status atau lempar ulang.6
  • Hentikan ExecutorService dengan pola dua tahap: shutdown → awaitTermination → shutdownNow. shutdownNow bersifat best-effort (implementasi standar bekerja lewat interruption), jadi ia mengandaikan tugas Anda merespons interruption.1
  • UI Swing milik eksklusif EDT (Event Dispatch Thread). Minta pembaruan dari thread lain lewat SwingUtilities.invokeLater.7

2. Mengapa multithreading sulit — kondisi balapan, deadlock, dan model memori

Saring masalah yang dibawa multithreading, terlepas dari bahasanya, dan ada dua jenis.

Kondisi balapan adalah bug di mana hasil bergantung pada urutan beberapa thread mencapai potongan kode tertentu. Contoh klasiknya adalah penghitung bersama: ekspresi tunggal count++ sebenarnya terurai menjadi tiga langkah — baca, tambah, tulis kembali. Jika dua thread masuk ke tiga langkah ini pada saat yang sama, kenaikan satu thread ditimpa dan hilang ketika yang lain menulis kembali. Hasilnya berubah setiap kali dijalankan, dan tidak ada cara meramal hasil mana yang akan Anda dapat.

Thread BVariabel bersama countThread AThread BVariabel bersama countThread Acount = 10Dua kenaikan terjadi,tetapi count = 11 — kenaikan Thread A hilangBaca (10)Baca (10)Tambah lokal (11)Tambah lokal (11)Tulis kembali (11)Tulis kembali (11)

Gambar 1: Kondisi balapan klasik di mana kenaikan pada penghitung bersama hilang. Jika thread lain menyisip di antara tiga langkah count++, penulisan terakhir menimpa yang lain

Deadlock adalah keadaan di mana dua thread masing-masing menunggu kunci yang dipegang yang lain, sehingga tidak ada yang bisa maju. Thread A memegang kunci 1 dan menunggu kunci 2; thread B memegang kunci 2 dan menunggu kunci 1 — itu saja cukup agar keduanya berhenti selamanya.

menunggu pelepasan kunci 2menunggu pelepasan kunci 1Thread Amemegang kunci 1Thread Bmemegang kunci 2

Gambar 2: Tunggu berputar dari deadlock. Begitu panah menunggu membentuk lingkaran, setiap thread di dalam lingkaran itu berhenti selamanya

Keduanya bergantung pada waktu. Sisipan yang hanya muncul sekali dalam puluhan ribu kali jalan di mesin pengembangan bisa terjadi setiap hari di server produksi dengan jumlah inti dan beban yang berbeda. «Berhenti bisa direproduksi begitu saya pasang debugger» dan «hilang ketika saya menambah pencatatan» keduanya karena pengamatan mengubah waktu — perilaku klasik bug balapan. Itulah mengapa setiap prinsip dalam artikel ini mengarah ke mengurangi tempat yang butuh sinkronisasi, sebelum khawatir menyinkronkan dengan benar.

2.1. Premis khas Java — model memori dan happens-before

Di atas itu, yang khas Java adalah bagaimana data bersama terlihat didefinisikan oleh relasi happens-before dalam Java Memory Model (JMM).

Membaca dan menulis variabel bersama tanpa sinkronisasi tidak menjadi «perilaku tak terdefinisi» gaya C++, tetapi secara sah dapat menghasilkan nilai usang yang terus terlihat, atau penulisan yang muncul di luar urutan. Kesalahan konsistensi memori di mana «sebuah loop sedang mengawasi bendera boolean, tetapi nilai yang diubah thread lain tidak pernah terlihat» adalah perilaku yang diizinkan JMM, bukan bug JVM.5 Alat yang menjaga dari ini adalah mekanisme yang menciptakan relasi happens-before — synchronized, volatile, dan kelas-kelas di java.util.concurrent. Pakai koleksi konkuren dengan benar dan pustaka menjamin «relasi happens-before antara operasi pembaruan dan pengambilan berikutnya» untuk Anda.8

Dengan kata lain, panduan praktis Java bisa diringkas begini: jangan licik dengan variabel bersama mentah. Pakai alat di java.util.concurrent untuk berbagi, dan biarkan pustaka menciptakan relasi happens-before.

3. Cara membuat thread — ExecutorService dan virtual thread

3.1. Memisahkan tugas dari cara menjalankannya

ExecutorService adalah yang membawa prinsip «jangan membuat thread sendiri» di Java. Ia memisahkan pekerjaan (Runnable / Callable) dari cara dieksekusi — berapa thread, antrean mana — dan menyerahkan pembuatan, penggunaan ulang, dan pembuangan thread kepada pustaka.1

Mulai JDK 21, memilih cara menjalankan menjadi pilihan biner yang sederhana.23

Sebagian besar menunggu I/Opanggilan HTTP, DB, berkasKomputasi CPU-boundTugas yang ingin dijalankan secara konkurenApa yang menggerakkan tugas?Virtual threadExecutors.newVirtualThreadPerTaskExecutorsatu per tugas, jangan pernah di-poolPool tetap thread platformExecutors.newFixedThreadPool - kira-kira sebanyak intiatau parallel streamBatasi konkurensi ke layanan eksternaldengan Semaphore, bukan ukuran pool

Gambar 3: Memilih cara mengeksekusi pekerjaan mulai JDK 21. Tarik garis dulu — «ubah cara menunggu I/O, paralelkan untuk CPU» — lalu serahkan pekerjaan I/O-bound ke virtual thread dan pekerjaan CPU-bound ke pool konvensional

Ada satu catatan di sisi CPU-bound. Executors.newFixedThreadPool membatasi jumlah thread, tetapi antreannya tidak terbatas. Di layanan yang berjalan lama di mana pengiriman terus mengungguli pemrosesan, hanya thread yang dibatasi sebanyak inti — tugas yang menumpuk di antrean, dan datanya, terus memakan memori. Dalam penyiapan semacam itu, entah pakai ThreadPoolExecutor langsung untuk mengonfigurasi antrean terbatas plus kebijakan penolakan, atau pasang kontrol penerimaan seperti Semaphore di sisi pengirim agar Anda bisa menerapkan tekanan balik (prinsip yang sama dengan pembahasan antrean di bagian 4).

3.2. Jangan salah memakai virtual thread

Virtual thread adalah thread ringan yang dilepas dari thread OS: selama operasi pemblokiran di JDK (I/O, penguncian, sleep, dan semacamnya di pustaka standar) mereka melepaskan thread OS-nya, itulah sebabnya satu JVM bisa menjalankan jutaan. Namun, mereka tidak melepaskannya untuk setiap jenis blok. Jika virtual thread terblokir saat menjalankan kode native (JNI) atau fungsi asing, ia tetap menempel ke thread pembawanya. Yang diperbaiki JDK 24 (JEP 491, dibahas di bawah) adalah pinning yang disebabkan synchronized; pinning di batas native tetap ada, jadi memuat banyak virtual thread dengan operasi yang memblokir lama lewat driver JNI atau API perangkat akan menghabiskan thread pembawa. Tetapi seperti yang ditegaskan panduan resmi, mereka bukan «thread yang lebih cepat». Kecepatan eksekusi kode tidak berubah — yang mereka berikan adalah skala (throughput).3

Ada tiga disiplin pemakaian.3

  1. Jangan pool mereka. Virtual thread murah dan sekali pakai; «jumlah tugas = jumlah virtual thread» adalah keadaan yang benar. Memasukkan virtual thread ke newFixedThreadPool adalah kesalahan — pakai bentuk try (var executor = Executors.newVirtualThreadPerTaskExecutor()).
  2. Batasi konkurensi dengan Semaphore. Nyatakan batasan seperti «paling banyak 10 koneksi bersamaan ke API eksternal» dengan semafor, bukan ukuran pool.
  3. Jangan pakai mereka untuk pekerjaan CPU-bound. Thread platform yang berukuran kira-kira sebanyak inti tetap alat yang tepat untuk memparalelkan komputasi, seperti sebelumnya.

Perhatikan bahwa yang berjalan di dalam virtual thread adalah kode sinkron biasa. Alih-alih menulis ulang kode seperti yang dilakukan async/await .NET, filosofi desain di balik virtual thread adalah membiarkan Anda menjalankan kode lurus «satu thread per permintaan», tanpa diubah, dalam skala besar.2

3.3. Kesalahpahaman umum — «tidak perlu pooling» hanya berlaku untuk virtual thread

Jangan baca disiplin «jangan pool mereka» sebagai «Java tidak punya pool thread (atau itu tidak efisien)». Kenyataannya sebaliknya: pool Java sudah bagian matang dari pustaka standar sejak JDK 5 (2004). Pool serbaguna yang dapat dikonfigurasi halus ThreadPoolExecutor (dibuat lewat berbagai pabrik Executors), ForkJoinPool pencuri kerja (instans bersama commonPool() adalah target eksekusi bawaan untuk parallel stream dan CompletableFuture), dan ScheduledThreadPoolExecutor untuk eksekusi berkala — ini masih pemain utama untuk pekerjaan CPU-bound.

Pool pada dasarnya adalah optimasi yang berpijak pada «membuat dan menahan thread OS mahal, jadi pakai ulang». Virtual thread menghapus premis ini dengan membuat biaya pembuatan mendekati nol, jadi tidak ada lagi alasan untuk memakai ulang — pemahaman yang akurat bukan bahwa pooling menjadi tidak efisien, melainkan bahwa thread sudah cukup ringan sehingga optimasi pooling tidak diperlukan. Dan di bawah virtual thread, penjadwal JDK menjalankan seperangkat thread pembawa (thread OS) yang jumlahnya kira-kira sebanyak inti, sebagai ForkJoinPool pencuri kerja.2 Dengan kata lain, bentuk «menangani pekerjaan konkuren yang sangat banyak dengan pool kecil thread OS» dipertahankan; hanya pengelolaan pool itu yang berpindah dari tangan pengembang ke JVM. Adil dikatakan Java mencapai tujuan yang sama dengan async/await .NET, yang mengembalikan thread-nya ke pool pada titik await, tanpa mengubah bentuk kode.

4. Mengurangi state bersama yang dapat berubah — partisi, kekekalan, koleksi konkuren, dan antrean

Kontensi muncul hanya ketika «beberapa thread» dan «data bersama yang dapat berubah» keduanya ada. Jumlah thread ditentukan kebutuhan, jadi yang bisa dipotong desain adalah bagian bersama. Ada tiga keluarga teknik — mempartisi, membuat kekal, dan menyerahkan data — dan begini Anda menulisnya di Java.

Partisi. Untuk agregasi paralel, alih-alih setiap thread menulis ke variabel total bersama, biarkan setiap thread membangun hasil parsial dan gabungkan di akhir. reduce / collect dari parallel stream menyediakan bentuk ini sebagai kerangka, dan LongAdder, dibahas di bawah, juga implementasi strategi partisi — secara internal membelah menjadi sel untuk menyebar kontensi, dan menjumlahkannya saat dibaca. Mengurangi seberapa sering Anda menulis ke state bersama datang sebelum menulis sinkronisasi dengan benar.

Buat kekal. Bangun data dengan record dan koleksi kekal (List.copyOf / Map.copyOf) yang tidak pernah ditulis ulang setelah konstruksi, dan Anda bisa membaginya tanpa sinkronisasi. Untuk konfigurasi dan data induk, pola standar: ketika perlu mengganti, bangun objek baru dan tukar referensi volatile. Namun, «tampak hanya-baca» dan «bersifat kekal» adalah hal berbeda. Aksesor record mengembalikan referensi mentah komponennya, dan salinan List.copyOf juga dangkal (tidak menduplikasi objek elemen), jadi jika elemen dapat berubah, siapa pun yang memegang alias bisa menulis ulang isi, dan kontensi tetap ada. Aman berbagi tanpa sinkronisasi hanya ketika seluruh graf objek — termasuk elemen — kekal. Jika elemen yang dapat berubah terlibat, entah berikan salinan dalam atau dorong elemen juga ke record / tipe kekal.

Pakai operasi majemuk pada koleksi konkuren. Untuk pola ConcurrentHashMap «buat jika belum ada, lalu sisipkan», pakai computeIfAbsent. Metode ini menjalankan seluruh pemanggilan secara atomik, dan jika kuncinya tidak ada, fungsi pemetaan dipanggil tepat sekali di dalam pemanggilan tunggal itu.8 Jaminannya berbeda dari ConcurrentDictionary.GetOrAdd .NET (yang pabriknya bisa berjalan lebih dari sekali di bawah kontensi) — titik yang mudah dikacaukan orang yang berpindah antara kedua bahasa. Namun itu bukan «tepat sekali sepanjang hidup kunci». Jika fungsi mengembalikan null atau melempar, tidak ada pemetaan yang didaftarkan, dan fungsi berjalan lagi pada pemanggilan berikutnya (sama jika entri dihapus setelah pendaftaran). Jika inisialisasi Anda tidak tahan efek samping yang terduplikasi, rancang agar fungsi berhasil dan mengembalikan non-null. Sebagai harga menjadi atomik, beberapa pembaruan dari thread lain diblokir selama komputasi berjalan, jadi jaga fungsi pemetaan tetap pendek, dan jangan pernah memperbarui peta yang sama dari dalam fungsi (pembaruan rekursif yang terdeteksi bisa melempar IllegalStateException).8

// Pola standar penghitung frekuensi: computeIfAbsent + LongAdder
ConcurrentHashMap<String, LongAdder> freqs = new ConcurrentHashMap<>();
freqs.computeIfAbsent(key, k -> new LongAdder()).increment();

Serahkan data lewat antrean. Arahkan aliran data antar-thread lewat BlockingQueue. Dengan ArrayBlockingQueue yang diberi kapasitas, put memblokir ketika penuh, memberi tekanan balik alami — bentuk yang sama dengan kanal terbatas di edisi .NET. Bahkan di era virtual thread, desain menarik batas jelas antara produsen dan konsumen ini tetap efektif.

5. Disiplin penguncian — synchronized dan ReentrantLock

5.1. Pada apa mengunci, dan apa yang tidak dilakukan sambil memegang kunci

Pikirkan satuan penguncian bukan sebagai «bagian kode» melainkan sebagai «data». Petakan satu objek kunci ke setiap kumpulan data yang dapat berubah yang ingin Anda lindungi, dan ambil kunci yang sama di setiap tempat yang menyentuh data itu — kenyataan bug balapan biasanya adalah pemetaan ini rusak di suatu tempat. Hindari synchronized(this) dan synchronized(SomeClass.class), karena kode luar bisa mengunci objek yang sama; alih-alih, pasangkan data yang ingin dilindungi satu-ke-satu dengan private final Object lock = new Object(); yang tidak pernah diekspos ke luar.

Dua disiplin lagi di atas itu. Pertama, jangan lakukan apa pun yang lambat, atau apa pun yang menyentuh dunia luar, sambil memegang kunci. I/O, memanggil pendengar, atau menjalankan kode tak dikenal sambil masih memegang kunci memperpanjang berapa lama Anda memegangnya dan berisiko pihak yang dipanggil mencoba mengambil kunci lain, menciptakan tunggu berputar dari Gambar 2. Kedua, tetapkan urutan akuisisi untuk beberapa kunci. Di tempat Anda mengambil dua kunci atau lebih, buat aturan bahwa setiap thread mengambilnya dalam urutan yang sama, dan untuk tempat yang tidak bisa menjamin urutan, siapkan jalur «lepaskan dan coba lagi jika tidak dapat» dengan tryLock(timeout), dibahas di bawah.

synchronized cukup untuk «eksklusi singkat dan sederhana». Pindah ke ReentrantLock begitu Anda butuh yang berikut.

  • Akuisisi berbatas waktu lewat tryLock(timeout) (mengubah hang permanen menjadi kegagalan yang bisa dicatat dan ditangani)
  • Kebijakan keadilan, beberapa Condition, atau ketika ingin memisahkan akuisisi dan pelepasan kunci ke metode berbeda

Saat memakai ReentrantLock, jangan pernah merusak pola try segera setelah lock() dan unlock() di blok finally (Java tidak punya padanan RAII C++, jadi pola ini adalah seluruh disiplin).

5.2. Virtual thread dan pinning — apa yang berubah di JDK 24

Ketika virtual thread pertama kali diperkenalkan (JDK 21-23), ada batasan di mana pemblokiran di dalam blok synchronized menempelkan virtual thread ke thread OS-nya (tidak bisa melepaskan thread OS, kehilangan manfaat skala), dan mengganti situs pemblokiran yang sering atau lama dengan ReentrantLock dianjurkan.3 Batasan itu terselesaikan ketika JEP 491 JDK 24 menulis ulang implementasi monitor, dan synchronized tidak lagi menempelkan virtual thread.4 Jika Anda di JDK 24 atau lebih baru, mengganti synchronized secara mekanis sebagai penanggulangan pinning tidak lagi diperlukan. Layak memeriksa apakah pedoman lama organisasi Anda masih tertahan pada peringatan era JDK 21.

5.3. Di mana atomik dan volatile masuk

Pembaruan atomik pada satu variabel ditangani oleh AtomicInteger / AtomicLong / AtomicReference (atau, untuk statistik yang hanya dinaikkan pada frekuensi tinggi, LongAdder yang tahan kontensi). volatile menjamin visibilitas dan pengurutan (happens-before), bukan atomisitas operasi majemuk.5 Kesimpulan yang sama dengan edisi .NET dan C++ berlaku juga di Java: pakai atomik untuk bendera dan nilai tunggal, kunci untuk state majemuk, dan jangan coba membuat volatile melakukannya sendiri.

6. Merancang cara berhenti — interruption sebagai bahasa bersama

6.1. Etiket interrupt

Berhenti dan pembatalan di Java disatukan di sekitar interruption. t.interrupt() menetapkan status interrupt thread sasaran, dan jika sasaran terblokir di sleep / wait / join atau semacamnya, ia melempar InterruptedException untuk membangunkannya segera (pada saat itu status interrupt dibersihkan).6 Thread.stop / suspend / resume, mekanisme paksa masa lalu, pada dasarnya tidak aman, jadi memanggilnya sekarang menghasilkan UnsupportedOperationException.6

Bisa selesai sendiriTidak bisa selesai - mis. di dalam pustakaPemanggil menghentikannya - memanggil t.interruptStatus interrupt ditetapkanThread yang menghitung:menyelidik Thread.interrupted dalam loopTerblokir di sleep / wait / join:InterruptedException terpicu dan membangunkan segerastatus dibersihkanMembersihkan dan selesai sendiriApa yang dilakukan catch?Thread.currentThread.interruptmemulihkan status dan meninggalkan sinyal

Gambar 4: Penghentian kooperatif lewat interruption. Menelan InterruptedException membuat sinyal berhenti lenyap — begitu Anda menangkapnya, pilihannya «selesai» atau «pulihkan»

Hanya satu disiplin yang perlu diingat di praktik: jangan menulis kode yang menangkap InterruptedException dan tidak melakukan apa pun. Jika Anda bisa selesai dalam tanggung jawab sendiri, selesai di situ; jika tidak, pulihkan status dengan Thread.currentThread().interrupt() dan teruskan sinyal ke pemanggil (lihat FAQ).

6.2. Shutdown dua tahap ExecutorService

API shutdown ExecutorService duduk di atas model interruption. shutdown() berhenti menerima tugas baru dan membiarkan tugas yang sudah dikirim berjalan sampai selesai; shutdownNow() mencoba menghentikan tugas yang sedang berjalan. Sebagai spesifikasi antarmuka ini best-effort, dan didokumentasikan secara eksplisit bahwa implementasi standar (seperti ThreadPoolExecutor) biasanya membatalkan lewat Thread.interrupt() — artinya tugas yang tidak merespons interruption tidak akan berhenti bahkan dengan shutdownNow, dan jika Anda memakai implementasi Executor kustom, Anda perlu memeriksa dokumentasinya untuk melihat bagaimana ia membatalkan (apakah mengirim interrupt sama sekali).1 Pola berhenti standar yang ditunjukkan dokumentasi resmi adalah pola dua tahap berikut.1

/** Benar begitu shutdown selesai. Jangan lanjut melepaskan sumber daya bersama selama ini salah. */
boolean shutdownAndAwaitTermination(ExecutorService pool) {
    pool.shutdown();                    // Tahap 1: berhenti menerima tugas baru dan menunggu penyelesaian
    try {
        if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
            // Tahap 2: minta pembatalan. Tugas yang diturunkan sebelum berjalan
            // dikembalikan, jadi tandai Future mereka dibatalkan untuk membangunkan pemanggil yang diblokir di get()
            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;           // Shutdown tidak selesai. Laporkan dalam bentuk yang bisa 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 thread ini
        return false;                   // Jalur ini pun bisa meninggalkan shutdown tidak lengkap
    }
}
selesai dalam tenggathabis waktuselesaimasih belum selesaishutdownberhenti menerima tugas baruawaitTerminationmenunggu penyelesaianShutdown selesaishutdownNowmengirim interrupt ke tugas yang berjalanapakah merespons terserah tugasawaitTerminationmenunggu lagiCatat sebagai anomalicurigai tugas yang mengabaikan interrupt

Gambar 5: Shutdown dua tahap ExecutorService. Desain bertahap «tunggu dengan sopan → minta lewat interruption → jika masih belum selesai, amati sebagai anomali»

Ada satu batasan pada membatalkan Runnable yang dikembalikan shutdownNow(). Yang kembali adalah objek yang duduk di antrean eksekusi — untuk submit biasa itu FutureTask yang sama yang diserahkan ke pemanggil, tetapi untuk tugas yang dikirim lewat pembungkus seperti ExecutorCompletionService, itu pembungkus di dalam antrean, objek berbeda dari Future pemanggil. Dalam konfigurasi itu, pembatalan di atas tidak akan menyelesaikan Future pemanggil, jadi rancang untuk menyimpan daftar Future sendiri saat pengiriman dan membatalkan itu saat shutdown (atau mengembalikan tugas yang diturunkan ke pemiliknya).

close() (AutoCloseable), tersedia mulai JDK 19, mengemas «panggil shutdown dan tunggu penyelesaian» ke bentuk yang bisa ditulis dengan try-with-resources, dan try (var executor = ...) digabung dengan newVirtualThreadPerTaskExecutor virtual thread adalah bentuk dasar modern.1 Namun, close() bukan pengganti pola dua tahap di atas. Karena ia menunggu penyelesaian tanpa batas waktu, jika bahkan satu tugas tidak merespons interruption atau tidak pernah selesai, thread yang mencoba menutupnya terblokir selamanya. Ini alat yang cocok untuk lingkup di mana tugas terbatas dan dijamin berjalan sampai selesai (kirim di situ, tunggu di situ); untuk tempat seperti jalur shutdown aplikasi, di mana Anda ingin selalu selesai dalam waktu terbatas, pakai pola dua tahap berbatas waktu. Membatalkan tugas individu juga dilakukan lewat interruption, dengan Future.cancel(true).

7. Thread UI — EDT Swing

Aplikasi desktop, terlepas dari bahasa atau kerangka, mengikuti aturan bahwa UI milik eksklusif thread yang mengelolanya. Di Swing, thread eksklusif itu adalah Event Dispatch Thread (EDT): metode komponen Swing, sebagai aturan, tidak thread-safe, dan menyentuhnya dari beberapa thread mengundang gangguan thread dan kesalahan konsistensi memori. Minta pembaruan layar dari thread lain lewat SwingUtilities.invokeLater ke EDT, dan sebaliknya, karena menjalankan operasi panjang di EDT membekukan UI, dorong pekerjaan berat ke thread pekerja lewat SwingWorker atau semacamnya.7 JavaFX mengikuti bentuk yang sama: pembaruan UI diminta pada thread aplikasi lewat Platform.runLater.

8. Verifikasi dan debug — dump thread sebagai senjata

Anda tidak bisa berharap pengujian menemukan bug balapan. Tes biasa menghitung jalan di mana kontensi kebetulan tidak terjadi sebagai sukses. Pikirkan pertahanan dalam tiga lapis.

Garis pertahanan pertama adalah desain. Dalam tinjauan, periksa dengan tabel: data mana yang dapat berubah yang dibagi, kunci mana yang melindungi setiap butir (pemetaan dari bagian 5.1), apakah urutan akuisisi kunci unik, apakah ada blok catch yang menelan InterruptedException, dan apakah jalur berhenti (shutdown/interruption) mencapai setiap tugas.

Kedua, manfaatkan dump thread dengan baik. Java punya alat standar untuk menangkap «keadaan setiap thread pada saat beku ini»: jstack (atau jcmd <pid> Thread.print) mencetak jejak tumpukan, dan opsi -l menambahkan informasi kunci juga.9 Perhatikan bahwa format dump tradisional ini untuk thread platform; ia tidak menyertakan virtual thread aplikasi Anda. Saat menelusuri permintaan terblokir dalam konfigurasi yang memakai virtual thread (bagian 3), pakai jcmd <pid> Thread.dump_to_file -format=json <file>, yang bisa men-dump virtual thread juga.2 Prosedur dasar menyelidiki hang adalah mengambil dua atau tiga dump berjarak beberapa detik dan mencocokkan kunci mana yang ditunggu setiap thread menganggur, dan siapa yang memegang kunci itu. Jika Anda mencatat habis waktu tryLock(timeout) (bagian 5.2), Anda bahkan bisa mengotomatiskan pemicu pengambilan dump.

Ketiga, goyangkan di bawah beban. Pengujian stres — berjalan lama dengan paralelisme lebih dari jumlah inti, mengacak urutan pemrosesan, menyisipkan penundaan buatan — adalah cara praktis membuat «kena» kondisi balapan lebih mungkin di mesin pengembangan. Jalankan setidaknya satu tes dengan volume data dan jumlah thread skala produksi sebelum rilis.

9. Ke mana konkurensi Java menuju — Structured Concurrency

Sekilas setengah langkah ke depan, untuk menutup. Dibangun di atas asumsi virtual thread, Structured Concurrency (StructuredTaskScope) — yang memperlakukan beberapa subtugas sebagai satu satuan kerja dan menstrukturkan perambatan kegagalan dan pembatalan — sedang dikembangkan dan, per Agustus 2026, masih fitur pratinjau. Ia direvisi ke bentuk API berbasis StructuredTaskScope.open() di pratinjau kelima JDK 25 (JEP 505), dan berlanjut ke pratinjau keenam (JEP 525) di JDK 26 saat ini.1011 Sementara itu, Scoped Values, berbagi konteks kekal yang menyelesaikan masalah ThreadLocal, difinalisasi di JDK 25.12 Prinsip artikel ini — batas tugas yang jelas, berbagi kekal, penghentian kooperatif — selaras juga dengan arah API baru ini.

10. Ringkasan — daftar periksa Java

  1. Apakah masih ada new Thread di kode bisnis (apakah dibangun di atas ExecutorService / virtual thread)?
  2. Apakah pekerjaan I/O-bound dan CPU-bound diarahkan ke mekanisme eksekusi berbeda (cabang di Gambar 3)?
  3. Apakah virtual thread tidak di-pool, dan konkurensi dibatasi dengan Semaphore?
  4. Apakah data bersama kekal (record / List.copyOf), atau dibangun di atas alat java.util.concurrent?
  5. Apakah tidak ada synchronized(this) atau penguncian pada objek yang diekspos publik?
  6. Apakah Anda memakai operasi majemuk ConcurrentHashMap (computeIfAbsent, dll.) dan menjaga fungsi pemetaan tetap pendek?
  7. Apakah Anda tidak mengharapkan atomisitas dari volatile (apakah penghitung memakai kelas Atomic / LongAdder)?
  8. Apakah tidak ada satu pun blok catch yang menelan InterruptedException?
  9. Apakah menghentikan ExecutorService mengikuti pola dua tahap berbatas waktu (dan tempat yang memakai close() dibatasi pada lingkup di mana penyelesaian tugas dijamin)?
  10. Apakah pembaruan UI Swing/JavaFX dikonsolidasikan ke EDT / thread aplikasi?

Java adalah salah satu bahasa yang paling lengkap diperlengkapi alat konkurensi, dan datangnya virtual thread membuka jalan untuk menskalakan kode sinkron yang lurus tanpa menulisnya ulang. Itulah mengapa memahami dengan benar pembagian kerja di antara alat — mana untuk throughput, mana untuk eksklusi, dan apa yang menandai berhenti — adalah substansi desain multithreading di Java.

Artikel terkait

Area konsultasi terkait

KomuraSoft LLC menangani tinjauan desain multithreading untuk sistem bisnis dan pemrosesan batch Java, investigasi akar masalah cacat terkait konkurensi seperti kerusakan state bersama dan gejala «kadang-kadang tidak berhenti / macet» (analisis dump thread), serta konsultasi teknis tentang mengadopsi virtual thread.

Tautan referensi

  1. Oracle, ExecutorService (Java SE 21 & JDK 21 API). Tentang shutdown() membiarkan tugas yang sudah dikirim berjalan sampai selesai sambil menghentikan pengiriman baru; tentang shutdownNow() mencoba menghentikan tugas yang berjalan dan mengembalikan daftar tugas yang menunggu eksekusi, meskipun implementasi tipikal membatalkan lewat Thread.interrupt(), tanpa jaminan di luar best-effort, jadi tugas yang tidak merespons interruption tidak akan berakhir; tentang dapat menunggu penyelesaian dengan awaitTermination; tentang close() (AutoCloseable, dari Java 19) memanggil shutdown dan menunggu penyelesaian, dapat dipakai dengan try-with-resources; dan tentang shutdown dua tahap shutdown → awaitTermination → shutdownNow ditampilkan sebagai contoh pemakaian.  2 3 4 5 6

  2. OpenJDK, JEP 444: Virtual Threads. Tentang virtual thread menjadi fitur resmi di JDK 21; tentang mereka sebagai thread ringan yang secara dramatis mengurangi upaya menulis, merawat, dan mengamati aplikasi konkuren berthroughput tinggi; tentang filosofi desain menskalakan kode sinkron lurus «satu permintaan, satu thread» tanpa diubah; tentang penjadwal virtual thread JDK sebagai ForkJoinPool pencuri kerja yang beroperasi dalam mode FIFO, dengan paralelisme bawaan sama dengan jumlah prosesor yang tersedia; dan tentang format dump thread baru yang menyertakan virtual thread ditambahkan sebagai jcmd Thread.dump_to_file (dalam bentuk teks biasa dan JSON), sementara dump thread tradisional tidak menyertakan virtual thread.  2 3 4 5

  3. Oracle Java SE Core Libraries, Virtual Threads. Tentang virtual thread sebagai thread ringan yang diimplementasikan runtime Java yang melepaskan thread OS selama I/O pemblokiran; tentang mereka sebagai fitur untuk skala (throughput) bukan kecepatan (latensi), dan tidak cocok untuk pemrosesan intensif CPU; tentang tidak pernah mem-pool virtual thread dan memakai satu per tugas (newVirtualThreadPerTaskExecutor); tentang memakai Semaphore daripada pool thread untuk membatasi konkurensi; tentang pemblokiran di dalam synchronized per JDK 21 menyebabkan pinning ke thread OS, yang untuk itu penggantian situs yang sering atau lama dengan ReentrantLock disarankan; dan tentang dapat mendeteksi pinning dengan -Djdk.tracePinnedThreads.  2 3 4 5 6 7

  4. OpenJDK, JEP 491: Synchronize Virtual Threads without Pinning. Tentang implementasi monitor JVM ditulis ulang untuk JDK 24 guna mendukung virtual thread, sehingga pemblokiran di dalam blok atau metode synchronized tidak lagi menempelkan virtual thread ke thread pembawanya; dan tentang ini berarti penanggulangan era JDK 21-23 «mengganti synchronized dengan ReentrantLock» pada prinsipnya tidak lagi diperlukan.  2

  5. Oracle, The Java Tutorials, Memory Consistency Errors. Tentang kesalahan konsistensi memori muncul ketika beberapa thread memiliki pandangan yang tidak konsisten atas data yang sama; tentang kunci untuk menghindarinya adalah relasi happens-before (jaminan bahwa penulisan memori oleh satu pernyataan terlihat oleh pernyataan lain); dan tentang synchronized, volatile, dan Thread.start / join, di antara lain, menciptakan relasi happens-before.  2 3

  6. Oracle, Thread (Java SE 21 & JDK 21 API). Tentang Thread.stop / suspend / resume pada dasarnya tidak aman (kunci dilepas dalam keadaan tidak konsisten dan objek rusak menjadi terlihat; suspend dapat mengundang deadlock), membuatnya didepresiasi untuk dihapus, dan sekarang melempar UnsupportedOperationException saat dipanggil; tentang interrupt() menetapkan status interrupt dan membangunkan thread yang terblokir di sleep / wait / join dengan melempar InterruptedException (yang membersihkan status interrupt); dan tentang perbedaan cara interrupted() dan isInterrupted() memperlakukan status itu.  2 3

  7. Oracle, The Java Tutorials, The Event Dispatch Thread. Tentang kode penanganan peristiwa Swing berjalan di Event Dispatch Thread (EDT); tentang sebagian besar metode objek Swing tidak thread-safe, sehingga memanggilnya dari beberapa thread mengundang gangguan thread dan kesalahan konsistensi memori, artinya akses ke komponen Swing sebaiknya, sebagai aturan, dilakukan di EDT; tentang meminta tugas di EDT dari thread lain lewat SwingUtilities.invokeLater / invokeAndWait; dan tentang tugas yang berjalan di EDT perlu selesai cepat.  2

  8. Oracle, ConcurrentHashMap (Java SE 21 & JDK 21 API). Tentang seluruh pemanggilan metode computeIfAbsent dieksekusi secara atomik, dengan fungsi pemetaan dipanggil tepat sekali ketika kuncinya tidak ada; tentang beberapa operasi pembaruan dari thread lain diblokir selama komputasi, jadi harus dijaga pendek dan sederhana; tentang fungsi pemetaan dilarang mengubah peta ini sendiri, dengan pembaruan rekursif yang terdeteksi menghasilkan IllegalStateException; dan tentang operasi pengambilan (get) tidak memblokir, dengan relasi happens-before berlaku antara pembaruan untuk kunci tertentu dan pengambilan berikutnya.  2 3

  9. Oracle, The jstack Command (Java SE 21 Tools Reference). Tentang jstack mencetak jejak tumpukan (nama kelas, nama metode, nomor baris) setiap thread dalam proses Java yang ditentukan; tentang opsi -l mengaktifkan tampilan terperinci yang menyertakan informasi kunci tambahan; dan tentang dipakai bersama alat diagnostik lain seperti jcmd. 

  10. OpenJDK, JEP 505: Structured Concurrency (Fifth Preview). Tentang API structured concurrency memperlakukan sekelompok subtugas terkait sebagai satu satuan kerja dan menstrukturkan perambatan kesalahan dan pembatalan; tentang StructuredTaskScope direvisi ke bentuk yang dibuka lewat metode pabrik statis (open); dan tentang ia, per JDK 25, adalah pratinjau kelima dan belum fitur yang difinalisasi. 

  11. OpenJDK, JEP 525: Structured Concurrency (Sixth Preview). Tentang structured concurrency berlanjut sebagai pratinjau keenam juga di JDK 26 — yaitu, per Agustus 2026, masih fitur pratinjau di JDK saat ini, yang memerlukan fitur pratinjau diaktifkan untuk memakainya. 

  12. OpenJDK, JEP 506: Scoped Values. Tentang Scoped Values difinalisasi di JDK 25; dan tentang mereka sebagai mekanisme untuk berbagi data konteks kekal dengan aman dan efisien di dalam dan antar thread, menyediakan solusi bagi masalah ThreadLocal (kebisaan berubah, pengelolaan siklus hidup, dan biaya pewarisan). 

Artikel terbaru dengan tag yang sama untuk mendalami topik-topik terdekat.

Halaman-halaman ini menempatkan topik dalam konteks layanan dan keputusan yang lebih luas.

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 jumlah tugas yang sangat besar yang didominasi menunggu I/O; mereka tidak membuat kode lebih cepat, mereka menaikkan throughput. Untuk pekerjaan I/O-bound, pakai satu virtual thread per tugas (Executors.newVirtualThreadPerTaskExecutor) dan jangan pernah 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 alat yang tepat, seperti sebelumnya. Dan jika Anda ingin membatasi jumlah koneksi bersamaan ke layanan eksternal, 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 Anda butuh fitur seperti akuisisi berbatas waktu lewat tryLock, kebijakan keadilan, atau beberapa Condition. Ada catatan historis saat menggabungkannya dengan virtual thread: di JDK 21-23 ada masalah di mana pemblokiran di dalam blok synchronized menempelkan (pin) virtual thread ke thread OS-nya, jadi mengganti situs pemblokiran yang sering atau lama dengan ReentrantLock dianjurkan. JDK 24 (JEP 491) menulis ulang implementasi monitor dan menyelesaikan batasan ini. Mulai JDK 24, Anda tidak perlu mengganti synchronized karena alasan pinning.
Apakah menambahkan volatile membuat sesuatu thread-safe?
Tidak. volatile Java menciptakan relasi happens-before antara penulisan ke variabel itu dan pembacaan darinya, menjamin visibilitas (bahwa penulisan terbaru terlihat oleh thread lain) dan pengurutan, tetapi tidak menjamin atomisitas untuk operasi majemuk seperti "baca, hitung, tulis kembali". Naikkan penghitung volatile int dengan ++ dari beberapa thread dan penambahan hilang. Pakai AtomicInteger / AtomicLong (atau LongAdder untuk agregasi berfrekuensi tinggi) untuk penghitung, dan kunci ketika melindungi beberapa variabel bersama. volatile hampir hanya cocok dalam situasi seperti bendera status sederhana — di mana satu thread menulis dan yang lain hanya membaca.
Bolehkah menangkap InterruptedException lalu mengabaikannya?
Tidak. Interruption adalah sinyal standar Java untuk berhenti dan pembatalan, dan menelannya menciptakan thread yang tidak akan berhenti. Pada saat InterruptedException dilempar, status interrupt sudah dibersihkan, jadi jika Anda tidak bisa menyelesaikan pekerjaan sendiri, pulihkan status dengan Thread.currentThread().interrupt() untuk meninggalkan sinyal 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 saya menghentikan thread dengan Thread.stop?
Tidak, Anda tidak bisa. Thread.stop pada dasarnya tidak aman (melepaskan kunci sambil meninggalkannya dalam keadaan tidak konsisten, mengekspos objek rusak ke thread lain), jadi sudah lama didepresiasi, 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 Anda 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 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.

Kembali ke blog