Java sunucu uygulamalarında yıllardır aynı temel gerilimle yaşadık: Senkron, bloklayan kod kolay anlaşılırdı; fakat yüksek eşzamanlılık altında yeterince ölçeklenmezdi. Non-blocking ve reactive kod ise donanımı verimli kullanabiliyor, ancak uygulamanın kontrol akışını daha karmaşık hâle getiriyordu.
Java 21 ile kalıcı hâle gelen Virtual Threads, bu denklemi değiştiriyor. Amaç, bloklayan kodu ortadan kaldırmak değil; bloklayan bir işin bekleme süresince pahalı bir işletim sistemi thread’ini işgal etmesini önlemek.
Bu nedenle Virtual Threads’i yalnızca “daha hafif thread” şeklinde tanımlamak eksik kalır. Asıl mimari değişiklik şudur:
Uygulamanın eşzamanlılık birimi yeniden
Threadile temsil edilebilir; fakat her Java thread’i için ayrı bir işletim sistemi thread’i ayırmak gerekmez.
Platform Thread ve Virtual Thread arasındaki fark
Geleneksel Java thread’leri, yani platform thread’leri, işletim sistemi thread’leri üzerine kuruludur. Basitleştirilmiş olarak ilişki 1:1’dir:
Java Platform Thread ─── OS Thread
Bir platform thread ağdan veri, veritabanından yanıt veya dosya sisteminden sonuç beklerken işletim sistemi thread’i de ona bağlı kalır. Thread pool kullanmak thread oluşturma maliyetini azaltır; fakat aynı anda çalışabilecek iş sayısını havuz büyüklüğüyle sınırlar.
Virtual Thread mimarisinde ise ilişki M biçimindedir:
Çok sayıda Virtual Thread
│
▼
Az sayıda Carrier Platform Thread
│
▼
OS Thread’leri
Virtual thread çalışacağı zaman JVM tarafından bir platform thread’e, yani carrier thread’e bağlanır. Desteklenen bir bloklayan I/O çağrısına geldiğinde virtual thread askıya alınabilir ve carrier serbest bırakılabilir. Aynı carrier bu sırada başka bir virtual thread’i çalıştırır.
I/O tamamlandığında virtual thread yeniden çalıştırılabilir; üstelik daha önce kullandığı carrier’a dönmek zorunda değildir. Uygulama açısından ise bu geçiş görünmezdir:
var response = httpClient.send(request, BodyHandlers.ofString());
Kod hâlâ senkron görünür. Metot sonucu bekler, normal try/catch kullanılabilir ve stack trace istek akışını korur. Fakat bekleyen virtual thread uygun durumda carrier’ı işgal etmez.
Platform ve virtual thread’leri şöyle karşılaştırabiliriz:
| Özellik | Platform Thread | Virtual Thread |
|---|---|---|
| OS thread ilişkisi | Yaklaşık 1:1 | M |
| Oluşturma maliyeti | Görece yüksek | Çok düşük |
| Tipik kullanım | Uzun ömürlü worker pool | Her görev için yeni thread |
| Bloklayan I/O | OS thread’i işgal eder | Çoğu durumda carrier’dan ayrılır |
| Stack | Native bellekte, görece büyük | Heap üzerinde büyüyüp küçülen stack chunk’ları |
| Uygun iş yükü | CPU ve sınırlı eşzamanlılık | Çok yüksek I/O eşzamanlılığı |
| Havuzlama | Genellikle gerekir | Yapılmamalıdır |
| Programlama modeli | Senkron | Senkron |
| Temel kazanç | Kontrollü kaynak kullanımı | Yüksek throughput ve basit kontrol akışı |
Virtual Threads Java 21’de JEP 444 ile kalıcı özellik hâline gelmiştir. OpenJDK’nın temel hedefi de thread-per-request biçimindeki sunucu uygulamalarının daha verimli ölçeklenmesini sağlamaktır. JEP 444: Virtual Threads
Thread-Per-Request modeline neden geri dönüyoruz?
Thread-per-request yaklaşımında her istek başından sonuna kadar tek bir mantıksal thread üzerinde ilerler:
HTTP isteği
→ kimlik doğrulama
→ veritabanı sorgusu
→ uzak servis çağrısı
→ yanıt üretimi
Bu modelin güçlü tarafı yapısal sadeliğidir:
- Kontrol akışı yukarıdan aşağı okunur.
- Yerel değişkenler istek durumunu doğal biçimde taşır.
- Hatalar
try/catch/finallyile yönetilir. - Transaction ve güvenlik sınırları daha kolay görülür.
- Debugger ile adım adım ilerlenebilir.
- Stack trace, iş çağrı zincirini doğrudan gösterir.
Sorun modelin kendisi değil, modelin platform thread’leriyle uygulanmasıydı.
Little Yasası’na göre sistemde aynı anda bulunan ortalama iş sayısı yaklaşık olarak throughput ile ortalama gecikmenin çarpımıdır:
Concurrency ≈ Throughput × Latency
Örneğin ortalama 200 ms süren bir işlemde saniyede 5.000 istek karşılamak istiyorsak sistemin yaklaşık 1.000 isteği eşzamanlı taşıması gerekir. Her istek bir platform thread tüketiyorsa thread sayısı kısa sürede temel darboğaza dönüşebilir.
Reactive ve callback tabanlı sistemler bu problemi, bekleme sırasında thread’i serbest bırakarak çözdü. Bunun karşılığında uygulamanın mantıksal çağrı zinciri callback, Mono, Flux veya CompletableFuture aşamalarına bölündü.
Virtual Threads aynı kaynak sorununa farklı bir katmanda çözüm getirir:
- Reactive yaklaşım, thread paylaşımını uygulama programlama modeline taşır.
- Virtual Threads ise thread paylaşımını JVM zamanlayıcısının sorumluluğuna bırakır.
Böylece “bir istek, bir thread” modeli yeniden ekonomik hâle gelir. Buradaki thread artık pahalı bir OS kaynağı değil, uygulamadaki bir görevi temsil eden hafif bir Java nesnesidir.
Önemli bir sonuç da şudur: Virtual Threads tek bir isteği kendiliğinden hızlandırmaz. Esas fayda, çok sayıda bekleyen işi daha az platform thread ile taşıyarak toplam throughput’u artırmaktır. I/O gecikmesi yine aynı gecikmedir.
Reactive programming artık gereksiz mi?
Kısa cevap: Hayır. Fakat reactive programlamanın seçim gerekçesi değişmiştir.
Reactive stack’lerin yaygınlaşmasındaki önemli nedenlerden biri, çok sayıda bloklayan isteği sınırlı platform thread havuzuyla taşımanın zor olmasıydı. Virtual Threads bu teknik zorunluluğu önemli ölçüde azaltır.
Tipik bir servis aşağıdaki özelliklere sahipse Spring MVC ve Virtual Threads güçlü bir seçenek olabilir:
- Request/response ağırlıklı çalışma
- JDBC gibi bloklayan istemciler
- Senkron HTTP çağrıları
- Kısa veya orta uzunlukta iş akışları
- Ekibin imperative Java konusunda daha deneyimli olması
- Kolay debugging ve okunabilir stack trace beklentisi
Spring Boot 3.2’den itibaren Java 21 üzerinde virtual thread desteği şu ayarla etkinleştirilebilir:
spring.threads.virtual.enabled=true
Spring ekibi de Spring MVC ile Virtual Threads kombinasyonunu geleneksel web uygulamaları için yalın bir seçenek olarak konumlandırmaktadır. Kaynak: Spring: Runtime Efficiency with Spring
Bununla birlikte WebFlux ve Reactive Streams hâlâ belirgin avantajlara sahiptir:
- Sonsuz veya uzun süreli veri akışları
- Backpressure’ın uçtan uca ifade edilmesi
- Server-Sent Events ve streaming response’lar
- Çok aşamalı event pipeline’ları
- Deklaratif dönüşüm ve birleştirme operasyonları
- Reactive sürücüler üzerine kurulmuş mevcut sistemler
- Akışın talep, iptal ve hata sinyallerinin bir protokol olarak modellenmesi
Virtual Threads bir zamanlama mekanizmasıdır; backpressure protokolü değildir. Tüketici üreticiden yavaşsa virtual thread kullanmak tek başına kuyruk büyümesini veya bellek baskısını önlemez.
Dolayısıyla doğru soru “WebFlux mü, Virtual Threads mü?” değildir. Daha doğru karar çerçevesi şöyledir:
- Yüksek sayıda bağımsız, bloklayan request/response işi için imperative kod ve Virtual Threads genellikle daha basittir.
- Akış semantiği, backpressure veya yoğun reaktif kompozisyon gerekiyorsa WebFlux değerini korur.
- Mevcut reactive sistem yalnızca virtual thread geldi diye yeniden yazılmamalıdır.
- Yeni bir CRUD servisi de sırf yüksek eşzamanlılık ihtimali var diye otomatik olarak reactive tasarlanmamalıdır.
I/O yoğunluklu bir örnek
Aşağıdaki servis, çok sayıda sipariş için iki uzak sistemden bloklayan HTTP çağrıları yapıyor. Her sipariş ayrı bir virtual thread üzerinde işleniyor.
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
public final class OrderAggregator {
private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2))
.build();
/*
* Virtual thread sayısını değil, kapasitesi sınırlı olan uzak servise
* yapılan eşzamanlı çağrı sayısını sınırlandırıyoruz.
*/
private static final Semaphore REMOTE_SERVICE_LIMIT =
new Semaphore(100);
public static void main(String[] args) throws Exception {
List<Long> orderIds = java.util.stream.LongStream
.rangeClosed(1, 1_000)
.boxed()
.toList();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Callable<OrderView>> tasks = orderIds.stream()
.<Callable<OrderView>>map(orderId ->
() -> loadOrderView(orderId))
.toList();
var futures = executor.invokeAll(tasks);
for (var future : futures) {
OrderView view = future.get();
System.out.println(view);
}
}
}
private static OrderView loadOrderView(long orderId)
throws IOException, InterruptedException {
String orderJson = get(
"https://orders.example.com/orders/" + orderId);
String shipmentJson = get(
"https://shipping.example.com/shipments/" + orderId);
return new OrderView(orderId, orderJson, shipmentJson);
}
private static String get(String url)
throws IOException, InterruptedException {
REMOTE_SERVICE_LIMIT.acquire();
try {
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(3))
.GET()
.build();
return HTTP_CLIENT.send(
request,
HttpResponse.BodyHandlers.ofString()
).body();
} finally {
REMOTE_SERVICE_LIMIT.release();
}
}
record OrderView(
long orderId,
String orderJson,
String shipmentJson
) {}
}
Kodun önemli tarafı, çağrıların bloklayan HttpClient.send() kullanmasına rağmen her görev için ayrı bir virtual thread oluşturulabilmesidir. I/O bekleyen görevler uygun olduğunda carrier thread’i serbest bırakır.
Burada executor bir virtual thread havuzu değildir. newVirtualThreadPerTaskExecutor() her görev için yeni bir virtual thread oluşturur. Virtual thread’ler ucuz olduğundan yeniden kullanılmaları veya sabit boyutlu bir havuzda tutulmaları amaçlanmaz.
Buna karşılık uzak servis bağlantıları sınırsız değildir. Bu nedenle Semaphore, iş parçacığı sayısını değil kıt kaynağa erişimi sınırlar. OpenJDK da virtual thread’lerin havuzlanmamasını ve sınırlı kaynakların semaphore gibi mekanizmalarla korunmasını önerir. Oracle Virtual Threads Adoption Guide
Üretim kodunda ayrıca timeout, retry bütçesi, circuit breaker, rate limit ve iptal davranışı açık biçimde tasarlanmalıdır. Bir milyon virtual thread oluşturabilmek, veritabanının bir milyon bağlantıyı veya uzak servisin bir milyon eşzamanlı isteği kaldırabileceği anlamına gelmez.
Kaçınılması gereken antipattern’ler
1. Virtual thread’leri sabit boyutlu havuzda toplamak
Yanlış yaklaşım:
var executor = Executors.newFixedThreadPool(
100,
Thread.ofVirtual().factory()
);
Bu tasarım virtual thread’in temel avantajını yapay olarak sınırlar. Bir göreve bir virtual thread oluşturulmalıdır:
var executor = Executors.newVirtualThreadPerTaskExecutor();
Eşzamanlılık sınırı gerekiyorsa thread sayısı yerine veritabanı, haricî API veya başka bir kıt kaynak doğrudan sınırlandırılmalıdır.
2. Uzun süren CPU işlerini sınırsız virtual thread’e dağıtmak
Virtual Threads CPU kapasitesini artırmaz. Binlerce CPU-bound görevi virtual thread üzerinde başlatmak çekirdek sayısını değiştirmez; yalnızca zamanlama baskısı ve kuyruk oluşturur.
CPU-bound işlerde çekirdek sayısına göre boyutlandırılmış executor, ForkJoinPool veya uygun paralel algoritma çoğu zaman daha doğrudur.
I/O-bound görev → Virtual Threads için güçlü aday
CPU-bound görev → Sınırlı paralellik gerekir
3. JDK 21–23 üzerinde bloklayan I/O’yu synchronized içinde yapmak
Java 21–23’te virtual thread, synchronized bir bölüm içinde bloklandığında carrier thread’e pinlenebilir:
synchronized (lock) {
callSlowRemoteService();
}
Bu durum programı hatalı yapmaz; fakat uzun ve sık gerçekleşiyorsa ölçeklenebilirliği düşürür. Bu JDK sürümlerinde gerekli noktalarda ReentrantLock kullanılabilir veya I/O kritik bölümün dışına taşınabilir:
lock.lock();
try {
updateSharedInMemoryState();
} finally {
lock.unlock();
}
callSlowRemoteService();
Ancak bu tavsiye sürüme bağlıdır. JDK 24’te gelen JEP 491 ile synchronized altında bekleyen virtual thread’lerin carrier’ı serbest bırakabilmesi sağlandı. Dolayısıyla JDK 24+ üzerinde sırf virtual thread kullanılıyor diye bütün synchronized bloklarını mekanik olarak ReentrantLock ile değiştirmek doğru değildir. Oracle JDK 24 değişiklikleri
Yine de sürümden bağımsız tasarım ilkesi geçerlidir: Ağ veya veritabanı çağrısı boyunca lock tutmak contention, uzun kuyruklar ve deadlock riski yaratabilir. Ayrıca native veya foreign-function çağrıları güncel JDK’larda hâlâ pinning oluşturabilir.
4. ThreadLocal içinde pahalı nesne cache’lemek
Virtual thread’ler ThreadLocal destekler. Sorun API’nin çalışmaması değil, thread sayısının artık çok büyük olabilmesidir:
private static final ThreadLocal<ExpensiveParser> PARSER =
ThreadLocal.withInitial(ExpensiveParser::new);
On binlerce veya yüz binlerce virtual thread, aynı sayıda pahalı nesne örneği oluşturabilir. Platform thread havuzunda makul görünen “thread başına cache” yaklaşımı virtual thread mimarisinde ciddi bellek tüketimine dönüşebilir.
Daha güvenli seçenekler şunlardır:
- Immutable ve thread-safe nesneleri paylaşmak
- Gerçekten sınırlı kaynaklar için açık bir pool kullanmak
- Request context’i mümkünse metot parametreleriyle taşımak
- Kullanılan JDK sürümünün durumuna göre
ScopedValuedeğerlendirmek - Framework’lerin context propagation davranışını test etmek
Correlation ID veya güvenlik bağlamı gibi küçük request-scoped veriler ThreadLocal ile çalışabilir. Ancak bellek maliyeti, yaşam süresi, temizleme davranışı ve InheritableThreadLocal ile istemsiz veri aktarımı özellikle incelenmelidir.
5. Sınırsız dış kaynak tüketimini ölçeklenebilirlik sanmak
Virtual thread sayısı arttığında darboğaz ortadan kalkmaz; başka bir katmana taşınır:
- JDBC connection pool
- HTTP connection pool
- Uzak servis kapasitesi
- Rate limit
- Heap
- Dosya tanıtıcıları
- CPU
- Kuyruklar
Bu nedenle virtual thread geçişi mutlaka bulkhead, timeout ve yük testi stratejisiyle birlikte ele alınmalıdır.
6. Yalnızca ortalama latency’ye bakmak
Virtual Threads’e geçiş değerlendirilirken şu metrikler birlikte izlenmelidir:
- Throughput
- p95 ve p99 latency
- Carrier thread kullanımı
- CPU ve heap tüketimi
- GC davranışı
- Bağlantı havuzu bekleme süreleri
- Uzak servis hata oranları
- Virtual thread pinning olayları
- Kuyruk uzunlukları
Java Flight Recorder, özellikle pinning ve virtual thread yaşam döngüsüyle ilgili olayları gözlemlemek için kullanılabilir. Java 21 dokümantasyonu jdk.VirtualThreadPinned olayını ve jdk.tracePinnedThreads seçeneğini açıklar. JDK 24+ değerlendirmelerinde ise monitor pinning davranışının değiştiği unutulmamalıdır.
Mimari karar özeti
Virtual Threads, reactive programlamanın yerini alan evrensel bir teknoloji değildir. Daha çok, reactive programlamanın zorunlu hâle gelmesine neden olan thread kıtlığını JVM seviyesinde azaltır.
İyi bir başlangıç matrisi şöyledir:
| Senaryo | Muhtemel tercih |
| JDBC ve senkron HTTP kullanan CRUD servisi | Spring MVC + Virtual Threads |
| Çok sayıda bağımsız I/O çağrısı | Virtual Threads |
| CPU yoğun hesaplama | Sınırlı platform thread paralelliği |
| Sürekli veya sonsuz event stream | WebFlux / Reactive Streams |
| Uçtan uca backpressure | Reactive yaklaşım |
| Mevcut, çalışan reactive mimari | Yeniden yazmak için tek başına gerekçe yok |
| Basit kontrol akışı ve kolay debugging önceliği | Virtual Threads |
| Karma model | Kontrollü biçimde MVC, virtual thread ve reactive istemcilerin birlikte kullanımı |
Sonuç
Project Loom’un en önemli katkısı “milyonlarca thread oluşturabilmek” değildir. Daha temel katkısı, Java’daki mantıksal görev ile işletim sistemi thread’i arasındaki zorunlu 1:1 ilişkiyi kaldırmasıdır.
Bu değişiklik sayesinde:
- Thread-per-request modeli yeniden ölçeklenebilir hâle gelir.
- Bloklayan ve okunabilir Java kodu yüksek I/O eşzamanlılığı taşıyabilir.
- Stack trace, debugging ve hata yönetimi doğal yapısını korur.
- Reactive programlama teknik bir zorunluluk olmaktan çıkarak bilinçli bir mimari seçeneğe dönüşür.
Fakat fiziksel kaynak sınırları ortadan kalkmaz. Virtual thread’ler sınırsız veritabanı bağlantısı, sınırsız CPU veya sınırsız uzak servis kapasitesi sağlamaz. Başarılı bir geçiş; thread sayısını büyütmekten çok, darboğazları doğru yerde sınırlandırmayı, kullanılan kütüphaneleri doğrulamayı ve sistemi gerçek yük altında ölçmeyi gerektirir.
Doğru zihinsel model şudur:
Virtual Threads, bloklamayı ucuzlatır; kaynakları sınırsızlaştırmaz.