Data pada aplikasi nyata hampir selalu asimetris: dibaca jauh lebih sering daripada ditulis. Katalog produk dibaca ribuan kali per detik tetapi harganya jarang berubah; konfigurasi dimuat sekali lalu dibaca terus-menerus. Melindungi data seperti itu dengan lock eksklusif berarti pembaca-pembaca yang sama sekali tidak mengubah apa pun dipaksa mengantre satu per satu — pemborosan paralelisme yang nyata. Interface ReadWriteLock memecah satu kendali menjadi dua kunci: read lock yang boleh dipegang banyak thread sekaligus dan write lock yang hanya boleh dipegang satu thread. Bab ini menguji kedua janji itu lalu menerapkannya pada pola cache yang lazim.

Dua Kunci dalam Satu Objek #

Implementasi standarnya adalah ReentrantReadWriteLock. Method readLock() dan writeLock() mengembalikan dua objek Lock yang saling terkoordinasi dengan aturan main berikut:

  • Tidak ada syarat untuk readLock() selama tidak ada pemegang writeLock() — pembaca boleh bergerombolan.
  • writeLock() hanya diberikan ketika seluruh readLock() sudah dilepas — penulis benar-benar sendirian.
  • Selama seorang penulis menunggu, kebijakan pemberian kunci berusaha mencegah penulis tertunda tanpa batas; mode fair (new ReentrantReadWriteLock(true)) menggilir menunggu terlama.

Kedua kunci bersifat reentrant, sama seperti ReentrantLock: thread pemegang readLock() boleh memperolehnya lagi, dan pemegang writeLock() boleh memperoleh baik writeLock() maupun readLock() kembali.

Pembaca Bersamaan, Penulis Eksklusif #

Janji pertama perlu dibuktikan, bukan dipercaya. Program berikut menempatkan pembaca-1 di dalam read lock selama 300 milidetik; pembaca-2 mencoba masuk di tengah-tengahnya; penulis mencoba merebut write lock sebelum pembaca-1 selesai. Bukti tumpang-tindih dikumpulkan lewat flag dan dicetak setelah semua thread bergabung:

import java.util.concurrent.locks.ReentrantReadWriteLock;

public class PembacaBersamaan {
    // Flag volatile agar tulisan pembaca-1 langsung terlihat thread lain.
    private static volatile boolean pembaca1DiDalam = false;

    public static void main(String[] args) throws InterruptedException {
        ReentrantReadWriteLock kunci = new ReentrantReadWriteLock();
        boolean[] buktiPembaca = new boolean[1];
        boolean[] buktiPenulis = new boolean[1];

        // pembaca-1 memegang read lock lama (300 ms).
        Thread pembaca1 = new Thread(() -> {
            kunci.readLock().lock();
            try {
                pembaca1DiDalam = true;
                Thread.sleep(300); // pembacaan lambat: read lock tetap bisa dibagi
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                pembaca1DiDalam = false;
                kunci.readLock().unlock();
            }
        }, "pembaca-1");

        // pembaca-2 masuk pada ms ke-100, saat pembaca-1 masih memegang read lock.
        Thread pembaca2 = new Thread(() -> {
            try {
                Thread.sleep(100); // pastikan pembaca-1 sudah berada di dalam
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }
            kunci.readLock().lock(); // BERHASIL: read lock boleh dipegang bersama
            try {
                buktiPembaca[0] = pembaca1DiDalam; // true: pembaca-1 masih di dalam
            } finally {
                kunci.readLock().unlock();
            }
        }, "pembaca-2");

        // penulis mencoba write lock pada ms ke-200, harus menunggu pembaca selesai.
        Thread penulis = new Thread(() -> {
            try {
                Thread.sleep(200); // mulai setelah pembaca-2 selesai membaca
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }
            kunci.writeLock().lock(); // menunggu sampai read lock benar-benar kosong
            try {
                buktiPenulis[0] = !pembaca1DiDalam; // true: penulis masuk saat bersih
            } finally {
                kunci.writeLock().unlock();
            }
        }, "penulis");

        pembaca1.start();
        pembaca2.start();
        penulis.start();
        pembaca1.join();
        pembaca2.join();
        penulis.join();

        System.out.println("pembaca-2 masuk saat pembaca-1 masih membaca : " + buktiPembaca[0]);
        System.out.println("penulis masuk setelah semua pembaca keluar   : " + buktiPenulis[0]);
    }
}

Output yang Dihasilkan:

text
pembaca-2 masuk saat pembaca-1 masih membaca : true
penulis masuk setelah semua pembaca keluar   : true

Mari kita telaah kronologi yang mendasari dua baris bukti tersebut:

  1. pembaca-2 ... : true: pembaca-1 memegang read lock dari milidetik ke-0 sampai ke-300. Pembaca-2 memperoleh read lock pada milidetik ke-100 — kunci dibagikan, tidak ada penantian — dan membaca flag yang menandai pembaca-1 masih berada di dalam wilayah read lock. Dua pembaca aktif pada saat yang sama: inilah paralelisme yang hilang bila kita memakai lock eksklusif.
  2. penulis ... : true: penulis mulai menunggu pada milidetik ke-200, saat read lock masih terisi. Write lock baru diperoleh setelah pembaca-1 melepas kunci pada milidetik ke-300 — penulis masuk saat wilayah kritis benar-benar kosong pembaca, ditandai flag yang sudah false.
  3. Pola finally { unlock(); }: berlaku untuk kedua kunci sama seperti ReentrantLock — pelepasan tidak boleh terlewat apa pun yang terjadi di wilayah kritis.
  4. Urutan cetak: kedua bukti tercetak oleh main setelah ketiga thread di-join(), sehingga keluaran tidak bergantung kecepatan thread mana pun.

Pola Cache Banyak Membaca Jarang Menulis #

Pola pemakaian paling lazim adalah cache atau tabel referensi. Program berikut menyimpan data di HashMap biasa — struktur yang sama sekali tidak aman untuk thread — namun menjadi aman karena seluruh aksesnya dijaga ReadWriteLock: penulis memakai write lock, para pembaca memakai read lock:

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class CacheBacaTulis {
    private static class CacheProduk {
        // HashMap biasa: aman karena SELURUH aksesnya dijaga ReadWriteLock.
        private final Map<String, Integer> data = new HashMap<>();
        private final ReentrantReadWriteLock kunci = new ReentrantReadWriteLock();

        void simpan(String kode, int harga) {
            kunci.writeLock().lock();
            try {
                data.put(kode, harga);
            } finally {
                kunci.writeLock().unlock();
            }
        }

        Integer baca(String kode) {
            kunci.readLock().lock();
            try {
                return data.get(kode);
            } finally {
                kunci.readLock().unlock();
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        CacheProduk cache = new CacheProduk();

        // Fase tulis: satu penulis mengisi tiga produk lebih dulu.
        Thread penulis = new Thread(() -> {
            cache.simpan("P-001", 15000);
            cache.simpan("P-002", 27500);
            cache.simpan("P-003", 9900);
        }, "penulis");
        penulis.start();
        penulis.join();

        // Fase baca: tiga pembaca membaca ketiga produk secara bersamaan.
        String[] daftarKode = {"P-001", "P-002", "P-003"};
        int[] total = new int[3];
        Thread[] pembaca = new Thread[3];
        for (int i = 0; i < pembaca.length; i++) {
            int indeks = i;
            pembaca[i] = new Thread(() -> {
                int jumlah = 0;
                for (String kode : daftarKode) {
                    Integer harga = cache.baca(kode);
                    if (harga != null) {
                        jumlah += harga;
                    }
                }
                total[indeks] = jumlah; // kumpulkan hasil, cetak setelah join
            }, "pembaca-" + (i + 1));
            pembaca[i].start();
        }
        for (Thread t : pembaca) {
            t.join();
        }

        // Pembaruan harga dilakukan setelah seluruh pembaca selesai.
        cache.simpan("P-003", 12500);

        System.out.println("total harga yang dibaca tiap pembaca:");
        for (int i = 0; i < total.length; i++) {
            System.out.println("  pembaca-" + (i + 1) + ": " + total[i]);
        }
        System.out.println("harga P-003 setelah pembaruan: " + cache.baca("P-003"));
    }
}

Output yang Dihasilkan:

text
total harga yang dibaca tiap pembaca:
  pembaca-1: 52400
  pembaca-2: 52400
  pembaca-3: 52400
harga P-003 setelah pembaruan: 12500

Tiga keputusan desain pada contoh tersebut:

  1. Fase tulis dibereskan lebih dulu (penulis.join() sebelum pembaca dimulai): pembaca mana pun dijamin melihat ketiga produk — total 52400 per pembaca bukan hasil undian penjadwal melainkan konsekuensi urutan fase yang kita kendalikan.
  2. Tiga pembaca berjalan bersamaan: masing-masing memanggil baca() tiga kali; selama fase ini write lock tidak pernah diminta, sehingga ketiga thread memegang read lock bergiliran bersama dan hasil masing-masing terkumpul di slot array tetap.
  3. HashMap di dalam class privat: peta itu tidak pernah bocor ke luar; satu-satunya jalur masuk adalah simpan() dan baca() yang terjaga kunci. Inilah syarat mutlak pola ini — satu method yang lupa mengunci cukup untuk meruntuhkan seluruh jaminan.

Turun Grade Boleh, Naik Grade Tidak #

ReentrantReadWriteLock mengizinkan satu arah perpindahan: pemegang write lock boleh memperoleh read lock lalu melepas write lock — pola downgrade yang berguna saat penulis selesai mengubah data dan ingin terus membacanya tanpa membuka jeda bagi penulis lain. Arah sebaliknya tidak ada: pemegang read lock yang meminta write lock akan menunggu pembaca lain melepas kunci — termasuk dirinya sendiri yang belum melepas — dan berakhir menggantung tanpa batas.

java Downgrade yang sah (kiri) vs upgrade yang menggantung (kanan)
// pola downgrade: sah
kunci.writeLock().lock();
try {
    data.put("P-001", 15000);
    kunci.readLock().lock();  // pemegang write boleh mengambil read
} finally {
    kunci.writeLock().unlock(); // kini hanya read yang tersisa
}
// ... akhirnya readLock().unlock()

// pola upgrade: menggantung selamanya (jangan ditiru)
kunci.readLock().lock();
try {
    if (data.get("P-001") == null) {
        kunci.writeLock().lock(); // menunggu pembaca lain... termasuk diri sendiri
    }
} finally {
    kunci.readLock().unlock();
}

Kunci baca-tulis melengkapi kotak alat koordinasi kita: synchronized untuk kesederhanaan, volatile untuk visibilitas, class Atomic untuk satu variabel, ReentrantLock untuk kendali penuh, dan ReadWriteLock untuk data yang dibaca ramai. Namun makin banyak kunci yang beredar, makin besar pula peluang dua thread saling menahan kunci milik lawannya — pola kebuntuan klasik yang menjadi topik Chapter berikutnya.

Referensi #

Berkas Sumber

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

CacheBacaTulis.java

Unduh berkas
java
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class CacheBacaTulis {
    private static class CacheProduk {
        // HashMap biasa: aman karena SELURUH aksesnya dijaga ReadWriteLock.
        private final Map<String, Integer> data = new HashMap<>();
        private final ReentrantReadWriteLock kunci = new ReentrantReadWriteLock();

        void simpan(String kode, int harga) {
            kunci.writeLock().lock();
            try {
                data.put(kode, harga);
            } finally {
                kunci.writeLock().unlock();
            }
        }

        Integer baca(String kode) {
            kunci.readLock().lock();
            try {
                return data.get(kode);
            } finally {
                kunci.readLock().unlock();
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        CacheProduk cache = new CacheProduk();

        // Fase tulis: satu penulis mengisi tiga produk lebih dulu.
        Thread penulis = new Thread(() -> {
            cache.simpan("P-001", 15000);
            cache.simpan("P-002", 27500);
            cache.simpan("P-003", 9900);
        }, "penulis");
        penulis.start();
        penulis.join();

        // Fase baca: tiga pembaca membaca ketiga produk secara bersamaan.
        String[] daftarKode = {"P-001", "P-002", "P-003"};
        int[] total = new int[3];
        Thread[] pembaca = new Thread[3];
        for (int i = 0; i < pembaca.length; i++) {
            int indeks = i;
            pembaca[i] = new Thread(() -> {
                int jumlah = 0;
                for (String kode : daftarKode) {
                    Integer harga = cache.baca(kode);
                    if (harga != null) {
                        jumlah += harga;
                    }
                }
                total[indeks] = jumlah; // kumpulkan hasil, cetak setelah join
            }, "pembaca-" + (i + 1));
            pembaca[i].start();
        }
        for (Thread t : pembaca) {
            t.join();
        }

        // Pembaruan harga dilakukan setelah seluruh pembaca selesai.
        cache.simpan("P-003", 12500);

        System.out.println("total harga yang dibaca tiap pembaca:");
        for (int i = 0; i < total.length; i++) {
            System.out.println("  pembaca-" + (i + 1) + ": " + total[i]);
        }
        System.out.println("harga P-003 setelah pembaruan: " + cache.baca("P-003"));
    }
}

PembacaBersamaan.java

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

public class PembacaBersamaan {
    // Flag volatile agar tulisan pembaca-1 langsung terlihat thread lain.
    private static volatile boolean pembaca1DiDalam = false;

    public static void main(String[] args) throws InterruptedException {
        ReentrantReadWriteLock kunci = new ReentrantReadWriteLock();
        boolean[] buktiPembaca = new boolean[1];
        boolean[] buktiPenulis = new boolean[1];

        // pembaca-1 memegang read lock lama (300 ms).
        Thread pembaca1 = new Thread(() -> {
            kunci.readLock().lock();
            try {
                pembaca1DiDalam = true;
                Thread.sleep(300); // pembacaan lambat: read lock tetap bisa dibagi
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                pembaca1DiDalam = false;
                kunci.readLock().unlock();
            }
        }, "pembaca-1");

        // pembaca-2 masuk pada ms ke-100, saat pembaca-1 masih memegang read lock.
        Thread pembaca2 = new Thread(() -> {
            try {
                Thread.sleep(100); // pastikan pembaca-1 sudah berada di dalam
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }
            kunci.readLock().lock(); // BERHASIL: read lock boleh dipegang bersama
            try {
                buktiPembaca[0] = pembaca1DiDalam; // true: pembaca-1 masih di dalam
            } finally {
                kunci.readLock().unlock();
            }
        }, "pembaca-2");

        // penulis mencoba write lock pada ms ke-200, harus menunggu pembaca selesai.
        Thread penulis = new Thread(() -> {
            try {
                Thread.sleep(200); // mulai setelah pembaca-2 selesai membaca
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }
            kunci.writeLock().lock(); // menunggu sampai read lock benar-benar kosong
            try {
                buktiPenulis[0] = !pembaca1DiDalam; // true: penulis masuk saat bersih
            } finally {
                kunci.writeLock().unlock();
            }
        }, "penulis");

        pembaca1.start();
        pembaca2.start();
        penulis.start();
        pembaca1.join();
        pembaca2.join();
        penulis.join();

        System.out.println("pembaca-2 masuk saat pembaca-1 masih membaca : " + buktiPembaca[0]);
        System.out.println("penulis masuk setelah semua pembaca keluar   : " + buktiPenulis[0]);
    }
}