Batuhan Gökçe
Yazılım Geliştirme Mühendisi

Java 21'de Gelen Yenilikler - Virtual Threads - Spring Framework'te Kullanımı

Java 21'de Gelen Yenilikler - Virtual Threads - Spring Framework'te Kullanımı

Giriş

19 Eylül 2023 tarihinde Java 21’in resmi olarak yayınlanmasıyla beraber Java ekosistemine birçok yenilik girmiş oldu; sequenced collections, pattern matching, string templates… Java 21’de gelen değişiklikler arasında en çok dikkat çeken yenilik ise hiç şüphesiz virtual threadler oldu.

Bu blog yazımda ilk olarak Java 21’de gelen bazı yeni yapılardan bahsedip devamında virtual threadler konusuna değineceğim. Yazının sonunda da Spring framework tabanlı uygulamalarda virtual thread kullanımından bahsedeceğim. Java 21’de yayınlanan veya preview olarak getirilen JEP’lere buradan ulaşılabilir.

Java 21'de Gelen Yenilikler

String Templates

Daha önce javascript kullananlar ${variableName} yapısına muhtemelen aşinadır. Hesaplanan bir değişkeni String tipinde bir metnin içine kodu çok kirletmeden yerleştirebiliyorsunuz. Java’da da bunu string concatenation ile veya StringBuilder, String.format(..) yapılarını kullanarak yapabiliyordunuz. Fakat, şahsen kodun okunabilirliği açısından string template yapısının göze daha hoş göründüğünü düşünüyorum. Bu özellik Java 21’de preview olarak getirildi.

1
2
3
4
5
6
7
static void logGreetings() {
    var name = "Batuhan";
    var surname = "Gökçe";
    var birthYear = 1998;

    System.out.println(STR."Merhaba \{name} \{surname}!\nYaşınız: \{Year.now().getValue() - birthYear}");
}

Bu özellik Java 21’e preview olarak eklendiğinden dolayı kullanmak istiyorsanız kodu compile ederken –enable-preview parametresini geçmelisiniz.

Sequenced Collections

Java 21’deki değişikliklerden biri de yeni eklenen Collection interfaceleri oldu.

Java 21'de Gelen Yenilikler

Java’nın meşhur Collections Framework diagramlarına daha önce denk gelmişsinizdir. Bu diagramlarda sıralı olarak kullanılan birçok veri yapısı olmasına rağmen sıralı yapıya hizmet eden pek bir metot bulunmuyordu. Örneğin, elinizdeki bir listenin son elemanına erişmek için şöyle bir kod kullanmanız gerekiyordu; list.get(list.size() - 1) Aşağıdaki yeni metotlarla birlikte bir listenin son elemanına daha net ve kısa şekilde erişilebilir; list.getLast()

1
2
3
4
5
6
7
8
9
10
public interface SequencedCollection<E> extends Collection<E> {
    SequencedCollection<E> reversed();
    
    default void addFirst(E e) {...}
    default void addLast(E e) {...}
    default E getFirst() {...}
    default E getLast() {...}
    default E removeFirst() {...}
    default E removeLast() {...}
}
1
2
3
4
5
6
7
8
9
10
11
12
@Test
void testSequencedList() {
    var naturalNumbers = new ArrayList<>(List.of(0, 1, 2, 3, 4, 5, 6));

    assertEquals(0, naturalNumbers.getFirst());
    assertEquals(6, naturalNumbers.reversed().getFirst());

    naturalNumbers.addLast(7);

    assertEquals(0, naturalNumbers.removeFirst());
    System.out.println(naturalNumbers); //[1, 2, 3, 4, 5, 6, 7]
}

Pattern Matching for switch

Daha önce Java 16’da instanceof ifadesine gelen pattern matching özelliği Java 21 sürümünde resmi olarak switch ifadelerine de geldi. Switch içerisinde null kontrolü, tip kontrolü, if kontrolleri vs birçok boilerplate diyebileceğimiz kodu daha temiz ve okunabilir yazmamızı sağlayabilecek bu özelliği aşağıdaki bir örnek üzerinden inceleyelim.

1
2
3
4
5
6
7
8
9
10
11
12
record Point(int x, int y) {}
    
static String formatObject(Object obj) {
    return switch (obj) {
        case null -> "Null value";
        case Point(var x, var y) when x != 0 && y != 0  -> String.format("Point is at %d,%d", x, y);
        case Point(var x, var y) when x != 0            -> String.format("Point is on x-axis %d,%d", x, y);
        case Point(var x, var y) when y != 0            -> String.format("Point is on y-axis %d,%d", x, y);
        case Point _ -> "Point is at the center";
        default -> throw new IllegalStateException("Unexpected value: " + obj);
    };
} 

Metotumuzun içerisinde null kontrolü yapmak yerine direkt olarak case null -> ... ile objenin null olup olmadığını kontrol edebiliyoruz. Null kontrolünden sonraki satırı da parçalara ayırırsak yapılan işlemleri şu şekilde sıralayabiliriz;

  • Objenin Point tipinde olup olmadığı kontrol ediliyor.
  • Eğer obje Point tipinde ise, içerisindeki değerler x, y değişkenlerine atanıyor. Burada x, y alanlarının tipi infer edildiğinden direkt var kullanabiliyoruz.
  • Objemiz Point tipinde ve içerisindeki x, y alanlarını da aldık, devamında da when x != 0 && y != 0 ile objenin içerisindeki değerler üzerinden bir koşulun sağlanıp sağlanmadığını kontrol edebiliyoruz.

Eğer böyle bir metot için pattern matching kullanmasaydık, uzun uzun null kontrolü, tip kontrolü ve devamında ayrı ayrı if/else bloklarıyla yazacağımız metodu çok kalabalıklaştıracaktık. Java 21’deki bu özelliği kullanarak daha temiz ve okunabilir kod yazabiliriz.

Virtual Threads

Java 21’de en çok dikkat çeken gelişmelerin başında virtual threadler geliyor desek muhtemelen yanlış olmaz. Bu konuya virtual threadlerin JEP-444’teki ufak özet tanımıyla başlayalım.

Introduce virtual threads to the Java Platform. Virtual threads are lightweight threads that dramatically reduce the effort of writing, maintaining, and observing high-throughput concurrent applications.

Burada vurgulanan iki önemli nokta var. Hem yüksek trafik alan eşzamanlı uygulamaların yazılması/sürdürülebilirliği ve izlenebilirliği için harcanan efor düşecek. Hem de bu virtual threadler geleneksel threadlere nispeten daha hafif olacak. Yani hem geliştiricilerin üzerindeki, hem de sistemin üzerindeki yükü hafifletecek bir yenilik olarak düşünülebilir.

Problem

Thread-per-requst modeliyle geliştirilen sunucu uygulamalarında, uygulamaya gelen her istek için yeni bir thread kullanılır ve isteğe yanıt dönülene kadar bu thread meşgul durumdadır, başka isteklere hizmet veremez. JDK’nın implementasyonuna göre JVM’de oluşturulan her bir platform thread için, işletim sisteminde yeni bir OS thread oluşturularak bununla birebir eşleştirilir. Burada şöyle bir problem karşımıza çıkıyor, OS threadleri oluşturmak maliyetli bir işlem. Çok yoğun trafik alan bir uygulamayı düşündüğümüzde sürekli olarak yeni bir OS thread oluşturmak sisteme çok fazla yük getirir ve verimli olmayacaktır.

Sürekli yeni thread oluşturma maliyetine çözüm olarak thread pool kullanımı düşünülebilir. Örneğin, Spring Boot ile yazdığımız bir uygulamada gelen istekleri thread poolda hazırda bekleyen threadler karşılar. Trafik artıp azaldıkça poolda bekleyen thread sayısı da ona göre değişir. Thread pool kullanımıyla birlikte sürekli yeni thread oluşturma ihtiyacı bir nebze azaltılabilse de açık OS threadlerin sayısı arttıkça memory kullanımı da buna bağlı olarak artacaktır. Uygulamanız yüksek trafik alıyorsa, üstünde bir de gelen istekler işlenirken uzun süre I/O bekleniyorsa o uygulamada thread sayısına bağlı olarak yüksek memory kullanımı problemi yaşanması olası. Bu probleme de asenkron programlama ile yanıt vermek mümkün.

Asenkron Programlama

Geleneksel senkron programlamanın aksine asenkron programlamada sunucuya gelen bir istek ile başından sonuna kadar tek bir thread bağımlı kalmıyor. Bunun yerine sunucuya gelen istek işlenirken bir I/O işlemi bekleneceği sırada ana threadi bekletmek yerine başka isteklere servis verebilmesi için thread, thread havuzuna geri döndürülür. I/O işlemi bittikten sonra isteğin işlenmesine thread havuzundan rastgele boş durumdaki bir başka (veya aynı) thread devam eder. Threadler paylaşımlı olarak kullanıldığı için toplam thread sayısı da kontrol altında tutulabilir.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@GetMapping("/log-thread-names")
public void doRequest() {
    var callerThreadName = Thread.currentThread().getName();
    client.get()
        .uri("/say-hi")
        .retrieve()
        .bodyToMono(String.class)
        .subscribe(response -> processResponse(response, callerThreadName));
}

private void processResponse(String ignored, String callerThreadName) {
    var currentThreadName = Thread.currentThread().getName();
    System.out.println(STR."Caller:\{callerThreadName} # Called:\{currentThreadName}");
}

Yukarıdaki uca ab -n 50 -c 10 localhost:8080/log-thread-names ile aynı anda 10, toplamda 50 istek gönderdiğimizde istekleri karşılayan ve I/O’dan sonra işleme devam eden threadlerin farklı olduklarını doğrulayabiliriz.

1
2
3
4
5
6
7
8
9
Caller:http-nio-8080-exec-5 # Called:reactor-http-nio-1
Caller:http-nio-8080-exec-6 # Called:reactor-http-nio-2
Caller:http-nio-8080-exec-5 # Called:reactor-http-nio-7
Caller:http-nio-8080-exec-8 # Called:reactor-http-nio-8
Caller:http-nio-8080-exec-7 # Called:reactor-http-nio-5
Caller:http-nio-8080-exec-2 # Called:reactor-http-nio-3
Caller:http-nio-8080-exec-4 # Called:reactor-http-nio-9
Caller:http-nio-8080-exec-7 # Called:reactor-http-nio-6
...

Madem asenkron programlamayla thread havuzumuz da var ve havuzdaki threadleri paylaşımlı olarak kullanabiliyoruz, neden virtual threadlere ihtiyaç duyuldu?

Asenkron programlama ile yazdığımız kod performans açısından fayda sağlayabilirken bazı dezavantajları ve riskleri de beraberinde getiriyor:

  • Karmaşık kod akışı: Asenkron programlama ile yazılan bir sunucu uygulamasında, bir isteği işlerken aynı anda birden çok bağımsız asenkron task çalışıyor olabilir. Her bağımsız taskın callbacklerini vs. de dahil ettiğimizde çalışan kodun akışını takip edebilmek çok zor hale gelebiliyor.
  • Exception handling: Asenkron olarak bir kod çalıştırılırken exception meydana geldiğinde bu asenkron kodu çalıştıran kod parçasına direkt olarak görünür durumda olmayacaktır. Asenkron tasklarda çıkabilecek exceptionlara, bunların nasıl handle edileceğine ekstradan dikkat etmek gerekiyor.
  • Kaynak yönetimi: Çalıştırılan asenkron tasklar I/O bağımlı değil de çok fazla kaynak tüketiyorsa paralelde çalışan asenkron taskların kaynak yönetimini dikkatli yapmak gerekiyor.

Virtual Threads

JDK 21 ile beraber hem geliştirici tarafından kodun yazılmasını, anlaşılmasını ve izlenebilmesini kolaylaştıracak, hem de sistemin üzerindeki yükü azaltabilecek virtual threadler Java ekosistemine dahil oldu. Geleneksel threadler işletim sistemi tarafındaki OS threadler ile bire bir eşleşirken, virtual threadler JVM tarafından yönetiliyor ve akış boyunca bir OS threadi bloke etmiyor.

Virtual Thread Mapping Diagram

JDK 21’den önce senkron programlama ile multi-threaded bir uygulama yazdığımızda, oluşturduğumuz threadler işletim sistemi tarafındaki bir OS thread ile eşleştiriliyor ve yaşam döngüsü boyunca bu threadi blokluyordu. Şu anki konseptte platform threadler hala OS threadler ile birebir eşleşiyor, fakat oluşturduğumuz virtual threadlere taşıyıcı rolü üstleniyor. Yani bir virtual thread oluşturduğumuzda bu threade bir taşıyıcı thread atanır, virtual thread geçici olarak taşıcıyıcı threade monte edilir ve Java kodu çalıştırılır. Virtual thread bloklandığı zaman veya görevi tamamlandığı zaman platform threadten ayrılır, platform thread başka virtual threadlere hizmet vermeye devam eder. Böylelikle, virtual threadler oluşturma maliyeti ve bellekte harcadığı alan açısından hafif olduğu için ve işletim sistemi tarafındaki threadi bloke etmediği için uygulama isteklere daha verimli olarak yanıt verebilecektir.

Virtual threadler sistemin yükü açısından avantajlıyken aynı zamanda geliştiriciler açısından da kullanımı asenkron programlamaya nispeten daha kolaydır. Birkaç örnek ile virtual threadlerin nasıl kullanılabileceğine göz atalım.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class ThreadApp {

    private static final Runnable task = () -> System.out.println("Hello World!");

    public static void main(String[] args) {
        //Örnek 1
        Thread.ofVirtual().start(task);

        //Örnek 2
        var thread = Thread.ofVirtual().unstarted(task);
        thread.start();

        //Örnek 3
        Thread.startVirtualThread(task);

        //Örnek 4
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            IntStream.range(0, 100).forEach(_ -> executor.submit(task));
        }
    }
}

Virtual threadler kullanım olarak geleneksel platform threadlere çok benzer. Sınıf yapılarına baktığımız zaman java.lang.VirtualThread‘in java.lang.Thread‘in alt sınıflarından olduğu görülebilir. Mevcut yapı korunarak virtual threadler için yeni metotlar eklenmiş durumda.

Spring’te Virtual Threadler

Virtual threadleri oluşturmak iyi, güzel, kolay görünüyor. Fakat, Spring Boot’u kullanarak yazdığımız sunucu uygulamalarında direkt manuel olarak yeni thread oluşturduğumuz nadir olabilir. Örneğin, uygulamaya gelen istekleri karşılayacak threadleri biz yönetmiyoruz, Spring Boot bunu bizim için hallediyor. Peki, Spring Boot bu istekleri virtual threadler ile mi yoksa geleneksel platform threadler ile mi karşılıyor?

Geçtiğimiz günlerde 23 Kasım tarihinde yayınlanan Spring Boot 3.2.0 versiyonu ile birlikte virtual thread desteği resmen gelmiş oldu. Varsayılan olarak false gelen aşağıdaki propertyi true geçerek uygulamaya gelen isteklerin virtual threadler tarafından karşılanmasını isteyebiliyoruz.

1
spring.threads.virtual.enabled=true

Spring Boot’ta bu şekilde global olarak virtual threadleri açıp kapatmak için bir konfigürasyon varken Quarkus’u incelediğimde orada her REST ucu için ayrı ayrı virtual threadlerin aktif edilip edilememesiyle ilgili konfigürasyon yapılabildiğini gördüm. Yani spesifik olarak bazı uçları virtual threadler karşılasın, diğerlerini platform threadler karşılasın gibi bir konfigürasyon bulunuyor. Quarkus framework kullananlar var ise dipnot olarak belirtmiş olayım.

Spring Boot 3.2’den aşağı versiyonlarda da bazı bean tanımlamaları ile virtual threadlerin kullanılabildiğini gördüm, fakat bu yazıda o konuda çok detaya girmeyeceğim.

Dikkat Edilmesi Gereken Noktalar

Virtual threadlerin hep iyi, güzel noktalarından bahsettik, fakat dikkatli kullanılmadığı takdirde getirebileceği bazı olumsuz etkileri de var. Bunlara kısa kısa değinmeye çalışacağım.

I/O Bağımlı Olmayan Virtual Threadler

Virtual threadler genel olarak I/O bağımlı taskler için kullanılması öneriliyor. I/O bağımlı olmayan ve uzun süre yüksek CPU tüketen bir işlemi virtual threadlerde yaptığımız zaman işlem sonlanana kadar virtual thread taşıyıcısına bağımlı olarak devam edecektir. Bu da kullanılan platform thread başka virtual threadlere hizmet veremeyeceği için virtual threadlerin getirdiği avantajı ortadan kaldırabilir. Bu tip I/O bağımlı olmayan işlemler için virtual threadlerden ziyade platform threadlerin kullanımı daha uygun olabilir.

Thread Local Kullanımı

Virtual threadler ThreadLocal’i destekliyor. Yani, milyonlarca virtual thread oluşturduğumuz durumda her threadin kendine ait ThreadLocal’i olacağı için elimizde milyonlarca ThreadLocal de olmuş oluyor. Bu ThreadLocal’in içerisinde ne tuttuğumuza da bağlı olarak memory tüketimini ciddi oranda artırabilir. Virtual threadlere geçmeden önce ThreadLocal kullanımlarını gözden geçirmekte fayda var.

Taşıyıcıdan Ayrılamayan Virtual Threadler

Virtual threadler bloklanan işlemler yaptığı zaman taşıyıcı threadten ayrıldığından ve taşıyıcı threadin başka virtual threadlere hizmet verdiğinden bahsetmiştik. Şöyle bir istisna bulunuyor, eğer virtual thread bir synchronized blok/metot içerisindeyken bloklandıysa o bloktan çıkana kadar taşıyıcıdan ayrılamaz, ve taşıyıcı platform thread ile eşleştiği OS thread de bloklanmış olur. Sonuç olarak virtual threadlerden aldığımız verim düşer. Bu sebeple, virtual threadleri kullanmadan önce synchronized blokları gözden geçirmeli ve mümkün olduğu yerlerde ReentrantLock kullanımına yönelmeliyiz.

Kullanılan 3. Parti Kütüphaneler

Kullandığımız kütüphaneler henüz virtual threadlere hazır durumda olmayabilir. Önceki maddelerde bahsettiğimiz iyileştirmeler kütüphanelere henüz uygulanmamış olabilir. Bu gibi durumlar için projede kullandığımız 3. parti kütüphaneleri gözden geçirmeli, virtual threadler için problem oluşturabilecek kullanımlar olup olmadığı kontrol edilmelidir.

Sonuç

Bu yazıda Java 21 ile gelen bazı yeniliklerden ve özellikle virtual threadlerin kullanımından, getirdiği avantajlardan ve dikkat edilmesi gereken hususlardan bahsettim. Virtual threadler bir çok fayda getirse de multi-threaded uygulamalarda yaşanabilecek problemlere tek geçerli çözüm değil. Duruma ve ortama göre kullanılabilecek çözümlerden sadece bir tanesi. Olası riskleri de değerlendirip karşılaşılan probleme uygun çözümü kullanmak akıllıca olacaktır.

Kaynakça