Deadlock punya dua saudara yang lebih senyap. Yang pertama, livelock: thread tetap aktif, terus berpindah state, tidak ada satu pun yang menggantung — tetapi pekerjaan tidak pernah selesai karena para thread sibuk saling memberi jalan. Yang kedua, starvation: thread tidak pernah kebagian giliran sama sekali, selalu kalah bersaing oleh para pesaing yang lebih rakus. Keduanya sama berbahayanya dengan deadlock pada sistem produksi, tetapi jauh lebih sulit didiagnosis karena thread dump tidak mencetak vonis semacam Found one Java-level deadlock untuk keduanya. Bab ini menyimulasi kedua fenomena tersebut secara terkendali, lalu meracik mitigasinya.

Livelock: Sibuk Tanpa Kemajuan #

Bayangkan Budi dan Siti berpapasan di koridor sempit. Keduanya melangkah ke kiri secara bersamaan, bertabrakan secara sopan, lalu sama-sama pindah ke kanan, bertabrakan lagi, dan seterusnya. Tidak ada yang berhenti — semua orang terus bergerak — namun tidak ada yang berhasil melewati koridor. Itulah livelock: thread merespons satu sama lain terus-menerus tanpa pernah menyelesaikan tugasnya.

Dalam kode Java, livelock paling sering lahir dari pola retry dengan penarikan diri. Dua thread gagal meraih dua kunci sekaligus, sama-sama melepas kunci yang dipegang, lalu sama-sama mencoba lagi — dengan ritme yang identik. Setiap kali thread pertama bangun, thread kedua juga baru saja bangun; keduanya kembali bertabrakan dan kembali mundur. Berbeda dengan deadlock yang mematahkan keempat syarat Coffman sekaligus, livelock justru memenuhi aturan kesopanan: tidak ada yang saling menahan, hanya tidak ada yang maju.

Simulasi Livelock yang Dibatasi #

Livelock di dunia nyata tidak pernah berakhir, sehingga tidak bisa dibawakan sebagai program runnable. Program berikut menyimulasikannya dengan batas 10 ronde, lalu melaporkan bahwa tidak ada kemajuan yang terjadi. Tiga pagar CyclicBarrier per ronde dipasang agar kedua thread selalu membaca niat lawan dalam keadaan terpasang — meniru kebetulan timing yang pada sistem nyata muncul sendiri:

import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.atomic.AtomicBoolean;

public class LivelockSimulasi {
    private static final int MAKS_RONDE = 10;

    // Pembungkus agar loop tidak perlu menangani exception barrier secara langsung.
    private static void tunggu(CyclicBarrier pagar) {
        try {
            pagar.await();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } catch (Exception e) {
            // BrokenBarrierException tidak mungkin terjadi pada simulasi ini.
        }
    }

    // Satu ronde ritual sopan-santun: pasang niat, baca niat lawan,
    // lalu mundur bila lawan juga ingin lewat. Tiga pagar memastikan
    // kedua thread selalu membaca niat lawan dalam keadaan terpasang.
    private static void jalankan(AtomicBoolean niatSendiri,
            AtomicBoolean niatLawan, CyclicBarrier pagar, int[] rondeTerpakai) {
        for (int ronde = 1; ronde <= MAKS_RONDE; ronde++) {
            niatSendiri.set(true); // pasang niat untuk ronde ini
            tunggu(pagar);         // pagar-1: niat kedua pihak terpasang

            boolean lawanJugaMau = niatLawan.get();
            tunggu(pagar);         // pagar-2: kedua pihak sudah selesai membaca

            if (lawanJugaMau) {
                niatSendiri.set(false); // lawan ingin lewat: mundur sopan
            }
            tunggu(pagar);         // pagar-3: keputusan ronde terekam
            rondeTerpakai[0] = ronde;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        AtomicBoolean budiMau = new AtomicBoolean(true);
        AtomicBoolean sitiMau = new AtomicBoolean(true);
        CyclicBarrier pagar = new CyclicBarrier(2); // menyamakan irama kedua thread
        int[] rondeBudi = new int[1];
        int[] rondeSiti = new int[1];

        Thread budi = new Thread(
                () -> jalankan(budiMau, sitiMau, pagar, rondeBudi), "Budi");
        Thread siti = new Thread(
                () -> jalankan(sitiMau, budiMau, pagar, rondeSiti), "Siti");

        budi.start();
        siti.start();
        budi.join();
        siti.join();

        System.out.println("Budi menjalani " + rondeBudi[0] + " ronde tanpa pernah maju");
        System.out.println("Siti menjalani " + rondeSiti[0] + " ronde tanpa pernah maju");
        System.out.println("kedua thread aktif sampai akhir, koridor tak pernah dilewati");
    }
}

Output yang Dihasilkan:

text
Budi menjalani 10 ronde tanpa pernah maju
Siti menjalani 10 ronde tanpa pernah maju
kedua thread aktif sampai akhir, koridor tak pernah dilewati

Mari kita bedah ritual yang berulang setiap ronde:

  1. niatSendiri.set(true) lalu tunggu(pagar) (pagar-1): kedua thread memasang niatnya lebih dulu; pagar memastikan tidak ada yang membaca sebelum kedua niat terpasang. Pada dunia nyata, “niat” ini setara niat transfer yang terlihat lawan lewat kondisi bersama.
  2. lawanJugaMau = niatLawan.get() lalu pagar-2: kedua thread membaca — dan hasilnya selalu true, karena lawan baru akan menurunkan nilainya setelah pagar-2. Kedua pihak merasa lawan ingin maju.
  3. niatSendiri.set(false): keduanya sama-sama mundur. Ronde berikutnya dimulai dengan kedua niat terpasang kembali, dan kisah yang sama terulang persis.
  4. MAKS_RONDE = 10: hanya penjaga keselamatan agar simulasi berakhir dan laporan tercetak. Hilangkan batas ini dan program menjadi livelock sungguhan — dua thread aktif selamanya tanpa satu pun baris “koridor dilewati”.

Memecah Irama dengan Jeda Acak #

Obat livelock sederhana namun ampuh: buat kedua thread tidak lagi berirama sama. Pada pola retry dua kunci dari Chapter Deadlock, jeda penarikan diri dibuat acak — sehingga kapan pun dua thread bertabrakan, jeda berikutnya hampir pasti berbeda dan salah satu maju lebih dulu. Program berikut memakai Random berseed tetap agar urutan jadinya dapat diulang:

import java.util.Random;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

public class BackoffJitter {
    private static final Random acak = new Random(42); // seed tetap: urutan jeda terjamin

    private static class Akun {
        private final ReentrantLock kunci = new ReentrantLock();
        private long saldo;

        Akun(long saldoAwal) {
            this.saldo = saldoAwal;
        }
    }

    // Dua tahap tryLock dengan jeda back-off ACAK (5-29 ms).
    // Keacakan memecah irama identik yang menjadi akar livelock:
    // jeda kedua thread hampir pasti berbeda, salah satu maju lebih dulu.
    private static void transfer(Akun dari, Akun ke, long jumlah)
            throws InterruptedException {
        while (true) {
            if (dari.kunci.tryLock(50, TimeUnit.MILLISECONDS)) {
                try {
                    if (ke.kunci.tryLock(50, TimeUnit.MILLISECONDS)) {
                        try {
                            dari.saldo -= jumlah;
                            ke.saldo += jumlah;
                            return;
                        } finally {
                            ke.kunci.unlock();
                        }
                    }
                } finally {
                    dari.kunci.unlock(); // gagal raih kunci kedua: lepas juga yang pertama
                }
            }
            Thread.sleep(5 + acak.nextInt(25)); // jeda acak 5-29 ms
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Akun budi = new Akun(1_000);
        Akun siti = new Akun(500);

        // Kedua thread memakai method yang sama dengan irama yang sama,
        // hanya jeda acak yang membedakan nasibnya.
        Thread transferBudi = new Thread(() -> {
            try {
                transfer(budi, siti, 100);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "transfer-Budi");
        Thread transferSiti = new Thread(() -> {
            try {
                transfer(siti, budi, 50);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "transfer-Siti");

        transferBudi.start();
        transferSiti.start();
        transferBudi.join();
        transferSiti.join();

        System.out.println("kedua transfer selesai");
        System.out.println("saldo Budi: " + budi.saldo);
        System.out.println("saldo Siti: " + siti.saldo);
    }
}

Output yang Dihasilkan:

text
kedua transfer selesai
saldo Budi: 950
saldo Siti: 550

Dua perbedaan penting dibanding simulasi koridor:

  1. Thread.sleep(5 + acak.nextInt(25)): jeda mundur berbeda pada hampir setiap percobaan. Thread yang bangun lebih pagi menemukan dua kunci bebas dan menyelesaikan transfernya; thread yang kalah cukup mengulang siklus berikutnya.
  2. Hasil akhir tetap 950 dan 550: jumlah percobaan tiap thread bervariasi mengikuti jadwal penjadwal, sehingga tidak dicetak; yang dicetak hanyalah keadaan akhir yang pasti — kedua transfer tercatat penuh.

Starvation: Yang Tak Pernah Kebagian #

Starvation adalah keadaan sebaliknya: satu thread tidak pernah mendapat giliran meskipun terus-menerus siap bekerja. Sumber klasiknya tiga: lock unfair yang selalu diserahkan kepada pendatang yang kebetulan lebih dulu, prioritas thread yang terlalu rendah dibanding pesaingnya, atau task berdurasi panjang yang memenuhi seluruh slot thread pool sehingga task kecil menginap di antrean. Java memperlakukan prioritas thread hanya sebagai saran bagi penjadwal — Chapter Thread Priority dan Daemon sudah memperingatkan bahwa prioritas tidak bisa diandalkan — sehingga mitigasi starvation tidak berpihak pada prioritas.

Perbandingan yang paling relevan ada pada ReentrantLock. Mode bawaan (unfair) tidak menjamin urutan kedatangan: thread yang baru datang boleh melompati antrean. Mode fair — new ReentrantLock(true) — melayani pemohon sesuai urutan tiba:

java
// Mode unfair (bawaan): pemohon baru bisa melompati antrean.
ReentrantLock kunciCepat = new ReentrantLock();

// Mode fair: giliran diberikan kepada pemohon terlama yang menunggu.
ReentrantLock kunciAdil = new ReentrantLock(true);

Konsekuensinya ada pada throughput: mode fair menuntut koordinasi antrean yang lebih rapat, dan thread yang baru datang — padahal kuncinya sedang bebas — tetap harus mengantre di belakang. Karena itu JDK memilih mode unfair sebagai bawaan: pada mayoritas beban, kelaparan antrean jarang terjadi dan keadilan tidak sebanding dengan biayanya.

Mitigasi Starvation #

TeknikCara kerja
Lock fair (ReentrantLock(true))Giliran mengikuti urutan kedatangan; antrean panjang tetap terlayani
Wilayah kritis singkatPemegang lock cepat melepasnya; giliran berputar lebih cepat
Task pendek di thread poolHindari satu task panjang menduduki seluruh slot pool
NIO / non-blocking I/OSatu thread mengelola banyak koneksi lewat Selector, bukan satu thread per koneksi yang saling merebut
Virtual threads (Chapter tersendiri)Ribuan task menunggu I/O tanpa merebut slot OS thread

Livelock maupun starvation adalah penyakit koordinasi: para thread saling bereaksi atau berebut giliran tanpa wasit yang menertibkan. Ada satu fasilitas wasit tertua di Java yang belum kita bedah — mekanisme wait, notify, dan notifyAll pada intrinsic lock — dan menjadi fokus Chapter berikutnya.

Referensi #

Berkas Sumber

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

BackoffJitter.java

Unduh berkas
java
import java.util.Random;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

public class BackoffJitter {
    private static final Random acak = new Random(42); // seed tetap: urutan jeda terjamin

    private static class Akun {
        private final ReentrantLock kunci = new ReentrantLock();
        private long saldo;

        Akun(long saldoAwal) {
            this.saldo = saldoAwal;
        }
    }

    // Dua tahap tryLock dengan jeda back-off ACAK (5-29 ms).
    // Keacakan memecah irama identik yang menjadi akar livelock:
    // jeda kedua thread hampir pasti berbeda, salah satu maju lebih dulu.
    private static void transfer(Akun dari, Akun ke, long jumlah)
            throws InterruptedException {
        while (true) {
            if (dari.kunci.tryLock(50, TimeUnit.MILLISECONDS)) {
                try {
                    if (ke.kunci.tryLock(50, TimeUnit.MILLISECONDS)) {
                        try {
                            dari.saldo -= jumlah;
                            ke.saldo += jumlah;
                            return;
                        } finally {
                            ke.kunci.unlock();
                        }
                    }
                } finally {
                    dari.kunci.unlock(); // gagal raih kunci kedua: lepas juga yang pertama
                }
            }
            Thread.sleep(5 + acak.nextInt(25)); // jeda acak 5-29 ms
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Akun budi = new Akun(1_000);
        Akun siti = new Akun(500);

        // Kedua thread memakai method yang sama dengan irama yang sama,
        // hanya jeda acak yang membedakan nasibnya.
        Thread transferBudi = new Thread(() -> {
            try {
                transfer(budi, siti, 100);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "transfer-Budi");
        Thread transferSiti = new Thread(() -> {
            try {
                transfer(siti, budi, 50);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "transfer-Siti");

        transferBudi.start();
        transferSiti.start();
        transferBudi.join();
        transferSiti.join();

        System.out.println("kedua transfer selesai");
        System.out.println("saldo Budi: " + budi.saldo);
        System.out.println("saldo Siti: " + siti.saldo);
    }
}

LivelockSimulasi.java

Unduh berkas
java
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.atomic.AtomicBoolean;

public class LivelockSimulasi {
    private static final int MAKS_RONDE = 10;

    // Pembungkus agar loop tidak perlu menangani exception barrier secara langsung.
    private static void tunggu(CyclicBarrier pagar) {
        try {
            pagar.await();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } catch (Exception e) {
            // BrokenBarrierException tidak mungkin terjadi pada simulasi ini.
        }
    }

    // Satu ronde ritual sopan-santun: pasang niat, baca niat lawan,
    // lalu mundur bila lawan juga ingin lewat. Tiga pagar memastikan
    // kedua thread selalu membaca niat lawan dalam keadaan terpasang.
    private static void jalankan(AtomicBoolean niatSendiri,
            AtomicBoolean niatLawan, CyclicBarrier pagar, int[] rondeTerpakai) {
        for (int ronde = 1; ronde <= MAKS_RONDE; ronde++) {
            niatSendiri.set(true); // pasang niat untuk ronde ini
            tunggu(pagar);         // pagar-1: niat kedua pihak terpasang

            boolean lawanJugaMau = niatLawan.get();
            tunggu(pagar);         // pagar-2: kedua pihak sudah selesai membaca

            if (lawanJugaMau) {
                niatSendiri.set(false); // lawan ingin lewat: mundur sopan
            }
            tunggu(pagar);         // pagar-3: keputusan ronde terekam
            rondeTerpakai[0] = ronde;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        AtomicBoolean budiMau = new AtomicBoolean(true);
        AtomicBoolean sitiMau = new AtomicBoolean(true);
        CyclicBarrier pagar = new CyclicBarrier(2); // menyamakan irama kedua thread
        int[] rondeBudi = new int[1];
        int[] rondeSiti = new int[1];

        Thread budi = new Thread(
                () -> jalankan(budiMau, sitiMau, pagar, rondeBudi), "Budi");
        Thread siti = new Thread(
                () -> jalankan(sitiMau, budiMau, pagar, rondeSiti), "Siti");

        budi.start();
        siti.start();
        budi.join();
        siti.join();

        System.out.println("Budi menjalani " + rondeBudi[0] + " ronde tanpa pernah maju");
        System.out.println("Siti menjalani " + rondeSiti[0] + " ronde tanpa pernah maju");
        System.out.println("kedua thread aktif sampai akhir, koridor tak pernah dilewati");
    }
}