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.
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ı
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.
Test ortamında
spring.threads.virtual.enabled=trueaçın;jdk.tracePinnedThreadsile pinning taraması yapın.Gerçekçi bir yük testi koşun: aynı senaryo, aynı veri, önce/sonra p99 ve verim.
Havuz bekleme metriklerini ve dış sistem hata oranlarını izleyerek üretime kademeli alın.
Ö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.