karawaci.kode

← Semua snippet

Java Lanjut Performance

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 ke ReentrantLock untuk full benefit.

# tags

javaspring-bootvirtual-threadsloomperformance

Ditulis oleh Asti Larasati · 5 Juli 2026