Chapter sebelumnya menutup dengan satu peringatan: makin banyak lock yang beredar, makin besar peluang dua thread saling menahan. Skenario itu punya nama resmi — deadlock — dan menjadi salah satu cacat perangkat lunak paling melelahkan karena program tidak melaporkan galat apa pun: program hanya diam, tidak maju, dan tidak pernah keluar. Kebuntuan tidak bisa diurai oleh JVM secara otomatis; penjadwal tidak memahami maksud bisnis di balik dua lock yang saling menunggu. Kabar baiknya, kebuntuan hampir selalu bisa dicegah dengan disiplin desain. Bab ini membedah cara kebuntuan terbentuk, cara mendeteksinya dengan thread dump, dan dua resep pencegahan yang teruji.

Empat Syarat Kebuntuan #

Teori konkurensi merumuskan empat kondisi yang harus terpenuhi bersamaan sebelum deadlock terjadi (dikenal sebagai syarat Coffman):

  1. Mutual exclusion: sumber daya (lock) hanya boleh dipegang satu thread pada satu waktu.
  2. Hold and wait: thread memegang satu lock sambil menunggu lock lain yang belum diperoleh.
  3. No preemption: lock tidak bisa direbut paksa dari pemegangnya; hanya pemegang yang bisa melepaskannya.
  4. Circular wait: rantai penungguan membentuk lingkaran — thread-1 menunggu kunci yang dipegang thread-2, thread-2 menunggu kunci yang dipegang thread-1.

Empat syarat tersebut berdiri sebagai satu paket: cukup satu yang dipatahkan, kebuntuan menjadi mustahil. Dua resep utama kita nanti membidik syarat keempat (urutan kunci konsisten memutus lingkaran) dan syarat kedua (tryLock dengan batas waktu membuat thread melepas apa yang dipegangnya sebelum terjebak).

Anatomi Kebuntuan #

Program berikut memperagakan kebuntuan klasik dua kunci silang. Kedua thread memegang satu kunci masing-masing, lalu keduanya menunggu kunci yang dipegang lawannya:

java
public class DeadlockDemo {
    public static void main(String[] args) throws InterruptedException {
        final Object kunciA = new Object();
        final Object kunciB = new Object();

        Thread thread1 = new Thread(() -> {
            synchronized (kunciA) {
                try {
                    Thread.sleep(100); // pastikan thread-2 sudah memegang kunciB
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    return;
                }
                synchronized (kunciB) {
                    System.out.println("thread-1 mendapat kunciB"); // tidak pernah tercetak
                }
            }
        }, "thread-1");

        Thread thread2 = new Thread(() -> {
            synchronized (kunciB) {
                try {
                    Thread.sleep(100); // pastikan thread-1 sudah memegang kunciA
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    return;
                }
                synchronized (kunciA) {
                    System.out.println("thread-2 mendapat kunciA"); // tidak pernah tercetak
                }
            }
        }, "thread-2");

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println("program selesai"); // tidak pernah tercetak
    }
}

Bila Anda menjalankan program di atas, tidak ada satu baris pun yang tercetak dan proses tidak pernah keluar — uji pada lingkungan Course ini dengan batas waktu 5 detik, proses dihentikan paksa oleh batas waktu karena kedua thread terkunci permanen pada milidetik ke-100. Thread.sleep(100) sebelum blok synchronized kedua sengaja dipasang agar jendela tumpang-tindih hampir pasti terjadi; pada beban nyata, jendela seruncing itu muncul secara alami sekali per sekian ribu eksekusi — itulah kenapa bug kebuntuan sering lolos dari pengujian lalu muncul di produksi.

Mendeteksi dengan jcmd #

Proses yang diam belum tentu deadlock — bisa jadi program memang sedang menunggu respons jaringan. Alat diagnosis bawaan JDK membedakannya secara pasti. Perintah jcmd <pid> Thread.print (atau jstack <pid>) mencetak thread dump: status setiap thread beserta tumpukan pemanggilannya. Dump proses DeadlockDemo di atas menghasilkan potongan berikut (alamat 0x... berbeda pada setiap proses):

text
Found one Java-level deadlock:
=============================
"thread-1":
  waiting to lock monitor 0x00007f9bd4048ce0 (object 0x00000000c3f16dc0, a java.lang.Object),
  which is held by "thread-2"

"thread-2":
  waiting to lock monitor 0x00007f9be0001450 (object 0x00000000c3f16db0, a java.lang.Object),
  which is held by "thread-1"

Java stack information for the threads listed above:
===================================================
"thread-1":
        at DeadlockDemo.lambda$main$0(DeadlockDemo.java:15)
        - waiting to lock <0x00000000c3f16dc0> (a java.lang.Object)
        - locked <0x00000000c3f16db0> (a java.lang.Object)
        ...

Baris Found one Java-level deadlock adalah vonis: JVM sendiri menganalisis grafik penungguan thread dan menemukan lingkaran circular wait. Laporan tersebut menyebutkan siapa menunggu kunci siapa — tepatnya informasi yang dibutuhkan untuk memperbaiki kode. Thread main pada dump lengkapnya berstate WAITING di Thread.join, menunggu dua thread yang tak akan pernah selesai.

Pencegahan dengan Urutan Kunci Konsisten #

Resep pertama memutus syarat circular wait: seluruh thread diwajibkan mengambil kunci dalam urutan global yang sama. Pada transfer antar rekening, aturannya sederhana — akun bernomor lebih kecil dikunci lebih dulu, apa pun arah transfernya:

import java.util.concurrent.locks.ReentrantLock;

public class UrutanKunciKonsisten {
    private static class Akun {
        private final String nomor;
        private long saldo;
        private final ReentrantLock kunci = new ReentrantLock();

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

    // Selalu kunci akun dengan nomor lebih kecil lebih dulu,
    // apa pun arah transfernya -- urutan global yang sama untuk semua thread.
    private static void transfer(Akun dari, Akun ke, long jumlah)
            throws InterruptedException {
        Akun pertama = dari.nomor.compareTo(ke.nomor) < 0 ? dari : ke;
        Akun kedua = (pertama == dari) ? ke : dari;

        pertama.kunci.lock();
        try {
            kedua.kunci.lock();
            try {
                Thread.sleep(50); // perpanjang jendela dua kunci terpegang bersama
                dari.saldo -= jumlah;
                ke.saldo += jumlah;
            } finally {
                kedua.kunci.unlock();
            }
        } finally {
            pertama.kunci.unlock();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Akun budi = new Akun("ACC-001", 1_000);
        Akun siti = new Akun("ACC-002", 500);

        // Dua transfer berlawanan arah pada saat bersamaan.
        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("program selesai tanpa kebuntuan");
        System.out.println("saldo ACC-001: " + budi.saldo);
        System.out.println("saldo ACC-002: " + siti.saldo);
    }
}

Output yang Dihasilkan:

text
program selesai tanpa kebuntuan
saldo ACC-001: 950
saldo ACC-002: 550

Mari kita bedah inti perbaikannya:

  1. Baris Akun pertama = dari.nomor.compareTo(ke.nomor) < 0 ? dari : ke;: transfer Budi→Siti maupun Siti→Budi sama-sama menghitung urutan pengerjaan dari nomor akun, bukan dari arah transfer. Kedua thread kini berebut kunci ACC-001 lebih dulu; pemenang melanjutkan ke ACC-002, yang kalah menunggu di antrean ACC-001 — penungguan yang linier, bukan melingkar.
  2. Thread.sleep(50) di dalam dua kunci: sengaja memperpanjang jendela rawan, meniru wilayah kritis yang sesungguhnya bekerja. Dengan urutan konsisten, tumpang-tindih selama itu tetap tidak menghasilkan kebuntuan.
  3. Struktur try/finally bersarang: kunci kedua dilepas lebih dulu, lalu kunci pertama — kebalikan urutan pemerolehan. Pola ini wajib agar tidak ada kunci yang bocor saat exception terjadi di tengah wilayah kritis.
  4. Saldo akhir: 1.000 - 100 + 50 = 950 dan 500 + 100 - 50 = 550 — kedua transfer tercatat penuh karena dijalankan bergiliran di dalam wilayah kritis yang tumpang-tindih.

Pemulihan dengan tryLock dan Penarikan Diri #

Resep kedua memutus syarat hold and wait: thread tidak menunggu kunci kedua tanpa batas. ReentrantLock.tryLock(waktu, satuan) — sudah diperkenalkan pada Chapter Locks dan ReentrantLock — memberi batas waktu; bila gagal, thread melepas juga kunci pertama, mundur sejenak, lalu mencoba lagi dari awal:

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

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

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

    // Transfer dua tahap tryLock dengan penarikan diri (back-off):
    // bila kunci kedua tidak diperoleh dalam 50 ms, kunci pertama dilepas
    // dan thread mundur selama jedaAtur milidetik sebelum mencoba lagi.
    private static void transfer(Akun dari, Akun ke, long jumlah, long jedaAtur)
            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; // transfer berhasil
                        } finally {
                            ke.kunci.unlock();
                        }
                    }
                } finally {
                    // Kunci kedua gagal diraih: lepas juga kunci pertama
                    // agar thread lawan bisa maju -- inilah inti pemulihan.
                    dari.kunci.unlock();
                }
            }
            Thread.sleep(jedaAtur); // mundur selangkah, lalu coba lagi
        }
    }

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

        // Back-off kedua thread sengaja berbeda (10 ms vs 25 ms)
        // agar keduanya tidak terus-menerus bertabrakan pada irama yang sama.
        Thread transferBudi = new Thread(() -> {
            try {
                transfer(budi, siti, 100, 10);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "transfer-Budi");
        Thread transferSiti = new Thread(() -> {
            try {
                transfer(siti, budi, 50, 25);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "transfer-Siti");

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

        System.out.println("program selesai tanpa kebuntuan");
        System.out.println("saldo Budi: " + budi.saldo);
        System.out.println("saldo Siti: " + siti.saldo);
    }
}

Output yang Dihasilkan:

text
program selesai tanpa kebuntuan
saldo Budi: 950
saldo Siti: 550

Tiga keputusan yang membuat pola ini bekerja:

  1. finally luar melepas kunci pertama: saat tryLock kedua gagal, eksekusi keluar dari blok try luar dan finally melepas kunci pertama. Tanpa pelepasan ini, thread tetap memegang satu kunci sambil tidur — kembali ke pola hold and wait.
  2. Jeda back-off berbeda (10 ms vs 25 ms): dua thread yang mundur dengan irama sama persis berisiko saling menabrak berulang-ulang — bibit livelock yang dibahas pada Chapter berikutnya. Jeda berbeda memastikan salah satu thread maju lebih dulu.
  3. while (true) di sekitar keseluruhan siklus: percobaan diulang dari nol setelah mundur. Loop ini berhenti karena pada setiap siklus, salah satu thread hampir pasti menemukan kedua kunci bebas (pemegang lain selalu melepas dalam 50 milidetik).

Panduan Ringkas Pencegahan #

StrategiSyarat yang dipatahkanKapan dipakai
Urutan kunci konsistenCircular waitSelalu, begitu dua lock atau lebih bisa terjadi bersamaan
tryLock + back-offHold and waitSistem yang boleh menunda dan mencoba ulang
Granularitas kecil / satu lock sajaHold and waitData sedikit atau tak terpisahkan
Hindari lock bersarangHold and waitKode bisa dirancang ulang agar cukup satu lock
API level lebih tinggi (queue, executor)Keempat syarat sekaligusKoordinasi lewat pesan, bukan berbagi memori

Kebuntuan adalah satu kata untuk “semua saling menunggu”. Ada dua saudaranya yang lebih halus: thread yang tetap aktif tetapi tidak maju (livelock), dan thread yang tidak pernah kebagian giliran (starvation) — keduanya menjadi fokus Chapter berikutnya.

Referensi #

Berkas Sumber

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

TryLockPemulihan.java

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

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

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

    // Transfer dua tahap tryLock dengan penarikan diri (back-off):
    // bila kunci kedua tidak diperoleh dalam 50 ms, kunci pertama dilepas
    // dan thread mundur selama jedaAtur milidetik sebelum mencoba lagi.
    private static void transfer(Akun dari, Akun ke, long jumlah, long jedaAtur)
            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; // transfer berhasil
                        } finally {
                            ke.kunci.unlock();
                        }
                    }
                } finally {
                    // Kunci kedua gagal diraih: lepas juga kunci pertama
                    // agar thread lawan bisa maju -- inilah inti pemulihan.
                    dari.kunci.unlock();
                }
            }
            Thread.sleep(jedaAtur); // mundur selangkah, lalu coba lagi
        }
    }

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

        // Back-off kedua thread sengaja berbeda (10 ms vs 25 ms)
        // agar keduanya tidak terus-menerus bertabrakan pada irama yang sama.
        Thread transferBudi = new Thread(() -> {
            try {
                transfer(budi, siti, 100, 10);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "transfer-Budi");
        Thread transferSiti = new Thread(() -> {
            try {
                transfer(siti, budi, 50, 25);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "transfer-Siti");

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

        System.out.println("program selesai tanpa kebuntuan");
        System.out.println("saldo Budi: " + budi.saldo);
        System.out.println("saldo Siti: " + siti.saldo);
    }
}

UrutanKunciKonsisten.java

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

public class UrutanKunciKonsisten {
    private static class Akun {
        private final String nomor;
        private long saldo;
        private final ReentrantLock kunci = new ReentrantLock();

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

    // Selalu kunci akun dengan nomor lebih kecil lebih dulu,
    // apa pun arah transfernya -- urutan global yang sama untuk semua thread.
    private static void transfer(Akun dari, Akun ke, long jumlah)
            throws InterruptedException {
        Akun pertama = dari.nomor.compareTo(ke.nomor) < 0 ? dari : ke;
        Akun kedua = (pertama == dari) ? ke : dari;

        pertama.kunci.lock();
        try {
            kedua.kunci.lock();
            try {
                Thread.sleep(50); // perpanjang jendela dua kunci terpegang bersama
                dari.saldo -= jumlah;
                ke.saldo += jumlah;
            } finally {
                kedua.kunci.unlock();
            }
        } finally {
            pertama.kunci.unlock();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Akun budi = new Akun("ACC-001", 1_000);
        Akun siti = new Akun("ACC-002", 500);

        // Dua transfer berlawanan arah pada saat bersamaan.
        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("program selesai tanpa kebuntuan");
        System.out.println("saldo ACC-001: " + budi.saldo);
        System.out.println("saldo ACC-002: " + siti.saldo);
    }
}