Chapter pertama Section ini membuktikan bahwa seluruh thread dalam satu proses berbagi heap yang sama. Berbagi memori membuat pertukaran data menjadi murah, tetapi juga membuka kategori bug yang paling sulit diburu: race condition, kesalahan yang muncul hanya ketika waktu pengambilan dua thread berpotongan secara tertentu. Program bisa lulus seratus kali uji lalu gagal di produksi, atau gagal hanya di mesin dengan jumlah core berbeda. Pada Chapter ini kita bedah anatomi race condition, mereproduksinya secara deterministik untuk pembelajaran, lalu melihat versi liarnya yang tak bisa diprediksi — beserta obat pertamanya.
Ketika Hasil Tergantung Waktu #
Race condition terjadi ketika kebenaran program bergantung pada urutan atau waktu eksekusi thread yang tidak kita kendalikan. Syaratnya selalu sama: dua thread atau lebih mengakses memori yang sama, minimal satu akses adalah penulisan, dan tidak ada koordinasi di antara mereka. Bila ketiga syarat terpenuhi, hasil program menjadi undian penjadwal — kadang benar, kadang salah, tanpa pola yang bisa kita janjikan.
Bentuk paling sering dijumpai adalah read-modify-write pada satu variabel. Kode counter++ tampak sebagai satu perintah, tetapi compiler menerjemahkannya menjadi tiga langkah: membaca nilai counter dari memori, menambah satu di register, lalu menulis hasilnya kembali ke memori. Dua thread yang menjalankan tiga langkah tersebut secara bersamaan bisa saling menimpa: keduanya membaca nilai yang sama, keduanya menambah satu, dan tulisan terakhir menghapus kontribusi tulisan pertama.
Anatomi Lost Update #
Mari kita gambar kronologi terburuknya. Dua teller menambah saldo yang sama; interleaving sial berikut ini membuat satu penambahan tidak pernah tercatat:
Kejadian ini bisa kita buat terjadi secara pasti. Program berikut memasang jeda sleep() di antara langkah baca dan tulis sehingga jendela race terbuka lebar dan hasilnya dapat direproduksi setiap eksekusi:
public class LostUpdate {
private static int saldo = 0;
public static void main(String[] args) throws InterruptedException {
// Dua teller menambah saldo secara bersamaan.
// Jeda sleep sengaja dipasang agar jendela race terbuka lebar
// dan hasilnya bisa direproduksi untuk pembelajaran.
Thread tellerA = new Thread(() -> {
int baca = saldo; // langkah 1: baca saldo (0)
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
saldo = baca + 1; // langkah 3: tulis hasil hitung (0 + 1)
}, "teller-A");
Thread tellerB = new Thread(() -> {
int baca = saldo; // langkah 2: baca saldo (masih 0!)
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
saldo = baca + 1; // langkah 4: tulis (0 + 1), menimpa tulisan teller-A
}, "teller-B");
tellerA.start();
tellerB.start();
tellerA.join();
tellerB.join();
System.out.println("saldo akhir : " + saldo);
System.out.println("seharusnya : 2");
}
}Output yang Dihasilkan:
saldo akhir : 1
seharusnya : 2Mari kita telaah kronologi program tersebut:
- Baris 9 (
int baca = saldo;): teller-A menyalin nilaisaldo(masih0) ke variabel lokal — langkah read. - Baris 13 (
Thread.sleep(100)): jeda buatan ini menahan langkah write teller-A selama 100 milidetik. Pada program nyata, jeda ini digantikan jeda alami: pemanggilan method lain, akses disk, atau sekadar ribuan instruksi lain di antara baca dan tulis. - Baris 19 (
int baca = saldo;): teller-B membaca pada saat teller-A masih menahan hasil hitungannya — teller-B pun melihat0, bukan1. - Baris 25 (
saldo = baca + 1;): teller-B menulis1lebih dulu (tidurnya hanya 50 milidetik), lalu teller-A menulis1lagi di baris 15 — dua penulisan, satu nilai hilang. - Baris 33-34: hasil akhir
1tercetak setelah kedua thread di-join(), membuktikan penambahan kedua tidak pernah sampai ke memori.
Versi Liar di Alam Nyata #
Jeda buatan memang perlu demi reproduksi, tetapi race nyata hidup di jendela berdurasi mikrodetik. Contoh klasik berikut menambah counter dua juta kali dari empat thread tanpa koordinasi apa pun:
public class CounterRapuh {
private static class Counter {
long nilai = 0;
void naik() {
nilai++;
}
}
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable tugas = () -> {
for (int i = 0; i < 2_000_000; i++) {
counter.naik();
}
};
Thread[] teller = new Thread[4];
for (int i = 0; i < teller.length; i++) {
teller[i] = new Thread(tugas, "teller-" + i);
teller[i].start();
}
for (Thread t : teller) {
t.join();
}
System.out.println("nilai akhir: " + counter.nilai);
}
}Angka yang benar adalah 8000000. Pada enam kali eksekusi berturut-turut di mesin saya (4 core penjadwal, JDK 25), nilai yang tercetak adalah 7841169, 7852445, 7883320, 7903532, 7936639, dan 7957916 — selalu kurang, dan tidak ada satu pun angka yang berulang. Anda hampir pasti mendapat hasil yang berbeda, bahkan di mesin yang sama: nilai akhir bergantung pada interleaving yang diatur penjadwal sistem operasi pada eksekusi itu.
Kategori Race Check-Then-Act #
Selain read-modify-write, pola check-then-act sama rawannya: thread memeriksa kondisi lalu bertindak berdasarkan hasil pemeriksaan, padahal kondisi bisa berubah di antara keduanya. Inisialisasi malas berikut adalah contoh kanoniknya:
if (konfigurasi == null) { // cek
konfigurasi = muatBerkas(); // isi: dua thread bisa lolos pemeriksaan yang sama
}Dua thread yang sama-sama melihat konfigurasi == null akan keduanya memanggil muatBerkas() — berkas dibaca dua kali, objek dibuat dua kali, dan objek yang dipakai akhirnya tergantung siapa menulis terakhir. Kerusakan serupa juga muncul pada operasi “transfer antar rekening” yang memeriksa saldo cukup lalu memotongnya, atau pada “tambah jika belum ada” di HashMap biasa (yang bahkan bisa merusak struktur internal map itu sendiri).
Menutup Race dengan synchronized #
Cara paling langsung menghilangkan race pada contoh kita adalah menandai operasi kritis dengan synchronized — hanya satu thread yang boleh menjalankan method pada objek tersebut dalam satu waktu, sehingga tiga langkah baca-hitung-tulis berubah menjadi satu kesatuan tak terpisahkan:
public class CounterAman {
private static class Counter {
private long nilai = 0;
// synchronized menjamin hanya satu thread yang menjalankan
// read-modify-write ini pada satu waktu.
synchronized void naik() {
nilai++;
}
}
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable tugas = () -> {
for (int i = 0; i < 100_000; i++) {
counter.naik();
}
};
Thread a = new Thread(tugas, "teller-A");
Thread b = new Thread(tugas, "teller-B");
a.start();
b.start();
a.join();
b.join();
System.out.println("nilai akhir: " + counter.nilai);
}
}Output yang Dihasilkan:
nilai akhir: 200000Kata kunci synchronized pada method naik() membuat 200.000 panggilan dari dua thread tetap menghasilkan 200000 — setiap eksekusi, tanpa undian. Mekanisme lengkap di baliknya (monitor, intrinsic lock, dan mengapa hasil baca setelah join() selalu segar) adalah topik Chapter synchronized dan Intrinsic Locks berikutnya.
Referensi #
- Oracle: The Java Tutorials — Synchronization
- Oracle: The Java Tutorials — Concurrency
- Oracle: Package java.util.concurrent (Summary)
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
CounterAman.java
Unduh berkaspublic class CounterAman {
private static class Counter {
private long nilai = 0;
// synchronized menjamin hanya satu thread yang menjalankan
// read-modify-write ini pada satu waktu.
synchronized void naik() {
nilai++;
}
}
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable tugas = () -> {
for (int i = 0; i < 100_000; i++) {
counter.naik();
}
};
Thread a = new Thread(tugas, "teller-A");
Thread b = new Thread(tugas, "teller-B");
a.start();
b.start();
a.join();
b.join();
System.out.println("nilai akhir: " + counter.nilai);
}
}
LostUpdate.java
Unduh berkaspublic class LostUpdate {
private static int saldo = 0;
public static void main(String[] args) throws InterruptedException {
// Dua teller menambah saldo secara bersamaan.
// Jeda sleep sengaja dipasang agar jendela race terbuka lebar
// dan hasilnya bisa direproduksi untuk pembelajaran.
Thread tellerA = new Thread(() -> {
int baca = saldo; // langkah 1: baca saldo (0)
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
saldo = baca + 1; // langkah 3: tulis hasil hitung (0 + 1)
}, "teller-A");
Thread tellerB = new Thread(() -> {
int baca = saldo; // langkah 2: baca saldo (masih 0!)
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
saldo = baca + 1; // langkah 4: tulis (0 + 1), menimpa tulisan teller-A
}, "teller-B");
tellerA.start();
tellerB.start();
tellerA.join();
tellerB.join();
System.out.println("saldo akhir : " + saldo);
System.out.println("seharusnya : 2");
}
}