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