Multithreading, Concurrency, dan Race Condition di Java: Panduan Lengkap dari Thread Klasik hingga Virtual Threads
Java adalah salah satu bahasa pertama yang menjadikan concurrency sebagai bagian inti dari desain bahasanya. Sejak versi 1.0 yang rilis tahun 1995, Java sudah memiliki java.lang.Thread dan keyword synchronized sebagai primitif concurrency bawaan. Selama tiga dekade, model concurrency Java terus berkembang — dari thread dan synchronized yang sederhana (Java 1.0), ke java.util.concurrent yang kaya fitur (Java 5), lalu ke CompletableFuture untuk pemrograman asynchronous (Java 8), hingga akhirnya ke Virtual Threads melalui Project Loom yang distabilkan di Java 21 dan disempurnakan di Java 24–25. Setiap lapisan ini hadir sebagai respons terhadap keterbatasan lapisan sebelumnya, dan memahami evolusi ini secara utuh adalah kunci untuk menulis kode concurrent Java yang benar, aman, dan efisien.
Namun kekuatan concurrency Java juga membawa kompleksitas yang nyata. Race condition, deadlock, livelock, visibility problem — semuanya adalah bug yang bisa sangat sulit dilacak karena hanya muncul pada kondisi tertentu dan sering tidak bisa direproduksi secara konsisten di lingkungan development. Artikel ini membahas seluruh spektrum concurrency di Java secara mendalam: mulai dari cara thread bekerja di level paling dasar, berbagai mekanisme sinkronisasi dan kapan harus memilih yang mana, race condition beserta cara mendeteksi dan mencegahnya, pola-pola concurrent yang umum di produksi, hingga virtual threads dan structured concurrency yang merupakan cara berpikir terbaru tentang concurrency di ekosistem Java.
Platform Thread: Fondasi Concurrency Java
Membuat dan Menjalankan Thread
Ada dua cara klasik membuat thread di Java: mengextend Thread atau mengimplementasikan Runnable.
// Cara 1: Extend Thread
class WorkerThread extends Thread {
@Override
public void run() {
System.out.println("Berjalan di thread: " + Thread.currentThread().getName());
}
}
// Cara 2: Implement Runnable (lebih dianjurkan)
Runnable task = () -> {
System.out.println("Berjalan di thread: " + Thread.currentThread().getName());
};
// Menjalankan thread
Thread t1 = new WorkerThread();
t1.start(); // start(), bukan run()!
Thread t2 = new Thread(task);
t2.start();
Jangan panggilrun()langsung — itu hanya memanggil metode biasa di thread yang sedang berjalan (biasanya main thread). Yang membuat kode berjalan di thread baru adalahstart(), yang meminta JVM membuat thread OS baru dan menjalankanrun()di sana.
Lifecycle Thread
Setiap thread Java memiliki lifecycle yang didefinisikan oleh enum Thread.State:
stateDiagram-v2
[*] --> NEW : new Thread()
NEW --> RUNNABLE : start()
RUNNABLE --> BLOCKED : menunggu monitor lock
BLOCKED --> RUNNABLE : lock tersedia
RUNNABLE --> WAITING : wait() / join() / park()
WAITING --> RUNNABLE : notify() / interrupt()
RUNNABLE --> TIMED_WAITING : sleep(ms) / wait(ms)
TIMED_WAITING --> RUNNABLE : timeout / notify()
RUNNABLE --> TERMINATED : run() selesai
TERMINATED --> [*]Platform Thread vs Kernel Thread
Setiap platform thread di Java dipetakan 1:1 ke OS thread. Ini berarti setiap thread membawa overhead yang signifikan:
- Stack memory tetap: biasanya 512 KB hingga 2 MB per thread (bisa dikonfigurasi dengan
-Xss) - Overhead context switch: melibatkan kernel OS, lebih lambat dari user-space switching
- Jumlah thread yang praktis terbatas: ratusan hingga beberapa ribu sebelum performa turun signifikan
flowchart TD
subgraph "JVM"
T1[Java Thread 1]
T2[Java Thread 2]
T3[Java Thread 3]
end
subgraph "Kernel OS"
KT1[OS Thread 1]
KT2[OS Thread 2]
KT3[OS Thread 3]
end
T1 <-->|1:1 mapping| KT1
T2 <-->|1:1 mapping| KT2
T3 <-->|1:1 mapping| KT3Inilah yang menjadi bottleneck utama dalam aplikasi Java modern dengan beban tinggi, dan motivasi utama lahirnya Virtual Threads yang akan dibahas nanti.
Race Condition: Musuh Utama Kode Concurrent
Race condition terjadi ketika dua atau lebih thread mengakses shared data secara bersamaan tanpa sinkronisasi yang tepat, dan hasil program menjadi tidak deterministik — bergantung pada urutan eksekusi thread, bukan logika program.
Contoh Klasik: Lost Update
// ANTI-PATTERN: race condition pada counter bersama
public class UnsafeCounter {
private int count = 0; // shared state
public void increment() {
count++; // TIDAK AMAN: bukan operasi atomik!
}
public int getCount() {
return count;
}
}
// Test
UnsafeCounter counter = new UnsafeCounter();
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 10; i++) {
threads.add(new Thread(() -> {
for (int j = 0; j < 1000; j++) {
counter.increment();
}
}));
}
threads.forEach(Thread::start);
threads.forEach(t -> {
try { t.join(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
System.out.println(counter.getCount()); // Bukan 10000! Hasilnya tidak terprediksi
Seperti pada bahasa lain, count++ bukan satu instruksi atomik — ia terdiri dari tiga langkah: baca, tambah, tulis. Ketika dua thread melakukan ini bersamaan, terjadi lost update:
sequenceDiagram
participant T1 as Thread 1
participant Mem as Memory (count)
participant T2 as Thread 2
Note over Mem: count = 42
T1->>Mem: READ (mendapat 42)
T2->>Mem: READ (mendapat 42)
T1->>T1: Hitung 42 + 1 = 43
T2->>T2: Hitung 42 + 1 = 43
T1->>Mem: WRITE 43
T2->>Mem: WRITE 43
Note over Mem: count = 43, bukan 44!<br/>Satu increment hilang.Visibility Problem: Masalah yang Lebih Tersembunyi
Selain race condition pada data, Java juga memiliki masalah visibility yang lebih halus. Karena model memori Java (JMM) memungkinkan setiap thread menyimpan salinan variabel di cache CPU-nya sendiri, perubahan yang dibuat oleh satu thread mungkin tidak langsung terlihat oleh thread lain.
// ANTI-PATTERN: visibility problem
public class StopTask {
private boolean running = true; // tidak ada jaminan visibility
public void stop() {
running = false; // thread lain mungkin tidak pernah melihat perubahan ini
}
public void run() {
while (running) { // mungkin loop selamanya!
doWork();
}
}
}
Thread yang menjalankan run() mungkin membaca running dari cache CPU-nya sendiri, yang belum diperbarui meskipun thread lain sudah memanggil stop(). Hasilnya: loop tak terbatas meski running sudah di-set false.
Synchronized: Mekanisme Sinkronisasi Paling Dasar
Keyword synchronized di Java menyediakan dua jaminan sekaligus: mutual exclusion (hanya satu thread yang bisa mengeksekusi blok synchronized pada satu objek di satu waktu) dan visibility (perubahan yang dibuat di dalam blok synchronized dijamin terlihat oleh thread lain yang masuk ke blok synchronized berikutnya pada objek yang sama).
Synchronized Method
// BENAR: synchronized method
public class SafeCounter {
private int count = 0;
public synchronized void increment() {
count++; // hanya satu thread yang bisa mengeksekusi ini pada satu waktu
}
public synchronized int getCount() {
return count;
}
}
Synchronized Block
Lebih fleksibel karena bisa menyempitkan scope lock ke bagian yang benar-benar butuh perlindungan:
public class BetterCounter {
private int count = 0;
private final Object lock = new Object(); // objek khusus sebagai lock
public void incrementAndLog() {
// pekerjaan yang tidak butuh lock — jalankan di luar blok
String logMsg = prepareLogMessage();
synchronized (lock) { // lock hanya saat mengakses shared state
count++;
}
// logging juga tidak butuh lock
logger.info(logMsg);
}
}
Synchronized Static Method
Untuk static member, synchronized menggunakan Class object sebagai monitor, bukan instance:
public class Registry {
private static int instanceCount = 0;
public static synchronized void register() {
instanceCount++; // lock pada Registry.class, bukan pada instance
}
}
synchronizedpada instance method dansynchronizedpada static method menggunakan lock yang berbeda. Instance method mengunci objek instance, static method mengunci objekClass. Keduanya tidak saling memblokir, yang bisa menjadi sumber bug jika tidak dipahami dengan baik.
Volatile: Jaminan Visibility Tanpa Mutual Exclusion
Keyword volatile menyelesaikan masalah visibility tanpa overhead full synchronization. Variabel volatile selalu dibaca langsung dari main memory, dan penulisan langsung ke main memory — tidak pernah di-cache di CPU register thread.
// BENAR: volatile untuk flag stopping
public class StopTask {
private volatile boolean running = true; // volatile menjamin visibility
public void stop() {
running = false; // perubahan ini langsung terlihat oleh thread lain
}
public void run() {
while (running) { // selalu membaca dari main memory
doWork();
}
System.out.println("Berhenti dengan benar");
}
}
Kapan Volatile Cukup, Kapan Tidak
Volatile CUKUP jika:
✓ Hanya satu thread yang menulis, satu atau lebih yang membaca
✓ Untuk flag boolean yang hanya di-set sekali (stopping flag)
✓ Untuk referensi ke objek immutable yang diganti atomik
Volatile TIDAK CUKUP jika:
✗ Ada operasi read-modify-write (count++, list.add())
✗ Ada beberapa variabel yang harus diperbarui secara atomik
✗ Ada kondisi check-then-act (if (x == null) { x = new X(); })
java.util.concurrent: Toolkit Concurrency Modern Java
Package java.util.concurrent (diperkenalkan di Java 5) menyediakan abstraksi level lebih tinggi yang jauh lebih aman dan ekspresif dibanding raw synchronized.
ReentrantLock: Kontrol Lock yang Lebih Fleksibel
ReentrantLock memberikan kemampuan yang tidak dimiliki synchronized:
import java.util.concurrent.locks.ReentrantLock;
public class SafeStore {
private final ReentrantLock lock = new ReentrantLock();
private Map<String, String> data = new HashMap<>();
public void put(String key, String value) {
lock.lock();
try {
data.put(key, value);
} finally {
lock.unlock(); // SELALU di finally block!
}
}
// Mencoba lock tanpa blok (non-blocking)
public boolean tryPut(String key, String value) {
if (lock.tryLock()) {
try {
data.put(key, value);
return true;
} finally {
lock.unlock();
}
}
return false; // gagal mendapatkan lock, tapi tidak blok
}
// Lock dengan timeout
public boolean putWithTimeout(String key, String value, long timeout, TimeUnit unit)
throws InterruptedException {
if (lock.tryLock(timeout, unit)) {
try {
data.put(key, value);
return true;
} finally {
lock.unlock();
}
}
return false;
}
}
ReadWriteLock: Optimasi untuk Read-Heavy Workload
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class CachedData {
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
private Map<String, Object> cache = new HashMap<>();
// Banyak thread bisa membaca secara bersamaan
public Object get(String key) {
rwLock.readLock().lock();
try {
return cache.get(key);
} finally {
rwLock.readLock().unlock();
}
}
// Hanya satu thread yang bisa menulis, dan tidak ada reader saat writer aktif
public void put(String key, Object value) {
rwLock.writeLock().lock();
try {
cache.put(key, value);
} finally {
rwLock.writeLock().unlock();
}
}
}
| Kondisi | synchronized | ReentrantLock | ReadWriteLock |
|---|---|---|---|
| Kemudahan penggunaan | Paling mudah | Sedang | Sedang |
| Interruptible lock | Tidak | Ya | Ya |
| Try lock (non-blocking) | Tidak | Ya | Ya |
| Fairness control | Tidak | Ya | Ya |
| Multiple conditions | Tidak (satu wait set) | Ya | Ya |
| Read-heavy optimization | Tidak | Tidak | Ya |
Atomic Classes: Operasi Atomik Tanpa Lock
Package java.util.concurrent.atomic menyediakan kelas-kelas yang mendukung operasi atomik menggunakan instruksi CPU level rendah (CAS — Compare-And-Swap), tanpa overhead mutex:
import java.util.concurrent.atomic.*;
// AtomicInteger untuk counter thread-safe
AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet(); // atomic increment, return nilai baru
counter.getAndIncrement(); // atomic increment, return nilai lama
counter.addAndGet(5); // atomic add
counter.compareAndSet(10, 20); // CAS: set 20 hanya jika saat ini 10
// AtomicReference untuk referensi object
AtomicReference<String> ref = new AtomicReference<>("awal");
ref.compareAndSet("awal", "baru"); // atomic CAS pada referensi
// AtomicLong untuk counter besar
AtomicLong bigCounter = new AtomicLong(0L);
// LongAdder: lebih efisien dari AtomicLong untuk increment-only counter
// dengan banyak thread (mengurangi contention lewat striping internal)
LongAdder adder = new LongAdder();
adder.increment();
long total = adder.sum();
Untuk counter yang hanya diincrement dari banyak thread dan sesekali dibaca totalnya (pola umum di monitoring/metrics),LongAdderlebih efisien dariAtomicLongkarena mengurangi contention dengan memaintain beberapa counter internal yang dijumlahkan saatsum()dipanggil.
Concurrent Collections
Jangan gunakan HashMap, ArrayList, atau HashSet dari beberapa thread tanpa sinkronisasi eksternal — mereka tidak thread-safe. Java menyediakan implementasi concurrent yang aman:
// ANTI-PATTERN: HashMap tidak thread-safe
Map<String, Integer> map = new HashMap<>(); // race condition!
// BENAR opsi 1: ConcurrentHashMap — lebih efisien dari synchronizedMap
Map<String, Integer> concurrentMap = new ConcurrentHashMap<>();
concurrentMap.put("key", 1);
// putIfAbsent, computeIfAbsent, merge — semua atomik
concurrentMap.computeIfAbsent("newKey", k -> k.length()); // atomik
// BENAR opsi 2: CopyOnWriteArrayList — ideal untuk read-heavy list
List<String> cowList = new CopyOnWriteArrayList<>();
cowList.add("item"); // setiap penulisan membuat salinan array baru
// Iterasi selalu aman, tidak pernah ConcurrentModificationException
// BENAR opsi 3: BlockingQueue — untuk producer-consumer pattern
BlockingQueue<String> queue = new LinkedBlockingQueue<>(100);
queue.put("item"); // blok jika queue penuh
queue.offer("item"); // return false jika queue penuh (non-blocking)
String item = queue.take(); // blok jika queue kosong
Thread Pool dan ExecutorService
Membuat thread baru untuk setiap tugas adalah anti-pattern — terlalu mahal. ExecutorService menyediakan thread pool yang bisa dipakai ulang:
import java.util.concurrent.*;
// Pool dengan jumlah thread tetap
ExecutorService fixed = Executors.newFixedThreadPool(4);
// Pool yang membuat thread baru jika semua sedang sibuk (berbahaya untuk produksi!)
ExecutorService cached = Executors.newCachedThreadPool();
// Single thread — menjamin urutan eksekusi
ExecutorService single = Executors.newSingleThreadExecutor();
// Scheduled executor — untuk task berkala
ScheduledExecutorService scheduled = Executors.newScheduledThreadPool(2);
// Mengirim task ke executor
fixed.submit(() -> {
System.out.println("Task berjalan di thread pool");
});
// Submit dengan return value (Future)
Future<Integer> future = fixed.submit(() -> {
Thread.sleep(1000);
return 42;
});
Integer result = future.get(); // blok hingga task selesai
Integer resultWithTimeout = future.get(2, TimeUnit.SECONDS); // dengan timeout
// Selalu shutdown executor setelah selesai
fixed.shutdown();
fixed.awaitTermination(10, TimeUnit.SECONDS);
ThreadPoolExecutor: Konfigurasi Manual
Untuk produksi, lebih baik mengkonfigurasi thread pool secara eksplisit daripada mengandalkan factory method Executors:
ThreadPoolExecutor executor = new ThreadPoolExecutor(
4, // corePoolSize: thread minimum yang selalu hidup
8, // maximumPoolSize: thread maksimum yang bisa dibuat
60L, TimeUnit.SECONDS, // keepAliveTime: waktu idle sebelum thread excess dihentikan
new LinkedBlockingQueue<>(100), // workQueue: antrian task yang menunggu
new ThreadFactory() { // custom thread factory untuk penamaan
private int count = 0;
@Override
public Thread newThread(Runnable r) {
return new Thread(r, "worker-" + count++);
}
},
new ThreadPoolExecutor.CallerRunsPolicy() // rejection policy jika queue penuh
);
HindariExecutors.newCachedThreadPool()di produksi tanpa batasan. Pool ini membuat thread baru tanpa batas jika semua thread sedang sibuk. Pada load spike, ini bisa membuat ratusan atau ribuan thread terbuat dalam sekejap, menyebabkan OutOfMemoryError. Selalu gunakanThreadPoolExecutordengan batas yang jelas, atau Virtual Threads untuk kasus I/O-bound.
CompletableFuture: Pemrograman Asynchronous Non-Blocking
CompletableFuture (Java 8) memungkinkan chaining operasi asynchronous tanpa callback hell:
CompletableFuture<String> future = CompletableFuture
.supplyAsync(() -> fetchUserFromDB(userId)) // async di ForkJoinPool
.thenApply(user -> enrichWithProfile(user)) // transformasi sync
.thenCompose(user -> fetchOrdersAsync(user.getId())) // flatMap async
.thenCombine(
fetchRecommendationsAsync(userId), // jalankan paralel
(orders, recs) -> buildResponse(orders, recs) // gabungkan hasilnya
)
.exceptionally(ex -> { // error handling
logger.error("Gagal", ex);
return defaultResponse();
});
String result = future.get(); // tunggu hasil
// Jalankan beberapa future secara paralel, tunggu semua selesai
CompletableFuture<Void> allDone = CompletableFuture.allOf(
futureA, futureB, futureC
);
// Tunggu yang pertama selesai
CompletableFuture<Object> firstDone = CompletableFuture.anyOf(
futureA, futureB, futureC
);
flowchart LR
A[supplyAsync<br/>fetch user] --> B[thenApply<br/>enrich profile]
B --> C[thenCompose<br/>fetch orders async]
D[fetchRecommendations<br/>async - paralel] --> E[thenCombine<br/>gabungkan]
C --> E
E --> F[exceptionally<br/>error handling]
F --> G[Result]Deadlock, Livelock, dan Starvation
Deadlock
Deadlock terjadi ketika dua atau lebih thread saling menunggu lock yang dipegang oleh thread lain, membentuk siklus ketergantungan yang tidak bisa diselesaikan:
// ANTI-PATTERN: deadlock klasik
Object lockA = new Object();
Object lockB = new Object();
Thread t1 = new Thread(() -> {
synchronized (lockA) {
Thread.sleep(100);
synchronized (lockB) { // menunggu lockB
System.out.println("T1 selesai");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (lockB) {
Thread.sleep(100);
synchronized (lockA) { // menunggu lockA — DEADLOCK!
System.out.println("T2 selesai");
}
}
});
flowchart LR
T1[Thread 1<br/>memegang lockA] -->|menunggu| LB[lockB]
T2[Thread 2<br/>memegang lockB] -->|menunggu| LA[lockA]
LA -->|dipegang oleh| T1
LB -->|dipegang oleh| T2// BENAR: selalu acquire lock dalam urutan yang sama
Thread t1 = new Thread(() -> {
synchronized (lockA) { // urutan: A dulu, lalu B
synchronized (lockB) {
System.out.println("T1 selesai");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (lockA) { // urutan: A dulu, lalu B (sama!)
synchronized (lockB) {
System.out.println("T2 selesai");
}
}
});
Mendeteksi Deadlock dengan Thread Dump
Saat aplikasi Java mengalami deadlock, kamu bisa mendeteksinya dengan thread dump:
# Kirim sinyal ke proses Java untuk mencetak thread dump ke stdout
kill -3 <pid>
# Atau gunakan jstack
jstack <pid>
Thread dump akan menampilkan baris seperti ini jika ada deadlock:
Found one Java-level deadlock:
=============================
"Thread-2":
waiting to lock monitor 0x... (object 0x..., a java.lang.Object),
which is held by "Thread-1"
"Thread-1":
waiting to lock monitor 0x... (object 0x..., a java.lang.Object),
which is held by "Thread-2"
Livelock
Livelock adalah kondisi di mana thread-thread terus bergerak tetapi tidak ada kemajuan — seperti dua orang yang saling mengalah di lorong sempit tapi terus bergerak ke arah yang sama:
// Contoh livelock: dua thread yang saling "mengalah"
public class LivelockExample {
volatile boolean step1Done = false;
volatile boolean step2Done = false;
void worker1() {
while (!step2Done) {
step1Done = true;
Thread.sleep(10);
step1Done = false; // "mengalah" karena melihat worker2 belum selesai
}
}
void worker2() {
while (!step1Done) {
step2Done = true;
Thread.sleep(10);
step2Done = false; // "mengalah" juga
}
}
}
Starvation
Starvation terjadi ketika satu atau beberapa thread tidak pernah mendapat akses ke resource karena thread lain terus-menerus mengambil giliran. Ini bisa terjadi jika:
- Thread dengan prioritas rendah selalu dikalahkan oleh thread prioritas tinggi
- Thread tertentu selalu kalah dalam “persaingan” mendapatkan lock (non-fair lock)
ReentrantLock dengan parameter fair = true mengatasi starvation dengan antrian FIFO untuk thread yang menunggu lock:
// Fair lock: thread yang paling lama menunggu yang pertama mendapat lock
ReentrantLock fairLock = new ReentrantLock(true);
Anti-Pattern Umum dalam Concurrency Java
Anti-Pattern 1: Double-Checked Locking yang Salah
// ANTI-PATTERN: double-checked locking tanpa volatile — TIDAK AMAN
public class Singleton {
private static Singleton instance; // missing volatile!
public static Singleton getInstance() {
if (instance == null) { // check pertama (tanpa lock)
synchronized (Singleton.class) {
if (instance == null) { // check kedua (dengan lock)
instance = new Singleton(); // compiler/CPU bisa reorder ini!
}
}
}
return instance;
}
}
// BENAR: volatile menjamin inisialisasi terlihat secara utuh
public class Singleton {
private static volatile Singleton instance; // volatile diperlukan!
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
// LEBIH BAIK: gunakan Initialization-on-Demand Holder idiom
public class Singleton {
private Singleton() {}
private static class Holder {
static final Singleton INSTANCE = new Singleton();
// Class loading di Java dijamin thread-safe oleh JVM
}
public static Singleton getInstance() {
return Holder.INSTANCE;
}
}
Anti-Pattern 2: Mengabaikan InterruptedException
// ANTI-PATTERN: menelan InterruptedException
public void doWork() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// jangan lakukan ini! Interrupt flag hilang!
}
}
// BENAR opsi 1: restore interrupt flag
public void doWork() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // restore flag
// lakukan cleanup jika perlu
return;
}
}
// BENAR opsi 2: propagate exception
public void doWork() throws InterruptedException {
Thread.sleep(1000); // biarkan caller yang menangani
}
Anti-Pattern 3: Publikasi Objek yang Belum Selesai Diinisialisasi
// ANTI-PATTERN: this escape — referensi disebarkan sebelum konstruktor selesai
public class EventListener {
private final String name;
public EventListener(EventBus bus) {
bus.register(this); // BUG: 'this' mungkin belum fully initialized!
this.name = "listener"; // ini belum selesai saat register dipanggil
}
}
// BENAR: gunakan factory method
public class EventListener {
private final String name;
private EventListener() {
this.name = "listener";
}
public static EventListener create(EventBus bus) {
EventListener listener = new EventListener(); // konstruktor selesai
bus.register(listener); // baru disebarkan setelah fully initialized
return listener;
}
}
Anti-Pattern 4: Synchronized pada Objek yang Bisa Berubah
// ANTI-PATTERN: lock pada variabel yang bisa diubah referensinya
public class BadStore {
private List<String> items = new ArrayList<>();
public void add(String item) {
synchronized (items) { // berbahaya!
items.add(item);
}
}
public void reset() {
synchronized (items) {
items = new ArrayList<>(); // mengganti referensi items!
// lock sekarang berbeda dari yang dipakai add()
}
}
}
// BENAR: lock pada objek final yang tidak berubah
public class GoodStore {
private final Object lock = new Object(); // dedicated lock object
private List<String> items = new ArrayList<>();
public void add(String item) {
synchronized (lock) {
items.add(item);
}
}
public void reset() {
synchronized (lock) {
items = new ArrayList<>(); // lock tetap sama
}
}
}
Anti-Pattern 5: Thread Leak — Thread yang Tidak Pernah Dihentikan
// ANTI-PATTERN: background thread yang tidak pernah berhenti
public class DataPoller {
public DataPoller() {
Thread t = new Thread(() -> {
while (true) { // tidak ada cara untuk menghentikan ini!
pollData();
Thread.sleep(1000);
}
});
t.start(); // thread ini hidup selamanya, bahkan setelah DataPoller tidak dipakai
}
}
// BENAR: daemon thread atau flag penghenti
public class DataPoller implements AutoCloseable {
private volatile boolean running = true;
private final Thread pollThread;
public DataPoller() {
pollThread = new Thread(() -> {
while (running) {
pollData();
try { Thread.sleep(1000); }
catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
});
pollThread.setDaemon(true); // akan berhenti ketika main thread selesai
pollThread.start();
}
@Override
public void close() {
running = false;
pollThread.interrupt();
}
}
Virtual Threads: Revolusi Concurrency di Java 21+
Masalah yang Diselesaikan Virtual Threads
Model 1:1 platform thread ke OS thread memiliki ceiling yang keras. Untuk aplikasi web server yang perlu menangani ribuan koneksi concurrent, setiap request yang melakukan I/O (query database, HTTP call ke service lain) akan memblokir thread OS selama menunggu respons. Hasilnya: ribuan thread OS dibuat hanya untuk duduk dan menunggu, membuang memori dan overhead context switch.
Solusi tradisional adalah reactive programming (WebFlux, RxJava) — kode non-blocking berbasis callback atau monad. Tapi ini datang dengan biaya kompleksitas yang sangat tinggi: stack trace yang tidak informatif, debugging yang sulit, dan mental model yang sangat berbeda dari kode synchronous biasa.
Virtual Threads (Project Loom) hadir dengan janji yang berbeda: tulis kode blocking yang sederhana seperti biasa, tapi biarkan JVM yang mengoptimalkan eksekusinya agar tidak memblokir OS thread yang berharga.
Cara Kerja Virtual Threads
Virtual Thread dipetakan ke platform thread (carrier thread) secara M:N, mirip goroutine di Go. Ketika virtual thread melakukan blocking I/O, JVM secara otomatis melepaskan virtual thread tersebut dari carrier thread-nya, memungkinkan carrier thread mengerjakan virtual thread lain. Saat I/O selesai, virtual thread dijadwalkan ulang ke carrier thread yang tersedia.
flowchart TD
subgraph "Virtual Threads (ribuan)"
VT1[VThread 1<br/>waiting DB]
VT2[VThread 2<br/>running]
VT3[VThread 3<br/>waiting HTTP]
VT4[VThread 4<br/>running]
end
subgraph "Carrier Threads (= jumlah core CPU)"
CT1[Carrier Thread 1]
CT2[Carrier Thread 2]
end
VT2 --> CT1
VT4 --> CT2
VT1 -.unmounted saat I/O.- CT1
VT3 -.unmounted saat I/O.- CT2Membuat Virtual Thread
// Cara 1: Thread.ofVirtual()
Thread vThread = Thread.ofVirtual().start(() -> {
System.out.println("Berjalan di virtual thread: " + Thread.currentThread());
});
// Cara 2: Thread.startVirtualThread()
Thread vt = Thread.startVirtualThread(() -> doWork());
// Cara 3: Executor dengan virtual thread (paling umum dipakai di produksi)
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
try (executor) { // ExecutorService implements AutoCloseable
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> {
// setiap task mendapat virtual thread-nya sendiri
String result = httpClient.get("https://api.example.com/data");
processResult(result);
});
}
} // otomatis shutdown dan tunggu semua task selesai
Perbandingan: Platform Thread vs Virtual Thread
// Platform thread: mahal, dibatasi jumlahnya
try (ExecutorService executor = Executors.newFixedThreadPool(200)) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> {
Thread.sleep(1000); // blokir platform thread selama 1 detik
});
}
// 10.000 task, tapi hanya 200 thread — sisanya antri
}
// Virtual thread: murah, bisa jutaan
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> {
Thread.sleep(1000); // virtual thread di-unmount, carrier thread bebas
});
}
// 10.000 task, 10.000 virtual thread — semua berjalan "bersamaan"
}
| Aspek | Platform Thread | Virtual Thread |
|---|---|---|
| Stack size | 512 KB – 2 MB | Beberapa KB (dinamis) |
| Dikelola oleh | OS Kernel | JVM |
| Jumlah praktis | Ribuan | Jutaan |
| I/O blocking | Memblokir OS thread | Unmount dari carrier thread |
| Context switch | Kernel mode (lambat) | User space (cepat) |
| Cocok untuk | CPU-intensive, thread pool | I/O-intensive, per-request |
Virtual Thread Bukan Peluru Perak
Ada beberapa hal penting yang perlu dipahami agar tidak salah menggunakan virtual threads:
// ANTI-PATTERN: thread-local dengan virtual thread yang banyak
// Thread-local bekerja, tapi bisa memperburuk memory jika ada jutaan virtual thread
// karena setiap virtual thread punya ThreadLocal map sendiri
ThreadLocal<Connection> connectionLocal = new ThreadLocal<>();
// BENAR: gunakan Scoped Values (Java 21+) sebagai pengganti ThreadLocal
// untuk data yang dilewatkan ke bawah call stack
ScopedValue<Connection> CONNECTION = ScopedValue.newInstance();
ScopedValue.where(CONNECTION, getConnection()).run(() -> {
doWork(); // bisa mengakses CONNECTION di mana saja dalam call stack ini
});
Pinning adalah kondisi di mana virtual thread tidak bisa di-unmount dari carrier thread saat blocking — ini terjadi ketika kode berjalan di dalam bloksynchronizedatau memanggil native method. Java 24 telah memperbaiki sebagian besar kasus pinning untuksynchronized. Jika kamu menggunakan Java 21-23 dan melihat thread dump dengan banyak virtual thread yang “pinned”, pertimbangkan migrasi keReentrantLocksebagai solusi sementara.
Virtual thread dioptimalkan untuk I/O-bound workload — aplikasi yang banyak melakukan network call, query database, atau file I/O. Untuk CPU-bound workload (komputasi berat tanpa I/O), virtual thread tidak memberikan keuntungan dibanding platform thread dan jumlah concurrent task sebaiknya tetap dibatasi sesuai jumlah core CPU.
Structured Concurrency: Cara Baru Mengelola Subtask
StructuredTaskScope (preview di Java 21, berkembang di versi berikutnya) membawa paradigma baru untuk mengelola beberapa concurrent task yang berkaitan erat sebagai satu unit kerja:
import java.util.concurrent.StructuredTaskScope;
// Tunggu semua subtask selesai (ShutdownOnFailure: cancel semua jika satu gagal)
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
StructuredTaskScope.Subtask<User> userTask =
scope.fork(() -> fetchUser(userId));
StructuredTaskScope.Subtask<List<Order>> ordersTask =
scope.fork(() -> fetchOrders(userId));
StructuredTaskScope.Subtask<List<Rec>> recsTask =
scope.fork(() -> fetchRecommendations(userId));
scope.join(); // tunggu semua selesai
scope.throwIfFailed(); // lempar exception jika ada yang gagal
// Semua subtask berhasil
User user = userTask.get();
List<Order> orders = ordersTask.get();
List<Rec> recs = recsTask.get();
return buildResponse(user, orders, recs);
}
// jika satu subtask gagal: scope secara otomatis cancel yang lain
// ShutdownOnSuccess: selesai segera setelah satu subtask berhasil
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
scope.fork(() -> fetchFromPrimaryDB());
scope.fork(() -> fetchFromReplicaDB());
scope.join();
return scope.result(); // hasil dari subtask pertama yang berhasil
}
flowchart TD
A[StructuredTaskScope] --> B[fork: fetchUser]
A --> C[fork: fetchOrders]
A --> D[fork: fetchRecommendations]
B --> E[scope.join]
C --> E
D --> E
E --> F{Semua berhasil?}
F -- Ya --> G[Proses hasil]
F -- Tidak --> H[Cancel subtask lain<br/>Lempar exception]Keunggulan Structured Concurrency dibanding CompletableFuture yang manual:
- Lifetime subtask dijamin tidak melebihi scope-nya (tidak ada leak)
- Cancellation otomatis yang benar saat ada kegagalan
- Stack trace dan observability yang lebih baik
- Kode yang jauh lebih mudah dibaca dan di-reason
Pola Concurrency Produksi di Java
Producer-Consumer dengan BlockingQueue
public class DataPipeline {
private final BlockingQueue<String> queue = new LinkedBlockingQueue<>(1000);
private final ExecutorService producerPool = Executors.newFixedThreadPool(2);
private final ExecutorService consumerPool = Executors.newFixedThreadPool(4);
private volatile boolean running = true;
public void start() {
// Producer
producerPool.submit(() -> {
while (running) {
try {
String data = fetchData();
queue.put(data); // blok jika queue penuh
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
});
// Consumer
for (int i = 0; i < 4; i++) {
consumerPool.submit(() -> {
while (running || !queue.isEmpty()) {
try {
String data = queue.poll(100, TimeUnit.MILLISECONDS);
if (data != null) processData(data);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
});
}
}
public void stop() throws InterruptedException {
running = false;
producerPool.shutdown();
consumerPool.shutdown();
producerPool.awaitTermination(5, TimeUnit.SECONDS);
consumerPool.awaitTermination(5, TimeUnit.SECONDS);
}
}
Parallel Processing dengan Fork/Join
ForkJoinPool dan RecursiveTask cocok untuk komputasi rekursif yang bisa dipecah menjadi subtask lebih kecil (divide and conquer):
import java.util.concurrent.*;
public class ParallelSum extends RecursiveTask<Long> {
private static final int THRESHOLD = 1000;
private final long[] array;
private final int start, end;
public ParallelSum(long[] array, int start, int end) {
this.array = array;
this.start = start;
this.end = end;
}
@Override
protected Long compute() {
if (end - start <= THRESHOLD) {
// Base case: hitung langsung
long sum = 0;
for (int i = start; i < end; i++) sum += array[i];
return sum;
}
// Divide: pecah menjadi dua subtask
int mid = (start + end) / 2;
ParallelSum left = new ParallelSum(array, start, mid);
ParallelSum right = new ParallelSum(array, mid, end);
left.fork(); // jalankan left secara async
long rightResult = right.compute(); // jalankan right di thread ini
long leftResult = left.join(); // tunggu left selesai
return leftResult + rightResult;
}
}
// Penggunaan
ForkJoinPool pool = ForkJoinPool.commonPool();
long[] data = new long[1_000_000];
Long result = pool.invoke(new ParallelSum(data, 0, data.length));
Checklist Review Kode Concurrent Java
THREAD SAFETY DASAR:
□ Apakah semua akses ke shared mutable state dilindungi (synchronized/Lock/atomic)?
□ Apakah volatile digunakan hanya untuk single-write / flag visibility, bukan compound ops?
□ Apakah tidak ada objek yang di-publish sebelum konstruktor selesai (this escape)?
□ Apakah lock object adalah final dan tidak pernah diubah referensinya?
SYNCHRONIZED DAN LOCK:
□ Apakah Unlock selalu di dalam finally block?
□ Apakah urutan akuisisi lock konsisten di seluruh codebase (mencegah deadlock)?
□ Apakah tidak ada nested synchronized yang menggunakan lock berbeda?
□ Apakah scope lock sesempit mungkin?
COLLECTIONS:
□ Apakah tidak ada HashMap/ArrayList/HashSet yang diakses dari beberapa thread?
□ Apakah ConcurrentHashMap.computeIfAbsent/merge dipakai untuk operasi atomik?
□ Apakah BlockingQueue digunakan untuk producer-consumer, bukan busy-waiting?
THREAD POOL DAN EXECUTOR:
□ Apakah ExecutorService selalu di-shutdown setelah tidak dipakai?
□ Apakah thread pool dikonfigurasi dengan ukuran yang tepat (bukan unbounded)?
□ Apakah tidak ada Executors.newCachedThreadPool() tanpa batas di produksi?
VIRTUAL THREADS (Java 21+):
□ Apakah virtual thread digunakan untuk I/O-bound, bukan CPU-bound tasks?
□ Apakah ThreadLocal dipertimbangkan untuk diganti ScopedValue jika ada jutaan vthread?
□ Apakah tidak ada synchronized block di hot path virtual thread (periksa pinning)?
INTERRUPTION:
□ Apakah InterruptedException tidak ditelan (selalu restore flag atau propagate)?
□ Apakah thread loop memeriksa interrupt flag secara berkala?
LIFECYCLE:
□ Apakah tidak ada background thread yang tidak punya mekanisme berhenti?
□ Apakah semua executor dan resource di-close dengan benar (try-with-resources)?
DEADLOCK:
□ Apakah thread dump diverifikasi tidak ada siklus lock?
□ Apakah timeout digunakan pada tryLock untuk mencegah deadlock permanen?
Ringkasan
- Platform thread di Java dipetakan 1:1 ke OS thread — mahal, terbatas ratusan hingga ribuan secara praktis, dan menjadi bottleneck untuk I/O-heavy workload.
synchronizedmemberikan mutual exclusion dan visibility sekaligus, tapi tidak bisa di-interrupt dan tidak punya timeout — gunakanReentrantLockjika butuh kemampuan ini.volatilehanya menjamin visibility, bukan atomicity — tidak cukup untuk compound operations seperticount++.java.util.concurrent.atomicmenyediakan operasi atomik berbasis CAS tanpa overhead mutex — gunakanAtomicInteger/AtomicLonguntuk counter,LongAdderuntuk high-contention increment-only counter.ReentrantLockvssynchronized: pilih Lock jika butuhtryLock, timeout, interruptibility, atau multiple Condition;synchronizeduntuk kasus sederhana.ReadWriteLockoptimal untuk data yang jauh lebih sering dibaca daripada ditulis.- Gunakan
ConcurrentHashMap,CopyOnWriteArrayList, danBlockingQueuealih-alih collection biasa untuk concurrent access.ExecutorServicedengan thread pool yang dikonfigurasi eksplisit lewatThreadPoolExecutorlebih aman dibandingExecutors.newCachedThreadPool()yang tidak terbatas.CompletableFutureuntuk chaining operasi asynchronous non-blocking; hindari blockingget()di dalam executor karena bisa menyebabkan thread exhaustion.- Deadlock dicegah dengan konsistensi urutan akuisisi lock; Livelock diatasi dengan randomisasi atau backoff; Starvation diatasi dengan fair lock.
- Virtual Threads (stable di Java 21, mature di Java 24-25) memungkinkan jutaan concurrent task I/O-bound dengan kode blocking biasa — tidak butuh reactive programming.
- Virtual thread bukan solusi untuk CPU-bound workload; jumlah concurrent CPU task tetap harus dibatasi sesuai jumlah core.
- Structured Concurrency (
StructuredTaskScope) memberikan lifetime guarantee, cancellation otomatis, dan observability yang lebih baik dibanding manualCompletableFuture.allOf().- Selalu jalankan test dengan
-ea(assertions) dan pertimbangkan tools sepertijcstressatauThreadSanitizeruntuk mencari race condition yang tidak terlihat dari review kode.