Chapter sebelumnya mengatur kapan pekerjaan berjalan; pertanyaan yang belum terjawab adalah bagaimana pekerjaan berjalan tanpa menyandera pemanggilnya. Saat pelanggan menyelesaikan checkout, aplikasi perlu mengirim surel konfirmasi, memperbarui analitik, dan menghubungi gudang — tiga pekerjaan yang tidak boleh membuat penjual menunggu di layar pembayaran. Pemrosesan asinkron memindahkan pekerjaan itu ke thread lain dan menyerahkan kontrak hasil lewat CompletableFuture. Pada Chapter ini kita bedah @Async, pemilihan executor, komposisi CompletableFuture, lalu membandingkan mode berurutan dan asinkron dalam satu program Java murni.

Mengaktifkan @Async #

Sama seperti @Scheduled, anotasi tidak bekerja sendirian: @EnableAsync menyalakan infrastruktur, @Async menandai method yang boleh berpindak thread — ditampilkan apa adanya untuk Spring Boot 3.4/3.5:

java
@SpringBootApplication
@EnableAsync
public class AplikasiToko { ... }

@Service
public class NotifikasiService {

    private final PengirimSurel surel;

    public NotifikasiService(PengirimSurel surel) {
        this.surel = surel;
    }

    @Async("executorNotifikasi")
    public CompletableFuture<Void> kirimKonfirmasi(String pembeli, String pesanan) {
        surel.kirim(pembeli, "Pesanan " + pesanan + " diterima");
        return CompletableFuture.completedFuture(null);
    }
}

Mekanismenya proxy AOP — sama dengan @Transactional dan @Cacheable. Pemanggil sebenarnya memanggil proxy; proxy mengambil Future dari executor, mengeksekusi method pada thread pool, lalu mengembalikan Future segera. Dua konsekuensi langsung mengikuti bentuk ini. Pertama, pemanggilan dari dalam kelas yang sama melewati proxy sehingga method berjalan sinkron tanpa peringatan. Kedua, void sebagai tipe balikan memutus jalur hasil: pemanggil tidak bisa tahu kapan pekerjaan selesai atau gagal — kembalikan CompletableFuture<T> agar kontraknya utuh.

Memilih Executor #

Tanpa konfigurasi, @Async tanpa nama memakai executor bawaan Spring Boot — ThreadPoolTaskExecutor dengan 8 core thread dan antrean tanpa batas. Antrean tanpa batas menyimpan risiko tersembunyi: beban lonjakan tidak pernah ditolak, hanya menumpuk diam-diam hingga memori habis. Konfigurasi eksplisit memberi kendali:

java
@Bean("executorNotifikasi")
public Executor executorNotifikasi() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(4);
    executor.setMaxPoolSize(8);
    executor.setQueueCapacity(100);
    executor.setThreadNamePrefix("notifikasi-");
    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
    return executor;
}

CallerRunsPolicy memaksa pemanggil mengeksekusi tugas sendiri saat penolakan terjadi — tekanan balik alami yang melambatkan penerimaan kerja alih-alih membuangnya. Sejak Spring Boot 3.2 tersedia jalur lain: spring.threads.virtual.enabled=true menggantikan executor bawaan dengan virtual threads (JEP 444) yang membiarkan puluhan ribu tugas tertunda murah. Pilihan executor per use case:

KondisiExecutor yang tepat
Tugas I/O (surel, HTTP, DB)thread kecil atau virtual threads
Tugas CPU (kompresi, enkripsi)ukuran = jumlah core, jangan lebih
Latensi ketat, boleh ditolakantrean kecil + kebijakan penolakan eksplisit
Virtual threads aktifcukup @Async tanpa nama

Komposisi CompletableFuture #

CompletableFuture bukan sekadar wadah hasil — method-methodnya menyusun pipeline: thenApply memetakan hasil, thenCombine menggabungkan dua pipeline, allOf menunggu banyak sekaligus, dan exceptionally menangkap kegagalan di titik mana pun rantai terputus. Pemanggil menunggu sekali di ujung rantai, bukan pada setiap langkah — itulah perbedaan struktural antara “non-blocking” dan “menunggu dengan cara lain”.

Membandingkan Berurutan dan Asinkron #

Program Java murni berikut memproses enam pembayaran dua kali: pertama secara berurutan (pemanggil tersita enam kali), lalu melalui CompletableFuture.supplyAsync pada executor dua thread dengan allOf().join() sebagai satu-satunya titik tunggu — diakhiri rantai thenApply, thenCombine, dan exceptionally:

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class PekerjaanAsync {

    record Pembayaran(int id, String pelanggan, long jumlah) { }

    record Hasil(int id, String status, long biayaAdmin) { }

    // "Proses pembayaran" yang memakan waktu: thread bekerja 100 ms lalu
    // mengembalikan hasil yang bisa dihitung tangan.
    static Hasil proses(Pembayaran p) {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return new Hasil(p.id(), "LUNAS", p.jumlah() / 1_000 + 2_500);
    }

    public static void main(String[] args) throws Exception {
        List<Pembayaran> antrean = List.of(
                new Pembayaran(1, "Budi", 850_000),
                new Pembayaran(2, "Siti", 1_200_000),
                new Pembayaran(3, "Dewi", 430_000),
                new Pembayaran(4, "Budi", 90_000),
                new Pembayaran(5, "Siti", 2_500_000),
                new Pembayaran(6, "Dewi", 675_000));

        System.out.println("== Mode berurutan: pemanggil menunggu tiap tugas ==");
        for (Pembayaran p : antrean) {
            Hasil h = proses(p);
            System.out.println("selesai: pembayaran-" + h.id()
                    + " " + p.pelanggan() + " status=" + h.status());
        }
        System.out.println("enam tuntas, pemanggil tersita enam kali");
        System.out.println();

        System.out.println("== Mode asinkron: executor 2 thread, allOf().join() ==");
        ExecutorService pool = Executors.newFixedThreadPool(2);
        try {
            List<CompletableFuture<Hasil>> futures = new ArrayList<>();
            for (Pembayaran p : antrean) {
                // Padanan method bertanda @Async: submit, jangan tunggu di sini.
                futures.add(CompletableFuture.supplyAsync(() -> proses(p), pool));
            }
            // Pemanggil menunggu SEKALI di titik pengumpulan, bukan per tugas.
            CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
            for (CompletableFuture<Hasil> f : futures) {
                Hasil h = f.join();
                System.out.println("hasil pembayaran-" + h.id() + ": " + h.status()
                        + " biayaAdmin=" + h.biayaAdmin());
            }
        } finally {
            pool.shutdown();
            if (!pool.awaitTermination(5, TimeUnit.SECONDS)) {
                pool.shutdownNow();
            }
        }
        System.out.println();

        System.out.println("== Rantai thenApply + thenCombine ==");
        CompletableFuture<Long> tagihan = CompletableFuture.supplyAsync(() -> 120_000L);
        CompletableFuture<Long> denganPajak =
                tagihan.thenApply(nilai -> nilai + nilai / 10);        // +10% pajak
        CompletableFuture<Long> ongkir = CompletableFuture.supplyAsync(() -> 15_000L);
        CompletableFuture<Long> total =
                denganPajak.thenCombine(ongkir, Long::sum);
        System.out.println("120000 + pajak 10% + ongkir 15000 = " + total.join());
        System.out.println();

        System.out.println("== exceptionally: fallback saat tugas gagal ==");
        CompletableFuture<String> gagal = CompletableFuture.supplyAsync(() -> {
            throw new IllegalStateException("koneksi pembayaran terputus");
        });
        String hasil = gagal
                .exceptionally(e -> "fallback: antre ulang pembayaran")
                .join();
        System.out.println(hasil);
    }
}

Output yang Dihasilkan:

text
== Mode berurutan: pemanggil menunggu tiap tugas ==
selesai: pembayaran-1 Budi status=LUNAS
selesai: pembayaran-2 Siti status=LUNAS
selesai: pembayaran-3 Dewi status=LUNAS
selesai: pembayaran-4 Budi status=LUNAS
selesai: pembayaran-5 Siti status=LUNAS
selesai: pembayaran-6 Dewi status=LUNAS
enam tuntas, pemanggil tersita enam kali

== Mode asinkron: executor 2 thread, allOf().join() ==
hasil pembayaran-1: LUNAS biayaAdmin=3350
hasil pembayaran-2: LUNAS biayaAdmin=3700
hasil pembayaran-3: LUNAS biayaAdmin=2930
hasil pembayaran-4: LUNAS biayaAdmin=2590
hasil pembayaran-5: LUNAS biayaAdmin=5000
hasil pembayaran-6: LUNAS biayaAdmin=3175

== Rantai thenApply + thenCombine ==
120000 + pajak 10% + ongkir 15000 = 147000

== exceptionally: fallback saat tugas gagal ==
fallback: antre ulang pembayaran

Mari kita bedah bagian kuncinya:

  1. Mode berurutan: loop memanggil proses(p) satu per satu — keluaran tercetak seiring tiap tugas tuntas karena pemanggil memang menunggu di setiap pemanggilan. Enam tugas berarti enam kali blok, dan pemanggil tidak bisa mengerjakan apa pun di sela-selanya.
  2. CompletableFuture.supplyAsync(() -> proses(p), pool): padanan method @Async — tugas diserahkan ke pool dan method mengembalikan CompletableFuture seketika, sebelum tugas selesai. Perhatikan pool diberikan eksplisit; tanpa itu, supplyAsync memakai ForkJoinPool.commonPool() bersama seluruh JVM — keputusan yang layak dihindari untuk tugas I/O.
  3. allOf(...).join(): satu titik tunggu untuk keenam tugas. Baris-baris hasil baru dicetak setelah join — pola kumpul-lalu-cetak yang menjaga keluaran tetap terurut meski thread bekerja bersamaan. join() vs get(): keduanya memblok, tetapi join() melempar unchecked exception sehingga rantai tetap ringkas.
  4. Rantai thenApply + thenCombine: 120000 + pajak 10% + ongkir 15000 = 147000 dihitung tanpa satu pun variabel sementara yang memblok — denganPajak dan ongkir berjalan sebagai pipeline terpisah, Long::sum menyambung keduanya saat keduanya siap.
  5. exceptionally(...): pipeline yang melempar IllegalStateException tidak merusak pemanggil — fallback antre ulang pembayaran menggantikan hasil pada titik gagal. Pada @Async sungguhan, kegagalan yang tidak ditangani di void mengalir ke AsyncUncaughtExceptionHandler — pastikan handler itu terpasang agar kegagalan latar tetap terlihat di log.

Kita kini memiliki aplikasi berlapis lengkap: REST API, data terpaginasi, cache, penjadwalan, dan pekerjaan latar. Seluruh perilaku itu harus terkunci oleh pengujian otomatis agar perubahan besok tidak membongkar yang sudah benar hari ini — cara menguji aplikasi Spring Boot tanpa menjalankan semuanya sekaligus menjadi pokok bahasan Chapter Testing Spring Boot berikutnya.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

PekerjaanAsync.java

Unduh berkas
java
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class PekerjaanAsync {

    record Pembayaran(int id, String pelanggan, long jumlah) { }

    record Hasil(int id, String status, long biayaAdmin) { }

    // "Proses pembayaran" yang memakan waktu: thread bekerja 100 ms lalu
    // mengembalikan hasil yang bisa dihitung tangan.
    static Hasil proses(Pembayaran p) {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return new Hasil(p.id(), "LUNAS", p.jumlah() / 1_000 + 2_500);
    }

    public static void main(String[] args) throws Exception {
        List<Pembayaran> antrean = List.of(
                new Pembayaran(1, "Budi", 850_000),
                new Pembayaran(2, "Siti", 1_200_000),
                new Pembayaran(3, "Dewi", 430_000),
                new Pembayaran(4, "Budi", 90_000),
                new Pembayaran(5, "Siti", 2_500_000),
                new Pembayaran(6, "Dewi", 675_000));

        System.out.println("== Mode berurutan: pemanggil menunggu tiap tugas ==");
        for (Pembayaran p : antrean) {
            Hasil h = proses(p);
            System.out.println("selesai: pembayaran-" + h.id()
                    + " " + p.pelanggan() + " status=" + h.status());
        }
        System.out.println("enam tuntas, pemanggil tersita enam kali");
        System.out.println();

        System.out.println("== Mode asinkron: executor 2 thread, allOf().join() ==");
        ExecutorService pool = Executors.newFixedThreadPool(2);
        try {
            List<CompletableFuture<Hasil>> futures = new ArrayList<>();
            for (Pembayaran p : antrean) {
                // Padanan method bertanda @Async: submit, jangan tunggu di sini.
                futures.add(CompletableFuture.supplyAsync(() -> proses(p), pool));
            }
            // Pemanggil menunggu SEKALI di titik pengumpulan, bukan per tugas.
            CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
            for (CompletableFuture<Hasil> f : futures) {
                Hasil h = f.join();
                System.out.println("hasil pembayaran-" + h.id() + ": " + h.status()
                        + " biayaAdmin=" + h.biayaAdmin());
            }
        } finally {
            pool.shutdown();
            if (!pool.awaitTermination(5, TimeUnit.SECONDS)) {
                pool.shutdownNow();
            }
        }
        System.out.println();

        System.out.println("== Rantai thenApply + thenCombine ==");
        CompletableFuture<Long> tagihan = CompletableFuture.supplyAsync(() -> 120_000L);
        CompletableFuture<Long> denganPajak =
                tagihan.thenApply(nilai -> nilai + nilai / 10);        // +10% pajak
        CompletableFuture<Long> ongkir = CompletableFuture.supplyAsync(() -> 15_000L);
        CompletableFuture<Long> total =
                denganPajak.thenCombine(ongkir, Long::sum);
        System.out.println("120000 + pajak 10% + ongkir 15000 = " + total.join());
        System.out.println();

        System.out.println("== exceptionally: fallback saat tugas gagal ==");
        CompletableFuture<String> gagal = CompletableFuture.supplyAsync(() -> {
            throw new IllegalStateException("koneksi pembayaran terputus");
        });
        String hasil = gagal
                .exceptionally(e -> "fallback: antre ulang pembayaran")
                .join();
        System.out.println(hasil);
    }
}