karawaci.kode

← Semua snippet

Java Menengah Performance

Spring @Async config dengan ThreadPoolTaskExecutor

Setup @Async di Spring Boot — bounded thread pool, queue capacity, exception handler. Bukan asal-asalan pakai default.

Dipublikasikan 19 Juli 2026

@EnableAsync + @Async di Spring kelihatan simple, tapi default executor-nya bahaya: SimpleAsyncTaskExecutor bikin thread baru setiap call, tanpa pool. Trafik tinggi = ribuan thread, JVM OOM. Snippet ini config production-ready dengan bounded queue + rejection handler + exception logger.

Kode

// AsyncConfig.java
package id.kodekarawaci.config;

import java.lang.reflect.Method;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
    private static final Logger LOG = LoggerFactory.getLogger(AsyncConfig.class);

    /**
     * Executor utama — dipakai @Async tanpa qualifier.
     * Sizing buat IO-bound task (kirim email, notifikasi, API call).
     */
    @Override
    @Bean(name = "applicationTaskExecutor")
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();

        // Pool size
        executor.setCorePoolSize(8);              // Minimum thread aktif
        executor.setMaxPoolSize(32);              // Maximum saat queue penuh

        // Queue capacity — bounded, jangan unbounded (memory bomb)
        executor.setQueueCapacity(500);

        // Thread keep alive — thread di atas core dirilis setelah idle
        executor.setKeepAliveSeconds(60);

        // Naming untuk debug
        executor.setThreadNamePrefix("app-async-");

        // Rejection policy — apa yang terjadi saat queue penuh DAN pool full
        // CallerRunsPolicy: caller thread eksekusi task — natural backpressure
        executor.setRejectedExecutionHandler(
            new ThreadPoolExecutor.CallerRunsPolicy()
        );

        // Graceful shutdown — tunggu task selesai saat app shutdown
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(30);

        // Allow core thread di-timeout (hemat resource saat idle)
        executor.setAllowCoreThreadTimeOut(true);

        executor.initialize();

        LOG.info("Async executor initialized: core={}, max={}, queue={}",
            executor.getCorePoolSize(),
            executor.getMaxPoolSize(),
            executor.getQueueCapacity());

        return executor;
    }

    /**
     * Executor terpisah untuk task berat (laporan, batch processing).
     * Sengaja pool kecil supaya tidak ganggu request handler.
     */
    @Bean(name = "heavyTaskExecutor")
    public Executor heavyTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(4);
        executor.setQueueCapacity(50);
        executor.setThreadNamePrefix("heavy-");
        executor.setKeepAliveSeconds(120);
        executor.setRejectedExecutionHandler(
            new ThreadPoolExecutor.AbortPolicy()  // throw RejectedExecutionException
        );
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(120);  // batch task butuh waktu lebih
        executor.initialize();
        return executor;
    }

    /**
     * Global exception handler — log error dari @Async void method.
     * Async method dengan return Future kasih exception lewat .get(),
     * tapi void method exception-nya disilent tanpa handler.
     */
    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return new LoggingAsyncExceptionHandler();
    }

    static class LoggingAsyncExceptionHandler implements AsyncUncaughtExceptionHandler {
        private static final Logger ERR_LOG = LoggerFactory.getLogger("async-error");

        @Override
        public void handleUncaughtException(Throwable ex, Method method, Object... params) {
            ERR_LOG.error("Async method {} throw exception. Params: {}",
                method.getDeclaringClass().getSimpleName() + "." + method.getName(),
                params,
                ex);
        }
    }
}
// NotifikasiService.java
package id.kodekarawaci.service;

import java.util.concurrent.CompletableFuture;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Service
public class NotifikasiService {
    private static final Logger LOG = LoggerFactory.getLogger(NotifikasiService.class);

    /**
     * Fire-and-forget — pakai default executor.
     * Return type void = exception ditangani LoggingAsyncExceptionHandler.
     */
    @Async
    public void kirimEmailKonfirmasi(Long orderId, String email) {
        LOG.info("Kirim email konfirmasi order={} ke {}", orderId, email);
        // ... actual email send (smtp / sendgrid)
        if (email == null) {
            throw new IllegalArgumentException("Email null");
        }
    }

    /**
     * Async dengan return — caller bisa block / chain.
     */
    @Async
    public CompletableFuture<Long> hitungSkorAffiliateAsync(Long userId) {
        long skor = computeFromDb(userId);
        return CompletableFuture.completedFuture(skor);
    }

    /**
     * Task berat — pakai executor terpisah.
     */
    @Async("heavyTaskExecutor")
    public CompletableFuture<String> generateLaporanPDF(String periode, Long koperasiId) {
        LOG.info("Mulai generate laporan PDF periode={} koperasi={}", periode, koperasiId);
        String pdfPath = generatePdf(periode, koperasiId);  // berat, 5-30 detik
        LOG.info("Laporan PDF selesai: {}", pdfPath);
        return CompletableFuture.completedFuture(pdfPath);
    }

    private long computeFromDb(Long userId) {
        // dummy
        return userId * 100L;
    }

    private String generatePdf(String p, Long k) {
        return "/tmp/laporan-" + k + "-" + p + ".pdf";
    }
}
// Metrics — expose pool state ke Prometheus
package id.kodekarawaci.config;

import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import jakarta.annotation.PostConstruct;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

@Configuration
public class AsyncMetricsConfig {

    @Autowired private MeterRegistry registry;
    @Autowired @Qualifier("applicationTaskExecutor") private ThreadPoolTaskExecutor appExecutor;
    @Autowired @Qualifier("heavyTaskExecutor") private ThreadPoolTaskExecutor heavyExecutor;

    @PostConstruct
    public void registerMetrics() {
        register("app", appExecutor);
        register("heavy", heavyExecutor);
    }

    private void register(String name, ThreadPoolTaskExecutor executor) {
        Gauge.builder("async_executor_active_threads", executor,
                e -> e.getThreadPoolExecutor().getActiveCount())
            .tag("executor", name).register(registry);

        Gauge.builder("async_executor_pool_size", executor,
                e -> e.getThreadPoolExecutor().getPoolSize())
            .tag("executor", name).register(registry);

        Gauge.builder("async_executor_queue_size", executor,
                e -> e.getThreadPoolExecutor().getQueue().size())
            .tag("executor", name).register(registry);

        Gauge.builder("async_executor_completed_tasks", executor,
                e -> e.getThreadPoolExecutor().getCompletedTaskCount())
            .tag("executor", name).register(registry);
    }
}

Pemakaian

// Pemanggilan dari controller / service lain
@Service
public class OrderService {

    private final NotifikasiService notifikasiService;

    public OrderService(NotifikasiService notif) {
        this.notifikasiService = notif;
    }

    public Order createOrder(OrderRequest request) {
        Order saved = saveToDb(request);

        // Fire-and-forget — tidak block response
        notifikasiService.kirimEmailKonfirmasi(saved.getId(), request.getEmail());

        return saved;
    }

    /**
     * Pattern parallel: chain async result.
     */
    public DashboardData buildDashboard(Long koperasiId) throws Exception {
        var skorFuture = notifikasiService.hitungSkorAffiliateAsync(koperasiId);
        var laporanFuture = notifikasiService.generateLaporanPDF("2026-07", koperasiId);

        // Tunggu keduanya
        CompletableFuture.allOf(skorFuture, laporanFuture).get();

        return new DashboardData(skorFuture.get(), laporanFuture.get());
    }
}

record DashboardData(Long skor, String laporanPath) {}
# Cek metric runtime via Prometheus / Actuator
curl http://localhost:8080/actuator/prometheus | grep async_executor

# async_executor_active_threads{executor="app"} 5.0
# async_executor_pool_size{executor="app"} 8.0
# async_executor_queue_size{executor="app"} 12.0
# async_executor_completed_tasks{executor="app"} 154823.0

# Alert kalau queue_size > 80% capacity (saturasi)
# expr: async_executor_queue_size{executor="app"} > 400

Kapan dipakai

  • Async logging / audit yang gak boleh block request.
  • Notification fanout (email, push, SMS) saat order created.
  • Background processing dengan latency budget jelas.
  • Aggregate parallel call ke beberapa service downstream.

Catatan

  • Default SimpleAsyncTaskExecutor BAHAYA — tidak ada pool, thread baru tiap call. Production wajib override.
  • CallerRunsPolicy — saat queue penuh + pool full, caller thread eksekusi task. Natural backpressure, request masuk slow down sendiri.
  • AbortPolicy untuk heavy task — better fail fast dari overload. Caller dapat exception, bisa handle (retry queue, fallback).
  • Queue bounded — unbounded queue (Integer.MAX_VALUE) = memory bomb. Hardcap di 500-1000 reasonable.
  • AsyncUncaughtExceptionHandler wajib — void async method tanpa handler = exception silent. Tidak ada log, mimpi buruk debug.
  • Self-invocation gak jalan — @Async dipanggil dari method same class via this.method() bypass proxy, eksekusi sync. Inject self via @Autowired self.
  • Thread name prefix — saat thread dump, bisa identify mana app-async, mana heavy-async, mana Tomcat.
  • Virtual thread compatible — kalau pakai Java 21, set executor.setVirtualThreads(true) di Spring Boot 3.2+. Override pool sizing tidak relevan.

@Async tidak sebagus reactive untuk extreme throughput, tapi cukup buat 90% use case backend Java. Kalau perlu millions concurrent, baru pertimbangkan reactive (WebFlux) atau virtual thread.

# tags

springasyncthread-poolexecutorjava

Ditulis oleh Asti Larasati · 19 Juli 2026