Multithreading & Concurrency di Java

18 min readAdvanced
ConcurrencyMultithreadingJavaPerformance

Pernahkah Anda melihat kode Java yang "tiba-tiba" menghasilkan angka salah, padahal logikanya terlihat benar? Misalnya counter yang seharusnya 10.000 malah jadi 9.872, atau email yang terkirim dua kali padahal hanya dipanggil sekali. Kemungkinan besar pelakunya adalah concurrency — banyak thread yang membaca dan menulis data yang sama secara bersamaan tanpa koordinasi. Artikel ini akan membongkar dunia multithreading di Java: dari Thread yang paling dasar, ExecutorService untuk mengelola sekumpulan thread, masalah klasik race condition, hingga CompletableFuture dan cara semua ini muncul di aplikasi Spring Boot.

Istilah yang perlu Anda pegang sejak awal: concurrency adalah kemampuan program menangani banyak tugas secara "bersamaan" (tidak harus di momen yang persis sama), sedangkan parallelism adalah menjalankan banyak tugas pada waktu yang sama benar-benar — biasanya di banyak core CPU. Java mendukung keduanya. Dokumentasi resmi Oracle menyebut concurrency sebagai salah satu kemampuan yang membuat Java menonjol sebagai bahasa pemrograman.

Kenapa Concurrency Penting untuk Backend

Setiap request HTTP yang masuk ke aplikasi Spring Boot tidak diproses oleh satu thread raksasa, melainkan oleh kumpulan thread dari servlet container. Di Tomcat (default Spring Boot), setiap request mendapat satu thread dari thread pool. Artinya, aplikasi Anda sudah multithreaded sejak request pertama — bahkan tanpa Anda menulis satu baris pun kode concurrency.

Dari sinilah muncul dua konsekuensi:

  1. Kode Anda bisa race tanpa disadari. Karena banyak request berjalan pada thread berbeda yang berbagi memori yang sama, satu variabel static yang "polos" bisa diakses banyak thread sekaligus. Ini penjelasan kenapa kode yang lulus di laptop bisa error aneh di produksi.
  2. Anda butuh paralelisme yang disengaja. Ada tugas yang sebenarnya bisa berjalan bersamaan — misalnya mengirim email sambil memproses upload file, atau memanggil tiga API eksternal sekaligus. Dengan concurrency, total waktu menunggu bisa dipangkas drastis.

Intuisi kunci: concurrency bukan tentang "membuat kode lebih cepat" secara ajaib, tapi tentang memanfaatkan waktu tunggu (menunggu database, menunggu network) dan memakai banyak core CPU sekaligus. Tanpa pemahaman tentang thread, Anda akan sulit menjelaskan perilaku aneh aplikasi backend Anda sendiri.

Proses vs Thread

Sebelum masuk ke kode, kita bedakan dua istilah yang sering tertukar: proses dan thread.

AspekProses (Process)Thread
Ruang memoriMemiliki memori terpisah sendiri (isolated)Berbagi memori yang sama dalam satu proses
Isolasi errorCrash di satu proses tidak menjatuhkan proses lainError di satu thread bisa memengaruhi seluruh proses (misal OutOfMemoryError)
Biaya pembuatanMahal (alokasi memori, tabel file, dll.)Lebih murah daripada proses, tapi tetap bukan operasi gratis
Komunikasi antar-unitButuh mekanisme berat: pipe, socket, shared memoryCukup baca-tulis variabel bersama (inilah sumber masalahnya!)
ContohSatu instance aplikasi Java (java -jar app.jar)Setiap request HTTP di Tomcat

Bayangkan proses sebagai sebuah rumah dan thread sebagai penghuni di dalam rumah itu. Setiap rumah punya alamat dan isi sendiri; tapi penghuni di rumah yang sama berbagi dapur dan kamar mandi. Kalau dua penghuni menggunakan dapur yang sama tanpa aturan, mereka bisa menabrak — itu analogi race condition. Oracle menjelaskan perbedaan ini lebih dalam: proses adalah program yang sedang dieksekusi dengan sumber daya sendiri, sedangkan thread kadang disebut lightweight processes.

Cara Kerja Thread di Java

Java memiliki dukungan thread sejak versi pertama. Ada tiga level yang akan kita lihat: Thread & Runnable (paling dasar), ExecutorService (level yang sebaiknya Anda pakai), dan nanti CompletableFuture (level async modern).

Thread & Runnable

Cara paling primitif adalah membuat thread secara manual lewat subclass Thread atau lewat Runnable:

Runnable task = () -> {
    System.out.println("Bekerja di thread: " + Thread.currentThread().getName());
};

Thread t = new Thread(task, "worker-1");
t.start();   // jangan panggil t.run() — itu hanya menjalankan di thread yang sama
t.join();    // menunggu thread selesai

Dua hal yang sering membuat pemula tersandung:

  • t.start() memulai thread baru. t.run() tidak — ia hanya memanggil method biasa di thread pemanggil.
  • t.join() membuat thread pemanggil menunggu hingga t selesai. Tanpa ini, program utama bisa selesai lebih dulu sebelum thread anak selesai bekerja.

Kenapa pola ini jarang dipakai langsung? Membuat thread baru untuk setiap tugas itu mahal dan tidak terkontrol: kalau ada 1.000 request datang bersamaan dan Anda membuat 1.000 thread, sistem bisa kehabisan memori. Untungnya Java menyediakan ExecutorService.

ExecutorService: Mengelola Pool Thread

ExecutorService adalah antarmuka untuk menjalankan tugas secara asinkron dengan thread pool — sekumpulan thread yang dibuat sekali dan dipakai ulang. Ini seperti layanan taksi: Anda tidak membeli mobil baru setiap kali mau pergi, cukup memesan dari armada yang ada.

import java.util.concurrent.*;

ExecutorService pool = Executors.newFixedThreadPool(4);

// submit mengembalikan Future — janji bahwa hasil akan tersedia nanti
Future<Integer> hasil = pool.submit(() -> {
    Thread.sleep(1_000);          // simulasi kerja berat
    return 40 + 2;
});

System.out.println(hasil.get()); // 42 — get() menunggu sampai tersedia
pool.shutdown();                 // wajib: berhenti menerima tugas baru

Kata kunci Future di sini adalah "tiket janji": submit(...) langsung kembali tanpa menunggu tugas selesai, dan Future.get() akan memblokir sampai hasilnya siap. Oracle menyarankan ExecutorService sebagai pengganti pengelolaan thread manual.

Executors menyediakan beberapa factory method dengan perilaku berbeda:

Factory methodPerilakuCocok untuk
newFixedThreadPool(n)Tepat n thread; tugas antre di BlockingQueue saat semua thread sibukBeban yang bisa diprediksi, misal batch job
newCachedThreadPool()Thread dibuat sesuai kebutuhan; thread idle di-recycle setelah 60 detikBanyak tugas pendek & sporadis
newSingleThreadExecutor()Tepat satu thread; tugas dieksekusi berurutanTugas yang harus serial (misal menulis log)
newScheduledThreadPool(n)Bisa menjadwalkan tugas dengan delay / intervalJob berkala (misal cleanup tiap jam)

Dua best practice yang wajib diingat:

  1. Jangan buat thread manual untuk setiap tugas. Pakai pool. Pool membatasi jumlah thread, jadi beban puncak tidak akan meledakkan memori.
  2. Selalu shutdown pool. Pool memelihara thread non-daemon yang akan mencegah JVM berhenti. Pastikan pool.shutdown() dipanggil — idealnya di blok try/finally atau lewat try-with-resources pada Java 19+ (ExecutorService sekarang AutoCloseable).
// Java 19+: ExecutorService bersifat AutoCloseable — try-with-resources menutup otomatis
try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
    List<Future<Integer>> semua = pool.invokeAll(List.of(
        () -> tugas("A"), () -> tugas("B"), () -> tugas("C")));
    System.out.println("Semua selesai: " + semua.size());
}

Race Condition & Atomicity

Sekarang kita masuk ke bagian tersulit dan paling penting: mengapa dua thread yang bekerja "benar" bisa menghasilkan jawaban salah.

Apa Itu Race Condition

Race condition terjadi ketika dua thread atau lebih membaca dan menulis data bersama secara tidak sinkron, sehingga hasil akhir bergantung pada urutan eksekusi yang kebetulan (dan tidak bisa kita kontrol). Klasiknya adalah operasi count++:

class Counter {
    int count = 0;

    void increment() {
        count++;        // BUKAN operasi atomic!
    }
}

count++ di atas tidaklah satu instruksi. Di level CPU, ia sebenarnya tiga langkah:

  1. Baca nilai count ke register.
  2. Tambah 1.
  3. Tulis nilai baru kembali ke count.

Jika dua thread menjalankan langkah-langkah ini bersamaan, bisa terjadi: keduanya membaca nilai 5, keduanya menambah menjadi 6, dan keduanya menulis 6. Hasil akhirnya 6, padahal seharusnya 7 setelah dua increment. Ini disebut lost update — satu update "hilang". Oracle mendokumentasikan fenomena ini sebagai masalah inti pada akses data bersama.

Tiga Senjata untuk Masalah Ini

MekanismeSifatKapan cocok
synchronizedBlokir akses bersamaan (mutual exclusion)Operasi gabungan (baca-modifikasi-tulis) pada kritikal section
volatileJamin visibilitas nilai antar thread (tanpa blokir)Flag / status yang hanya ditulis satu thread, dibaca thread lain
AtomicInteger/AtomicLongOperasi atomik berbasis CAS tanpa blokir (lock-free)Counter, akumulator, kode performa-tinggi

1. synchronized — jaminan "satu demi satu":

class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++;        // sekarang aman: hanya satu thread masuk pada satu waktu
    }

    public synchronized int get() {
        return count;
    }
}

Ketika sebuah method ditandai synchronized, hanya satu thread yang bisa mengeksekusinya pada satu waktu untuk objek yang sama. Penting untuk dipahami: synchronized pada method instance mengunci objek (this), bukan method-nya. Artinya, dua method synchronized berbeda pada objek yang sama saling memblokir satu sama lain — karena mereka mengunci objek yang sama. Objek berbeda berarti lock berbeda, jadi dua thread yang bekerja pada dua objek Counter berbeda tidak saling mengganggu.

2. volatile — jaminan visibilitas:

class TaskRunner {
    private volatile boolean running = true;

    public void stop() {
        running = false;      // thread lain yang membaca running pasti melihat false
    }

    public void runLoop() {
        while (running) {
            // bekerja...
        }
    }
}

Tanpa volatile, JVM dan CPU boleh caching nilai running di memori per-thread; thread di loop bisa terus berjalan meski stop() sudah dipanggil dari thread lain. volatile memaksa setiap baca-tulis langsung ke memori utama. Tapi volatile tidak membuat count++ atomic — ia hanya menjamin visibilitas, bukan mutual exclusion.

3. AtomicInteger — tanpa lock, pakai CAS:

import java.util.concurrent.atomic.AtomicInteger;

class Counter {
    private final AtomicInteger count = new AtomicInteger(0);

    public void increment() {
        count.incrementAndGet();    // atomic, lock-free
    }

    public int get() {
        return count.get();
    }
}

AtomicInteger memakai CAS (Compare-And-Swap): instruksi yang membandingkan nilai saat ini dengan nilai yang diharapkan, lalu menukar hanya jika cocok — semuanya dalam satu operasi atomik di level hardware. Ini jauh lebih ringan daripada synchronized karena tidak ada thread yang "tidur menunggu lock". Oracle memaparkan operasi atomic dan CAS secara detail di sini.

Aturan cepat: pilih synchronized ketika Anda perlu mengelompokkan beberapa operasi menjadi satu unit yang tak terputus (misal "cek saldo lalu kurangi" dalam satu kritikal section). Pilih AtomicInteger ketika Anda hanya butuh satu operasi atomic tunggal seperti increment. Pilih volatile hanya untuk flag/status sederhana.

Locks (java.util.concurrent.locks)

java.util.concurrent menyediakan API lock yang lebih fleksibel daripada synchronized. Dua yang paling berguna:

LockKekuatanKapan dipilih
ReentrantLockBisa tryLock() (tidak memblokir), bisa interruptible, bisa unlock di method berbedaButuh lock yang bisa dilepas dengan timeout / terkontrol
ReadWriteLockMemisahkan lock baca dan lock tulis: banyak pembaca boleh bersamaan, penulis eksklusifBaca jauh lebih sering daripada tulis (misal cache)

ReentrantLock

ReentrantLock berarti thread yang sudah memegang lock boleh memasukinya lagi (re-entrant). Keunggulannya dibanding synchronized: Anda bisa mencoba mengambil lock tanpa mau menunggu selamanya.

import java.util.concurrent.locks.ReentrantLock;

class Transfer {
    private final ReentrantLock lock = new ReentrantLock();

    public boolean tryTransfer(int amount) {
        if (lock.tryLock()) {           // langsung kembali; tidak menunggu
            try {
                // lakukan transfer
                return true;
            } finally {
                lock.unlock();          // selalu unlock di finally!
            }
        }
        return false;                   // lock sedang dipegang thread lain
    }
}

tryLock() juga bisa diberi timeout: lock.tryLock(2, TimeUnit.SECONDS). Ini menghindari skenario thread menunggu selamanya karena lock tidak pernah dilepas.

ReadWriteLock

Untuk data yang jarang berubah tapi sering dibaca — misalnya cache konfigurasi — ReadWriteLock memungkinkan banyak thread membaca bersamaan, selama tidak ada yang sedang menulis. Ini meningkatkan throughput baca dibanding synchronized yang memaksa baca juga serial.

import java.util.concurrent.locks.ReentrantReadWriteLock;

class ConfigCache {
    private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    private String config = "";

    public String read() {
        lock.readLock().lock();
        try { return config; }
        finally { lock.readLock().unlock(); }
    }

    public void update(String newConfig) {
        lock.writeLock().lock();
        try { config = newConfig; }
        finally { lock.writeLock().unlock(); }
    }
}

Deadlock dan Cara Menghindarinya

Deadlock adalah kondisi di mana dua thread (atau lebih) saling menunggu lock yang dipegang lawannya, sehingga tidak ada yang bisa maju. Contoh klasik dengan dua resource:

  • Thread A memegang lock X, lalu menunggu lock Y.
  • Thread B memegang lock Y, lalu menunggu lock X.

Tidak ada yang mau melepas lock yang dipegangnya, jadi keduanya menggantung selamanya.

Cara paling sederhana menghindari deadlock: urutkan pengambilan lock secara konsisten di seluruh kode. Jika semua thread selalu mengambil lock X sebelum lock Y (dalam urutan yang sama, misal urutan ID resource), tidak akan pernah ada pertukaran silang yang mengunci jalan. Alat deteksi: cari pola nested lock saat code review, dan perhatikan stack trace aplikasi yang "membeku".

CompletableFuture: Masa Depan Async di Java Modern

Future dari ExecutorService punya kelemahan besar: ia hanya blocking. Anda tidak bisa merangkai operasi: "ambil user, lalu ambil order-nya, lalu gabungkan" sebagai pipeline tanpa memblokir. CompletableFuture (sejak Java 8) memecahkan ini — ia mendukung komposisi asinkron dan menangani error.

Contoh Pipeline Async

import java.util.concurrent.*;
import java.util.concurrent.CompletableFuture;

CompletableFuture<OrderSummary> result =
    getUser("u-42")
        .thenCompose(user -> getOrders(user.id()))       // langkah 2, hasil step 1 jadi input
        .thenApply(orders -> new OrderSummary(orders.size(), orders.total()))
        .exceptionally(ex -> new OrderSummary(0, 0.0));  // fallback bila ada error

Method yang dipakai, satu per satu:

MethodFungsi
supplyAsync(() -> ...)Menjalankan tugas asinkron di ForkJoinPool.commonPool() dan mengembalikan CompletableFuture
thenApply(fn)Transformasi sinkron: hasil stage sebelumnya diubah jadi nilai baru
thenCompose(fn)Menyambung stage asinkron lain (flatMap): hasil stage sebelumnya dipakai untuk memulai tugas asinkron baru
exceptionally(fn)Menyediakan nilai fallback bila ada Exception di pipeline
allOf(...)Menunggu semua future selesai (parallel fan-out), misal untuk memanggil beberapa API sekaligus
join()Menunggu hasil akhir (mirip get() tapi tidak throw checked exception)

Contoh lengkap memanggil beberapa API eksternal sekaligus lalu menggabungkan:

import java.util.concurrent.CompletableFuture;

CompletableFuture<User> userFuture   = CompletableFuture.supplyAsync(() -> userService.fetch("u-42"));
CompletableFuture<Cart> cartFuture   = CompletableFuture.supplyAsync(() -> cartService.fetch("u-42"));
CompletableFuture<Stock> stockFuture = CompletableFuture.supplyAsync(() -> stockService.fetch("sku-99"));

CompletableFuture<CheckoutResponse> response =
    CompletableFuture.allOf(userFuture, cartFuture, stockFuture)
        .thenApply(v -> new CheckoutResponse(
            userFuture.join(), cartFuture.join(), stockFuture.join()));

Tiga API dipanggil bersamaan alih-alih berurutan — jika tiap API butuh 300 ms, total waktu turun dari 900 ms menjadi ~300 ms. Inilah nilai praktis concurrency untuk backend.

Perbandingan singkat:

FutureCompletableFuture@Async Spring
SifatBlocking (get())Non-blocking, bisa dirangkaiMembungkus method agar jalan di thread pool
Rantai operasiTidakYa (thenApply, thenCompose)Tidak langsung — harus return CompletableFuture
Penanganan errorCukup kasar (ExecutionException)exceptionally(), handle()Lewat Future/callback
Kapan dipakaiKode lamaPipeline async kompleksIntegrasi mulus dengan Spring beans

Catatan penting: supplyAsync() default memakai ForkJoinPool.commonPool(). Untuk beban produksi, sebaiknya berikan Executor eksplisit — misal pool dengan ukuran yang Anda kendalikan — supaya tidak berebut dengan pekerjaan internal JVM. Baeldung punya panduan lengkap CompletableFuture untuk referensi.

Konteks Spring Boot

Spring Boot memakai multithreading secara intensif tanpa Anda sadari. Mari lihat tiga titik yang paling sering Anda sentuh.

@Async untuk Tugas Background

Misal Anda ingin mengirim email setelah user registrasi — tanpa membuat user menunggu proses SMTP. Dengan Spring, tandai method dengan @Async dan aktifkan dengan @EnableAsync:

@Configuration
@EnableAsync
public class AsyncConfig {
}

@Service
public class NotificationService {

    @Async("mailExecutor")                       // jalankan di pool khusus
    public void sendWelcomeEmail(User user) {
        // koneksi SMTP + kirim — berjalan di background thread
        mailSender.send(buildWelcomeEmail(user));
    }
}

Controller cukup memanggil notificationService.sendWelcomeEmail(user); method langsung kembali, dan email dikirim di thread lain.

Hati-hati dengan self-invocation. @Async (dan @Transactional) bekerja lewat proxy: Spring membungkus bean Anda. Ketika Anda memanggil method dari objek yang sama (this.method()), proxy tidak terlibat dan anotasi diabaikan — method jalan sinkron. Solusinya: panggil lewat bean lain, atau inject self. Ini konsep proxy yang sama yang kita bahas di artikel transaksi database.

Thread Pool untuk Request (server.tomcat.threads.max)

Tiap request HTTP memakai satu thread dari pool Tomcat. Kalau thread habis, request baru antre (atau ditolak). Ini konfigurasi yang perlu dikenal:

PropertyDefaultArti
server.tomcat.threads.max200Jumlah maksimum thread worker untuk request
server.tomcat.threads.min-spare10Thread minimal yang selalu hidup
server.tomcat.accept-count100Ukuran antrean saat semua thread sibuk
server.tomcat.keep-alive-timeout20sBerapa lama koneksi idle dipertahankan

Kenali & tuning: jika endpoint Anda melakukan I/O lambat (misal menunggu API eksternal 5 detik) dan trafik tinggi, 200 thread akan cepat habis. Naikkan max secukupnya — tetapi ingat setiap thread memakan memori (default stack 512 KB–1 MB). Solusi yang lebih elegan adalah membuat endpoint tersebut non-blocking atau memindahkan kerja berat ke @Async sehingga request thread cepat bebas.

Jangan Block Event-Loop, Jangan Async Tanpa Perlu

Dua nasihat yang tampak bertolak belakang tapi sama pentingnya:

  1. Jangan memblokir thread request tanpa alasan. Thread.sleep, blocking get(), atau query database yang sangat lambat akan mengunci thread request dalam waktu lama. Untuk aplikasi WebFlux / Netty, aturan ini lebih keras lagi: memblokir event-loop bisa meruntuhkan seluruh server.
  2. Jangan bikin semuanya async. Setiap @Async menambah kompleksitas: error handling, konteks logging, dan transaction. Async hanya memberi nilai bila ada workload yang benar-benar bisa berjalan paralel atau menunggu I/O. Kalau tidak, Anda hanya memindahkan masalah dari stack trace ke callback.

Kesalahan Umum & Best Practice

Setelah semua materi di atas, berikut peta jalan yang biasanya paling sering ditabrak di produksi:

1. Shared Mutable State

Variabel static atau field instance yang bisa berubah adalah magnet masalah. Best practice: buat field final bila mungkin, atau bungkus dalam struktur thread-safe (ConcurrentHashMap, AtomicReference). Jangan mengembalikan field yang bisa diubah langsung dari getter.

2. Lock yang Terlalu Luas

Mengunci seluruh method padahal hanya 2 baris di tengah yang butuh sinkronisasi akan membuat request lain antre tanpa perlu. Best practice: gunakan blok synchronized dengan cakupan sekecil mungkin, atau gunakan AtomicXxx/ReadWriteLock sesuai profil akses.

3. Race di Inisialisasi (Double-Checked Locking)

Pola lazy singleton klasik sering ditulis seperti ini:

class Singleton {
    private static Singleton instance;      // SEHARUSNYA volatile!

    static Singleton getInstance() {
        if (instance == null) {             // check pertama (tanpa lock)
            synchronized (Singleton.class) {
                if (instance == null) {     // check kedua (di dalam lock)
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

Logikanya tampak benar, tapi tanpa volatile ia rusak: JVM boleh menulis referensi instance sebelum konstruktor selesai dipublikasikan, sehingga thread lain bisa membaca objek setengah jadi. volatile memastikan nilai instance baru terlihat setelah konstruktor selesai. Ini kasus di mana kombinasi synchronized + volatile keduanya dibutuhkan.

4. Memblokir Thread Pool sampai Habis (Thread Starvation)

Jika semua thread pool sibuk menunggu (misal semua @Async saling menunggu hasil satu sama lain), tidak ada thread tersisa untuk tugas lain — aplikasi "menggantung" tanpa crash. Best practice: jangan buat rantai async yang menunggu balasan sesamanya tanpa batas, dan beri pool ukuran yang cukup.

5. Fork-Join & Parallel Stream (Singkat)

stream.parallel() dan ForkJoinPool memecah kerja ke banyak thread untuk memanfaatkan banyak core. Tapi ada dua jebakan: (a) hasilnya bisa tidak deterministik jika operasi punya side effect pada state bersama; (b) memakainya di dalam servlet request bisa mengganggu karena memakai commonPool yang sama dengan internal JVM. Best practice: gunakan parallel stream hanya untuk tugas komputasi murni tanpa efek samping, dan mengerti bahwa default pool dipakai bersama.

Ringkasan Kesalahan vs Perbaikan

KesalahanGejalaPerbaikan
State mutable dibagikanHasil salah tak menentufinal, struktur thread-safe
Lock terlalu luasThroughput turun drastisSempitkan kritikal section
Lazy init tanpa volatileObjek setengah jadi terlihatTambah volatile
Pool terlalu kecil / block saling menungguAplikasi "menggantung"Ukur beban, perbesar pool, hindari rantai async
parallelStream() pada state bersamaHasil non-deterministikBatasi untuk komputasi murni

Kapan Berhati-hati

Concurrency adalah sumber bug yang paling sulit dideteksi dan direproduksi — bug race sering hanya muncul di produksi dengan beban nyata. Beberapa disiplin yang menolong:

  • Mulai dari satu pool. Jangan langsung membuat pool custom di tiap service. Mulai dari satu pool konfigurasi global, amati, baru sesuaikan.
  • Test dengan beban. Uji race condition dengan beban nyata: k6, JMeter, atau sederhananya banyak request paralel dari curl. Pastikan ada test yang menjalankan operasi bersamaan secara berulang.
  • Perlakukan sinkronisasi sebagai kontrak, bukan hiasan. Setiap kali Anda membiarkan data bersama dibaca-tulis lintas thread, tuliskan siapa yang boleh mengubahnya dan kapan.

Ringkasan

Concurrency di Java bukan satu fitur, melainkan tangga berjenjang: Thread & Runnable sebagai fondasi (jarang dipakai langsung), ExecutorService untuk mengelola pool thread dengan Future, lalu CompletableFuture untuk pipeline asinkron modern. Di tengah-tengahnya ada masalah klasik race condition yang diselesaikan dengan synchronized, volatile, dan AtomicInteger/CAS — masing-masing punya tempat sendiri. Untuk skenario yang lebih halus, ReentrantLock dan ReadWriteLock memberi kendali lebih dibanding synchronized, dengan ancaman deadlock yang dihindari lewat urutan lock yang konsisten. Di Spring Boot, concurrency sudah hadir sejak request pertama lewat thread pool Tomcat (server.tomcat.threads.max), dan @Async memungkinkan tugas background dengan satu anotasi — selama Anda paham masalah self-invocation dan proxy. Kesalahan terbesar adalah menganggap sinkronisasi opsional: shared mutable state tanpa koordinasi akan menggigit Anda tepat di saat trafik sedang tinggi. Mulai dari satu pool, uji dengan beban, dan perlakukan setiap thread sebagai warga yang harus bekerja sama dengan aturan yang jelas.

Lanjut membaca

← Back to technical articles