Multithreading & Concurrency di Java
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:
- Kode Anda bisa race tanpa disadari. Karena banyak request berjalan pada thread berbeda yang berbagi memori yang sama, satu variabel
staticyang "polos" bisa diakses banyak thread sekaligus. Ini penjelasan kenapa kode yang lulus di laptop bisa error aneh di produksi. - 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.
| Aspek | Proses (Process) | Thread |
|---|---|---|
| Ruang memori | Memiliki memori terpisah sendiri (isolated) | Berbagi memori yang sama dalam satu proses |
| Isolasi error | Crash di satu proses tidak menjatuhkan proses lain | Error di satu thread bisa memengaruhi seluruh proses (misal OutOfMemoryError) |
| Biaya pembuatan | Mahal (alokasi memori, tabel file, dll.) | Lebih murah daripada proses, tapi tetap bukan operasi gratis |
| Komunikasi antar-unit | Butuh mekanisme berat: pipe, socket, shared memory | Cukup baca-tulis variabel bersama (inilah sumber masalahnya!) |
| Contoh | Satu 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 hinggatselesai. 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 method | Perilaku | Cocok untuk |
|---|---|---|
newFixedThreadPool(n) | Tepat n thread; tugas antre di BlockingQueue saat semua thread sibuk | Beban yang bisa diprediksi, misal batch job |
newCachedThreadPool() | Thread dibuat sesuai kebutuhan; thread idle di-recycle setelah 60 detik | Banyak tugas pendek & sporadis |
newSingleThreadExecutor() | Tepat satu thread; tugas dieksekusi berurutan | Tugas yang harus serial (misal menulis log) |
newScheduledThreadPool(n) | Bisa menjadwalkan tugas dengan delay / interval | Job berkala (misal cleanup tiap jam) |
Dua best practice yang wajib diingat:
- Jangan buat thread manual untuk setiap tugas. Pakai pool. Pool membatasi jumlah thread, jadi beban puncak tidak akan meledakkan memori.
- Selalu shutdown pool. Pool memelihara thread non-daemon yang akan mencegah JVM berhenti. Pastikan
pool.shutdown()dipanggil — idealnya di bloktry/finallyatau lewattry-with-resourcespada Java 19+ (ExecutorServicesekarangAutoCloseable).
// 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:
- Baca nilai
countke register. - Tambah 1.
- 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
| Mekanisme | Sifat | Kapan cocok |
|---|---|---|
synchronized | Blokir akses bersamaan (mutual exclusion) | Operasi gabungan (baca-modifikasi-tulis) pada kritikal section |
volatile | Jamin visibilitas nilai antar thread (tanpa blokir) | Flag / status yang hanya ditulis satu thread, dibaca thread lain |
AtomicInteger/AtomicLong | Operasi 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
synchronizedketika Anda perlu mengelompokkan beberapa operasi menjadi satu unit yang tak terputus (misal "cek saldo lalu kurangi" dalam satu kritikal section). PilihAtomicIntegerketika Anda hanya butuh satu operasi atomic tunggal seperti increment. Pilihvolatilehanya untuk flag/status sederhana.
Locks (java.util.concurrent.locks)
java.util.concurrent menyediakan API lock yang lebih fleksibel daripada synchronized. Dua yang paling berguna:
| Lock | Kekuatan | Kapan dipilih |
|---|---|---|
ReentrantLock | Bisa tryLock() (tidak memblokir), bisa interruptible, bisa unlock di method berbeda | Butuh lock yang bisa dilepas dengan timeout / terkontrol |
ReadWriteLock | Memisahkan lock baca dan lock tulis: banyak pembaca boleh bersamaan, penulis eksklusif | Baca 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:
| Method | Fungsi |
|---|---|
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:
Future | CompletableFuture | @Async Spring | |
|---|---|---|---|
| Sifat | Blocking (get()) | Non-blocking, bisa dirangkai | Membungkus method agar jalan di thread pool |
| Rantai operasi | Tidak | Ya (thenApply, thenCompose) | Tidak langsung — harus return CompletableFuture |
| Penanganan error | Cukup kasar (ExecutionException) | exceptionally(), handle() | Lewat Future/callback |
| Kapan dipakai | Kode lama | Pipeline async kompleks | Integrasi 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 injectself. 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:
| Property | Default | Arti |
|---|---|---|
server.tomcat.threads.max | 200 | Jumlah maksimum thread worker untuk request |
server.tomcat.threads.min-spare | 10 | Thread minimal yang selalu hidup |
server.tomcat.accept-count | 100 | Ukuran antrean saat semua thread sibuk |
server.tomcat.keep-alive-timeout | 20s | Berapa 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:
- 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. - Jangan bikin semuanya async. Setiap
@Asyncmenambah 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
| Kesalahan | Gejala | Perbaikan |
|---|---|---|
| State mutable dibagikan | Hasil salah tak menentu | final, struktur thread-safe |
| Lock terlalu luas | Throughput turun drastis | Sempitkan kritikal section |
Lazy init tanpa volatile | Objek setengah jadi terlihat | Tambah volatile |
| Pool terlalu kecil / block saling menunggu | Aplikasi "menggantung" | Ukur beban, perbesar pool, hindari rantai async |
parallelStream() pada state bersama | Hasil non-deterministik | Batasi 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 daricurl. 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
- Java Concurrency — Official Oracle Tutorial — titik awal resmi: threads, synchronization, locks, dan atomic variables
- Package
java.util.concurrent— Oracle API Reference — ringkasan seluruh toolkit concurrency: executor, locks, dan collections thread-safe - Atomic Variables — Oracle Tutorial — CAS dan
AtomicInteger/AtomicLonglebih dalam - Java CompletableFuture — Baeldung — panduan lengkap perangkai operasi async
- Terkait: Transaksi Database — memahami konsep proxy di
@Transactionalyang juga berlaku untuk@Async, plus perbedaan transaksi dan concurrency control - Selanjutnya: Message Brokers: Kafka & RabbitMQ — ketika komunikasi antar-service tidak lagi butuh sinkronisasi thread, melainkan antrean pesan terdistribusi