in

Java 21 Virtual Threads: Spring Boot'ta Gerçek Kazanım Nerede?

Sanal thread'ler CPU'yu hızlandırmaz; bekleyen isteklerin thread tüketmesini bitirir. Spring Boot'ta tek satırla nasıl açıldığını, I/O ağırlıklı hangi senaryolarda ölçülebilir kazanım verdiğini ve pinning, bağlantı havuzu darboğazı, ThreadLocal şişmesi gibi tuzakları anlatıyoruz.

Az sayıda taşıyıcı thread üzerinde çalışan çok sayıda sanal thread'i gösteren şema

Java 21 ile gelen sanal thread'ler (JEP 444), Java dünyasının son on yıldaki en büyük eşzamanlılık değişikliği. Ama pazarlama dili bir şeyi karıştırıyor: sanal thread'ler kodu hızlandırmaz. Yaptıkları şey, bekleyen bir isteğin pahalı bir işletim sistemi thread'ini işgal etmesini bitirmektir. Bu ayrım, hangi uygulamanın gerçekten kazanacağını belirler.

Bu yazıda mekanizmayı, Spring Boot'ta tek satırla nasıl açıldığını, hangi iş yüklerinde ölçülebilir kazanım verdiğini ve üretimde karşılaştığımız beş tuzağı — pinning, bağlantı havuzu darboğazı, ThreadLocal şişmesi, sınırsız eşzamanlılık ve gözlemlenebilirlik — kod örnekleriyle anlatıyoruz.

Mekanizma: taşıyıcı thread'ler ve bekleme anında ayrılma

Klasik modelde her istek bir platform thread'i alır; Tomcat'in varsayılan 200 thread'i dolduğunda yeni istekler kuyrukta bekler. Thread'lerin çoğu o sırada hiçbir iş yapmaz: veritabanı yanıtını, bir HTTP çağrısını ya da yavaş bir istemciyi bekler. Sanal thread bu bekleyişi ucuza çevirir: JVM tarafından yönetilen hafif bir thread, az sayıda taşıyıcı (carrier) platform thread'i üzerinde çalışır ve bloklayan bir I/O çağrısında taşıyıcıdan ayrılır (unmount). Taşıyıcı hemen başka bir sanal thread'i çalıştırır; I/O tamamlanınca bekleyen thread yeniden bağlanır (mount).

Sonuç: yüz binlerce sanal thread aynı anda "bekleyebilir"; bellek maliyeti kilobaytlar mertebesindedir ve yığın ihtiyaç oldukça büyür. CPU'nun kendisi değişmez — sekiz çekirdekli makine yine sekiz şeyi aynı anda hesaplar.

Spring Boot'ta açmak

spring:
  threads:
    virtual:
      enabled: true        # Spring Boot 3.2+ ve Java 21+

Bu tek ayar; Tomcat/Jetty istek işlemeyi, @Async için otomatik yapılandırılan applicationTaskExecutor'ı, zamanlanmış görevleri ve RabbitMQ/Kafka gibi mesaj dinleyici konteynerlerini sanal thread'lere taşır (kapsam Boot sürümüyle genişlemektedir; kendi sürümünüzün notlarını kontrol edin). Kendi başlattığınız işler için de artık havuz değil, görev başına thread modeli geçerlidir:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<Fiyat>> sonuclar = tedarikciler.stream()
        .map(t -> executor.submit(() -> fiyatServisi.sorgula(t)))   // her sorgu kendi sanal thread'inde
        .toList();
    return sonuclar.stream().map(Future::resultNow).toList();
}

Sanal thread'ler havuzlanmaz: newFixedThreadPool(50) ile sanal thread kullanmak, kazanımın tamamını geri verir.

Gerçek kazanım nerede, nerede değil

Senaryo

Kazanım

Neden

Her istekte 2–5 dış HTTP çağrısı yapan API gateway / BFF

Yüksek

Thread'ler zamanının %90'ını beklemekle geçirir; 200 thread sınırı kalkar

Yavaş istemciler, uzun bağlantılar (rapor indirme, büyük yanıt)

Yüksek

Bağlantı açık kaldığı sürece thread tüketilmez

Çok sayıda kuyruk/dinleyici, saniyede binlerce küçük I/O

Orta–yüksek

Dinleyici başına thread maliyeti kalkar

Veritabanı ağırlıklı CRUD (HikariCP 10–20 bağlantı)

Düşük

Darboğaz thread değil bağlantı havuzu; kuyruk yer değiştirir

CPU ağırlıklı işler (hesaplama, sıkıştırma, şifreleme)

Yok

Sanal thread CPU eklemez; çekirdek sayısı belirleyici

Pratik kural: bir isteğin süresinin yarısından fazlası dış sistem beklemesiyse sanal thread'ler ölçülebilir fark yaratır. Değilse yük testi yapmadan "hızlandı" demeyin.

Beş tuzak

1. Pinning: taşıyıcıya yapışan sanal thread

Java 21'de synchronized bloğu içinde bloklayan I/O yapıldığında sanal thread taşıyıcıdan ayrılamaz; taşıyıcı da bloke olur ve kazanım o noktada yok olur. Bu, eski JDBC sürücüleri ve synchronized kullanan kütüphanelerde sık görülür. JDK 24 (JEP 491) bu kısıtı kaldırdı; Java 21'de kalıyorsanız tespit edip düzeltmeniz gerekir:

# Java 21–23: yapışan thread'leri yığın izleriyle logla
java -Djdk.tracePinnedThreads=full -jar uygulama.jar

# Alternatif: JFR olayı
jfr print --events jdk.VirtualThreadPinned kayit.jfr
// synchronized + I/O yerine ReentrantLock: sanal thread taşıyıcıyı bırakabilir
private final ReentrantLock lock = new ReentrantLock();

void onbellegiYenile() {
    lock.lock();
    try { veri = uzakServis.getir(); }      // bloklayan çağrı
    finally { lock.unlock(); }
}

Sürücü ve kütüphane sürümlerini güncellemek çoğu pinning sorununu kendiliğinden çözer; PostgreSQL JDBC, HikariCP ve büyük HTTP istemcileri sanal thread uyumlu sürümler yayınladı.

2. Bağlantı havuzu yeni darboğaz olur

10.000 eşzamanlı istek, 10 bağlantılı bir HikariCP havuzuna geldiğinde 9.990'ı havuzda bekler. Sanal thread'ler veritabanı kapasitesi eklemez; kuyruğu Tomcat'ten havuza taşır. Havuz boyutunu veritabanının kaldırabileceği kadar tutun (çoğu OLTP için çekirdek sayısının küçük katları), transaction'ları kısa tutun ve havuz bekleme süresini (hikaricp.connections.pending) izleyin. Bekleyen sayısı sürekli yüksekse çözüm daha çok thread değil, daha az sorgu ya da önbellektir.

3. ThreadLocal şişmesi

Thread başına önbellek tutan kodlar (biçimlendiriciler, tamponlar, güvenlik bağlamı kopyaları) 200 thread'de zararsızdır; 200.000 sanal thread'de belleği doldurur. Büyük nesneleri ThreadLocal'da tutmayın; Java 25 ile kesinleşen ScopedValue bu ihtiyaç için tasarlandı, Java 21'de ise değeri açıkça parametre olarak geçirmek en güvenli yoldur.

4. Sınırsız eşzamanlılık

Eskiden thread havuzu istemeden de olsa bir fren görevi görürdü. Sanal thread'lerle 50.000 ürün için 50.000 eşzamanlı çağrı başlatmak kolaydır ve karşı sistemi düşürür. Eşzamanlılığı bilinçli sınırlayın:

private final Semaphore tedarikciLimiti = new Semaphore(50);   // aynı anda en çok 50 çağrı

Fiyat sorgula(Tedarikci t) throws InterruptedException {
    tedarikciLimiti.acquire();
    try { return istemci.fiyat(t); }
    finally { tedarikciLimiti.release(); }
}

Resilience4j'nin bulkhead ve rate limiter modülleri aynı işi bildirimsel olarak yapar ve metrik üretir.

5. Gözlemlenebilirlik ve hata ayıklama

Klasik thread dump yüz binlerce sanal thread'i listeleyemez; jcmd <pid> Thread.dump_to_file -format=json dump.json sanal thread'leri de içeren yeni biçimi üretir. "Aktif thread sayısı" metriği anlamını yitirir; onun yerine istek gecikmesi (p95/p99), havuz bekleme süreleri ve pinning olaylarını izleyin.

Geçiş planı

  1. Java 21 (tercihen 25) ve Spring Boot 3.2+ sürümüne geçin; JDBC sürücüsü, HTTP istemcisi ve önbellek kütüphanelerini güncelleyin.

  2. Test ortamında spring.threads.virtual.enabled=true açın; jdk.tracePinnedThreads ile pinning taraması yapın.

  3. Gerçekçi bir yük testi koşun: aynı senaryo, aynı veri, önce/sonra p99 ve verim.

  4. Havuz bekleme metriklerini ve dış sistem hata oranlarını izleyerek üretime kademeli alın.

  5. Ölçülebilir kazanım yoksa açık bırakmanın da zararı yoktur; ama "hızlandık" iddiasını ölçüme dayandırın.

Sık sorulan sorular

WebFlux / reaktif programlamaya artık gerek yok mu?

Yüksek eşzamanlı I/O için sanal thread'ler bloklayan, okunması kolay kodla aynı verimi sunar; yeni projelerde reaktif modeli yalnızca bu gerekçeyle seçmek artık zor. Reaktif akış semantiği (backpressure, akış işleme) ihtiyacı varsa WebFlux hâlâ geçerlidir.

Java 17'de sanal thread kullanabilir miyiz?

Hayır. Java 19 ve 20'de önizleme, 21'de kesin özellik. Kurumsal projelerde Java 21 LTS ya da 25 LTS hedeflenmelidir.

Mevcut kod değişecek mi?

Çoğunlukla hayır; bloklayan kod aynen çalışır. Değişmesi gerekenler synchronized içinde I/O yapan bölümler, elle yönetilen thread havuzları ve büyük ThreadLocal kullanımlarıdır.

Sonuç

Sanal thread'ler, I/O beklemesinin thread maliyetini ortadan kaldırır; kazanım dış sistem çağrısı yoğun uygulamalarda büyük, veritabanı havuzuyla sınırlı CRUD uygulamalarında küçüktür. Pinning'i tarayan, havuz bekleme süresini izleyen ve eşzamanlılığı bilinçli sınırlayan bir ekip, tek satırlık ayarı gerçek bir verim artışına dönüştürür.

Uygulamanızın Java 21/25'e geçişini ve yük testine dayalı performans planını birlikte kurgulamak için yazılım tasarımı ve mimari ile bakım ve destek hizmetlerimize göz atabilirsiniz.