Java Concurrency

Virtual Thread'ler: Java 21'den Java 24'e carrier thread meselesi

Thread kavramını hiç bilmeyen biri için en baştan başlayıp, Java'da thread nasıl kullanılır, virtual thread neyi değiştirir, carrier thread neden bloklanır ve Java 24 ile ne iyileşti adım adım.

Tasks
Request-1 Request-2 Request-3 Request-10k
Virtual
VT-1 VT-2 VT-3 VT-...
Carrier
OS-1 OS-2 OS-3
Risk
Pinned carrier Queue grows

1. Eşzamanlı programlamanın temeli: thread, process ve çalışma birimi

Program dediğimiz şey aslında bilgisayara verilen işlerin toplamı. Mesela bir web sunucusu aynı anda kullanıcıdan istek alır, veritabanına gider, dosya okur, başka bir servise HTTP isteği atar ve cevap üretir. Thread, bu işlerden birini yürüten çalışma hattıdır.

Basit düşünelim: Bir restoran var. Sipariş almak, yemeği hazırlamak ve hesabı kapatmak farklı işler. Tek çalışan varsa herkes sırada bekler. Birden fazla çalışan varsa işler paralel yürür. Thread de program içindeki çalışan gibidir.

Process

Uygulamanın kendisi. Belleği, kaynakları ve çalışma alanı vardır.

Thread

Process içindeki çalışma yolu. Aynı process içinde birden fazla thread olabilir.

En kısa tanım

Thread, programın aynı anda birden fazla işi yürütmesini sağlayan çalışma birimidir.

Concurrency Birden fazla işin aynı zaman aralığında ilerlemesidir. Tek CPU çekirdeğinde bile işler sırayla küçük parçalara bölünüp ilerleyebilir.
Parallelism Birden fazla işin gerçekten aynı anda, farklı CPU çekirdeklerinde çalışmasıdır.
Blocking Thread'in bir cevabı beklerken ilerleyememesidir. Database, HTTP veya dosya I/O sırasında sık görülür.

2. Java'da platform thread modeli: Thread sınıfı, lifecycle ve thread pool kullanımı

Java'da yıllardır kullandığımız klasik thread modeli platform thread modelidir. Platform thread, Java tarafındaki `Thread` nesnesinin işletim sistemi thread'iyle eşleşmesi demektir. Yani pahalı bir kaynaktır: stack belleği ayırır, oluşturması maliyetlidir ve sayısı sınırlıdır.

Platform threadJava
public class PlatformThreadExample {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            System.out.println("Bu iş ayrı bir platform thread üzerinde çalışır");
        });

        thread.start();
        thread.join();
    }
}
Thread Java'nın temel çalışma hattı sınıfıdır. Buradaki `new Thread(...)` işletim sistemi tarafından yönetilen bir platform thread oluşturur. `Thread` sınıfı `java.lang` paketindedir; bu yüzden ayrıca import yazmana gerek yoktur.
start() thread'i gerçekten başlatır. Sadece `run()` çağırırsan yeni thread açılmaz, kod mevcut thread üzerinde normal method gibi çalışır.
join() ana thread'in diğer thread bitene kadar beklemesini sağlar. Örneklerde sonucu görmek ve programın hemen kapanmasını engellemek için kullanılır.

Daha gerçek hayatta her istek için elle `new Thread()` açmayız. Bunun yerine thread pool kullanırız. Pool, sınırlı sayıda thread tutar ve gelen işleri bu thread'lere dağıtır.

Thread poolJava
import java.util.concurrent.Executors;

public class ThreadPoolExample {
    public static void main(String[] args) {
        try (var executor = Executors.newFixedThreadPool(100)) {
            for (int i = 0; i < 1000; i++) {
                executor.submit(() -> {
                    callDatabase();
                    return "ok";
                });
            }
        }
    }
}
import java.util.concurrent.Executors; thread pool ve executor factory methodlarını kullanmak için gerekir.
Executors.newFixedThreadPool(100) en fazla 100 platform thread çalıştırır. 101. iş gelirse sırada bekler.
executor.submit(...) işi pool'a verir. Pool boş thread bulursa çalıştırır, yoksa kuyruğa alır.
callDatabase() burada temsili bir methoddur. Gerçek projede bu satır JDBC, Hibernate, JPA repository veya HTTP client çağrısı olabilir.
Problem nerede?

Thread pool'daki 100 thread'in tamamı veritabanı cevabı bekliyorsa, CPU boş olsa bile yeni işler sırada kalır. Çünkü thread'ler çalışmıyor gibi görünse de bekleme sırasında tutulur.

3. Virtual thread modeli: JVM tarafından yönetilen hafif thread yaklaşımı

Virtual thread, Java'nın JDK tarafında yönettiği hafif thread modelidir. Kod yazarken yine thread gibi düşünürsün: iş başlar, bekler, devam eder, biter. Fakat işletim sistemi tarafında her virtual thread için ayrı bir OS thread tutulmaz.

Bu özellikle backend için büyük farktır. Çünkü backend işlerinin çoğu CPU hesabı değil, I/O bekleme işidir: database, HTTP, dosya, queue, cache. Virtual thread beklemeye girince JVM onu askıya alabilir ve alttaki gerçek thread'i başka işe verebilir.

Virtual thread çalışma akışı

1

Request gelir

2

Virtual thread açılır

3

DB beklenir

4

Carrier boşa çıkar

5

Cevap dönülür

Virtual thread ile task çalıştırmaJava 21+
import java.time.Duration;
import java.util.concurrent.Executors;

public class VirtualThreadExecutorExample {
    public static void main(String[] args) {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 10_000; i++) {
                executor.submit(() -> {
                    Thread.sleep(Duration.ofMillis(200));
                    return "ok";
                });
            }
        }
    }
}
import java.time.Duration; bekleme süresini okunabilir yazmak için kullanılır. `Duration.ofMillis(200)` ifadesi 200 milisaniyelik beklemeyi temsil eder.
Executors.newVirtualThreadPerTaskExecutor() her task için yeni bir virtual thread açar. Platform thread pool'daki gibi 100 thread sınırı koymaz; JVM virtual thread'leri carrier thread'ler üzerinde planlar.
try-with-resources executor'ın kapanmasını garanti eder. Java 21'de executor `AutoCloseable` olduğu için bu kullanım temiz ve güvenlidir.
Tek virtual thread oluşturmaJava 21+
public class SingleVirtualThreadExample {
    public static void main(String[] args) throws InterruptedException {
        Thread worker = Thread.startVirtualThread(() -> {
            System.out.println("Bu kod virtual thread üzerinde çalışıyor");
        });

        worker.join();
    }
}
Thread.startVirtualThread(...) tek seferlik, basit işler için pratik bir API'dir. Bir web request modeli kuruyorsan genelde executor yaklaşımı daha düzenlidir.
worker.join() burada sadece demo için var. Ana thread beklemezse program virtual thread çıktısını görmeden bitebilir.

Platform thread ile virtual thread arasındaki davranış farkı

  • Platform thread: OS thread'e bağlıdır, pahalıdır, sayısı sınırlıdır.
  • Virtual thread: JVM tarafından yönetilir, hafiftir, çok yüksek sayılara çıkabilir.
  • Platform thread: uzun CPU işleri için hâlâ doğrudur.
  • Virtual thread: çok sayıda beklemeli backend işi için çok uygundur.

4. Carrier thread, mounting/unmounting ve pinning probleminin mekanizması

Virtual thread kendi başına işlemcide koşmaz. Onu gerçekten çalıştıran alttaki platform thread'e carrier thread denir. JVM, çok sayıda virtual thread'i daha az sayıda carrier thread üstünde taşır.

Normal durumda virtual thread I/O beklerken carrier thread'i bırakır. Böylece carrier başka virtual thread'i çalıştırabilir. Ama bazı durumlarda virtual thread carrier'ı bırakamaz. Buna pinning denir.

Mounting Virtual thread'in bir carrier thread üzerine yerleştirilip çalışmaya başlamasıdır.
Unmounting Virtual thread'in beklemeye geçerken carrier thread'i bırakmasıdır. Virtual thread'in ölçeklenmesini sağlayan kritik davranış budur.
Pinning Virtual thread'in beklese bile carrier thread'i bırakamamasıdır. Bu olduğunda hafif thread modeli platform thread darboğazına yaklaşır.

İyi durum ve kötü durum

İyi
VT bekler Carrier serbest Başka VT çalışır
Kötü
VT bekler Carrier pinned İşler kuyrukta
Java 21'de riskli örneksynchronized
import java.time.Duration;

class SlowService {
    synchronized String load() throws InterruptedException {
        // Java 21'de virtual thread burada beklerse carrier thread'i de tutabilir.
        Thread.sleep(Duration.ofSeconds(2));
        return "done";
    }
}
synchronized Java monitor mekanizmasını kullanır. Java 21'de virtual thread bir monitor içindeyken bloklanırsa carrier thread'e pinned kalabilir.
Thread.sleep(...) normalde virtual thread'i askıya alabilir. Ancak sleep veya I/O synchronized bölgenin içindeyse Java 21'de carrier'ın serbest kalması engellenebilir.
Çözüm yaklaşımı Java 21 kullanıyorsan uzun beklemeleri synchronized blokların dışına almak, `ReentrantLock` veya concurrent koleksiyonlar gibi modern araçları tercih etmek daha sağlıklıdır.
Performans kaybı neden olur?

8 carrier thread olduğunu düşün. 8 virtual thread synchronized blok içinde beklerken carrier'ları bırakmazsa, sistemde binlerce virtual thread olsa bile çalıştıracak carrier kalmayabilir. Sonuç: throughput düşer, latency artar, bazı senaryolarda açlık veya kilitlenmeye benzeyen davranışlar görülür.

5. Java 21 ve Java 24 karşılaştırması: final virtual thread desteğinden synchronized pinning iyileştirmesine

Java 21, virtual thread'leri final hale getirdi. Yani artık preview değil, gerçek production özelliği. Fakat Java 21'de önemli bir dikkat noktası vardı: virtual thread, `synchronized` method/blok içinde bloklanırsa carrier thread'e pinned kalabiliyordu.

Java 24 ile gelen JEP 491 bu konuda büyük bir iyileştirme yaptı: virtual thread'lerin monitor yani `synchronized` kullanımında carrier'a pinned kalmadan çalışabilmesi hedeflendi. Bu, eski Java kodlarını virtual thread ile kullanırken önemli bir rahatlama sağlar.

Java 21

  • Virtual thread final özelliktir.
  • `synchronized` içinde bloklama pinning oluşturabilir.
  • Uzun bekleme varsa carrier thread tutulabilir.
  • Yoğun trafikte performans kaybı görülebilir.

Java 24

  • JEP 491 ile synchronized pinning sorunu iyileştirilir.
  • Monitor kullanımı virtual thread'lerle daha uyumlu hale gelir.
  • Kod değişmeden daha iyi ölçeklenme alınabilir.
  • Native/foreign çağrılar hâlâ dikkat ister.
Önemli nüans

Java 24 "artık her şey sınırsız hızlı" demek değildir. CPU-bound işleri virtual thread hızlandırmaz. Virtual thread'in güçlü olduğu yer, çok sayıda beklemeli I/O işidir.

Performans kaybını nasıl okumak gerekir?

Java 21 ile Java 24 arasındaki farkı "Java 21 yavaş, Java 24 hızlı" diye okumak doğru olmaz. Doğru okuma şudur: Eğer uygulamada virtual thread'ler sık sık `synchronized` içinde bloklanıyorsa, Java 21'de carrier thread'ler tutulabilir ve concurrency düşer. Java 24 bu özel durumda daha iyi davranır. Ancak uygulama zaten synchronized içinde beklemiyorsa veya iş CPU-bound ise fark sınırlı olabilir.

I/O-bound senaryo HTTP, database, cache veya queue bekleyen işlerde virtual thread anlamlı kazanç sağlayabilir.
CPU-bound senaryo Ağır hesaplama yapan işlerde virtual thread daha fazla CPU üretmez; burada çekirdek sayısı ve algoritma belirleyicidir.
Pinning-heavy senaryo Java 21'de synchronized içinde bekleyen çok iş varsa carrier thread darboğazı oluşabilir; Java 24 bu riski azaltır.

6. Semaphore kullanımı: virtual thread çokluğunu dış kaynak kapasitesiyle dengelemek

Virtual thread çok hafif diye her şeyi sınırsız açmak iyi fikir değildir. Veritabanı connection pool'un 50 ise, aynı anda 10.000 sorguyu database'e yığmak sistemi boğar. Burada Semaphore kullanarak aynı anda kaç işin kritik kaynağa gireceğini sınırlarsın.

Virtual thread + SemaphoreJava
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;

public class VirtualThreadSemaphoreExample {
    public static void main(String[] args) {
        var databaseLimit = new Semaphore(50);

        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 10_000; i++) {
                executor.submit(() -> {
                    databaseLimit.acquire();
                    try {
                        return queryDatabase();
                    } finally {
                        databaseLimit.release();
                    }
                });
            }
        }
    }
}
import java.util.concurrent.Semaphore; aynı anda kaç işin kritik bölgeye gireceğini sınırlamak için gerekir.
new Semaphore(50) aynı anda 50 izin demektir. Burada örnek olarak database'e aynı anda en fazla 50 iş göndermeyi temsil eder.
acquire() izin yoksa bekler. Virtual thread beklerken genelde ucuz şekilde askıya alınabilir; bu yüzden semaphore virtual thread ile uyumlu bir sınırlama aracıdır.
finally içinde release() kritik önemdedir. Hata olsa bile izin geri verilmezse sistem zamanla kilitlenir.

Buradaki fikir şu: 10.000 virtual thread açabilirsin, ama aynı anda sadece 50 tanesi database'e gider. Diğerleri bekler. Virtual thread beklemeyi ucuz hale getirir; semaphore ise dış kaynakları korur.

Java 21 performans notu

Java 21'de sorun daha çok `synchronized`/monitor pinning tarafındadır. `Semaphore` gibi `java.util.concurrent` araçları virtual thread'lerle daha uygun bir bekleme modeli sunar. Yine de limitleri doğru seçmezsen sistem yavaşlar; çünkü dış kaynak zaten sınırlıdır.

7. Mini demo: pinning ve limitler performansı nasıl etkiler?

Bu gerçek benchmark değil; kavramı zihinde oturtmak için basit bir model. İş sayısını, bekleme süresini ve Java 21'deki pinning oranını değiştir. Java 24 tarafında synchronized pinning etkisini azaltılmış kabul ediyoruz.

Virtual thread simülatörü

--
Java 21 tahmini süre
--
Java 24 tahmini süre
--
Pinning kaynaklı fark

Sonuç

Virtual thread, Java backend tarafında "okunabilir kod + yüksek concurrency" dengesini çok güçlendirdi. Java 21 ile production hayatına girdi; Java 24 ile synchronized kaynaklı carrier pinning problemi önemli ölçüde azaltıldı. Pratikte iyi kullanım şudur: I/O ağırlıklı işlerde virtual thread kullan, dış kaynakları semaphore veya pool limitleriyle koru, CPU-bound işleri ayrı ele al ve production'da JFR/observability ile pinning davranışını izle.

Kaynaklar