Java’da Virtual Threads ve Serial GC Tuzağı

Merhaba Arkadaşlar,

Bu makalede Java’da Virtual Threads kullanırken başımıza gelen bir production sorununa değineceğim. Birileri aynı şeye takılır belki diye tarihe not düşmek istedim.

Ne Olmuştu?

Spring Boot 3 kullandığımız bir mikroservisimiz var, Kubernetes üzerinde koşuyor. Uygulama aralıklı olarak donuyor. Health check’ler timeout’a düşüyor, pod restart yiyor. Loglara baktık, ne OOM var ne exception.Thread dump aldık, 41 platform thread, hepsi idle ya da waiting. Deadlock yok, BLOCKED thread yok. İlk başta “acaba network mi?” dedik, network ekibine sorduk, onlar da bir şey görmüyor. Sonra APM’e baktık, GC pause süreleri dikkat çekti. Oradan çektik ipi.

Serial GC
Meğersem container imajımızda GC türü açıkça belirtilmemiş (Bak Allah’ın işine:)). JVM de container’ın kaynak limitlerine bakıp Serial GC‘yi seçmiş. Container’a düşük CPU ve memory verdiğinizde JVM bunu “küçük makine” olarak görüyor ve Serial GC’yi varsayılan yapıyor.

Serial GC çok basit çalışıyor:
Uygulama çalışıyor -> GC tetikleniyor -> TÜM THREAD'LER DURUYOR -> GC tek thread ile çalışıyor -> GC bitiyor -> Uygulama devam ediyor

Tam bir stop-the-world. GC çalışırken uygulamadaki her şey duruyor. Normal thread modeli ile bu genelde tolere edilebilir ama Virtual Threads devreye girince iş değişiyor.

Virtual Threads ve Carrier Thread Meselesi
Konuya girmeden önce bir iki kavramı açıklayayım.Java’da klasik thread’ler (platform thread) işletim sisteminde gerçek bir thread’e karşılık geliyor, oluşturması pahalı, sayısı sınırlı. PHP’den geliyorsanız her php-fpm worker process’i gibi düşünebilirsiniz, her biri gerçek bir OS kaynağı tüketiyor.

Virtual Thread’ler ise Java 21+ ile gelen hafif thread’ler. OS seviyesinde bir karşılığı yok, JVM yönetiyor. Bu virtual thread’ler doğrudan OS üzerinde koşamıyor, carrier thread denen platform thread’lerine biniyorlar. Yani “taşıyıcı thread”, virtual thread’i sırtında taşıyan asıl platform thread. Carrier thread’ler de ForkJoinPool adında bir havuzda yaşıyor, CPU core sayısı kadar (genelde 2-4 tane) worker thread’i olan bir thread pool. Virtual thread bir I/O işlemi için beklemeye geçtiğinde (DB sorgusu, HTTP çağrısı gibi) carrier thread onu bırakıp başka bir virtual thread’i alıyor.

Yani olay şu:

Virtual Thread 1 ──┐
Virtual Thread 2 ──┤──→ ForkJoinPool-1-worker-1 (carrier)
Virtual Thread 3 ──┘

Virtual Thread 4 ──┐
Virtual Thread 5 ──┤──→ ForkJoinPool-1-worker-2 (carrier)
Virtual Thread 6 ──┘

Spring Boot’da spring.threads.virtual.enabled=true açtığınızda Tomcat’in handler thread’leri virtual thread oluyor. Her HTTP isteği bir virtual thread üzerinde çalışıyor ve hepsi bu 2-3 carrier thread’e biniyor. PHP-FPM’de 200 worker açarsınız, burada 2-3 carrier binlerce isteği taşıyabiliyor.

Sorun şu: Serial GC tetiklendiğinde bu 2-3 carrier thread de dahil tüm thread’ler duruyor. Heap büyüklüğüne göre yüzlerce milisaniye sürebiliyor ve bu süre boyunca hiçbir virtual thread schedule edilemiyor. HTTP istekleri, DB sorguları, her şey donuyor. Kubernetes health check timeout’a düşüyor, pod restart.

Biz ilk başta bunu anlayamadık çünkü thread dump’ta bir sıkıntı göremiyorduk, GC pause bitince thread’ler normal devam ediyordu, dump’a bakınca “her şey normal” diyordun. Sorun thread’lerde değil, thread’lerin arasındaki boşluktaydı.

G1GC ile Çözüm

JVM flag’lerine -XX:+UseG1GC ekledik, o kadar:ENV JAVA_OPTS="-XX:+UseG1GC -XX:MaxGCPauseMillis=200 -Xms512m -Xmx1g"* Pause süreleri heap boyutu, allocation rate ve workload’a göre değişir. Yukarıdaki değerler tipik bir mikroservis senaryosu için, büyük heap’lerde Serial GC pauseları saniye mertebesine çıkabilir.

G1GC marking aşamasını (hangi objeler çöp hangisi değil) uygulama çalışırken concurrent yapıyor. Thread’ler durmadan GC büyük işin çoğunu hallediyor, sadece kısa bir pause ile collection’ı tamamlıyor.

Geçiş sonrası thread dump aldık:GC toplam CPU: ~22 saniye / 66161 saniye uptime = %0.03Carrier thread’ler: idle bekliyorHeap: 115 MB / 1 GBDonma yok, restart yok

JVM Neden Serial GC Seçiyor?
JVM container-aware, container kaynaklarını görebiliyor ama şu kurala göre karar veriyor:

  • 2’den az CPU veya 1792 MB’dan az memorySerial GC
  • 2+ CPU ve 1792 MB+ memoryG1GC
  • Container’a 1 CPU, 1 GB memory verdiyseniz JVM Serial GC’yi seçiyor. Kontrol etmek için:java -XX:+PrintFlagsFinal -version 2>&1 | grep UseSerialGC

    Bu arada bu sorun OpenJDK topluluğunda da biliniyor. G1GC, Java 9’dan beri JEP 248 ile “server” ortamlarda varsayılan GC. Küçük bir not: JEP 248 aslında Parallel GC’den G1GC’ye geçişi sağladı, yani server-class makinelerde varsayılanı değiştirdi. Server-class olmayan ortamlarda (bizim düşük kaynaklı container’lar gibi) Serial GC zaten varsayılandı ve öyle kalmaya devam etti. Dolayısıyla küçük container’lar bu değişiklikten hiç etkilenmedi, Serial GC’ye düşmeye devam ettiler.

    Bunun üzerine JEP 523 önerisi geldi G1GC’yi tüm ortamlarda varsayılan yapmayı hedefliyor. JEP’in özetinde aynen şöyle diyor: “G1 is now competitive with Serial at all heap sizes.” JEP şu an candidate aşamasında, henüz bir JDK sürümüne hedeflenmedi. Hayata geçene kadar -XX:+UseG1GC flag’ini elle vermemiz lazım.

    Kubernetes Tarafı

    Deployment’ta resource’ları yüksek verseniz bile GC’yi açıkça belirtin derimresources:
    requests:
    cpu: "2"
    memory: "2Gi"
    limits:
    cpu: "2"
    memory: "2Gi"

    ENV JAVA_OPTS="-XX:+UseG1GC"

    JVM’in otomatik seçimine güvenmeyin.

    Velhâsılıkelâm, virtual thread kullanıyorsanız ilk iş JVM flag’lerinizi kontrol edin 🙂

    Sağlıcakla kalın (: