Lock yang sudah kita bahas menjawab pertanyaan “siapa yang boleh masuk”, tetapi belum menjawab pertanyaan yang sama pentingnya: “kapan giliran saya tiba”. Sebuah konsumen tidak bisa sibuk berputar terus-menerus memeriksa apakah data sudah tersedia — pemborosan CPU yang luar biasa — dan juga tidak boleh mengambil data yang belum ada. Mekanisme yang dibutuhkan adalah menunggu dengan efisien: thread melepas lock, tertidur, lalu dibangunkan tepat ketika kondisi yang ditunggukannya berubah. Fasilitas itu sudah tertanam pada monitor setiap objek Java sejak versi pertama: method wait(), notify(), dan notifyAll() milik Object. Bab ini membedah ketiganya, menjelaskan mengapa wait() wajib dikelilingi loop while, lalu menutup dengan alternatif modern yang jauh lebih praktis: BlockingQueue.

Monitor dan Ruang Tunggu #

Chapter Intrinsic Locks menyinggung bahwa monitor objek menyimpan dua hal: pemegang lock dan satu wait set — ruang tunggu khusus. Tiga method dari Object mengoperasikan ruang tunggu tersebut dengan aturan main yang ketat:

  • wait() — dipanggil oleh pemegang lock; thread melepaskan lock, pindah ke wait set objek, dan tertidur hingga dibangunkan. Setelah bangun, thread harus memperoleh lock kembali sebelum eksekusi berlanjut di baris setelah wait().
  • notify() — membangunkan satu thread yang kebetulan dipilih dari wait set.
  • notifyAll() — membangunkan seluruh thread di wait set; mereka berebut lock, dan yang kalah kembali tertidur di dalam loop penungguannya.

Ketiganya hanya boleh dipanggil oleh thread yang sedang memegang lock objek tersebut — lazimnya berada di dalam method synchronized pada objek yang sama. Memanggil tanpa memegang lock melempar IllegalMonitorStateException saat runtime, bukan saat kompilasi, sehingga kesalahan ini kerap lolos dari pemeriksaan compiler javac.

Pola Penyangga Satu Slot #

Program berikut mengimplementasikan koordinasi produsen-konsumen paling kecil: satu penyangga berkapasitas satu item. Produsen harus menunggu slot kosong sebelum menaruh; konsumen harus menunggu slot terisi sebelum mengambil. Kronologi lengkap dicatat di dalam monitor lalu dicetak setelah kedua thread bergabung:

import java.util.ArrayList;
import java.util.List;

public class WaitNotifyPenyangga {
    // Penyangga satu slot: taruh harus menunggu bila slot terisi,
    // ambil harus menunggu bila slot kosong. Seluruh pencatatan kronologi
    // dilakukan DI DALAM monitor sehingga urutannya terjamin.
    private static class Penyangga {
        private int nilai;
        private boolean terisi = false;
        private final List<String> kronologi;

        Penyangga(List<String> kronologi) {
            this.kronologi = kronologi;
        }

        public synchronized void taruh(int v) throws InterruptedException {
            while (terisi) {
                wait(); // melepas monitor sambil menunggu slot dikosongkan
            }
            nilai = v;
            terisi = true;
            kronologi.add("taruh " + v);
            notifyAll(); // bangunkan semua penunggu monitor ini
        }

        public synchronized int ambil() throws InterruptedException {
            while (!terisi) {
                wait(); // melepas monitor sambil menunggu slot diisi
            }
            terisi = false;
            kronologi.add("ambil " + nilai);
            notifyAll(); // bangunkan semua penunggu monitor ini
            return nilai;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        List<String> kronologi = new ArrayList<>();
        Penyangga penyangga = new Penyangga(kronologi);

        Thread produsen = new Thread(() -> {
            try {
                for (int i = 1; i <= 5; i++) {
                    penyangga.taruh(i);
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "produsen");

        Thread konsumen = new Thread(() -> {
            try {
                for (int i = 1; i <= 5; i++) {
                    penyangga.ambil();
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "konsumen");

        produsen.start();
        konsumen.start();
        produsen.join();
        konsumen.join();

        // Kronologi dicetak SETELAH kedua thread selesai: deterministik.
        for (String baris : kronologi) {
            System.out.println(baris);
        }
    }
}

Output yang Dihasilkan:

text
taruh 1
ambil 1
taruh 2
ambil 2
taruh 3
ambil 3
taruh 4
ambil 4
taruh 5
ambil 5

Kronologi taruh-ambil yang berselang-seling sempurna adalah hasil desain, bukan kebetulan penjadwal. Mari kita bedahnya:

  1. while (terisi) { wait(); }: produsen menunggu selama slot masih terisi. Setelah dibangunkan, kondisi diperiksa ulang — bila konsumen lain keburu mengosongkan lalu mengisi ulang, loop berputar lagi dan thread kembali menunggu. Penggunaan while, bukan if, adalah aturan wajib yang alasan lengkapnya dibahas pada bagian berikutnya.
  2. notifyAll() di kedua method: setiap perubahan kondisi (slot terisi, slot kosong) membangunkan seluruh penunggu. Pada contoh dua thread, satu penunggu saja yang ada; notifyAll() tetap dipakai karena aman untuk jumlah penunggu berapa pun.
  3. Kronologi yang berselang-seling: produsen tidak bisa menaruh item ke-2 sebelum item ke-1 diambil (taruh menunggu terisi == false), dan konsumen tidak bisa mengambil sebelum ada yang ditaruh. Penjadwal boleh mengatur kecepatan sesukanya — urutan kejadian terkunci oleh kondisi, bukan oleh timing.
  4. kronologi dijaga monitor yang sama: pencatatan dilakukan di dalam method synchronized, sehingga ArrayList biasa cukup aman dan urutan catatan sesuai urutan kepemilikan lock.

wait Selalu Dikelilingi while #

Dua fenomena memaksa kondisi diperiksa ulang setelah bangun. Pertama, spurious wakeup: JVM spesifikasinya mengizinkan thread terbangun dari wait() tanpa notify() apa pun — perilaku warisan optimasi pada arsitektur tertentu. Kedua, kondisi yang berubah lagi: dengan beberapa konsumen menunggu kondisi yang sama, notifyAll() membangunkan semuanya, tetapi hanya satu yang berhasil memperoleh lock dan benar-benar mengambil data; sisanya bangun di tengah kondisi yang sudah tidak berlaku.

java
// SALAH: kondisi diperiksa sekali sebelum tidur.
if (!terisi) {
    wait();            // bila terbangun palsu, eksekusi lanjut padahal slot masih kosong
}

// BENAR: kondisi diperiksa ulang setiap kali terbangun.
while (!terisi) {
    wait();            // bangun -> periksa -> bila belum cocok, tidur lagi
}

Pilihan antara notify() dan notifyAll() menyusul dari aturan yang sama. notify() membangunkan tepat satu penunggu yang dipilih sembarangan; bila wait set berisi penunggu dengan kondisi berbeda — seorang menunggu “slot kosong”, seorang lagi menunggu “slot terisi” — thread yang dibangunkan bisa jadi bukan pihak yang kondisinya baru berubah. Thread yang tepat tetap tertidur dan sinyalnya hilang: program berhenti diam tanpa jejak error. notifyAll() membayar sedikit biaya pembangunan massal untuk menghapus seluruh kategori bug ini, dan itulah sebabnya contoh-contoh di bab ini memakai notifyAll() sejak awal.

Producer-Consumer Modern dengan BlockingQueue #

Mekanisme wait/notify di atas adalah fondasi, bukan alat kerja harian. Pustaka java.util.concurrent sudah membungkus pola produsen-konsumen ke dalam interface BlockingQueue: put() menunggu bila antrian penuh, take() menunggu bila antrian kosong — koordinasi yang sama tanpa satu baris wait pun:

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;

public class ProdusenKonsumenAntrian {
    public static void main(String[] args) throws InterruptedException {
        // Kapasitas 2 sengaja lebih kecil dari jumlah item (4)
        // agar put() benar-benar pernah menunggu slot kosong.
        BlockingQueue<Integer> antrian = new ArrayBlockingQueue<>(2);

        Thread produsen = new Thread(() -> {
            try {
                for (int i = 1; i <= 4; i++) {
                    antrian.put(i); // menunggu bila antrian penuh
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "produsen");

        int[] hasil = new int[4]; // dikumpulkan per indeks, dicetak setelah join
        Thread konsumen = new Thread(() -> {
            try {
                for (int i = 0; i < hasil.length; i++) {
                    hasil[i] = antrian.take(); // menunggu bila antrian kosong
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "konsumen");

        produsen.start();
        konsumen.start();
        produsen.join();
        konsumen.join();

        System.out.println("urutan item yang diterima konsumen:");
        for (int i = 0; i < hasil.length; i++) {
            System.out.println("  item ke-" + (i + 1) + ": " + hasil[i]);
        }
        System.out.println("sisa item di antrian: " + antrian.size());
    }
}

Output yang Dihasilkan:

text
urutan item yang diterima konsumen:
  item ke-1: 1
  item ke-2: 2
  item ke-3: 3
  item ke-4: 4
sisa item di antrian: 0

Tiga catatan untuk contoh tersebut:

  1. Kapasitas 2 dengan 4 item: produsen pasti pernah terhenti di put() item ke-3 — pembuktian bahwa put() sungguh menunggu, bukan sekadar memasukkan. Di balik layar, ArrayBlockingQueue memakai satu ReentrantLock dan dua Condition (ruang tunggu terpisah untuk produsen dan konsumen) — generasi lanjutan dari wait set monitor.
  2. Urutan FIFO terjaga: take() selalu mengembalikan item terlama; hasil terkumpul berurutan 1, 2, 3, 4 berapa pun cepatnya masing-masing thread.
  3. interrupt() tetap efektif: put() dan take() merespons interupsi dengan melempar InterruptedException, sehingga thread yang menunggu bisa dimatikan dengan sopan — fasilitas yang juga dimiliki wait().

Kini thread-thread kita bisa berkomunikasi secara efisien. Namun semua contoh sejauh ini masih mengelola Thread secara manual: membuat, memulai, dan menunggu satu per satu. Ketika jumlah task mencapai ratusan, pengelolaan manual menjadi beban tersendiri — bab berikutnya menyerahkan urusan itu kepada ExecutorService.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

ProdusenKonsumenAntrian.java

Unduh berkas
java
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;

public class ProdusenKonsumenAntrian {
    public static void main(String[] args) throws InterruptedException {
        // Kapasitas 2 sengaja lebih kecil dari jumlah item (4)
        // agar put() benar-benar pernah menunggu slot kosong.
        BlockingQueue<Integer> antrian = new ArrayBlockingQueue<>(2);

        Thread produsen = new Thread(() -> {
            try {
                for (int i = 1; i <= 4; i++) {
                    antrian.put(i); // menunggu bila antrian penuh
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "produsen");

        int[] hasil = new int[4]; // dikumpulkan per indeks, dicetak setelah join
        Thread konsumen = new Thread(() -> {
            try {
                for (int i = 0; i < hasil.length; i++) {
                    hasil[i] = antrian.take(); // menunggu bila antrian kosong
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "konsumen");

        produsen.start();
        konsumen.start();
        produsen.join();
        konsumen.join();

        System.out.println("urutan item yang diterima konsumen:");
        for (int i = 0; i < hasil.length; i++) {
            System.out.println("  item ke-" + (i + 1) + ": " + hasil[i]);
        }
        System.out.println("sisa item di antrian: " + antrian.size());
    }
}

WaitNotifyPenyangga.java

Unduh berkas
java
import java.util.ArrayList;
import java.util.List;

public class WaitNotifyPenyangga {
    // Penyangga satu slot: taruh harus menunggu bila slot terisi,
    // ambil harus menunggu bila slot kosong. Seluruh pencatatan kronologi
    // dilakukan DI DALAM monitor sehingga urutannya terjamin.
    private static class Penyangga {
        private int nilai;
        private boolean terisi = false;
        private final List<String> kronologi;

        Penyangga(List<String> kronologi) {
            this.kronologi = kronologi;
        }

        public synchronized void taruh(int v) throws InterruptedException {
            while (terisi) {
                wait(); // melepas monitor sambil menunggu slot dikosongkan
            }
            nilai = v;
            terisi = true;
            kronologi.add("taruh " + v);
            notifyAll(); // bangunkan semua penunggu monitor ini
        }

        public synchronized int ambil() throws InterruptedException {
            while (!terisi) {
                wait(); // melepas monitor sambil menunggu slot diisi
            }
            terisi = false;
            kronologi.add("ambil " + nilai);
            notifyAll(); // bangunkan semua penunggu monitor ini
            return nilai;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        List<String> kronologi = new ArrayList<>();
        Penyangga penyangga = new Penyangga(kronologi);

        Thread produsen = new Thread(() -> {
            try {
                for (int i = 1; i <= 5; i++) {
                    penyangga.taruh(i);
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "produsen");

        Thread konsumen = new Thread(() -> {
            try {
                for (int i = 1; i <= 5; i++) {
                    penyangga.ambil();
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "konsumen");

        produsen.start();
        konsumen.start();
        produsen.join();
        konsumen.join();

        // Kronologi dicetak SETELAH kedua thread selesai: deterministik.
        for (String baris : kronologi) {
            System.out.println(baris);
        }
    }
}