İçeriğe geç

Java Virtual Threads: Daha Fazla Thread Değil, Daha Doğru Bir Eşzamanlılık Modeli

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 Thread ile 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:

ÖzellikPlatform ThreadVirtual Thread
OS thread ilişkisiYaklaşık 1:1M
Oluşturma maliyetiGörece yüksekÇok düşük
Tipik kullanımUzun ömürlü worker poolHer görev için yeni thread
Bloklayan I/OOS thread’i işgal ederÇoğu durumda carrier’dan ayrılır
StackNative bellekte, görece büyükHeap ü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ığı
HavuzlamaGenellikle gerekirYapılmamalıdır
Programlama modeliSenkronSenkron
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/finally ile 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 ScopedValue değ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:

SenaryoMuhtemel tercih
JDBC ve senkron HTTP kullanan CRUD servisiSpring MVC + Virtual Threads
Çok sayıda bağımsız I/O çağrısıVirtual Threads
CPU yoğun hesaplamaSınırlı platform thread paralelliği
Sürekli veya sonsuz event streamWebFlux / Reactive Streams
Uçtan uca backpressureReactive yaklaşım
Mevcut, çalışan reactive mimariYeniden yazmak için tek başına gerekçe yok
Basit kontrol akışı ve kolay debugging önceliğiVirtual Threads
Karma modelKontrollü 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.

Bir sonraki ne okunmalı?

GraalVM Native Image ile Serverless ve Mikroservislerde Cold Start Sorununu Çözmek

Java; güçlü ekosistemi, olgun araçları ve yüksek çalışma zamanı performansıyla mikroservis geliştirmede en güvenilir seçeneklerden biri. Ancak konu serverless fonksiyonlar, kısa ömürlü container’lar ve…

11 dk okuma süresi
Sonraki yazıyı oku

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir