Tampilan
Kenapa penting
Setiap aplikasi backend yang kamu tulis sudah berjalan secara konkuren, sadar atau tidak. Saat seratus pelanggan menekan tombol "Bayar" pada detik yang sama, server seperti Tomcat di Spring Boot melayani mereka di banyak thread sekaligus — dan semua thread itu memakai objek service, repository, serta cache yang sama. Bug konkurensi jarang muncul di laptop saat kamu mengetes sendirian; ia muncul di production saat beban tinggi, berupa stok minus, saldo yang tidak cocok, atau counter yang meleset, dan sulit direproduksi. Bab ini membangun fondasinya: apa itu thread, cara membuat dan menunggunya, lalu dua masalah klasik — race condition dan visibility — yang menjadi alasan seluruh bab berikutnya ada.
Konsep inti
Proses vs thread
Proses adalah program yang sedang berjalan dengan ruang memori sendiri. Satu JVM yang menjalankan aplikasi Spring Boot-mu adalah satu proses. Thread adalah jalur eksekusi di dalam proses. Satu proses bisa punya ratusan thread, dan semuanya berbagi heap (objek, field static, bean) tetapi masing-masing punya stack sendiri (variabel lokal dan parameter method).
| Aspek | Proses | Thread |
|---|---|---|
| Memori | Terpisah dari proses lain | Berbagi heap dengan thread lain di proses sama |
| Biaya membuat | Mahal | Lebih murah, tapi tetap tidak gratis |
| Komunikasi | Lewat jaringan, file, pipe | Langsung lewat objek bersama |
| Risiko kerusakan | Crash satu proses terisolasi | Satu thread bisa merusak data thread lain |
Kalimat terakhir di tabel itulah inti masalahnya: berbagi memori itu cepat, tetapi berbahaya bila tidak dikendalikan.
Thread-per-request di server
Server servlet klasik (Tomcat bawaan Spring Boot) memakai model thread-per-request: setiap request HTTP diambil oleh satu thread dari thread pool, diproses dari controller sampai repository, lalu thread dikembalikan ke pool. Secara default pool Tomcat berisi hingga 200 thread (server.tomcat.threads.max). Artinya:
- Variabel lokal di method controller atau service aman — setiap request punya stack sendiri.
- Field di bean singleton, field
static, dan cache in-memory dibagi oleh semua request.
Ingat pembagian ini; sebagian besar keputusan thread safety bermuara di sini.
Membuat thread
Pekerjaan yang ingin dijalankan dibungkus dalam Runnable — interface fungsional dengan satu method run(), jadi bisa ditulis sebagai lambda. Ada dua cara umum membuat thread platform (thread yang dipetakan ke thread sistem operasi):
java
public class StartVsRun {
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> System.out.println("Dijalankan oleh: " + Thread.currentThread().getName());
Thread t1 = new Thread(task, "worker-1");
t1.run(); // salah kaprah: dieksekusi di thread main
t1.start(); // benar: thread baru dibuat
t1.join();
Thread t2 = Thread.ofPlatform().name("worker-2").start(task);
t2.join();
System.out.println("main selesai");
}
}Output:
text
Dijalankan oleh: main
Dijalankan oleh: worker-1
Dijalankan oleh: worker-2
main selesaiPerhatikan baris pertama: memanggil run() langsung tidak membuat thread baru — itu hanya pemanggilan method biasa di thread main. Hanya start() yang meminta JVM membuat thread baru, lalu thread itu yang memanggil run(). Satu objek Thread hanya boleh di-start() sekali; memanggilnya lagi melempar IllegalThreadStateException.
Thread.ofPlatform() adalah builder modern (sejak Java 21) yang lebih jelas untuk memberi nama, menandai daemon, atau langsung start. Pasangannya, Thread.ofVirtual(), akan dibahas di bab virtual thread.
Selalu beri nama thread
Nama seperti worker-2 atau payment-poller muncul di log dan thread dump. Saat debugging production, Thread-17 hampir tidak berguna.
join: menunggu thread selesai
join() membuat thread pemanggil menunggu sampai thread target selesai. Tanpa join, main bisa mencetak "selesai" sebelum worker bahkan mulai — urutan antar thread tidak dijamin. join juga punya efek memori penting: semua yang ditulis thread target sebelum selesai pasti terlihat oleh thread yang memanggil join setelah kembali.
sleep dan interrupt
Thread.sleep(ms) menjeda thread saat ini. Java tidak punya cara aman untuk "membunuh" thread dari luar; yang ada adalah interrupt — permintaan sopan agar thread berhenti. Thread yang sedang sleep, join, atau menunggu akan langsung dibangunkan dengan InterruptedException.
java
public class InterruptDemo {
public static void main(String[] args) throws InterruptedException {
Thread poller = Thread.ofPlatform().name("poller").start(() -> {
while (!Thread.currentThread().isInterrupted()) {
System.out.println("Cek status pembayaran...");
try {
Thread.sleep(300);
} catch (InterruptedException e) {
System.out.println("Diminta berhenti, keluar dengan rapi");
Thread.currentThread().interrupt(); // pulihkan flag
}
}
});
Thread.sleep(700);
poller.interrupt();
poller.join();
System.out.println("Poller berhenti");
}
}Output:
text
Cek status pembayaran...
Cek status pembayaran...
Cek status pembayaran...
Diminta berhenti, keluar dengan rapi
Poller berhentiSaat InterruptedException dilempar, flag interrupt dibersihkan. Karena itu blok catch memanggil interrupt() lagi agar kondisi loop melihatnya dan berhenti. Menelan exception ini dengan catch kosong adalah bug klasik: permintaan berhenti hilang dan thread terus berjalan saat aplikasi di-shutdown.
Race condition: count++ yang meleset
Race condition terjadi ketika hasil program bergantung pada urutan eksekusi antar thread. Contoh paling sederhana:
java
public class RaceCondition {
static int count = 0;
public static void main(String[] args) throws InterruptedException {
Runnable increment = () -> {
for (int i = 0; i < 1_000_000; i++) {
count++;
}
};
Thread a = new Thread(increment);
Thread b = new Thread(increment);
a.start();
b.start();
a.join();
b.join();
System.out.println("Harapan: 2000000, hasil: " + count);
}
}Tiga kali dijalankan di mesin yang sama, outputnya:
text
Harapan: 2000000, hasil: 1016567
Harapan: 2000000, hasil: 1561968
Harapan: 2000000, hasil: 1228676Angkamu hampir pasti berbeda — itu justru intinya. count++ terlihat seperti satu operasi, padahal terdiri dari tiga langkah: baca nilai count, tambah satu, tulis kembali. Bila dua thread berselang-seling seperti ini, satu increment hilang:
| Waktu | Thread A | Thread B | count di memori |
|---|---|---|---|
| 1 | baca count = 41 | 41 | |
| 2 | baca count = 41 | 41 | |
| 3 | tulis 42 | 42 | |
| 4 | tulis 42 | 42 |
Dua increment, tetapi nilai hanya naik satu. Ini disebut lost update. Pola yang sama terjadi pada "cek lalu lakukan" (check-then-act): if (stock > 0) stock--; — dua thread bisa sama-sama lolos pengecekan sebelum salah satunya mengurangi stok. Cara memperbaikinya (synchronized, atomic, lock) adalah topik bab berikutnya.
Visibility dan volatile
Masalah kedua lebih halus. Demi kecepatan, CPU menyimpan nilai di cache per core, dan JIT compiler boleh menyusun ulang atau mengangkat pembacaan field keluar dari loop. Akibatnya, tulisan satu thread tidak dijamin terlihat oleh thread lain kecuali ada sinkronisasi. Worker berikut bisa berputar selamanya bila running bukan volatile, karena JIT boleh menganggap nilainya tidak pernah berubah di dalam loop:
java
public class VolatileFlag {
private static volatile boolean running = true;
public static void main(String[] args) throws InterruptedException {
Thread worker = Thread.ofPlatform().start(() -> {
long loops = 0;
while (running) {
loops++;
}
System.out.println("Worker melihat running=false, berhenti");
});
Thread.sleep(100);
running = false;
worker.join();
System.out.println("Selesai");
}
}Output:
text
Worker melihat running=false, berhenti
Selesaivolatile menjamin setiap pembacaan melihat tulisan terbaru dari thread lain. Tetapi volatile tidak membuat operasi gabungan menjadi atomik: volatile int count tetap kehilangan update pada count++. Pakai volatile untuk flag sederhana yang ditulis satu pihak dan dibaca banyak pihak.
Bug yang "kadang-kadang"
Versi tanpa volatile mungkin berhenti dengan normal di laptopmu dan macet di server. Perilaku tanpa sinkronisasi tidak terdefinisi, bukan "pasti gagal". Jangan pernah menyimpulkan kode konkuren aman hanya karena lulus sekali dijalankan.
Happens-before sekilas
Java Memory Model (JLS bab 17) mendefinisikan aturan happens-before: bila aksi X happens-before aksi Y, maka efek X pasti terlihat oleh Y. Beberapa aturan yang sering dipakai:
- Semua aksi di thread induk sebelum
t.start()happens-before aksi di dalamt. - Semua aksi di
thappens-beforet.join()kembali di thread lain. - Tulisan ke field
volatilehappens-before pembacaan berikutnya terhadap field itu. - Melepas lock (keluar dari
synchronized) happens-before mengambil lock yang sama berikutnya.
Kamu tidak perlu menghafal spesifikasinya. Cukup pegang prinsipnya: data bersama yang bisa berubah harus dilindungi oleh salah satu mekanisme di atas, atau hasilnya tidak bisa dipercaya.
Contoh di dunia backend
Bean Spring berscope singleton dipakai bersama oleh semua thread request. Bean tanpa state mutable (hanya field final berisi dependency) otomatis aman. Masalah muncul saat developer menyimpan data per request di field:
java
// Potongan — BUG: state per request disimpan di field bean singleton
@Service
public class CheckoutService {
private final OrderRepository orderRepository;
private BigDecimal currentTotal; // dibagi SEMUA thread request!
private int checkoutCount; // race condition: count++
public CheckoutService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public OrderReceipt checkout(Cart cart) {
currentTotal = cart.items().stream()
.map(CartItem::subtotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
checkoutCount++;
// Request lain bisa menimpa currentTotal di antara dua baris ini
Order order = orderRepository.save(Order.from(cart, currentTotal));
return new OrderReceipt(order.id(), currentTotal);
}
}Di bawah beban, pelanggan A bisa menerima struk berisi total milik pelanggan B, dan checkoutCount lebih kecil dari jumlah checkout sebenarnya. Perbaikannya: simpan data per request di variabel lokal, dan serahkan metrik ke struktur yang memang thread-safe (atau ke Micrometer).
java
// Potongan — aman: tanpa state mutable per request
@Service
public class CheckoutService {
private final OrderRepository orderRepository;
public CheckoutService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public OrderReceipt checkout(Cart cart) {
BigDecimal total = cart.items().stream() // lokal: hidup di stack thread ini
.map(CartItem::subtotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
Order order = orderRepository.save(Order.from(cart, total));
return new OrderReceipt(order.id(), total);
}
}Aturan praktisnya: bean singleton boleh punya field final berisi dependency dan konfigurasi, tetapi jangan field yang diubah saat melayani request.
Pertanyaan interview
1. Apa beda memanggil start() dan run() pada objek Thread?
start() meminta JVM membuat thread baru yang kemudian mengeksekusi run() secara konkuren dengan pemanggil. Memanggil run() langsung hanyalah pemanggilan method biasa di thread saat ini — tidak ada konkurensi sama sekali. Selain itu, start() hanya boleh dipanggil sekali per objek Thread.
2. Apa itu race condition? Berikan contoh nyata di backend.
Race condition adalah kondisi ketika kebenaran hasil bergantung pada urutan eksekusi antar thread yang tidak bisa diprediksi. Contohnya count++ yang kehilangan update, atau pola check-then-act seperti if (stock > 0) stock-- pada stok in-memory, di mana dua request bisa sama-sama lolos pengecekan dan stok menjadi minus. Solusinya membuat operasi itu atomik, misalnya dengan lock atau kelas atomic.
3. Apakah bean singleton di Spring thread-safe?
Spring tidak membuat bean thread-safe secara otomatis. Singleton berarti satu instance dipakai bersama semua thread request. Bean tanpa state mutable (hanya dependency final) aman secara alami. Bila bean menyimpan state yang berubah, kamu harus menyimpannya di variabel lokal, memakai struktur konkuren, atau menyinkronkannya sendiri.
Jebakan umum
- Memanggil
run()dan mengira kode berjalan di thread baru. - Lupa
join, lalu membaca hasil worker sebelum worker selesai. - Menelan
InterruptedExceptiondengancatchkosong sehingga thread tidak bisa dihentikan saat shutdown. - Mengira
volatilemembuatcount++atomik —volatilehanya menjamin visibility, bukan atomisitas. - Menyimpan data per request di field bean singleton atau field
static. - Menganggap kode konkuren benar karena "sudah dites dan lulus" di laptop dengan beban rendah.
Ringkasan
- Thread dalam satu proses berbagi heap tetapi punya stack sendiri; variabel lokal aman, field bersama tidak.
- Server servlet memakai thread-per-request, sehingga bean singleton diakses banyak thread sekaligus.
- Buat thread dengan
new Thread(runnable)atauThread.ofPlatform().name(...).start(...), panggilstart()bukanrun(), dan tunggu denganjoin(). - Hentikan thread dengan
interrupt()dan pulihkan flag-nya bila menangkapInterruptedException. count++dan check-then-act tidak atomik sehingga memicu race condition;volatilehanya menyelesaikan masalah visibility.