Dua thread yang menulis ke data yang sama tanpa pengaturan akan saling menimpa, dan hasil akhirnya bergantung pada kebetulan waktu eksekusi. Kondisi semacam ini disebut race condition, yaitu cacat yang muncul ketika kebenaran program bergantung pada urutan antar-thread yang tidak bisa dikendalikan. Topik ini menyaksikan race condition itu terjadi pada penghitung sederhana, lalu menutupnya dengan tiga perlengkapan Java, yakni kata kunci synchronized, pasangan wait dan notify untuk koordinasi, serta Lock dari paket java.util.concurrent.locks.
Race condition yang dapat disaksikan#
Penyebab klasik race condition adalah operasi baca-ubah-tulis yang tampak satu baris padahal tiga langkah. Pernyataan hitungan = hitungan + 1 membaca nilai lama, menambahkannya di register, lalu menulisnya kembali, dan thread lain dapat muncul di antara langkah-langkah itu dengan membawa nilai lama yang sama. Demonya sengaja menyisipkan jeda satu milidetik di antara pembacaan dan penulisan untuk melebarkan jendela tabrakan, karena pada kode nyata pekerjaan lain selalu ada di antara keduanya, misalnya perhitungan atau panggilan jaringan.
public class DemoRace {
static int hitungan = 0;
static void tambah(int banyak) {
for (int i = 0; i < banyak; i++) {
int nilai = hitungan;
try {
Thread.sleep(1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
hitungan = nilai + 1;
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> tambah(10));
Thread t2 = new Thread(() -> tambah(10));
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Harapan: 20");
if (hitungan == 20) {
System.out.println("Hitungan akhir sesuai harapan");
} else {
System.out.println("Hitungan akhir tidak sama dengan yang diharapkan");
}
}
}Harapan: 20
Hitungan akhir tidak sama dengan yang diharapkanDua thread masing-masing menambah sepuluh kali, sehingga hitungannya seharusnya 20. Program sengaja tidak mencetak angka akhirnya karena nilai itu berubah-ubah antar eksekusi. Yang jelas adalah kategorinya: pada pengujian berulang, hasilnya selalu gagal dengan angka yang mendarat jauh di bawah 20. Thread yang menulis ulang memakai nilai bacaan lamanya, sehingga kenaikan milik thread lain musnah tanpa pesan error apa pun. Kerusakan yang senyap seperti inilah yang membuat race condition berbahaya di produksi.
Menutup celah dengan synchronized#
Kata kunci synchronized menjadikan satu blok kode eksklusif bagi satu thread pada satu waktu. Setiap objek Java memiliki satu monitor, yaitu kunci bawaan yang harus diperoleh sebelum memasuki blok atau metode synchronized, dan thread kedua menunggu sampai kunci itu dilepas. Metode static synchronized memakai monitor milik kelasnya, sedangkan metode instance memakai monitor milik objeknya.
public class DemoSinkron {
static int hitungan = 0;
static synchronized void tambah(int banyak) {
for (int i = 0; i < banyak; i++) {
int nilai = hitungan;
try {
Thread.sleep(1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
hitungan = nilai + 1;
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> tambah(10));
Thread t2 = new Thread(() -> tambah(10));
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Harapan: 20");
System.out.println("Hitungan akhir: " + hitungan);
}
}Harapan: 20
Hitungan akhir: 20Tubuh metodenya identik dengan versi race, tetapi tambahan synchronized mengubah nasibnya. Thread kedua menunggu sampai thread pertama selesai melepaskan monitor kelas, sehingga operasi baca-ubah-tulis tidak pernah terpotong dan hitungannya tepat 20 pada setiap eksekusi. Selain pada metode, synchronized dapat ditempelkan pada blok tertentu dengan kunci yang dipilih sendiri, sehingga wilayah eksklusifnya sekecil mungkin.
// tidak-dijalankan
private final Object kunciSaldo = new Object();
public void setor(long nominal) {
synchronized (kunciSaldo) {
saldo = saldo + nominal;
}
}Koordinasi dengan wait dan notify#
Sinkronisasi hanya mencegah tabrakan, sedangkan koordinasi mengatur giliran. Metode wait() membuat thread melepaskan monitornya dan tidur sampai dibangunkan, sedangkan notify() atau notifyAll() membangunkan thread yang menunggu pada monitor yang sama. Keduanya wajib dipanggil dari dalam blok atau metode synchronized pada objek yang sama, karena tanpa memegang monitor, pemanggilannya melempar IllegalMonitorStateException. Pemeriksaan syaratnya ditulis dalam perulangan while, bukan if, agar thread yang terbangun memeriksa ulang keadaannya sendiri.
public class DemoWaitNotify {
static class Buffer {
private int isi;
private boolean ada = false;
synchronized void produksi(int nilai) throws InterruptedException {
while (ada) {
wait();
}
isi = nilai;
ada = true;
notifyAll();
}
synchronized int konsumsi() throws InterruptedException {
while (!ada) {
wait();
}
ada = false;
notifyAll();
return isi;
}
}
public static void main(String[] args) throws InterruptedException {
Buffer buffer = new Buffer();
Thread produsen = new Thread(() -> {
for (int i = 1; i <= 5; i++) {
try {
buffer.produksi(i);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});
Thread konsumen = new Thread(() -> {
for (int i = 0; i < 5; i++) {
try {
System.out.println("Dikonsumsi: " + buffer.konsumsi());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});
produsen.start();
konsumen.start();
produsen.join();
konsumen.join();
System.out.println("Selesai");
}
}Dikonsumsi: 1
Dikonsumsi: 2
Dikonsumsi: 3
Dikonsumsi: 4
Dikonsumsi: 5
SelesaiBuffer satu slot di atas memaksa kedua thread berbaris rapi. Produsen yang menemukan buffer penuh akan tidur lewat wait(), lalu dibangunkan konsumen yang baru saja mengosongkannya, dan sebaliknya. Pola produsen-konsumen semacam ini adalah penggunaan wait dan notify yang paling umum, dan BlockingQueue dari paket java.util.concurrent mengemas pola yang sama sehingga keduanya tidak perlu ditulis manual.
Kunci eksplisit dengan Lock#
Kelas ReentrantLock menawarkan kunci dengan perilaku yang sama seperti monitor, tetapi diperoleh dan dilepas secara eksplisit lewat lock() dan unlock(). Pasangan itu wajib dibungkus try dan finally agar kunci tetap terlepas meski bloknya melempar exception. Keunggulannya dibanding synchronized adalah kemampuan mencoba mengambil kunci tanpa menunggu (tryLock), menunggu dengan batas waktu, dan memilih kunci yang adil bagi thread terlama menunggu.
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class DemoLock {
static int hitungan = 0;
static final Lock kunci = new ReentrantLock();
static void tambah(int banyak) {
for (int i = 0; i < banyak; i++) {
kunci.lock();
try {
int nilai = hitungan;
try {
Thread.sleep(1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
hitungan = nilai + 1;
} finally {
kunci.unlock();
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> tambah(10));
Thread t2 = new Thread(() -> tambah(10));
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Harapan: 20");
System.out.println("Hitungan akhir: " + hitungan);
}
}Harapan: 20
Hitungan akhir: 20Hasilnya sama persis dengan versi synchronized, dan justru itulah poinnya, karena keduanya menjamin hal yang sama untuk kasus ini. Pilihan keduanya menyesuaikan kebutuhan, synchronized untuk penjagaan biasa yang ringkas, dan Lock bila fitur lanjutannya benar-benar diperlukan.
Kesalahan umum#
- Mengira satu pernyataan berarti satu operasi atomik.
hitungan = hitungan + 1adalah tiga langkah yang dapat terpotong, sehingga butuhsynchronized,Lock, atau kelas atomik sepertiAtomicInteger. - Memakai dua objek kunci berbeda untuk data yang sama. Thread yang mengunci
kunciAdan thread yang menguncikunciBtidak pernah saling menunggu, sehingga race condition tetap terjadi. Satu data yang dijaga, satu kunci. - Memanggil
wait()ataunotify()di luar bloksynchronizedpada objek yang sama. Pemanggilannya melemparIllegalMonitorStateException, karena syaratnya adalah memegang monitor itu. - Melepas kunci
Locktanpafinally. Exception di dalam blok kritis membuatunlock()terlewat, dan semua thread lain menunggu selamanya. Bungkus selalu bagian kritis dengantrydanfinally.
Ringkasan#
- Race condition muncul karena operasi baca-ubah-tulis dapat terpotong di tengah jalan oleh thread lain.
synchronizedmemakai monitor objek atau kelas untuk menjadikan blok kode eksklusif bagi satu thread pada satu waktu.waitdannotifyAllmengatur giliran pada monitor yang sama, selalu dipanggil dari dalamsynchronizeddan diperiksa ulang lewatwhile.ReentrantLockmenawarkan penjagaan yang sama dengan fitur tambahan, danunlock()-nya wajib berada difinally.
Referensi#
- Synchronization — Oracle
— tutorial resmi tentang race condition,
synchronizedmetode, dansynchronizedblok. - Guarded Blocks — Oracle
— tutorial resmi pola
waitdannotifyuntuk koordinasi antar-thread. - Class ReentrantLock — Oracle
— dokumentasi resmi kunci eksplisit beserta
tryLockdan mode fairness-nya.