Spring Boot virtual thread configuration
Aktifkan virtual thread di Spring Boot 3.2+ — handle 10rb concurrent request tanpa thread pool exhaustion. JDK 21 Project Loom.
Dipublikasikan 5 Juli 2026
JDK 21 bawa Project Loom — virtual thread yang lightweight. 1 juta virtual thread = lebih ringan dari 1000 platform thread. Untuk Spring Boot app yang biasanya bottleneck di Tomcat thread pool (default 200), enable virtual thread bikin throughput naik drastis. Snippet ini config production-ready.
Kode
// application.yml — enable virtual threads
// spring.threads.virtual.enabled: true
// (configure via Java instead of yml)
// VirtualThreadConfig.java
package id.kodekarawaci.config;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.boot.web.embedded.tomcat.TomcatProtocolHandlerCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.SimpleAsyncTaskScheduler;
import org.springframework.scheduling.support.TaskUtils;
import org.springframework.core.task.AsyncTaskExecutor;
import org.springframework.core.task.support.TaskExecutorAdapter;
@Configuration
@EnableAsync
public class VirtualThreadConfig {
/**
* Customize Tomcat: setiap incoming request handle by virtual thread.
* Tanpa ini, Tomcat tetap pakai platform thread pool.
*/
@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
return protocolHandler -> {
ThreadFactory factory = Thread.ofVirtual()
.name("tomcat-vt-", 0)
.factory();
protocolHandler.setExecutor(Executors.newThreadPerTaskExecutor(factory));
};
}
/**
* @Async executor pakai virtual thread.
* Untuk task background yang IO-bound.
*/
@Bean(name = "applicationTaskExecutor")
public AsyncTaskExecutor applicationTaskExecutor() {
AtomicLong counter = new AtomicLong();
ThreadFactory factory = Thread.ofVirtual()
.name("app-vt-async-", counter.get())
.factory();
return new TaskExecutorAdapter(Executors.newThreadPerTaskExecutor(factory));
}
/**
* @Scheduled task executor — juga virtual thread.
*/
@Bean(name = "taskScheduler")
public SimpleAsyncTaskScheduler taskScheduler() {
SimpleAsyncTaskScheduler scheduler = new SimpleAsyncTaskScheduler();
scheduler.setVirtualThreads(true);
scheduler.setThreadNamePrefix("app-vt-sched-");
return scheduler;
}
}
// Sample service yang benefit dari virtual thread
// OrderService.java
package id.kodekarawaci.service;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final UserClient userClient;
private final InventoryClient inventoryClient;
private final ShippingClient shippingClient;
public OrderService(UserClient u, InventoryClient i, ShippingClient s) {
this.userClient = u;
this.inventoryClient = i;
this.shippingClient = s;
}
/**
* Aggregator: panggil 3 service paralel.
* Sebelum virtual thread, ini butuh ExecutorService dengan tuning hati-hati.
* Sekarang? Spawn 3 thread, blocking call masing-masing.
*/
public OrderDetail aggregateOrderDetail(long orderId) throws InterruptedException {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// Spawn 3 virtual thread paralel
var userTask = scope.fork(() -> userClient.fetchUser(orderId));
var inventoryTask = scope.fork(() -> inventoryClient.fetchStock(orderId));
var shippingTask = scope.fork(() -> shippingClient.estimateShipping(orderId));
scope.join(); // tunggu semua selesai
scope.throwIfFailed(); // kalau ada yang error, propagate
return new OrderDetail(
userTask.get(),
inventoryTask.get(),
shippingTask.get()
);
} catch (Exception e) {
throw new RuntimeException("Aggregate fail", e);
}
}
@Async // Pakai applicationTaskExecutor (virtual thread)
public CompletableFuture<Void> sendNotificationsAsync(List<Long> userIds) {
userIds.forEach(id -> {
// Setiap call blocking — tapi virtual thread, fine
notificationClient.sendEmail(id, "Pesanan dikonfirmasi");
notificationClient.sendPush(id, "Pesanan dikonfirmasi");
});
return CompletableFuture.completedFuture(null);
}
}
record OrderDetail(User user, Inventory inventory, Shipping shipping) {}
// Dummy clients
interface UserClient { User fetchUser(long id); }
interface InventoryClient { Inventory fetchStock(long id); }
interface ShippingClient { Shipping estimateShipping(long id); }
record User(long id, String email) {}
record Inventory(long produkId, int stok) {}
record Shipping(String kurir, int ongkir) {}
# application.yml — flag enable + tuning
spring:
threads:
virtual:
enabled: true # Enable virtual thread untuk @Async, scheduler, dll
# JDBC connection pool tetap penting — virtual thread tidak bantu DB connection
datasource:
hikari:
maximum-pool-size: 50 # Sesuaikan DB capacity, bukan thread count
minimum-idle: 10
connection-timeout: 5000
server:
tomcat:
threads:
max: 200 # Ignored kalau executor di-override ke virtual thread
Pemakaian
// Controller — kode tidak berubah, otomatis dapat virtual thread
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
@GetMapping("/{id}")
public OrderDetail getOrder(@PathVariable long id) throws InterruptedException {
// Method ini dipanggil di virtual thread Tomcat.
// aggregateOrderDetail spawn 3 virtual thread anak.
// Semua blocking call jadi non-issue.
return orderService.aggregateOrderDetail(id);
}
}
# Test load — 10rb concurrent request
hey -n 50000 -c 10000 http://localhost:8080/api/orders/123
# Sebelum virtual thread (Tomcat pool 200):
# Throughput: 800 req/s
# p99 latency: 12.4s (kena thread pool queue)
# Setelah virtual thread:
# Throughput: 4200 req/s
# p99 latency: 480ms (limit jadi DB connection)
Kapan dipakai
- Spring Boot REST API yang IO-bound (banyak call ke DB / downstream).
- Aggregator service yang fan-out paralel ke beberapa backend.
- Long-polling endpoint yang banyak idle.
- WebSocket server (banyak koneksi idle).
Catatan
- Pinning — virtual thread “pin” ke platform thread kalau ada synchronized block, native call, atau ThreadLocal-heavy code. Pinned = lose benefit. Cek pakai
-Djdk.tracePinnedThreads=full. - DB connection pool tetap critical — HikariCP pool 50, jutaan virtual thread tetap antri 50 connection. Tuning DB connection sama pentingnya.
- Don’t pool virtual threads — Anti-pattern. Virtual thread cheap, bikin baru per task. Pool justru hilangkan benefit.
- ThreadLocal mahal — virtual thread spawn jutaan kali, ThreadLocal init di setiap thread = memory bloat. Pakai ScopedValue (preview) di Java 21+.
- CPU-bound tidak dapat speedup — virtual thread bantu IO concurrency, bukan CPU parallelism. Pakai parallel stream / ForkJoinPool untuk CPU work.
- Library compatibility — JDBC driver lama bisa pinning. Pakai versi terbaru Postgres JDBC, OkHttp, dll yang sudah Loom-friendly.
Jika app banyak pakai
synchronized(legacy code), profile dulu — virtual thread bisa lebih lambat karena pinning. Migrate keReentrantLockuntuk full benefit.
# tags
javaspring-bootvirtual-threadsloomperformance
Ditulis oleh Asti Larasati · 5 Juli 2026