Kode concurrency adalah wilayah tersulit untuk diuji: kegagalan bergantung pada urutan langkah antar thread yang berbeda di setiap eksekusi, sehingga test bisa lulus seratus kali lalu gagal sekali di CI tanpa perubahan kode apa pun. Penyebabnya bukan test yang kurang teliti, melainkan sifat masalahnya — interleaving thread yang jumlah kombinasinya berlipat ganda secara perkalian. Kabar baiknya: strategi pengujian concurrency yang matang tidak mengejar semua interleaving, melainkan menyusun ulang kode dan test agar sebagian besar wilayah menjadi deterministik. Pada Chapter ini kita akan mempelajari tiga strategi bertingkat — ekstraksi logika murni, join dengan timeout, dan stress test terbatas — lalu mengenal jcstress untuk wilayah yang benar-benar membutuhkan penelusuran interleaving.
Kenapa Kode Paralel Sulit Diuji #
Kegagalan concurrency khas muncul dari jendela waktu mikro: dua thread membaca variabel yang sama, keduanya menambah satu, lalu keduanya menulis balik — satu pembaruan hilang diam-diam (lost update). Kesalahan seperti ini tidak meninggalkan jejak pada nilai aktual per thread; hanya total akhirnya yang lebih kecil dari seharusnya. Test yang menjalankan kode paralel sekali jarang menabrak jendela tersebut, sehingga hijau palsu; test yang menabraknya sekali sulit direproduksi untuk diperbaiki. Ditambah lagi, test sendiri bisa mengubah timing: sinkronisasi tambahan untuk memeriksa hasil menutup jendela race yang dicari — gejala yang dikenal sebagai efek pengamat.
Strategi Pertama Ekstraksi Logika Murni #
Sebagian besar kode bertanda “concurrency” sebenarnya terdiri dari dua lapis: orkestrasi thread (membagi tugas, mengumpulkan hasil) dan logika keputusan (bagaimana hasil dihitung). Pisahkan keduanya — logika keputusan ditarik menjadi method murni yang menerima dan mengembalikan nilai tanpa menyentuh status bersama. Method murni tersebut kemudian diuji seperti kode biasa: deterministik, berparameter, cepat. Wilayah yang tersisa untuk pengujian paralel menyusut menjadi orkestrasi yang tipis, dan itulah yang diuji strategi berikutnya.
Strategi Kedua Join dengan Timeout #
Aturan emas pengujian paralel: jangan tidur dan berharap — tunggu dan pastikan. Thread.sleep(500) sebelum memeriksa hasil adalah tebakan yang membuat suite lambat dan tetap rapuh; join() dengan batas waktu membuat test menunggu tepat selama yang diperlukan, dan pemeriksaan isAlive() setelahnya mengubah kegagalan timeout menjadi assertion yang jelas. Hasil dari tiap thread dikumpulkan ke array ber-indeks tetap, diverifikasi setelah seluruh thread bergabung, lalu dicetak dalam urutan indeks — bukan dari dalam thread — agar laporan tetap deterministik.
Strategi Ketiga Stress Test Terbatas #
Untuk invarian bersama — total sama dengan jumlah kontribusi, tidak ada item hilang, kapasitas tidak terlampaui — stress test terbatas memberi jaminan praktis: jumlah thread tetap kecil (4–8), beban per thread tetap, invarian diperiksa setelah semua selesai, dan seluruh test diulang beberapa kali untuk memperluas variasi interleacing. Di JUnit, pengulangan ini ditulis dengan @RepeatedTest:
@RepeatedTest(50)
void totalSelaluSamadenganJumlahKontribusi() throws InterruptedException {
Penghitung penghitung = new Penghitung();
Thread[] pekerja = new Thread[4];
long[] kontribusi = new long[4];
for (int t = 0; t < pekerja.length; t++) {
final int indeks = t;
pekerja[t] = new Thread(() -> {
penghitung.tambah(1_000);
kontribusi[indeks] = 1_000;
});
pekerja[t].start();
}
for (Thread t : pekerja) {
t.join(5_000);
assertThat(t.isAlive()).isFalse();
}
assertThat(penghitung.nilai()).isEqualTo(4_000);
}Sebagai pembanding, versi yang salah kelihatan tidak bersalah padahal berisi race:
class PenghitungSalah {
private long nilai = 0; // status bersama tanpa proteksi
void tambah(int berapa) {
for (int i = 0; i < berapa; i++) {
nilai++; // baca-tambah-tulis: tidak atomik
}
}
}nilai++ terlihat satu langkah, tetapi bytecode-nya tiga langkah: baca, tambah, tulis. Dua thread yang berada di tengah urutan itu saling menimpa tulisan — inilah lost update yang membuat hasil akhir kadang di bawah 4.000.
Demo dari Java Murni #
Program berikut merangkai ketiga strategi: agregasi murni diuji tanpa thread, penghitung atomik diuji empat pekerja paralel, dan hasil diverifikasi hanya setelah seluruh pekerja bergabung:
import java.util.concurrent.atomic.AtomicInteger;
public class ContohUjiKonkuren {
// ---- strategi 1: logika agregasi murni, diuji seperti biasa ----
static long hitungTotal(long[] kontribusi) {
long total = 0;
for (long nilai : kontribusi) {
total += nilai;
}
return total;
}
// ---- kode paralel yang diuji: penghitung atomik ----
static final class Penghitung {
private final AtomicInteger nilai = new AtomicInteger();
void tambah(int berapa) {
for (int i = 0; i < berapa; i++) {
nilai.incrementAndGet();
}
}
int nilai() {
return nilai.get();
}
}
public static void main(String[] args) throws InterruptedException {
// uji 1: bagian murni diuji deterministik tanpa thread
long[] contoh = {1000, 1000, 1000, 1000};
System.out.println("uji logika murni : total = " + hitungTotal(contoh));
// uji 2: stress test terbatas dengan join ber-timeout
int jumlahPekerja = 4;
int tambahanPerPekerja = 1000;
Penghitung penghitung = new Penghitung();
Thread[] pekerja = new Thread[jumlahPekerja];
long[] kontribusi = new long[jumlahPekerja];
for (int t = 0; t < jumlahPekerja; t++) {
final int indeks = t;
pekerja[t] = new Thread(() -> {
penghitung.tambah(tambahanPerPekerja);
kontribusi[indeks] = tambahanPerPekerja;
});
pekerja[t].start();
}
for (Thread t : pekerja) {
t.join(5_000); // batas atas: suite tidak menggantung tanpa akhir
if (t.isAlive()) {
throw new AssertionError("pekerja tidak selesai dalam batas waktu");
}
}
// seluruh pekerja sudah bergabung: aman membaca hasil bersama
int hasil = penghitung.nilai();
System.out.println("uji paralel : hasil = " + hasil);
System.out.println("total kontribusi : " + hitungTotal(kontribusi));
System.out.println("konsisten : " + (hasil == hitungTotal(kontribusi)));
}
}Output yang Dihasilkan:
uji logika murni : total = 4000
uji paralel : hasil = 4000
total kontribusi : 4000
konsisten : trueKeluaran tersebut identik pada setiap eksekusi meskipun urutan thread di dalam JVM berbeda-beda. Bedah baris-per-baris bagian kuncinya:
- Baris
static long hitungTotal(long[] kontribusi): lapisan logika murni — tanpavolatile, tanpa lock, tanpa thread; seluruh aturan agregasi bisa diuji sebagai test biasa. - Baris
private final AtomicInteger nilai = new AtomicInteger();: status bersama dilindungi struktur data atomik;incrementAndGet()menjamin operasi baca-tambah-tulis sebagai satu langkah tak terbagi. - Baris
final int indeks = t;dankontribusi[indeks] = tambahanPerPekerja;: tiap pekerja menulis ke slot array miliknya sendiri — tidak ada dua thread menulis sel yang sama, jadi tidak perlu sinkronisasi pada array. - Baris
t.join(5_000);danif (t.isAlive()): test tidak pernah menggantung lebih dari batas atas; bila pekerja macet, test gagal dengan pesan yang menyebut penyebabnya alih-alih menunggu tanpa akhir. - Baris
int hasil = penghitung.nilai();setelah seluruh loop join: pembacaan status bersama hanya dilakukan setelah semua thread selesai — terjadi-setelah (happens-before) yang dibentukjoin()menjamin nilai yang dibaca sudah final. - Baris
hasil == hitungTotal(kontribusi): invarian kunci stress test — total sistem harus sama dengan jumlah seluruh kontribusi; pelanggaran invarian inilah yang mendeteksi lost update.
jcstress untuk Penelusuran Interleaving #
Ketika target pengujian adalah struktur data konkuren buatan sendiri — lock-free queue, cache dengan TTL — jumlah interleaving yang perlu ditekan jauh melebihi kemampuan stress test biasa. Proyek OpenJDK jcstress dirancang persis untuk ini: ia menjalankan method uji berpasangan pada dua thread dalam jutaan kombinasi penjadwalan yang dikendalikan, mencatat hasil menurut model memori Java, dan melaporkan hasil yang tidak diharapkan beserta status memori yang teramati. jcstress memperlakukan race sebagai objek studi berdata, bukan kebetulan yang menunggu; hasilnya adalah tabel frekuensi hasil yang menunjukkan apakah invarian benar-benar dijaga oleh seluruh interleaving yang dicoba.
Tiga strategi di atas menutup mayoritas kebutuhan nyata: pisahkan yang murni, tunggu dengan batas, tekan yang invarian. Chapter penutup section ini merangkum seluruh praktik yang telah kita kumpulkan menjadi satu daftar periksa untuk suite yang cepat, jelas, dan dapat dipercaya.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
ContohUjiKonkuren.java
Unduh berkasimport java.util.concurrent.atomic.AtomicInteger;
public class ContohUjiKonkuren {
// ---- strategi 1: logika agregasi murni, diuji seperti biasa ----
static long hitungTotal(long[] kontribusi) {
long total = 0;
for (long nilai : kontribusi) {
total += nilai;
}
return total;
}
// ---- kode paralel yang diuji: penghitung atomik ----
static final class Penghitung {
private final AtomicInteger nilai = new AtomicInteger();
void tambah(int berapa) {
for (int i = 0; i < berapa; i++) {
nilai.incrementAndGet();
}
}
int nilai() {
return nilai.get();
}
}
public static void main(String[] args) throws InterruptedException {
// uji 1: bagian murni diuji deterministik tanpa thread
long[] contoh = {1000, 1000, 1000, 1000};
System.out.println("uji logika murni : total = " + hitungTotal(contoh));
// uji 2: stress test terbatas dengan join ber-timeout
int jumlahPekerja = 4;
int tambahanPerPekerja = 1000;
Penghitung penghitung = new Penghitung();
Thread[] pekerja = new Thread[jumlahPekerja];
long[] kontribusi = new long[jumlahPekerja];
for (int t = 0; t < jumlahPekerja; t++) {
final int indeks = t;
pekerja[t] = new Thread(() -> {
penghitung.tambah(tambahanPerPekerja);
kontribusi[indeks] = tambahanPerPekerja;
});
pekerja[t].start();
}
for (Thread t : pekerja) {
t.join(5_000); // batas atas: suite tidak menggantung tanpa akhir
if (t.isAlive()) {
throw new AssertionError("pekerja tidak selesai dalam batas waktu");
}
}
// seluruh pekerja sudah bergabung: aman membaca hasil bersama
int hasil = penghitung.nilai();
System.out.println("uji paralel : hasil = " + hasil);
System.out.println("total kontribusi : " + hitungTotal(kontribusi));
System.out.println("konsisten : " + (hasil == hitungTotal(kontribusi)));
}
}