Multithreading, Concurrency, dan Race Condition di Java: Panduan Lengkap dari Thread Klasik hingga Virtual Threads
23 min read

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 panggil run() langsung — itu hanya memanggil metode biasa di thread yang sedang berjalan (biasanya main thread). Yang membuat kode berjalan di thread baru adalah start(), yang meminta JVM membuat thread OS baru dan menjalankan run() 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| KT3

Inilah 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
    }
}
synchronized pada instance method dan synchronized pada static method menggunakan lock yang berbeda. Instance method mengunci objek instance, static method mengunci objek Class. 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();
        }
    }
}
KondisisynchronizedReentrantLockReadWriteLock
Kemudahan penggunaanPaling mudahSedangSedang
Interruptible lockTidakYaYa
Try lock (non-blocking)TidakYaYa
Fairness controlTidakYaYa
Multiple conditionsTidak (satu wait set)YaYa
Read-heavy optimizationTidakTidakYa

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), LongAdder lebih efisien dari AtomicLong karena mengurangi contention dengan memaintain beberapa counter internal yang dijumlahkan saat sum() 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
);
Hindari Executors.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 gunakan ThreadPoolExecutor dengan 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.- CT2

Membuat 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"
}
AspekPlatform ThreadVirtual Thread
Stack size512 KB – 2 MBBeberapa KB (dinamis)
Dikelola olehOS KernelJVM
Jumlah praktisRibuanJutaan
I/O blockingMemblokir OS threadUnmount dari carrier thread
Context switchKernel mode (lambat)User space (cepat)
Cocok untukCPU-intensive, thread poolI/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 blok synchronized atau memanggil native method. Java 24 telah memperbaiki sebagian besar kasus pinning untuk synchronized. Jika kamu menggunakan Java 21-23 dan melihat thread dump dengan banyak virtual thread yang “pinned”, pertimbangkan migrasi ke ReentrantLock sebagai 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.
  • synchronized memberikan mutual exclusion dan visibility sekaligus, tapi tidak bisa di-interrupt dan tidak punya timeout — gunakan ReentrantLock jika butuh kemampuan ini.
  • volatile hanya menjamin visibility, bukan atomicity — tidak cukup untuk compound operations seperti count++.
  • java.util.concurrent.atomic menyediakan operasi atomik berbasis CAS tanpa overhead mutex — gunakan AtomicInteger/AtomicLong untuk counter, LongAdder untuk high-contention increment-only counter.
  • ReentrantLock vs synchronized: pilih Lock jika butuh tryLock, timeout, interruptibility, atau multiple Condition; synchronized untuk kasus sederhana.
  • ReadWriteLock optimal untuk data yang jauh lebih sering dibaca daripada ditulis.
  • Gunakan ConcurrentHashMap, CopyOnWriteArrayList, dan BlockingQueue alih-alih collection biasa untuk concurrent access.
  • ExecutorService dengan thread pool yang dikonfigurasi eksplisit lewat ThreadPoolExecutor lebih aman dibanding Executors.newCachedThreadPool() yang tidak terbatas.
  • CompletableFuture untuk chaining operasi asynchronous non-blocking; hindari blocking get() 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 manual CompletableFuture.allOf().
  • Selalu jalankan test dengan -ea (assertions) dan pertimbangkan tools seperti jcstress atau ThreadSanitizer untuk mencari race condition yang tidak terlihat dari review kode.

Portofolio