Multi Threading #
Program yang hanya punya satu thread berjalan satu langkah dalam satu waktu — seperti kasir tunggal yang melayani antrean panjang. Saat satu tugas berjalan, semua yang lain menunggu. Multithreading memecah antrean itu menjadi beberapa jalur paralel: kompilasi kode sambil mengunduh dependency, memproses request HTTP sambil menulis log, atau menghitung data sambil menampilkan progress ke pengguna. Java mendukung multithreading sejak versi pertamanya dan terus memperkaya alatnya — dari Thread primitif hingga ExecutorService, CompletableFuture, dan koleksi concurrent. Artikel ini membahas cara membuat dan mengontrol thread, menjaga konsistensi data bersama lewat sinkronisasi, mengelola pool thread dengan Executor Framework, dan menghindari jebakan klasik seperti deadlock dan race condition.
Konsep Dasar #
Sebelum menulis kode, ada beberapa istilah yang perlu dipahami dengan jelas karena sering dipakai secara bergantian tapi punya arti berbeda:
| Istilah | Arti |
|---|---|
| Thread | Unit eksekusi terkecil dalam sebuah proses. Setiap thread punya stack sendiri tapi berbagi heap dengan thread lain dalam proses yang sama. |
| Concurrency | Kemampuan menangani banyak tugas — tidak harus secara harfiah bersamaan, bisa bergantian sangat cepat (time-slicing). |
| Parallelism | Eksekusi benar-benar bersamaan di beberapa CPU core. Butuh hardware dengan lebih dari satu core. |
| Race condition | Bug yang muncul saat dua thread mengakses dan mengubah data yang sama secara bersamaan tanpa koordinasi, menghasilkan nilai yang tidak terduga. |
| Deadlock | Kondisi dua thread atau lebih saling menunggu satu sama lain selamanya — tidak ada yang bisa maju. |
| Sinkronisasi | Mekanisme untuk memastikan hanya satu thread yang mengakses sumber daya bersama dalam satu waktu. |
flowchart TD
A[Main Thread] -->|"new Thread().start()"| B[Thread 1]
A -->|"new Thread().start()"| C[Thread 2]
A -->|"new Thread().start()"| D[Thread 3]
B --> E[("Heap bersama\n(objek, variabel static)")]
C --> E
D --> E
B --- F[Stack Thread 1]
C --- G[Stack Thread 2]
D --- H[Stack Thread 3]Membuat Thread #
Java menyediakan tiga cara utama untuk mendefinisikan tugas yang berjalan di thread — pilih berdasarkan apakah tugasmu perlu mengembalikan nilai atau tidak.
Mengimplementasikan Runnable #
Runnable adalah cara yang paling direkomendasikan. Dengan mengimplementasikan interface, kelas kamu masih bebas meng-extend kelas lain — berbeda dengan meng-extend Thread yang memblokir pewarisan lebih lanjut.
// Cara 1: kelas terpisah
class ProsesData implements Runnable {
private final String nama;
public ProsesData(String nama) {
this.nama = nama;
}
@Override
public void run() {
for (int i = 1; i <= 3; i++) {
System.out.println("[" + nama + "] langkah " + i);
try {
Thread.sleep(500); // simulasi pekerjaan
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // pola yang benar untuk handle interrupt
return;
}
}
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new ProsesData("Proses-A"));
Thread t2 = new Thread(new ProsesData("Proses-B"));
t1.start(); // mulai eksekusi di thread baru
t2.start();
t1.join(); // tunggu t1 selesai sebelum lanjut
t2.join();
System.out.println("Semua proses selesai.");
}
}
// Cara 2: lambda — lebih ringkas untuk logika sederhana (Java 8+)
Runnable tugasSingkat = () -> {
System.out.println("Berjalan di: " + Thread.currentThread().getName());
};
Thread t = new Thread(tugasSingkat, "thread-kustom");
t.start();
Meng-extend Thread #
Meng-extend Thread lebih langsung tapi kurang fleksibel. Gunakan jika perlu mengakses metode Thread secara langsung dari dalam run().
class UnduhFile extends Thread {
private final String url;
public UnduhFile(String url) {
super("unduh-" + url); // beri nama thread agar mudah di-debug
this.url = url;
}
@Override
public void run() {
System.out.println(getName() + " mulai mengunduh: " + url);
try {
Thread.sleep(1000); // simulasi unduhan
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println(getName() + " selesai.");
}
}
// Pemakaian
UnduhFile t1 = new UnduhFile("file-a.zip");
UnduhFile t2 = new UnduhFile("file-b.zip");
t1.start();
t2.start();
Memilih Runnable vs Thread #
Gunakan RUNNABLE (atau lambda) jika:
✓ Tidak perlu mengakses metode Thread secara langsung dari dalam run()
✓ Kelas perlu meng-extend kelas lain
✓ Ingin memisahkan definisi tugas dari cara menjalankannya
✓ Hampir selalu — ini pilihan yang lebih baik
Gunakan extends THREAD jika:
✓ Perlu override perilaku Thread itu sendiri (bukan hanya run())
✗ Hindari jika hanya ingin menjalankan satu blok kode
Siklus Hidup Thread #
Thread tidak langsung berjalan saat dibuat. Ia melewati beberapa state selama hidupnya.
flowchart LR
A[NEW\nnew Thread()] -->|"start()"| B[RUNNABLE\nmenunggu CPU]
B -->|"CPU dijadwalkan"| C[RUNNING\nrun() aktif]
C -->|"sleep() / wait()\njoin()"| D[BLOCKED/WAITING\nmenunggu]
D -->|"notified / timeout\nthread lain selesai"| B
C -->|"run() selesai"| E[TERMINATED]
C -->|"interrupt()"| EThread t = new Thread(() -> {
try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
System.out.println(t.getState()); // NEW
t.start();
System.out.println(t.getState()); // RUNNABLE atau TIMED_WAITING
t.join();
System.out.println(t.getState()); // TERMINATED
Metode Thread yang Penting #
Thread t = new Thread(() -> { /* ... */ });
t.start(); // mulai thread — JANGAN panggil run() langsung
t.join(); // tunggu thread ini selesai sebelum lanjut
t.join(3000); // tunggu maksimal 3 detik
t.interrupt(); // kirim sinyal interrupt ke thread
t.isAlive(); // true jika thread masih berjalan
t.getName(); // nama thread
t.setName("worker"); // beri nama
t.getPriority(); // 1 (MIN) sampai 10 (MAX), default 5 (NORM)
t.setDaemon(true); // daemon thread: otomatis mati saat main thread selesai
// Static methods — beroperasi pada thread yang sedang berjalan
Thread.sleep(500); // tidur 500 ms, lempar InterruptedException jika di-interrupt
Thread.currentThread(); // referensi ke thread yang sedang berjalan sekarang
Thread.yield(); // beri kesempatan thread lain untuk berjalan
Jangan pernah panggilrun()langsung — itu hanya memanggil metode biasa di thread yang sama, bukan menjalankan thread baru. Selalu gunakanstart(). Dan saat menangkapInterruptedException, selalu panggilThread.currentThread().interrupt()setelahnya agar status interrupt tidak hilang.
Sinkronisasi #
Saat dua thread membaca dan menulis variabel yang sama tanpa koordinasi, hasilnya tidak terduga. Ini disebut race condition — salah satu bug paling sulit dilacak karena tidak selalu bisa direproduksi.
Race Condition dan synchronized #
// ANTI-PATTERN: counter tanpa sinkronisasi
class CounterTidakAman {
private int nilai = 0;
public void tambah() {
nilai++; // BUKAN operasi atomik: read → increment → write (3 langkah!)
}
public int getNilai() { return nilai; }
}
// Dengan dua thread yang masing-masing memanggil tambah() 1000 kali,
// hasilnya BUKAN 2000 — bisa kurang karena race condition
// BENAR: gunakan synchronized
class CounterAman {
private int nilai = 0;
// synchronized memastikan hanya satu thread yang bisa masuk pada satu waktu
public synchronized void tambah() {
nilai++;
}
public synchronized int getNilai() { return nilai; }
}
// Uji coba
CounterAman counter = new CounterAman();
Runnable tugas = () -> {
for (int i = 0; i < 1000; i++) counter.tambah();
};
Thread t1 = new Thread(tugas);
Thread t2 = new Thread(tugas);
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println(counter.getNilai()); // selalu 2000
Synchronized Block #
synchronized di level metode mengunci seluruh objek — kasar tapi mudah. Untuk kontrol lebih halus, gunakan synchronized block yang hanya mengunci bagian kode yang benar-benar perlu dilindungi.
class DataStore {
private final Object lockBaca = new Object();
private final Object lockTulis = new Object();
private int bacaan = 0;
private int tulisan = 0;
public void prosesRequest() {
// Hanya bagian update bacaan yang perlu dikunci
synchronized (lockBaca) {
bacaan++;
}
// Lakukan operasi lain yang tidak butuh lock di sini
prosesPanjang();
// Bagian update tulisan punya lock sendiri
synchronized (lockTulis) {
tulisan++;
}
}
private void prosesPanjang() {
// operasi yang tidak akses data bersama
}
}
volatile #
volatile memastikan bahwa perubahan variabel oleh satu thread langsung terlihat oleh thread lain — mengatasi masalah visibility tapi tidak masalah atomicity. Cocok untuk flag sederhana, tidak untuk operasi seperti nilai++.
class Worker extends Thread {
// volatile: perubahan dari thread lain langsung terlihat
private volatile boolean berjalan = true;
@Override
public void run() {
while (berjalan) {
// kerjakan sesuatu
}
System.out.println("Worker berhenti.");
}
public void hentikan() {
berjalan = false; // thread lain set ini, Worker langsung lihat perubahannya
}
}
// Pemakaian
Worker w = new Worker();
w.start();
Thread.sleep(2000);
w.hentikan(); // sinyal berhenti
AtomicInteger dan Tipe Atomic #
Untuk operasi sederhana seperti counter, java.util.concurrent.atomic menyediakan tipe-tipe yang operasinya benar-benar atomik tanpa perlu synchronized.
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicBoolean;
AtomicInteger counter = new AtomicInteger(0);
// Semua operasi berikut atomik — aman dari banyak thread sekaligus
counter.incrementAndGet(); // ++counter, kembalikan nilai baru
counter.getAndIncrement(); // counter++, kembalikan nilai lama
counter.addAndGet(5); // counter += 5
counter.compareAndSet(10, 0); // jika nilai == 10, set ke 0 (CAS operation)
int nilai = counter.get(); // baca nilai saat ini
// AtomicInteger jauh lebih cepat dari synchronized untuk counter sederhana
Executor Framework #
Membuat new Thread() setiap kali ada tugas adalah cara yang tidak efisien — membuat thread itu mahal, dan terlalu banyak thread aktif bersamaan justru memperlambat sistem karena overhead context switching. ExecutorService mengelola pool thread yang bisa dipakai ulang untuk menjalankan banyak tugas tanpa biaya pembuatan thread setiap kali.
Jenis Thread Pool #
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
// Fixed pool: jumlah thread tetap, tugas antri saat semua thread sibuk
// Cocok untuk: server yang punya beban kerja stabil
ExecutorService fixedPool = Executors.newFixedThreadPool(4);
// Cached pool: buat thread baru jika semua sibuk, hapus yang idle > 60 detik
// Cocok untuk: banyak tugas pendek yang datang tidak menentu
ExecutorService cachedPool = Executors.newCachedThreadPool();
// Single thread: satu thread, semua tugas antri berurutan
// Cocok untuk: tugas yang harus berjalan satu per satu (sequential)
ExecutorService singleThread = Executors.newSingleThreadExecutor();
// Scheduled pool: jalankan tugas pada waktu tertentu atau secara periodik
ScheduledExecutorService scheduledPool = Executors.newScheduledThreadPool(2);
Menjalankan Tugas dengan submit() #
ExecutorService pool = Executors.newFixedThreadPool(3);
// submit Runnable — tidak ada nilai kembali
pool.submit(() -> System.out.println("Tugas tanpa hasil"));
// execute Runnable — sama dengan submit tapi tidak return Future
pool.execute(() -> System.out.println("Tugas dieksekusi"));
// Mengirim banyak tugas sekaligus
for (int i = 1; i <= 10; i++) {
final int nomor = i;
pool.submit(() -> {
System.out.println("Tugas " + nomor + " di " + Thread.currentThread().getName());
try { Thread.sleep(500); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
}
// WAJIB: shutdown setelah selesai — tanpa ini, program tidak akan berhenti
pool.shutdown(); // tolak tugas baru, tunggu tugas yang sedang berjalan selesai
try {
// Tunggu maksimal 30 detik
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
pool.shutdownNow(); // paksa hentikan jika masih ada yang berjalan
}
} catch (InterruptedException e) {
pool.shutdownNow();
}
Scheduled Executor #
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
// Jalankan sekali setelah delay 2 detik
scheduler.schedule(() -> System.out.println("Terlambat 2 detik"), 2, TimeUnit.SECONDS);
// Jalankan setiap 5 detik, mulai setelah 1 detik awal
scheduler.scheduleAtFixedRate(
() -> System.out.println("Ping: " + System.currentTimeMillis()),
1, 5, TimeUnit.SECONDS
);
// Jalankan 3 detik setelah tugas sebelumnya selesai
scheduler.scheduleWithFixedDelay(
() -> System.out.println("Delay setelah tugas selesai"),
0, 3, TimeUnit.SECONDS
);
Callable dan Future #
Runnable tidak bisa mengembalikan nilai dan tidak bisa melempar checked exception. Callable<V> memecahkan keduanya. Hasil eksekusi Callable dibungkus dalam Future<V> yang bisa diambil nanti.
Callable dan Future Dasar #
import java.util.concurrent.*;
ExecutorService pool = Executors.newFixedThreadPool(2);
// Callable: seperti Runnable tapi bisa return nilai dan throw exception
Callable<Integer> hitungTotal = () -> {
int total = 0;
for (int i = 1; i <= 100; i++) {
total += i;
Thread.sleep(10); // simulasi kalkulasi
}
return total; // 5050
};
// submit Callable mengembalikan Future
Future<Integer> future = pool.submit(hitungTotal);
System.out.println("Menghitung di background...");
// lakukan pekerjaan lain sementara menunggu
System.out.println("Bisa kerjakan hal lain di sini");
try {
// get() memblokir sampai hasilnya tersedia
Integer hasil = future.get(); // 5050
System.out.println("Total: " + hasil);
// get() dengan timeout — lebih aman
Integer hasilAman = future.get(5, TimeUnit.SECONDS);
} catch (ExecutionException e) {
// Callable lempar exception — terbungkus di sini
System.err.println("Tugas gagal: " + e.getCause().getMessage());
} catch (TimeoutException e) {
System.err.println("Timeout!");
future.cancel(true); // batalkan tugas
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
pool.shutdown();
}
Menunggu Banyak Future Sekaligus #
ExecutorService pool = Executors.newFixedThreadPool(4);
List<Callable<String>> tugasList = List.of(
() -> { Thread.sleep(1000); return "Hasil A"; },
() -> { Thread.sleep(500); return "Hasil B"; },
() -> { Thread.sleep(800); return "Hasil C"; }
);
try {
// invokeAll: kirim semua, tunggu semua selesai
List<Future<String>> futures = pool.invokeAll(tugasList);
for (Future<String> f : futures) {
System.out.println(f.get()); // sudah pasti done, tidak blokir lama
}
// invokeAny: kirim semua, kembalikan hasil yang pertama selesai
String tercepat = pool.invokeAny(tugasList);
System.out.println("Yang pertama selesai: " + tercepat);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
} finally {
pool.shutdown();
}
Struktur Data Concurrent #
Koleksi standar Java (ArrayList, HashMap, dll.) tidak thread-safe. Jangan pakai di lingkungan multithreading tanpa perlindungan. java.util.concurrent menyediakan alternatif yang dirancang khusus.
Koleksi Concurrent #
import java.util.concurrent.*;
// ConcurrentHashMap: pengganti HashMap untuk multithreading
// (sudah dibahas di artikel Map — lebih detail di sana)
ConcurrentHashMap<String, Integer> mapAman = new ConcurrentHashMap<>();
// CopyOnWriteArrayList: pengganti ArrayList jika baca >> tulis
// (sudah dibahas di artikel List)
CopyOnWriteArrayList<String> listAman = new CopyOnWriteArrayList<>();
// BlockingQueue: antrian yang bisa memblokir thread saat penuh atau kosong
// Sangat berguna untuk pola Producer-Consumer
BlockingQueue<String> antrian = new LinkedBlockingQueue<>(100); // kapasitas 100
// Producer: tambah ke antrian, blokir jika penuh
antrian.put("item-1"); // blokir sampai ada tempat
antrian.offer("item-2"); // coba tambah, return false jika penuh (tidak blokir)
antrian.offer("item-3", 2, TimeUnit.SECONDS); // tunggu maks 2 detik
// Consumer: ambil dari antrian, blokir jika kosong
String item = antrian.take(); // blokir sampai ada item
String item2 = antrian.poll(); // ambil atau null jika kosong (tidak blokir)
String item3 = antrian.poll(3, TimeUnit.SECONDS); // tunggu maks 3 detik
Pola Producer-Consumer #
// Skenario: producer menghasilkan data, consumer memprosesnya
// BlockingQueue bertindak sebagai buffer di antaranya
BlockingQueue<Integer> buffer = new LinkedBlockingQueue<>(10);
// Producer: terus hasilkan angka
Thread producer = new Thread(() -> {
try {
for (int i = 1; i <= 20; i++) {
buffer.put(i); // blokir jika buffer penuh (maks 10)
System.out.println("Diproduksi: " + i);
Thread.sleep(100);
}
buffer.put(-1); // sentinel: tanda selesai
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "producer");
// Consumer: ambil dan proses satu per satu
Thread consumer = new Thread(() -> {
try {
while (true) {
int item = buffer.take(); // blokir jika buffer kosong
if (item == -1) break; // terima sentinel, berhenti
System.out.println("Dikonsumsi: " + item);
Thread.sleep(200); // consumer lebih lambat dari producer
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "consumer");
producer.start();
consumer.start();
producer.join();
consumer.join();
Deadlock dan Cara Menghindarinya #
Deadlock terjadi saat dua thread saling menunggu lock yang dipegang oleh thread yang lain. Keduanya tidak bisa maju selamanya.
Contoh Deadlock #
// ANTI-PATTERN: dua thread mengunci dua objek dalam urutan berlawanan
Object lockA = new Object();
Object lockB = new Object();
Thread thread1 = new Thread(() -> {
synchronized (lockA) { // thread1 pegang lockA
System.out.println("T1 pegang A, menunggu B...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lockB) { // thread1 tunggu lockB (dipegang thread2!)
System.out.println("T1 pegang keduanya");
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (lockB) { // thread2 pegang lockB
System.out.println("T2 pegang B, menunggu A...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lockA) { // thread2 tunggu lockA (dipegang thread1!)
System.out.println("T2 pegang keduanya");
}
}
});
// Kedua thread saling tunggu → DEADLOCK
Cara Menghindari Deadlock #
// BENAR: selalu kunci dalam urutan yang sama di semua thread
Object lockA = new Object();
Object lockB = new Object();
// Thread 1 dan Thread 2 keduanya: A dulu, baru B
Thread thread1 = new Thread(() -> {
synchronized (lockA) {
synchronized (lockB) {
System.out.println("T1 selesai");
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (lockA) { // urutan sama: A dulu, baru B
synchronized (lockB) {
System.out.println("T2 selesai");
}
}
});
// Tidak ada deadlock — keduanya kunci dalam urutan yang konsisten
Tips menghindari deadlock:
✓ Selalu kunci beberapa lock dalam urutan yang sama dan konsisten
✓ Kurangi scope synchronized — lock sesedikit mungkin
✓ Gunakan tryLock() dengan timeout dari ReentrantLock sebagai alternatif
✓ Hindari memanggil metode eksternal dari dalam synchronized block
✓ Pertimbangkan desain ulang untuk mengurangi kebutuhan multiple lock
Kapan Menggunakan Multithreading #
Gunakan MULTITHREADING jika:
✓ Tugas bisa dibagi menjadi bagian yang bisa dikerjakan secara paralel
✓ Ada operasi I/O lambat (disk, jaringan) yang sebaiknya tidak memblokir
✓ Butuh responsivitas UI — operasi berat di background thread
✓ Punya banyak CPU core yang ingin dimanfaatkan
Hindari atau hati-hati jika:
✗ Tugas bergantung satu sama lain secara ketat (sulit diparalelkan)
✗ Overhead sinkronisasi lebih besar dari keuntungan paralel
✗ Kode sudah cukup cepat — jangan optimize prematur
✗ Tim tidak familiar — bug multithreading sangat sulit dilacak
Pilihan tool berdasarkan kebutuhan:
→ Thread + Runnable : tugas sederhana tanpa nilai kembali
→ Callable + Future : tugas yang perlu mengembalikan hasil
→ ExecutorService : kelola pool thread untuk banyak tugas
→ ScheduledExecutorService: tugas berkala atau terjadwal
→ AtomicInteger/Long : counter atau flag sederhana tanpa synchronized
→ ConcurrentHashMap : map yang diakses banyak thread
→ BlockingQueue : pola producer-consumer
Ringkasan #
Runnableatau lambda adalah pilihan utama untuk mendefinisikan tugas thread. Meng-extendThreadmemblokir pewarisan kelas lain — hindari kecuali benar-benar perlu.- Selalu panggil
start(), bukanrun()—run()hanya menjalankan metode biasa di thread yang sama, tidak membuat thread baru.- Race condition terjadi saat dua thread mengubah data bersama tanpa sinkronisasi —
nilai++bukan operasi atomik. Gunakansynchronized,AtomicInteger, atau koleksi concurrent.synchronizedmengunci seluruh objek — untuk kontrol lebih halus, gunakan synchronized block dengan lock object terpisah. Kunci sesedikit mungkin untuk mengurangi bottleneck.volatilemengatasi masalah visibility, bukan atomicity — cocok untuk flagbooleanatauintyang hanya ditulis oleh satu thread, dibaca oleh banyak thread.- Gunakan
ExecutorService, bukannew Thread()manual — thread pool jauh lebih efisien karena thread dipakai ulang. Selalu panggilshutdown()setelah selesai.Callable + Futureuntuk tugas yang mengembalikan hasil —future.get()memblokir sampai hasil tersedia. Selalu gunakanget(timeout)untuk menghindari tunggu selamanya.- Deadlock terjadi saat thread saling menunggu lock — selalu kunci beberapa lock dalam urutan yang konsisten di semua thread untuk mencegahnya.
BlockingQueueadalah fondasi pola producer-consumer — buffer yang otomatis memblokir producer saat penuh dan consumer saat kosong, tanpa perluwait()/notify()manual.