Tampilan
Kenapa penting
Di bab sebelumnya kamu melihat count++ kehilangan hampir separuh increment, dan field bean singleton tertimpa request lain. Masalahnya sudah jelas; sekarang saatnya memperbaikinya. Di pekerjaan backend, kebutuhan ini muncul terus: counter metrik, cache in-memory, rate limiter per klien, reservasi stok flash sale, daftar listener event. Memilih alat yang salah berakibat dua arah — terlalu longgar memunculkan data korup, terlalu ketat membuat ratusan thread request antre di satu lock dan latensi melonjak. Bab ini memetakan alat yang disediakan Java beserta kapan masing-masing paling cocok, lalu menutup dengan strategi yang paling murah dan paling aman: tidak berbagi state yang bisa berubah sama sekali.
Konsep inti
synchronized: satu thread dalam satu waktu
Setiap objek Java punya monitor lock bawaan. Blok atau method synchronized mengambil lock itu; thread lain yang ingin masuk harus menunggu sampai lock dilepas. Ini memberi dua jaminan sekaligus: atomisitas (blok dieksekusi utuh tanpa diselang) dan visibility (perubahan di dalam blok terlihat oleh thread berikutnya yang mengambil lock yang sama).
java
synchronized void incrementSync() { // lock pada `this`
syncCount++;
}
void reserve(int qty) {
synchronized (stockLock) { // lock pada objek khusus
if (stock >= qty) {
stock -= qty;
}
}
}Lock dilepas otomatis saat blok selesai, termasuk ketika exception dilempar. synchronized juga reentrant: thread yang sudah memegang lock boleh masuk lagi ke blok lain dengan lock yang sama tanpa macet. Kelemahannya: kamu tidak bisa membatalkan penantian atau memberi timeout.
ReentrantLock: lock yang bisa diatur
java.util.concurrent.locks.ReentrantLock memberi kontrol lebih: tryLock() dengan timeout, penantian yang bisa di-interrupt (lockInterruptibly()), dan opsi fair. Imbalannya, kamu wajib melepas lock sendiri di finally.
java
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public class Inventory {
private final ReentrantLock lock = new ReentrantLock();
private int stock;
Inventory(int stock) {
this.stock = stock;
}
boolean reserve(int qty) throws InterruptedException {
if (!lock.tryLock(200, TimeUnit.MILLISECONDS)) {
throw new IllegalStateException("Inventory sibuk, coba lagi");
}
try {
if (stock < qty) {
return false;
}
stock -= qty;
return true;
} finally {
lock.unlock(); // selalu di finally
}
}
public static void main(String[] args) throws InterruptedException {
Inventory inv = new Inventory(5);
Thread[] buyers = new Thread[8];
for (int i = 0; i < buyers.length; i++) {
buyers[i] = Thread.ofPlatform().start(() -> {
try {
inv.reserve(1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
for (Thread t : buyers) {
t.join();
}
System.out.println("Sisa stok: " + inv.stock);
}
}Output:
text
Sisa stok: 0Delapan pembeli berebut lima unit, dan stok berhenti tepat di nol — tidak minus — karena cek dan pengurangan terjadi di dalam satu lock. Aturan praktis: mulai dengan synchronized karena lebih sederhana; pindah ke ReentrantLock hanya bila kamu butuh timeout, interrupt, atau fairness.
Deadlock dan cara menghindarinya
Deadlock terjadi ketika dua thread atau lebih saling menunggu lock yang dipegang yang lain. Contoh transfer antar akun berikut mengunci akun asal lalu akun tujuan:
java
public class Deadlock {
record Account(String id) {}
static void transfer(Account from, Account to) throws InterruptedException {
synchronized (from) {
System.out.println(Thread.currentThread().getName() + " mengunci " + from.id());
Thread.sleep(50); // memperbesar peluang tabrakan
synchronized (to) {
System.out.println(Thread.currentThread().getName() + " transfer selesai");
}
}
}
public static void main(String[] args) {
Account a = new Account("A");
Account b = new Account("B");
Thread.ofPlatform().name("t1").start(() -> run(a, b));
Thread.ofPlatform().name("t2").start(() -> run(b, a));
}
static void run(Account from, Account to) {
try {
transfer(from, to);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}Output, lalu program menggantung selamanya (baris "transfer selesai" tidak pernah muncul):
text
t1 mengunci A
t2 mengunci Bt1 memegang A dan menunggu B; t2 memegang B dan menunggu A. Urutan dua baris di atas bisa tertukar, tetapi hasilnya sama. Cara menghindarinya:
- Urutan penguncian konsisten. Selalu kunci berdasarkan urutan global, misalnya ID terkecil dulu. Dengan perubahan ini kedua transfer selesai:
java
// Kunci selalu diambil dengan urutan yang sama: id terkecil dulu
Account first = from.id().compareTo(to.id()) < 0 ? from : to;
Account second = first == from ? to : from;
synchronized (first) {
synchronized (second) {
// pindahkan saldo
}
}text
t1: A -> B selesai
t2: B -> A selesaitryLockdengan timeout — bila lock kedua tidak didapat, lepaskan yang pertama dan coba lagi atau gagal dengan jelas.- Perkecil cakupan lock — jangan memanggil I/O, HTTP client, atau method milik objek lain sambil memegang lock.
Saat deadlock terjadi di production, thread dump (jstack <pid> atau jcmd <pid> Thread.print) akan menampilkan bagian "Found one Java-level deadlock".
Atomic dan LongAdder
Untuk counter atau nilai tunggal, kelas di java.util.concurrent.atomic memakai instruksi CPU compare-and-swap (CAS) — tanpa lock, tanpa thread yang tertidur:
java
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.LongAdder;
public class Counters {
private int syncCount = 0;
private final AtomicInteger atomicCount = new AtomicInteger();
private final LongAdder adderCount = new LongAdder();
synchronized void incrementSync() {
syncCount++;
}
public static void main(String[] args) throws InterruptedException {
Counters c = new Counters();
Runnable work = () -> {
for (int i = 0; i < 1_000_000; i++) {
c.incrementSync();
c.atomicCount.incrementAndGet();
c.adderCount.increment();
}
};
Thread a = Thread.ofPlatform().start(work);
Thread b = Thread.ofPlatform().start(work);
a.join();
b.join();
System.out.println("synchronized : " + c.syncCount);
System.out.println("AtomicInteger: " + c.atomicCount.get());
System.out.println("LongAdder : " + c.adderCount.sum());
}
}Output:
text
synchronized : 2000000
AtomicInteger: 2000000
LongAdder : 2000000Ketiganya benar. Bedanya di karakter performa:
| Alat | Cocok untuk |
|---|---|
AtomicInteger | Counter atau ID yang nilainya sering dibaca; compareAndSet untuk update bersyarat |
LongAdder | Statistik yang sangat sering ditulis banyak thread dan jarang dibaca |
synchronized | Invarian yang melibatkan lebih dari satu variabel |
LongAdder menyebar increment ke beberapa sel internal, sehingga di bawah kontensi tinggi lebih cepat daripada AtomicInteger. Harganya, sum() bukan snapshot atomik bila masih ada penulisan yang berjalan — cukup untuk metrik, tidak cocok untuk saldo.
ConcurrentHashMap: atomik per kunci
HashMap biasa tidak aman diakses beberapa thread: update bisa hilang, dan struktur internalnya bisa rusak. ConcurrentHashMap aman, tetapi gabungan beberapa panggilan tetap bukan operasi atomik. Bandingkan pola getOrDefault lalu put dengan merge:
java
import java.util.HashMap;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.ConcurrentHashMap;
public class OrderStats {
public static void main(String[] args) throws InterruptedException {
Map<String, Integer> unsafe = new HashMap<>();
Map<String, Integer> safe = new ConcurrentHashMap<>();
String[] statuses = {"PAID", "SHIPPED", "CANCELLED"};
Runnable record = () -> {
for (int i = 0; i < 300_000; i++) {
String status = statuses[i % 3];
unsafe.put(status, unsafe.getOrDefault(status, 0) + 1); // cek lalu tulis: tidak atomik
safe.merge(status, 1, Integer::sum); // atomik per kunci
}
};
Thread a = Thread.ofPlatform().start(record);
Thread b = Thread.ofPlatform().start(record);
a.join();
b.join();
System.out.println("HashMap : " + new TreeMap<>(unsafe));
System.out.println("ConcurrentHashMap: " + new TreeMap<>(safe));
}
}Tiga kali dijalankan:
text
HashMap : {CANCELLED=189307, PAID=189057, SHIPPED=190485}
ConcurrentHashMap: {CANCELLED=200000, PAID=200000, SHIPPED=200000}
HashMap : {CANCELLED=183303, PAID=182908, SHIPPED=181701}
ConcurrentHashMap: {CANCELLED=200000, PAID=200000, SHIPPED=200000}
HashMap : {CANCELLED=189142, PAID=192099, SHIPPED=187509}
ConcurrentHashMap: {CANCELLED=200000, PAID=200000, SHIPPED=200000}Baris HashMap berubah setiap kali dan selalu kurang dari 200.000 — jumlah update yang hilang bergantung pada seberapa sering kedua thread bertabrakan, yang dipengaruhi jumlah core, beban mesin, dan keputusan JIT. Baris ConcurrentHashMap selalu tepat. Method atomik yang perlu kamu kuasai:
merge(key, value, fn)— gabungkan dengan nilai lama, ideal untuk penjumlahan.compute(key, (k, old) -> ...)— hitung nilai baru dari nilai lama; kembalikannulluntuk menghapus entri.computeIfAbsent(key, k -> ...)— buat nilai hanya bila belum ada, cocok untuk cache dan inisialisasiLongAdder.
Fungsi di dalam compute/merge dijalankan sambil memegang lock untuk kunci itu, jadi buatlah singkat: jangan memanggil database atau HTTP di dalamnya, dan jangan mengubah map yang sama dari dalam fungsi tersebut.
CopyOnWriteArrayList: banyak baca, jarang tulis
CopyOnWriteArrayList menyalin seluruh array setiap kali ditulis. Iterator membaca snapshot, sehingga iterasi tidak pernah melempar ConcurrentModificationException:
java
List<String> listeners = new CopyOnWriteArrayList<>(List.of("email", "sms"));
for (String l : listeners) {
System.out.println("Kirim notifikasi via " + l);
listeners.add("push"); // tidak memengaruhi iterasi yang sedang berjalan
}
System.out.println("Isi akhir: " + listeners);text
Kirim notifikasi via email
Kirim notifikasi via sms
Isi akhir: [email, sms, push, push]Pola yang sama pada ArrayList berakhir dengan java.util.ConcurrentModificationException. Pakai CopyOnWriteArrayList untuk daftar listener, konfigurasi, atau whitelist yang dibaca terus tetapi jarang berubah. Untuk daftar yang sering ditulis, biaya menyalin array setiap kali akan sangat mahal.
Immutability: strategi terbaik
Objek yang tidak bisa berubah otomatis thread-safe — tidak ada yang perlu dikunci karena tidak ada yang ditulis. record dengan field yang juga immutable adalah alat utamanya:
java
public record PriceList(String currency, Map<String, BigDecimal> prices) {
public PriceList {
prices = Map.copyOf(prices); // salinan defensif yang immutable
}
}Bila daftar harga perlu diperbarui, buat objek baru lalu tukar referensinya sekali, misalnya lewat field volatile PriceList current atau AtomicReference<PriceList>. Pembaca selalu melihat versi lama yang utuh atau versi baru yang utuh, tidak pernah setengah jadi. Urutan preferensi yang sehat: tidak berbagi state → berbagi state immutable → struktur konkuren dari JDK → lock buatanmu sendiri.
CountDownLatch sekilas
CountDownLatch membuat satu thread menunggu sampai N pekerjaan lain selesai — misalnya menunggu beberapa cache selesai dimuat sebelum aplikasi menerima traffic:
java
CountDownLatch ready = new CountDownLatch(caches.length);
for (String name : caches) {
Thread.ofPlatform().name("warmup-" + name).start(() -> {
System.out.println("Cache " + name + " siap");
ready.countDown();
});
}
ready.await();
System.out.println("Semua cache siap, mulai terima traffic");Dua kali dijalankan:
text
Cache produk siap
Cache harga siap
Cache promo siap
Semua cache siap, mulai terima traffictext
Cache produk siap
Cache promo siap
Cache harga siap
Semua cache siap, mulai terima trafficUrutan tiga baris "siap" bergantung pada penjadwalan thread dan bisa berbeda di setiap eksekusi; yang dijamin hanya baris terakhir selalu muncul paling akhir, karena await() baru kembali setelah hitungan mencapai nol. Di bab berikutnya kamu akan melihat cara yang lebih nyaman lewat ExecutorService dan CompletableFuture.
Contoh di dunia backend
Layanan katalog menghitung view per produk dan menangani reservasi stok flash sale di memori (sebelum disinkronkan ke database secara berkala):
java
// Potongan — komponen Spring dengan state konkuren yang aman
@Component
public class FlashSaleStore {
private final ConcurrentHashMap<String, LongAdder> views = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, Integer> stock = new ConcurrentHashMap<>();
public void recordView(String sku) {
views.computeIfAbsent(sku, k -> new LongAdder()).increment();
}
public long viewsOf(String sku) {
LongAdder adder = views.get(sku);
return adder == null ? 0 : adder.sum();
}
/** Mengurangi stok secara atomik; false bila stok tidak cukup. */
public boolean reserve(String sku, int qty) {
boolean[] reserved = {false};
stock.computeIfPresent(sku, (k, current) -> {
if (current < qty) {
return current; // tidak berubah
}
reserved[0] = true;
return current - qty;
});
return reserved[0];
}
public void restock(String sku, int qty) {
stock.merge(sku, qty, Integer::sum);
}
}Cek "stok cukup?" dan pengurangan terjadi di dalam satu computeIfPresent, jadi tidak ada check-then-act yang bisa diselang. Satu catatan penting: semua mekanisme di bab ini hanya melindungi data di dalam satu JVM. Bila aplikasimu berjalan di tiga instance di belakang load balancer, stok yang sesungguhnya harus dijaga di database (misalnya UPDATE ... WHERE stock >= ? atau optimistic locking dengan @Version), bukan di ConcurrentHashMap.
Pertanyaan interview
1. Kapan kamu memilih ReentrantLock dibanding synchronized?
synchronized adalah pilihan default karena sederhana dan lock dilepas otomatis. ReentrantLock dipilih ketika butuh fitur tambahan: tryLock dengan timeout untuk menghindari penantian tanpa batas, penantian yang bisa di-interrupt, fairness, atau beberapa Condition. Konsekuensinya, unlock() wajib dipanggil di blok finally.
2. Apa itu deadlock dan bagaimana mencegahnya?
Deadlock adalah kondisi dua thread atau lebih saling menunggu lock yang dipegang pihak lain sehingga tidak ada yang bisa maju. Pencegahan paling efektif adalah mengambil lock selalu dalam urutan global yang sama, misalnya berdasarkan ID. Cara lain: tryLock dengan timeout, memperkecil cakupan lock, dan tidak memanggil kode asing atau I/O sambil memegang lock. Diagnosisnya lewat thread dump.
3. Kenapa ConcurrentHashMap tidak otomatis membuat semua kode yang memakainya thread-safe?
Karena jaminannya berlaku per operasi. Urutan get lalu put, atau containsKey lalu put, bisa diselang thread lain di antara dua panggilan itu. Untuk operasi baca–ubah–tulis, gunakan method atomik seperti merge, compute, computeIfAbsent, atau putIfAbsent.
Jebakan umum
- Memanggil
lock()tanpatry/finally, sehingga lock tidak pernah dilepas saat terjadi exception. - Mengunci dua sumber daya dalam urutan berbeda di tempat berbeda — resep deadlock.
- Memakai
getlaluputpadaConcurrentHashMapdan mengira sudah atomik. - Melakukan query database atau panggilan HTTP di dalam
synchronizedataucompute, sehingga semua thread lain ikut antre. - Memakai
CopyOnWriteArrayListuntuk daftar yang sering ditulis — setiap tulisan menyalin seluruh array. - Mengandalkan lock in-memory untuk menjaga data yang sebenarnya dibagi beberapa instance aplikasi.
Ringkasan
synchronizedmemberi atomisitas dan visibility dengan lock yang dilepas otomatis;ReentrantLockmenambahtryLock, interrupt, dan fairness dengan kewajibanunlock()difinally.- Deadlock dicegah dengan urutan penguncian konsisten, timeout, dan cakupan lock yang kecil.
AtomicIntegeruntuk nilai tunggal yang sering dibaca,LongAdderuntuk counter dengan kontensi tinggi.- Di
ConcurrentHashMap, pakaimerge/compute/computeIfAbsentuntuk baca–ubah–tulis;CopyOnWriteArrayListuntuk data yang jarang berubah. - Immutability adalah strategi paling aman: objek yang tidak bisa berubah tidak perlu dikunci.