Java · Bölüm 3

JVM, bellek ve garbage collection: heap, stack, GC'ler, sızıntılar ve container

JVM'in bellek alanlarını, bir nesnenin doğumundan toplanmasına kadar yaşam döngüsünü, generational GC'nin mantığını, G1 ve ZGC'yi, bellek sızıntısı türlerini ve teşhis araçlarını, ve JVM'in Kubernetes container'ında bellek ve CPU'yu nasıl gördüğünü production problemi çözebilecek derinlikte öğretir.

~45 dk okumaQuiz: j9, j11, q16
Cevaplar:

Bu bölümde

Bu bölüm üç soruyu cevaplar: Bir Java sürecinin belleği nelerden oluşur? Garbage collector neyi, ne zaman ve ne pahasına toplar? Bellek veya GC kaynaklı bir production problemini nasıl teşhis edersin? Son kısımda JVM'in Kubernetes container'ında bellek limitini ve CPU limitini nasıl yorumladığını, bir pod'un neden OOMKilled olduğunu ve neden beklenmedik bir GC seçtiğini anlatıyoruz.

Mülakatta bu konu üç biçimde gelir: "bu metotta ne nerede yaşar?" gibi kısa kod okuma soruları, "heap her gün doluyor, ne yaparsın?" gibi açık uçlu teşhis senaryoları ve bir Kubernetes manifest'inin JVM ayarlarının incelenmesi. Senior seviyede beklenen, araç isimlerini saymak değil, belirtiden kök nedene giden yolu adım adım anlatabilmektir.

Konu anlatımı

Bir Java sürecinin belleği

-Xmx sadece Java heap'in üst sınırıdır. İşletim sisteminin (ve Kubernetes'in) gördüğü süreç belleği (RSS) bundan çok daha fazlasını içerir:

AlanİçerikSınırı belirleyenTaşınca
Java heapTüm nesneler ve diziler, String pool, static alan değerleri-Xmx, -XX:MaxRAMPercentageOutOfMemoryError: Java heap space
Thread stack'leriHer platform thread'i için frame'ler-Xss (Linux x64'te varsayılan 1 MB, thread başına)StackOverflowError (tek thread), ya da unable to create native thread
MetaspaceSınıf metadata'sı, metot bytecode'u, constant pool'lar-XX:MaxMetaspaceSize (varsayılan: sınırsız)OutOfMemoryError: Metaspace
Compressed class spaceSıkıştırılmış sınıf işaretçilerinin gösterdiği sınıf yapıları-XX:CompressedClassSpaceSizeOutOfMemoryError: Compressed class space
Code cacheJIT'in ürettiği native kod-XX:ReservedCodeCacheSizeUyarı verilir, JIT derlemeyi durdurur, uygulama yavaşlar
Direct memoryByteBuffer.allocateDirect (Netty, Kafka client, NIO)-XX:MaxDirectMemorySize (verilmezse yaklaşık heap üst sınırı kadar)OutOfMemoryError: Direct buffer memory
GC'nin kendi yapılarıRemembered set'ler, mark bitmap'leriGC'ye ve heap boyutuna bağlıContainer limitini aşarsa OOMKilled
Diğer nativeJIT derleyici arenaları, sembol tabloları, native kütüphaneler, malloc arenalarıDoğrudan bir JVM sınırı yokContainer limitini aşarsa OOMKilled

Bu tablonun pratik sonucu: container memory limitini -Xmx'e eşitlemek, pod'un OOMKilled olmasını garanti eder. Heap dolmadan bile heap dışı alanlar limiti aşar ve kernel süreci SIGKILL ile öldürür (çıkış kodu 137). Bu ölüm bir Java exception'ı değildir: HeapDumpOnOutOfMemoryError çalışmaz, finally blokları çalışmaz, log'a hiçbir şey yazılmaz.

Heap dışı alanları ölçmenin yolu Native Memory Tracking'tir:

bash
# JVM'i NMT ile başlat (küçük bir ek maliyeti var; teşhis süresince açılır)
java -XX:NativeMemoryTracking=summary -jar app.jar

# Çalışan süreçte alan bazında rezerve ve commit edilmiş bellek
jcmd <pid> VM.native_memory summary

# Sızıntı arıyorsan: önce taban çizgisi, sonra fark
jcmd <pid> VM.native_memory baseline
jcmd <pid> VM.native_memory summary.diff

NMT, JVM'in kendi ayırdığı belleği gösterir. JNI ile yüklenen native kütüphanelerin malloc'larını göremez. RSS ile NMT toplamı arasında büyük ve büyüyen bir fark varsa şüphe JVM dışına (native kütüphane, glibc malloc arenaları) kayar.

Stack: frame'ler ve yerel değişkenler

Her platform thread'inin kendi stack'i vardır. Her metot çağrısı stack'e bir frame ekler. Frame'in içinde:

  • Yerel değişken dizisi: Metot parametreleri ve yerel değişkenler. Primitive'ler değerleriyle, nesneler referanslarıyla durur.
  • Operand stack: Bytecode'un hesaplama için kullandığı çalışma alanı.
  • Dönüş adresi ve sınıfın constant pool'una referans gibi frame verileri.

Metot döndüğünde frame atılır. Stack'te GC yoktur: bellek, çağrı bittiği anda geri kazanılır. Stack'teki referanslar ise GC root'tur: bir nesneye herhangi bir thread'in aktif frame'inden ulaşılabiliyorsa o nesne yaşar.

Stack thread'e özel olduğu için yerel değişkenler doğal olarak thread-safe'tir. Dikkat: yerel bir değişkenin gösterdiği nesne heap'tedir. O referans başka bir thread'e verilirse (bir executor'a gönderilen lambda'nın içinde yakalanırsa) nesne paylaşılmış olur.

-Xss ile verilen boyut thread başına rezerve edilen sanal bellektir. Fiziksel bellek, stack gerçekten kullanıldıkça sayfa sayfa commit edilir. Bu yüzden 200 Tomcat thread'i 200 MB RSS demek değildir, ama derin çağrı zincirleri olan bir uygulamada yine de ciddi bir kalemdir. Derin ya da sonsuz recursion stack'i doldurduğunda StackOverflowError alınır. Bu bir Error'dur ve genellikle bir bug'ı (sonsuz recursion, döngüsel toString/hashCode, birbirini çağıran Jackson serializer'ları) gösterir.

Virtual thread'ler (Java 21+) bu tabloyu değiştirir: bir virtual thread taşıyıcı thread'den (carrier) ayrıldığında stack frame'leri heap'te "stack chunk" nesneleri olarak saklanır ve gerektiği kadar büyüyüp küçülür. Bir milyon virtual thread'in bir milyon MB'lık rezerve stack istememesinin nedeni budur.

Heap: nesnelerin yaşadığı yer

Tüm nesneler ve diziler heap'te oluşturulur. Heap tüm thread'ler tarafından paylaşılır. Heap'te yaşayan ve bazen başka yerde sanılan şeyler:

  • Bir nesnenin primitive alanları. Order.quantity bir int'tir ama Order nesnesinin içinde, heap'te durur. "Primitive'ler stack'te tutulur" ifadesi sadece yerel primitive'ler için doğrudur.
  • String pool. Java 7'den beri heap'tedir.
  • Static alanların değerleri. HotSpot'ta Java 7'den beri sınıfın java.lang.Class nesnesinde (heap'te) tutulur. Sınıfın metadata'sı ise Metaspace'tedir. Static alan bir nesneyi gösteriyorsa, o nesne sınıf yüklü kaldığı sürece erişilebilirdir.

Bir nesne bellekte ne kadar yer kaplar?

HotSpot'ta 64-bit JVM'de (JDK 26'ya kadarki varsayılan ayarlarla) her nesnenin bir başlığı (header) vardır: 8 byte'lık mark word (identity hash, kilit durumu, GC yaşı) ve sınıf işaretçisi (sıkıştırılmış olarak 4 byte). Nesneler 8 byte'a hizalanır. Sonuç:

  • new Object(): 12 byte başlık + 4 byte hizalama dolgusu = 16 byte.
  • Integer: 12 byte başlık + 4 byte int = 16 byte. Yani bir int'in kutulanmış hali 4 kat yer kaplar. List<Long> içinde bir milyon id, long[]'e kıyasla birkaç kat bellek demektir.
  • Compact Object Headers: Başlığı 8 byte'a indiren bir HotSpot özelliği. JDK 24'te deneysel (JEP 450), JDK 25'te ürün özelliği oldu (JEP 519, -XX:+UseCompactObjectHeaders ile açılır), JDK 27'de varsayılan hale geldi (JEP 534). Çok sayıda küçük nesnesi olan uygulamalarda heap kullanımını belirgin şekilde azaltır.

Compressed oops: 64-bit JVM'de referanslar normalde 8 byte'tır. Heap yaklaşık 32 GB'ın altındaysa JVM referansları 4 byte'a sıkıştırır (nesne hizalaması sayesinde 32 bitlik bir offset, 8 ile çarpılarak 32 GB'ı adresleyebilir). Heap 32 GB'ı geçtiği anda bu optimizasyon kapanır ve her referans 8 byte olur. Bu yüzden 31 GB'lık bir heap, 33 GB'lık bir heap'ten daha fazla nesne taşıyabilir. Büyük heap'li servislerde (arama, cache) bu sınır bilinçli olarak gözetilir. Not: ZGC sıkıştırılmış referans kullanmaz, bu hesap onun için geçerli değildir.

Allocation: TLAB ve escape analysis

Java'da new çok ucuzdur. Her thread'in heap'in genç bölgesinde kendine ait bir TLAB (Thread-Local Allocation Buffer) alanı vardır. Allocation, bu tampon içinde bir işaretçiyi nesne boyutu kadar ilerletmekten ibarettir (bump-the-pointer). Kilit yok, arama yok. Tampon dolunca thread yeni bir TLAB alır. Çok büyük nesneler TLAB dışında ayrılır.

Pahalı olan allocation değil, hayatta kalan nesnelerdir: GC'nin onları kopyalaması ve eski nesile terfi ettirmesi gerekir. Bu yüzden "nesne oluşturmaktan kaçın" genel bir kural olarak yanlıştır. Kısa ömürlü nesneler neredeyse bedavadır. Uzun yaşayan büyük nesne grafikleri ise maliyetlidir.

Escape analysis, C2 JIT derleyicisinin bir nesnenin oluşturulduğu metottan (ve inline edilen çağrılardan) dışarı kaçıp kaçmadığını analiz etmesidir:

  • Nesne hiç kaçmıyorsa (bir alana yazılmıyor, döndürülmüyor, inline edilmemiş bir metoda verilmiyor), JIT scalar replacement yapar: nesneyi hiç oluşturmaz, alanlarını register'larda veya stack'te ayrı yerel değişkenler olarak tutar.
  • Kaçmayan bir nesne üzerindeki synchronized blok kaldırılabilir (lock elision).

Mülakatta sık yapılan bir hata, "JIT nesneyi stack'te oluşturur" demektir. HotSpot nesneyi stack'e taşımaz, nesneyi ortadan kaldırır ve alanlarını ayrıştırır. Ayrıca bu optimizasyon sadece C2'nin derlediği sıcak kodda, inlining'in başarılı olduğu durumlarda çalışır. Yorumlanan (interpreted) kodda her new heap'te nesne oluşturur. Kısacası: "Nesneler heap'te oluşturulur. JIT, kaçmayan nesneleri tamamen ortadan kaldırabilir, ama buna güvenerek tasarım yapılmaz."

Metaspace, code cache ve direct memory

Metaspace (Java 8+) sınıf metadata'sını native bellekte tutar. Java 8 öncesindeki sabit boyutlu PermGen'in yerini aldı. Varsayılan olarak üst sınırı yoktur. Metaspace'in sürekli büyümesi neredeyse her zaman sınıf üretimi ya da sınıf yükleyici sızıntısı demektir:

  • Her istekte yeni bir dinamik proxy, CGLIB sınıfı, Groovy/script sınıfı veya runtime'da derlenen bir ifade üreten kod.
  • Uygulama sunucusunda redeploy: eski uygulamanın ClassLoader'ına bir referans (bir thread, bir ThreadLocal, JDBC driver kaydı) kalırsa, o ClassLoader'ın yüklediği tüm sınıflar toplanamaz. Spring Boot fat jar'larında her deploy yeni bir süreç olduğu için bu sorun büyük ölçüde ortadan kalkar.

jcmd <pid> VM.metaspace ve GC.class_histogram, hangi sınıf yükleyicinin ve hangi sınıfların arttığını gösterir. Production'da -XX:MaxMetaspaceSize vermek iyi bir pratiktir: sızıntı, kontrolsüz native büyüme ve OOMKilled yerine açık bir OutOfMemoryError: Metaspace olarak görünür.

Code cache, JIT'in derlediği native kodu tutar. Dolarsa JVM "CodeCache is full. Compiler has been disabled" uyarısı verir ve yeni metotları derlemeyi bırakır. Uygulama çalışmaya devam eder ama yorumlanan koda düştüğü için ciddi şekilde yavaşlar. Çok büyük kod tabanlarında -XX:ReservedCodeCacheSize artırılır.

Direct memory, heap dışında ayrılan ve I/O için kopyasız kullanılan tamponlardır. Netty (Spring WebFlux, Reactor Netty, gRPC) ve Kafka client'ları yoğun kullanır. Önemli bir ayrıntı: bir direct ByteBuffer'ın native belleği, onu temsil eden küçük Java nesnesi GC tarafından toplanınca serbest kalır. Heap'te baskı azsa GC nadiren çalışır ve native bellek birikebilir. JDK, direct memory sınırına ulaşıldığında System.gc() çağırıp tekrar dener. -XX:+DisableExplicitGC bu mekanizmayı da kapatır ve OutOfMemoryError: Direct buffer memory riskini artırır.

Nesne yaşam döngüsü ve erişilebilirlik

JVM'in GC'leri referans sayımı yapmaz, erişilebilirlik (reachability) analizi yapar. Bir grup GC root'tan başlayarak referansları takip eder. Ulaşılamayan her nesne çöptür. GC root'lar:

  • Tüm thread'lerin aktif frame'lerindeki yerel değişkenler ve parametreler.
  • Yüklü sınıfların static alanları (pratikte: sistem sınıf yükleyicisinin yüklediği her sınıfın static alanları uygulama boyunca yaşar).
  • JNI referansları, tutulan monitor'ler ve JVM'in iç referansları.

Bu modelin iki önemli sonucu var:

  1. Döngüsel referanslar sorun değildir. Order ↔ OrderLine birbirini gösterse bile root'tan ulaşılamıyorsa ikisi birlikte toplanır.
  2. Java'da bellek sızıntısı, "artık ihtiyaç duyulmayan ama hâlâ bir root'tan erişilebilen nesne" demektir. GC hata yapmıyor. Sen hâlâ bir referans tutuyorsun.

Referansın gücü de önemlidir:

TürGC ne zaman toplar?Tipik kullanım
Strong (normal)Ulaşılamaz olduğundaHer şey
SoftReferenceBellek baskısı olduğunda, OutOfMemoryError'dan önceBellek duyarlı cache (önerilmez: davranış öngörülemez, GC'yi zorlar)
WeakReferenceBir sonraki GC'de, başka strong referans yoksaWeakHashMap, ThreadLocalMap anahtarları, kanonik eşlemeler
PhantomReferenceToplandıktan sonra bildirim içinjava.lang.ref.Cleaner, native kaynak temizliği
diyagram yükleniyor…
flowchart LR
    A["new Order()"] --> B["Eden (TLAB içinde)"]
    B -->|"Young GC: hâlâ erişilebilir"| C["Survivor (yaş +1)"]
    C -->|"Young GC: yaş < eşik"| C
    C -->|"Yaş eşiği aşıldı ya da survivor doldu"| D["Old"]
    B -->|"Young GC: erişilemez"| X["Toplandı (maliyet yok)"]
    C -->|"Erişilemez"| X
    D -->|"Old/Mixed GC: erişilemez"| X

Generational GC: neden nesiller var?

Weak generational hypothesis: Nesnelerin büyük çoğunluğu genç ölür. Bir HTTP isteği sırasında oluşturulan DTO'lar, String'ler, iterator'lar, lambda'lar istek bitince erişilemez olur. Az sayıda nesne (cache girdileri, connection pool'lar, Spring bean'leri) uzun yaşar.

Generational GC bu gözleme göre heap'i ikiye ayırır:

  • Young generation: Eden + iki Survivor alanı. Yeni nesneler Eden'de doğar.
  • Old generation: Belirli sayıda young GC'den sağ çıkan nesneler buraya terfi eder (promotion).

Young GC'nin neden ucuz olduğu mülakatta ayırt edici bir noktadır: Young GC kopyalayan (copying) bir toplayıcıdır. Eden'deki ve dolu Survivor'daki canlı nesneleri boş Survivor'a (veya Old'a) kopyalar, sonra Eden'i bütünüyle boş kabul eder. Maliyeti canlı nesne sayısıyla orantılıdır, çöp miktarıyla değil. Eden'deki nesnelerin %98'i ölmüşse young GC sadece %2'yi kopyalar. Çöpe dokunmaz bile.

Her young GC'den sağ çıkan nesnenin yaşı bir artar. Yaş eşiğine (MaxTenuringThreshold, en fazla 15) ulaşan ya da Survivor'a sığmayan nesneler Old'a terfi eder. Erken terfi (premature promotion), aslında kısa ömürlü olan nesnelerin Survivor yetersizliği yüzünden Old'a taşınmasıdır. Old'u gereksiz yere doldurur ve daha pahalı old toplamalarını tetikler.

Young GC sırasında Old'dan Young'a giden referansları bulmak için tüm Old'u taramak gerekmez: JVM, Old'daki bir alana genç bir nesnenin referansı yazıldığında bunu bir write barrier ile kaydeder (card table, G1'de remembered set). Young GC sadece bu kayıtlı alanlara bakar.

Stop-the-world (STW) ve concurrent: Bazı GC aşamaları tüm uygulama thread'lerini safepoint denen güvenli noktalarda durdurur. Diğer aşamalar uygulama çalışırken eşzamanlı yürür. Modern GC'lerin farkı, işin ne kadarını eşzamanlı yapabildikleridir. GC seçimi her zaman üç hedef arasındaki bir dengedir:

  • Throughput: Toplam CPU'nun ne kadarı uygulamaya gidiyor?
  • Latency: En uzun duraklama (ve p99 yanıt süresi) ne kadar?
  • Footprint: Ne kadar ek bellek ve CPU gerekiyor?

G1: varsayılan GC

G1 (Garbage-First), JDK 9'dan beri server-class makinelerde varsayılan GC'dir. JDK 27 ile (JEP 523) her ortamda varsayılan oldu (bkz. bu bölümdeki container başlığı).

  • Bölgeler (regions): Heap, eşit boyutlu (2'nin kuvveti, heap boyutuna göre seçilen) bölgelere ayrılır. Her bölge o an Eden, Survivor, Old ya da Humongous rolündedir. Nesiller fiziksel olarak bitişik değildir.
  • Humongous nesneler: Bölge boyutunun yarısından büyük nesneler (büyük diziler, dev String'ler, büyük JSON tamponları) doğrudan özel humongous bölgelere yerleşir. Çok sayıda humongous allocation fragmentasyon ve beklenmedik GC'ler üretir. GC log'larında "humongous allocation" görüyorsan büyük tamponları küçültmek ya da bölge boyutunu (-XX:G1HeapRegionSize) artırmak gerekir.
  • Duraklama hedefi: -XX:MaxGCPauseMillis (G1 için varsayılan 200 ms) yumuşak bir hedeftir. G1, her toplamada kaç bölge toplayacağını bu hedefe göre ayarlar.
  • Döngü: Young-only toplamalar çalışır. Old doluluğu bir eşiğe (IHOP, Initiating Heap Occupancy Percent) ulaşınca eşzamanlı işaretleme (concurrent marking) başlar. İşaretleme bitince G1 hangi Old bölgelerin en çok çöp içerdiğini bilir ve sonraki young toplamalara bu bölgelerden bir kısmını ekler (mixed collections). "Garbage-First" adı buradan gelir: önce en çok çöp içeren bölgeler toplanır.
  • Sorun belirtileri: "to-space exhausted"/evacuation failure (kopyalanacak yer bulunamadı) ve Full GC. G1'de Full GC bir yedek mekanizmadır (JDK 10'dan beri paralel) ve görülmesi, heap'in yetersiz olduğunu, bir sızıntıyı ya da eşzamanlı işaretlemenin allocation hızına yetişemediğini gösterir.

ZGC ve generational ZGC

ZGC, duraklama süresini heap boyutundan bağımsız hale getirmek için tasarlanmış düşük gecikmeli bir GC'dir. İşaretleme ve nesnelerin taşınması (relocation/compaction) dahil işin neredeyse tamamı uygulama çalışırken eşzamanlı yapılır. Bunu iki mekanizmayla sağlar:

  • Renkli işaretçiler (colored pointers): Referansın kendi bitlerinde GC durum bilgisi taşınır.
  • Load barrier: Uygulama heap'ten bir referans okuduğunda küçük bir kontrol çalışır. Nesne taşınmışsa referans o anda düzeltilir.

Sonuç: Duraklamalar tipik olarak milisaniyenin altındadır ve heap 4 GB da olsa 1 TB da olsa değişmez. Bedeli:

  • Barrier'lar ve eşzamanlı iş ek CPU harcar. Throughput, G1 veya Parallel'e göre biraz düşük olabilir.
  • Eşzamanlı toplama boş alan payına (headroom) ihtiyaç duyar. Allocation hızı toplama hızını geçerse uygulama thread'leri allocation stall'a girer. Bu, duraklama metriklerinde görünmeyen ama latency'de görünen bir beklemedir.
  • Sıkıştırılmış referans (compressed oops) kullanmaz.

Generational ZGC'nin sürüm geçmişi: İlk ZGC nesil ayrımı yapmıyordu: her toplama tüm heap'i işaretliyordu. JEP 439 ile Java 21'de generational mod geldi ve -XX:+UseZGC -XX:+ZGenerational ile açılıyordu. JDK 23'te (JEP 474) generational mod varsayılan oldu, JDK 24'te (JEP 490) nesilsiz mod tamamen kaldırıldı. Yani Java 21 LTS kullanıyorsan generational ZGC için -XX:+UseZGC'ye ek olarak -XX:+ZGenerational vermen gerekir, Java 25 LTS'te -XX:+UseZGC yeterlidir. Generational mod, genç nesneleri daha sık ve ucuz toplayarak allocation stall riskini, heap payı ihtiyacını ve CPU maliyetini azaltır.

GC seçimi

GCBayrakGüçlü yanıNe zaman
Serial-XX:+UseSerialGCEn düşük ek yük, tek threadÇok küçük heap, tek CPU, CLI araçları
Parallel-XX:+UseParallelGCEn yüksek throughput, STW ama çok thread'liBatch job'lar, gece raporları, duraklamanın önemsiz olduğu işler
G1-XX:+UseG1GCThroughput ve latency dengesi, hedef duraklamaÇoğu web servisi ve API (varsayılan)
ZGC-XX:+UseZGCHeap boyutundan bağımsız, ms altı duraklamaSıkı p99 hedefi, büyük heap, ek CPU'nun kabul edilebildiği yerler
Shenandoah-XX:+UseShenandoahGCZGC'ye benzer eşzamanlı sıkıştırmaDağıtımda mevcutsa ZGC'nin alternatifi
Epsilon-XX:+UseEpsilonGC (deneysel)Hiç toplama yapmazPerformans testi, çok kısa ömürlü süreçler

Senior yaklaşım: önce ölç, sonra değiştir. Bir e-ticaret API'sinde p99 latency problemi varsa önce GC log'larında duraklamaların gerçekten p99'a katkı verip vermediğine bakılır. Çoğu zaman sorun GC değil, downstream bir çağrı, connection pool beklemesi ya da CPU throttling'dir. GC'yi değiştirmeden önce allocation hızını düşürmek (gereksiz nesne kopyaları, büyük ara listeler) genellikle daha büyük kazanç verir.

Bellek sızıntısı türleri

Java'da sızıntı, erişilebilir kalan gereksiz nesnelerdir. Production'da en sık görülen kaynaklar:

1. Static koleksiyonlar ve sınırsız cache'ler. static Map bir GC root'tan erişilebilir ve hiçbir şey silinmezse sonsuza kadar büyür. Elle yazılmış her ConcurrentHashMap cache'i bir boyut sınırı ve bir sona erme politikası gerektirir. Doğrusu Caffeine gibi bir kütüphanedir:

java
private final Cache<Long, ProductView> productCache = Caffeine.newBuilder()
        .maximumSize(50_000)
        .expireAfterWrite(Duration.ofMinutes(10))
        .recordStats()                  // hit oranını Micrometer'a vermek için
        .build();

2. ThreadLocal ve thread havuzları. ThreadLocal'ın değerleri aslında her Thread nesnesinin içindeki bir ThreadLocalMap'te durur. Map'in anahtarı ThreadLocal nesnesine weak referanstır, ama değer strong referanstır. Tomcat'in, executor'ların ve Kafka listener container'larının thread'leri havuzdan gelir ve süreç boyunca yaşar. remove() çağrılmazsa değer, thread yaşadığı sürece yaşar. Daha kötüsü, aynı thread bir sonraki istekte önceki isteğin değerini taşır: bu bir veri sızıntısıdır (bkz. j11). Kural: set ettiğin her ThreadLocal'ı finally içinde remove et.

3. Kaydı silinmeyen listener ve callback'ler. Uzun ömürlü bir nesneye (bir event bus, bir singleton servis) kısa ömürlü nesneler listener olarak kaydolur ve hiç çıkmazsa, uzun ömürlü nesne onları yaşatır. Dış sınıfın üyelerine erişen anonim sınıflar ve inner class'lar dış nesneye de örtük referans taşıdığı için zinciri büyütür (bkz. Bölüm 1).

4. Kapatılmayan kaynaklar. Kapatılmayan stream'ler, connection'lar, HttpClient response gövdeleri hem heap'te nesne hem native kaynak (file descriptor, socket, direct buffer) sızdırır.

5. Uzun bir persistence context. Hibernate'in birinci seviye cache'i, transaction boyunca yüklenen her entity'yi tutar. Bir milyon satırı tek transaction'da işleyen bir batch job, tüm entity'leri bellekte biriktirir. Çözüm: parçalar halinde işlemek ve her parçadan sonra flush() + clear(), ya da read-only projection'lar ve stream tabanlı okuma.

6. Yüksek kardinaliteli metrik etiketleri. Micrometer'da her farklı etiket kombinasyonu ayrı bir meter nesnesi üretir ve registry onu tutar. userId, orderId veya ham URL (path variable'lar dahil) etiket olarak kullanılırsa meter sayısı sınırsız büyür. Bu, gözlemlenebilirlik kodunun kendisinin ürettiği, gözden kaçan bir sızıntıdır.

7. Hash tabanlı koleksiyonlarda mutable key. Hash'i değişen nesne bulunamaz ve silinemez (bkz. Bölüm 1).

8. Sınıf yükleyici sızıntısı. Metaspace'i büyüten sızıntı (yukarıda anlatıldı).

Virtual thread'ler ve ThreadLocal: Virtual thread'ler havuzlanmaz, her görev yeni bir thread'dir. Bu yüzden "ThreadLocal'da pahalı bir nesneyi thread başına cache'le" deseni (her thread için bir SimpleDateFormat gibi) milyonlarca kopya üretebilir. Java 25'te final olan Scoped Values (JEP 506), istek bağlamını taşımak için daha güvenli bir alternatiftir: değer bir kez bağlanır, değiştirilemez ve bağlandığı kapsam bitince otomatik olarak sona erer. remove unutma riski yoktur.

java
private static final ScopedValue<UserContext> CONTEXT = ScopedValue.newInstance();

ScopedValue.where(CONTEXT, UserContext.from(request))
           .run(() -> handler.handle(request));   // kapsam bitince bağlama kendiliğinden biter

UserContext current = CONTEXT.get();               // kapsam içindeki herhangi bir derinlikte

Teşhis: belirtiden kök nedene

1. Belirtiyi doğru oku

Sızıntının tipik görünümü: heap kullanımı grafiği bir testere dişi çizer (allocation ile yükselir, GC ile düşer). Testere dişinin tabanı, yani her GC sonrası kalan canlı veri, zamanla yükseliyorsa sızıntı vardır. Taban sabitse ve sadece tepeler yüksekse sızıntı yoktur, sadece allocation hızı yüksektir.

Takip edilecek metrikler (Spring Boot Actuator + Micrometer):

  • jvm.memory.used (area=heap, özellikle old gen havuzu) ve GC sonrası değerler.
  • jvm.gc.pause (sayı ve süre), jvm.gc.memory.promoted, jvm.gc.memory.allocated.
  • Container seviyesinde RSS (container_memory_working_set_bytes). Heap sabitken RSS büyüyorsa sorun heap dışındadır: NMT'ye geç.

GC log'larını production'da her zaman açık tut. Maliyeti ihmal edilebilir, değeri büyüktür:

bash
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=20m

2. jcmd ile canlı süreci incele

jcmd, JDK ile gelen ve çalışan bir JVM'e komut gönderen araçtır. Eski jmap, jstack, jinfo araçlarının işlerini tek çatı altında yapar:

bash
jcmd                                   # çalışan JVM'leri listele
jcmd <pid> VM.flags                    # gerçekte hangi bayraklarla çalışıyor (ergonomi sonrası)
jcmd <pid> GC.heap_info                # nesillerin doluluğu
jcmd <pid> GC.class_histogram | head -30   # sınıf başına nesne sayısı ve boyutu (full GC tetikler)
jcmd <pid> Thread.print                # thread dump (deadlock, takılı thread'ler)
jcmd <pid> GC.heap_dump /dumps/heap-$(date +%s).hprof
jcmd <pid> VM.native_memory summary    # NMT açıksa heap dışı alanlar

Kubernetes'te dikkat edilmesi gereken pratik noktalar:

  • Minimal imajlarda jcmd olmayabilir. Sadece JRE ya da jlink ile küçültülmüş bir runtime içeren imajlarda JDK araçları yoktur. Bu yüzden teşhis yeteneği önceden tasarlanmalıdır: JFR'nin sürekli açık olması, Actuator'ın /actuator/heapdump endpoint'inin sadece iç ağdan ve yetkiyle erişilebilir şekilde açılması, ya da process namespace'ini paylaşan bir debug container'ı.
  • Heap dump uygulamayı durdurur. Dump alınırken JVM safepoint'te bekler. Varsayılan olarak sadece erişilebilir nesneleri yazmak için önce bir full GC yapar. Birkaç GB'lık bir heap için bu saniyeler sürebilir ve liveness probe'u fail edip pod'u restart ettirebilir. Dump'ı trafiği azaltılmış (readiness'tan çıkarılmış) bir pod'dan almak daha güvenlidir.
  • Dump dosyası heap kadar büyüktür ve container'ın yazılabilir alanına sığmalıdır (bir volume gerekir).
  • Heap dump müşteri verisi içerir. Bellekteki her String, her token, her kart numarası, her şifre dump'ın içindedir. Bankacılık ve e-ticarette heap dump'lar hassas veri olarak ele alınır: erişim kontrolü, şifreli depolama, saklama süresi. Dump'ı laptop'a indirip Slack'te paylaşmak bir güvenlik ihlalidir.

3. Heap dump'ı analiz et (Eclipse MAT)

  • Leak Suspects raporu: MAT'in otomatik olarak işaret ettiği, heap'in büyük kısmını tutan nesneler.
  • Dominator tree: "A nesnesi silinseydi hangi nesneler de erişilemez olurdu?" sorusunun ağacı. En üstteki girdiler, belleğin kimin tarafından tutulduğunu gösterir.
  • Shallow ve retained size: Shallow, nesnenin kendi boyutudur. Retained, nesne silinseydi serbest kalacak toplam bellektir. Sızıntı avında retained size'a bakılır. Örneğin bir ConcurrentHashMap'in shallow boyutu küçüktür ama retained boyutu birkaç GB olabilir.
  • Path to GC roots: Şüpheli nesneden root'a giden referans zinciri. Weak/soft referansları hariç tutarak bakılır. Bu zincir genellikle kök nedeni doğrudan söyler: RequestContextFilter.AUDIT (static) → ConcurrentHashMap → ArrayList<AuditEvent>.
  • İki dump'ı karşılaştır: Birkaç saat arayla alınan iki dump'ın histogram farkı, büyüyen sınıfı gürültüden ayırır.

4. JFR ile sürekli kayıt

Java Flight Recorder, JVM'e gömülü, production'da sürekli açık tutulacak kadar düşük maliyetli bir olay kaydedicisidir (JDK 11'de açık kaynak oldu). GC olaylarını, allocation örneklerini, lock contention'ı, I/O'yu, thread durumlarını ve CPU profilini kaydeder.

bash
# Başlangıçta: son 6 saati diskte tutan sürekli kayıt, çıkışta dump
-XX:StartFlightRecording=disk=true,maxage=6h,dumponexit=true,filename=/dumps/app.jfr

# Çalışan süreçte anlık dump; sızıntı adayları için GC root yollarını da yaz
jcmd <pid> JFR.dump name=1 filename=/dumps/now.jfr path-to-gc-roots=true

JFR'nin OldObjectSample olayı, uzun süre yaşayan nesnelerden örnekler tutar ve path-to-gc-roots=true ile dump alındığında bu nesnelerin root'a giden yolunu da kaydeder. Bu, tam heap dump almadan sızıntı adayını bulmanın hafif bir yoludur. Kayıtlar JDK Mission Control (JMC) ile ya da jfr print/jfr summary komutlarıyla incelenir. Allocation profili için async-profiler'ın allocation modu da yaygın bir alternatiftir.

OutOfMemoryError türleri

OutOfMemoryError'ın mesajı, hangi alanın tükendiğini ve nereye bakılacağını söyler:

MesajAnlamıİlk bakılacak yer
Java heap spaceHeap'te yer yok, GC yeterince boşaltamadıHeap dump: sızıntı mı, yoksa heap gerçek ihtiyaç için mi küçük?
GC overhead limit exceededGC zamanın neredeyse tamamını harcıyor ve çok az bellek geri kazanıyorAynı: heap dump. Klasik olarak Parallel GC'nin verdiği hatadır; G1 desteği JDK 26'da geldi (JDK-8212084, varsayılan açık) ve 25.0.3 gibi güncellemelere de taşındı
MetaspaceSınıf metadata alanı dolduSınıf üretimi, ClassLoader sızıntısı, VM.metaspace
Compressed class spaceSıkıştırılmış sınıf alanı dolduAşırı sayıda yüklenen sınıf
unable to create native threadİşletim sistemi yeni thread vermiyor (bellek, ulimit, container pids limiti)Thread sayısı (Thread.print), sınırsız thread üreten kod, executor yapılandırması
Direct buffer memoryMaxDirectMemorySize dolduNetty/Kafka tampon ayarları, DisableExplicitGC
Requested array size exceeds VM limitDizi boyutu JVM sınırını aşıyorGenellikle bir bug (hatalı boyut hesabı)
(Exception yok) Pod OOMKilled, çıkış kodu 137Container memory limiti aşıldı, kernel süreci öldürdüHeap + heap dışı toplamı limitten büyük: MaxRAMPercentage, NMT

OOM sonrası en güvenli davranış genellikle süreci sonlandırmaktır. OOM herhangi bir thread'de fırlayabilir. Örneğin bir Kafka consumer thread'i OOM ile ölürse uygulama "ayakta" görünür, health check geçer, ama hiçbir mesaj tüketilmez. Bu yüzden production'da şu bayraklar birlikte kullanılır:

bash
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/dumps                 # kalıcı bir volume
-XX:+ExitOnOutOfMemoryError             # yarı ölü bir süreç yerine restart

Container'da JVM

Container farkındalığı

JDK 10'dan beri (8u191'e backport edildi) Linux'ta JVM, cgroup'lar üzerinden container'ın bellek limitini ve CPU kotasını okur. -XX:+UseContainerSupport varsayılan olarak açıktır. cgroup v2 desteği JDK 15 ile geldi ve sonradan 11 ve 8 güncellemelerine taşındı. Eski bir JDK 8 sürümü container limitini görmez ve node'un tüm belleğini kendi belleği sanır. Bu yüzden "Java container'da belleği yanlış görüyor" şikayetlerinin çoğu eski sürümlerden kalmadır.

JVM'in ne gördüğünü doğrulamak için:

bash
java -Xlog:os+container=info -version            # okunan cgroup limitleri
java -XX:+PrintFlagsFinal -version | grep -E 'MaxHeapSize|UseSerialGC|UseG1GC|ActiveProcessorCount'
jcmd <pid> VM.flags                              # çalışan süreçte nihai değerler

Bellek: heap'i limitin yüzdesi olarak ver

-Xmx verilmezse JVM heap üst sınırını kullanılabilir belleğin %25'i olarak hesaplar (MaxRAMPercentage varsayılanı 25). Yaklaşık 250 MB'ın altındaki çok küçük bellekli ortamlarda bunun yerine MinRAMPercentage (varsayılan 50) kullanılır. Başlangıç heap'i varsayılan olarak belleğin 1/64'üdür (InitialRAMPercentage = 1.5625). 2 GB'lık bir container'da %25, 512 MB'lık bir heap demektir ve çoğu Spring Boot servisi için azdır.

İki yanlış uç var:

  • Varsayılanı bırakmak: Belleğin dörtte üçü kullanılmaz, heap gereksiz yere küçük kalır, GC sık çalışır.
  • -Xmx'i limite eşitlemek: Heap dışı alanlara yer kalmaz, pod OOMKilled olur.

Doğrusu, heap'i limitin bir yüzdesi olarak vermek ve kalan payın heap dışı ihtiyaçlara yettiğini ölçmektir:

text
container limit  = heap (MaxRAMPercentage)
                 + metaspace + compressed class space
                 + thread sayısı × kullanılan stack
                 + code cache
                 + direct memory (Netty, Kafka client)
                 + GC yapıları + diğer native

Tipik bir başlangıç noktası -XX:MaxRAMPercentage=60 ile 75 arasıdır. Container ne kadar küçükse heap dışı alanların payı o kadar büyüktür ve yüzde o kadar düşük tutulmalıdır: 512 MB'lık bir container'da heap dışı alanlar belleğin büyük bir kısmını kaplar, 8 GB'lık bir container'da küçük bir kısmını. Doğru değer, yük altında NMT ve RSS ölçümüyle bulunur. Bellek için Kubernetes requests ve limits değerlerinin eşit tutulması da önerilir: bellek sıkıştırılabilir bir kaynak değildir, limitin üstündeki kullanımın tek sonucu öldürülmektir.

-XX:MaxRAMPercentage limiti değiştiğinde (pod yeniden boyutlandırıldığında) heap'i de otomatik ölçekler. Sabit -Xmx her boyut değişikliğinde elle güncellenmesi gereken ikinci bir sayı yaratır.

CPU: JVM kaç işlemci görüyor?

JVM'in "kaç işlemcim var?" sorusuna verdiği cevap (Runtime.availableProcessors()), container'da CPU kotasından (Kubernetes limits.cpu) hesaplanır ve yukarı yuvarlanır. limits.cpu: 250m ise JVM 1 işlemci görür. Bu sayı birçok şeyi belirler:

  • GC thread sayıları (ParallelGCThreads, ConcGCThreads).
  • ForkJoinPool.commonPool() paralelliği (işlemci sayısı - 1, en az 1). parallelStream() ve CompletableFuture.supplyAsync(...)'ın varsayılan executor'ı bu havuzdur. Ortak havuzun paralelliği 2'nin altındaysa CompletableFuture async görevleri için her görevde yeni bir thread oluşturan bir executor kullanır.
  • Netty event loop sayısı gibi birçok kütüphanenin varsayılan thread havuzu boyutları.
  • GC seçimi (aşağıda).

JDK 19'dan beri (JDK-8281181) JVM, CPU sayısını hesaplarken cgroup CPU shares'ini (Kubernetes requests.cpu'dan türetilen değer) kullanmaz, sadece kotaya (limit) bakar. Bunun önemli bir sonucu var: CPU limiti hiç verilmemiş bir pod'da JVM node'un tüm çekirdeklerini görür. 64 çekirdekli bir node'da, 1 CPU request'i olan bir pod 64 işlemci görür, onlarca GC thread'i ve 63 paralellikli bir common pool kurar. Bu durumda -XX:ActiveProcessorCount=<n> ile JVM'e gerçekçi bir sayı verilir.

CFS throttling: Linux, CPU limitini bir periyot (varsayılan 100 ms) içinde kullanılabilecek CPU zamanı kotası olarak uygular. 250m limit, her 100 ms'de 25 ms CPU demektir. JVM açılışta JIT derlemesi ve sınıf yükleme için yoğun CPU harcar, GC ise birden fazla thread'le paralel çalışır. Kota periyodun başında tükenirse süreç periyodun geri kalanında durdurulur. Sonuç, GC log'larında görünmeyen ama yanıt sürelerinde görülen sıçramalar ve çok yavaş açılıştır. container_cpu_cfs_throttled_periods_total metriği bunu gösterir. Bu yüzden birçok ekip JVM servislerinde CPU limitini cömert tutar ya da hiç koymaz ve requests.cpu'yu gerçekçi verir. Limit koymamanın ise yukarıdaki işlemci sayısı etkisi vardır.

GC seçimi ve "server-class" kuralı

JDK 26 ve öncesinde, -XX:+UseXxxGC ile açıkça bir GC seçilmemişse JVM önce makinenin "server-class" olup olmadığına bakar: en az 2 işlemci ve yaklaşık 1792 MB (2 GB eksi küçük bir pay) bellek. Bu şart sağlanmazsa G1 yerine SerialGC seçilir. Container'da bu iki şekilde tetiklenir:

  • CPU limiti 1 veya altında (250m, 500m, 1): JVM 1 işlemci görür → SerialGC.
  • Memory limiti 1792 MB'ın altında (ör. 1Gi): CPU sayısından bağımsız olarak → SerialGC.

SerialGC tek thread'li, STW bir toplayıcıdır. Küçük heap'lerde kötü değildir, ama ekip G1 ile çalıştığını sanırken farkında olmadan Serial ile çalışmak, duraklama davranışını ve tuning kararlarını tamamen değiştirir. JDK 27 (Eylül 2026) ile JEP 523 bu istisnayı kaldırdı: G1 artık her ortamda varsayılan. Java 21 ve 25 LTS kullanan servislerde ise GC'yi açıkça seçmek (-XX:+UseG1GC) ve jcmd <pid> VM.flags ile doğrulamak en güvenli yoldur.

Toparlarsak, Java 21/25 üzerinde 1 GiB bellek ve gerçekçi CPU ile çalışan bir Spring Boot servisi için makul bir başlangıç:

yaml
env:
  - name: JAVA_TOOL_OPTIONS
    value: >-
      -XX:MaxRAMPercentage=65
      -XX:+UseG1GC
      -XX:ActiveProcessorCount=2
      -XX:MaxMetaspaceSize=256m
      -XX:+ExitOnOutOfMemoryError
      -XX:+HeapDumpOnOutOfMemoryError
      -XX:HeapDumpPath=/dumps
      -Xlog:gc*:file=/dumps/gc.log:time,uptime,level,tags:filecount=5,filesize=20m
resources:
  requests: { cpu: "1", memory: "1Gi" }
  limits:   { memory: "1Gi" }          # CPU limiti yok: throttling yok, işlemci sayısını JVM'e biz söylüyoruz

Buradaki sayılar ezberlenecek değerler değil, ölçümle doğrulanacak başlangıç noktalarıdır. Probe'lar, graceful shutdown ve rollout ayarları için bkz. Bölüm 15.

Quiz'deki sorular: çözüm ve nedenleri

j9: Bu metotta ne nerede yaşar?

Senaryo:

java
private static final Map<Long, Order> cache = new ConcurrentHashMap<>();

void process() {
    int count = 5;                              // (1)
    Order order = new Order(42L);               // (2)
    List<Order> list = new ArrayList<>();       // (3)
    list.add(order);
    cache.put(42L, order);
}

Doğru seçenekler: a, b, d, e, g.

  • (a) Doğru: count yerel bir primitive'dir, bu metodun frame'indeki yerel değişken dizisinde durur ve metot bitince yok olur.
  • (b) Doğru: order referansı frame'dedir, gösterdiği Order nesnesi heap'tedir. Escape analysis istisnası burada geçerli değildir: nesne bir static map'e yazıldığı için kesin olarak kaçar (global escape).
  • (d) Doğru: Her thread'in kendi stack'i, tüm thread'lerin ortak heap'i vardır. Yerel değişkenler bu yüzden thread-safe'tir, paylaşılan heap nesneleri değildir.
  • (e) Doğru: Derin/sonsuz recursion StackOverflowError, dolu heap OutOfMemoryError: Java heap space verir. Stack boyutu -Xss, heap -Xmx veya MaxRAMPercentage ile ayarlanır.
  • (g) Doğru, bir sürüm nüansıyla: Sınıf metadata'sı Metaspace'te (native), static alanların değerleri heap'teki Class nesnesinde. Quiz bunu "Java 8+ ile" diye bağlıyor. Daha kesin ifade: static alanlar Java 7'de Class nesnesine (heap'e) taşındı, Java 8'de ise PermGen tamamen kaldırılıp yerine Metaspace geldi. Java 8+ için ifade doğrudur, sadece değişikliğin başladığı sürüm 7'dir.

Yanlış seçenekler:

  • (c) Yanlış: Metot bitince list ve order yerel referansları kaybolur, ama cache static bir alan, yani bir GC root'tan erişilebilir ve Order'ı tutuyor. Nesne, cache'ten çıkarılana kadar yaşar. ArrayList ise gerçekten GC'ye uygun hale gelir. Sınırsız bir static cache klasik sızıntı kaynağıdır.
  • (f) Yanlış: Primitive'ler bulundukları yere göre yaşar. Bir nesnenin primitive alanı (Order.quantity) nesneyle birlikte heap'tedir, static bir primitive alan da heap'teki Class nesnesindedir. Sadece yerel primitive'ler frame'dedir.

Arkadaki kavram: Bellek alanı, değişkenin türüne değil nerede bildirildiğine göre belirlenir: yerel mi, alan mı, static mi. Production karşılığı: "bu nesne neden toplanmıyor?" sorusunun cevabı her zaman bir root'tan gelen referans zinciridir ve heap dump'taki "path to GC roots" tam olarak bunu gösterir.

j11: Heap her gün biraz daha doluyor

Senaryo: Servis 3-4 gün sorunsuz çalışıyor, sonra yanıt süreleri artıyor, Full GC sıklaşıyor ve OOM ile çöküyor. GC sonrası heap tabanı her gün yükseliyor. Bir müşteri sayfada başka bir kullanıcının adını görmüş.

java
@Component
public class RequestContextFilter implements Filter {

    private static final ThreadLocal<UserContext> CONTEXT = new ThreadLocal<>();
    private static final Map<String, List<AuditEvent>> AUDIT = new ConcurrentHashMap<>();

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        UserContext ctx = UserContext.from(req);
        CONTEXT.set(ctx);
        AUDIT.computeIfAbsent(ctx.sessionId(), k -> new ArrayList<>()).add(AuditEvent.of(req));
        chain.doFilter(req, res);
    }

    public static UserContext current() { return CONTEXT.get(); }
}

1) Problemler

a. AUDIT static ve sınırsız: heap tabanını yükselten sızıntı bu. Her yeni session id için bir liste açılır, her istek bir event ekler ve hiçbir şey silinmez. Static alan bir GC root'tan erişilebildiği için hiçbir event toplanmaz. "GC sonrası taban her gün yükseliyor" belirtisinin kaynağı budur: canlı veri miktarı trafikle orantılı ve sınırsız büyüyor.

b. ThreadLocal temizlenmiyor: veri sızıntısı ve güvenlik açığı. Tomcat thread'leri havuzdan gelir ve yeniden kullanılır. remove() çağrılmadığı için bir thread, bir sonraki isteğe önceki kullanıcının UserContext'ini taşır. Bu filtre her istekte set çağırdığı için normal akışta üzerine yazılır. Ama current()'ın eski değeri döndürdüğü yollar vardır: UserContext.from(req) exception fırlatırsa set hiç çalışmaz. Filtre zincirinde bu filtreden önce çalışan bir filtre veya bir error dispatch'i current() çağırırsa önceki isteğin değerini görür. Async işlem, aynı thread'de bu filtreden geçmeyen işler de aynı riski taşır. "Başka kullanıcının adını gördüm" şikayeti bu sınıf bir hatadır ve bankacılıkta bir veri ihlali olarak raporlanması gereken bir olaydır.

Bir nüans (quiz'in model cevabını tamamlayan): Bu servisteki bellek büyümesinin kaynağı ThreadLocal değildir. Thread havuzu sınırlı olduğu için (Tomcat varsayılanı 200 thread) ThreadLocal en fazla thread sayısı kadar UserContext tutar. Bu sabit ve küçük bir miktardır. ThreadLocal'ın asıl zararı güvenlik tarafındadır. Ancak değer büyük bir nesne grafiği tutuyorsa (tüm istek gövdesi, bir Hibernate session'ı) sabit ama büyük bir bellek kalemi olur. Uygulama sunucusunda redeploy yapılıyorsa ClassLoader sızıntısına da yol açar.

c. Map değeri ArrayList: thread-safe değil. computeIfAbsent sadece listenin oluşturulmasını atomik yapar. Aynı session'dan gelen eşzamanlı istekler (bir sayfanın paralel API çağrıları) aynı ArrayList'e kilitsiz add yapar. Kayıp eleman, ArrayIndexOutOfBoundsException veya bozuk iç durum oluşabilir.

2) Teşhis adımları

  1. Metrikle doğrula. Micrometer'da old gen için GC sonrası kullanım tabanının günler içinde yükseldiğini gör. Taban yükseliyorsa sızıntı, sabitse allocation yükü.
  2. GC log'una bak. Full GC'lerin sıklaşması ve her Full GC sonrası geri kazanılan belleğin azalması, canlı verinin heap'i doldurduğunu teyit eder.
  3. Heap dump al. Çökmeyi beklemeden, trafikten çıkarılmış bir pod'dan jcmd <pid> GC.heap_dump. Mümkünse birkaç saat arayla iki dump. Ayrıca HeapDumpOnOutOfMemoryError ile çöküş anının dump'ı.
  4. MAT ile analiz et. Dominator tree'nin tepesinde büyük retained size'lı bir ConcurrentHashMap görürsün. Path to GC roots: RequestContextFilter sınıfı → static AUDIT → ConcurrentHashMap → ArrayList<AuditEvent>. İki dump'ın histogram farkında büyüyen sınıf AuditEvent olur.
  5. Hafif alternatif: Heap dump almak riskliyse JFR ile OldObjectSample ve path-to-gc-roots=true.

3) Düzeltme

java
@Component
public class RequestContextFilter extends OncePerRequestFilter {

    private static final ThreadLocal<UserContext> CONTEXT = new ThreadLocal<>();
    private final AuditPublisher auditPublisher;

    public RequestContextFilter(AuditPublisher auditPublisher) {
        this.auditPublisher = auditPublisher;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws ServletException, IOException {
        try {
            CONTEXT.set(UserContext.from(req));
            auditPublisher.publish(AuditEvent.of(req));    // bellekte biriktirme: Kafka'ya / log'a / DB'ye
            chain.doFilter(req, res);
        } finally {
            CONTEXT.remove();                              // thread havuza temiz dönsün
        }
    }

    public static Optional<UserContext> current() {
        return Optional.ofNullable(CONTEXT.get());
    }
}

set çağrısı da try içinde: from(req) exception fırlatsa bile finally çalışır ve önceki değer kesin olarak temizlenir. Audit bellekte tutulmak zorundaysa sınırlı bir Caffeine cache (maximumSize + expireAfterAccess) ve thread-safe bir değer tipi kullanılır. Kullanıcı bağlamı için Spring Security kullanılıyorsa SecurityContextHolder zaten filtre zinciri tarafından yönetilir ve temizlenir. Java 25'te ScopedValue aynı ihtiyacı remove unutma riski olmadan karşılar.

Production karşılığı: Sızıntıların büyük kısmı "geçici olarak burada tutuyorum" diye başlayan static map'lerden gelir. Kod review'da her static, değiştirilebilir koleksiyon için "bunu ne siler?" sorusu sorulmalıdır. Cevap yoksa sızıntıdır.

q16 (JVM kısmı): Pod sürekli restart oluyor

Senaryo (ilgili kısım):

yaml
env:
  - name: JAVA_TOOL_OPTIONS
    value: "-Xmx1024m"
resources:
  requests: { cpu: "250m", memory: "1Gi" }
  limits:   { cpu: "250m", memory: "1Gi" }

Pod bazen OOMKilled oluyor. Probe'larla ilgili problemler (liveness'ın DB'yi içeren health endpoint'ine bakması, readiness ve startup probe eksikliği, graceful shutdown) Kubernetes bölümünde ele alınıyor (bkz. Bölüm 15). Burada JVM tarafını çözüyoruz.

Problem 1: -Xmx container limitine eşit. -Xmx1024m = 1Gi limit. JVM heap'e 1 GiB ayırabilir, üstüne Metaspace (Spring Boot uygulamasında kolayca yüz MB mertebesinde), thread stack'leri, code cache, Kafka client ve Netty gibi kütüphanelerin direct buffer'ları ve GC'nin kendi yapıları eklenir. Heap dolmadan bile toplam limit aşılır, kernel süreci öldürür. Bu bir Java OOM değildir: heap dump alınmaz, log'a bir şey düşmez, sadece OOMKilled ve çıkış kodu 137 görülür. Düzeltme: -XX:MaxRAMPercentage ile heap'i limitin bir yüzdesi olarak ver (1 GiB için 60-70 arası bir başlangıç), NMT ile heap dışı tüketimi ölç.

Quiz'deki açıklama "thread başına ~1 MB" diyor. Bu rezerve edilen sanal bellektir. RSS'e yansıyan, stack'in gerçekten kullanılan sayfalarıdır ve genellikle daha azdır. Yine de 200 Tomcat thread'i, Kafka consumer thread'leri ve executor'lar birlikte hesaba katılmalıdır.

Problem 2: CPU limiti 250m. JVM, kotayı yukarı yuvarlayarak 1 işlemci görür. Sonuçları:

  • GC thread'leri ve ForkJoinPool.commonPool() 1 işlemciye göre boyutlanır. CompletableFuture.supplyAsync varsayılan executor'ı, ortak havuzun paralelliği 2'nin altında olduğu için her görevde yeni thread açar.
  • Açılışta JIT ve sınıf yükleme CPU kotasını hızla tüketir, CFS throttling açılışı çok uzatır. Liveness probe erken başlıyorsa pod daha açılamadan öldürülür (CrashLoopBackOff). Bu, Kubernetes tarafındaki startup probe eksikliğiyle birleşen JVM tarafı nedendir.
  • Çalışma sırasında GC ve istek işleme aynı küçük kotayı paylaşır, throttling latency sıçramaları üretir.

Problem 3 (quiz'in model cevabında eksik): GC seçimi. Quiz, 1 işlemci yüzünden SerialGC seçildiğini söylüyor. Bu doğru, ama tek neden değil: memory limiti 1 GiB, yani server-class eşiği olan yaklaşık 1792 MB'ın altında. JDK 26 ve öncesinde bu tek başına SerialGC seçilmesine yeter. Bu yüzden quiz'in önerdiği düzeltilmiş manifest (CPU request 1, CPU limiti yok, memory 1Gi) Java 21 ve 25'te hâlâ SerialGC ile çalışır. G1 isteniyorsa -XX:+UseG1GC açıkça verilmelidir. JDK 27 (JEP 523) ile bu istisna kalktı.

Problem 4 (yine quiz'in düzeltmesinde gözden kaçan): CPU limitini kaldırmanın JVM tarafı. JDK 19+ JVM'ler CPU sayısını hesaplarken requests.cpu'yu kullanmaz. Limit yoksa node'un tüm çekirdeklerini görür. 32 çekirdekli bir node'da 1 CPU request'li pod, 32 işlemciye göre GC thread'i ve common pool kurar. Limiti kaldırmak throttling'i çözer ama bu yeni problemi getirir. -XX:ActiveProcessorCount=2 gibi bir değerle JVM'e gerçekçi sayı verilmelidir.

Düzeltilmiş JVM ayarları: Container bölümünün sonundaki YAML örneği tam olarak bu sorunun JVM tarafının düzeltmesidir: MaxRAMPercentage, açık UseG1GC, ActiveProcessorCount, MaxMetaspaceSize, ExitOnOutOfMemoryError ve kalıcı bir volume'a yazılan heap dump ve GC log'ları.

Arkadaki kavram: JVM'in ergonomisi (heap boyutu, GC seçimi, thread sayıları) container'ın bildirdiği kaynaklara göre otomatik çalışır. Bu otomatik kararları bilmeyen bir ekip, aslında hiç seçmediği bir konfigürasyonla production'a çıkar. jcmd <pid> VM.flags çıktısını her yeni servisin ilk deploy'unda kontrol etmek bu sürprizleri önler.

Üç seviyede cevap

Soru: "Heap ile stack arasındaki fark nedir?"

Stack'te primitive'ler ve referanslar, heap'te nesneler tutulur. Stack her thread için ayrı, heap ortak. Stack hızlıdır ve metot bitince temizlenir, heap'i GC temizler. Stack dolarsa StackOverflowError, heap dolarsa OutOfMemoryError alırız.

Stack thread başına ve metot çağrısı başına bir frame tutar: yerel değişkenler, parametreler ve operand stack. Frame metot bitince atılır, yani stack'te GC yok. Nesneler ve diziler heap'te. Bir nesnenin primitive alanları da nesneyle birlikte heap'te, static alan değerleri de heap'te, sınıf metadata'sı Metaspace'te. Yerel değişkenler thread-safe çünkü stack thread'e özel, ama gösterdikleri nesneler paylaşılabilir. Stack'teki referanslar GC root'tur. Bir nesne static bir alandan erişilebiliyorsa metot bitse bile yaşar.

Ayrımı "bellek nerede bildirildi" üzerinden kurarım: yerel olan frame'de, alan olan nesneyle birlikte heap'te, static olan heap'teki Class nesnesinde. "Primitive'ler stack'te" ifadesi bu yüzden eksik. Stack'in önemli olduğu iki nokta var: GC root olması ve thread başına rezerve edilen bellek. 200 Tomcat thread'i, Kafka ve executor thread'leri container bellek bütçesinde ayrı bir kalem. Virtual thread'lerde ise stack frame'leri park edildiğinde heap'te chunk olarak tutuluyor, bu yüzden milyonlarca virtual thread mümkün.

JIT tarafında escape analysis var, ama HotSpot nesneyi stack'e koymaz, kaçmıyorsa scalar replacement ile tamamen ortadan kaldırır. Bu sadece C2'nin derlediği sıcak kodda olur, tasarımı buna göre yapmam. Production'da bu ayrımı en çok "bu nesne neden toplanmıyor?" sorusunda kullanırım: cevap her zaman bir root'tan, genelde bir static koleksiyondan ya da havuzlanmış bir thread'in ThreadLocal'ından gelen referans zinciridir ve heap dump'ta path to GC roots ile görünür.

Junior cevap ezber cümleyi kuruyor ve "primitive'ler stack'te" gibi yaygın bir yanlışı içeriyor: bir nesnenin int alanının heap'te olduğunu bilmiyor. Mid cevap bu yanlışı düzeltiyor, GC root kavramını ve static alanın etkisini biliyor. Senior cevap konuyu bir kurala bağlıyor (bildirildiği yer), stack'in container bellek bütçesindeki yerini, virtual thread'lerin farkını ve escape analysis hakkındaki yaygın yanlışı ("stack'e koyar") doğru şekilde anlatıyor, sonra bilgiyi bir teşhis aracına (path to GC roots) bağlıyor.

Takip sorusu: "Bir metodun içinde oluşturduğun ve hiçbir yere vermediğin bir nesne her zaman heap'te mi oluşur?"

Soru: "Production'da memory leak olduğundan şüpheleniyorsun. Adım adım ne yaparsın?"

Heap'i artırırım, sorun devam ederse heap dump alıp VisualVM ile bakarım. Bir de kodda kapatılmayan connection ya da stream var mı diye bakarım.

Önce metriklerde GC sonrası heap kullanımının sürekli arttığını doğrularım. Sonra -XX:+HeapDumpOnOutOfMemoryError ile çalıştırır, gerekirse jcmd <pid> GC.heap_dump ile canlı dump alırım. Eclipse MAT'te Leak Suspects ve dominator tree'ye, en büyük retained size'a sahip nesneye ve onun path to GC roots'una bakarım. Tipik nedenler static map'ler, sınırsız cache'ler, temizlenmeyen ThreadLocal'lar ve kaydı silinmeyen listener'lar. Düzeltmede cache'e boyut ve TTL veririm, ThreadLocal'ı finally'de remove ederim.

Önce gerçekten sızıntı mı, onu ayırırım: GC sonrası tabanın yükselmesi sızıntı, sadece tepelerin yüksek olması allocation yükü. Heap sabitken pod'un RSS'i büyüyorsa sorun heap dışında, NMT'ye geçerim. Heap'i artırmak teşhis değil, sadece çöküşü erteler.

Heap sızıntısında iki dump alırım, birkaç saat arayla, ve histogram farkına bakarım. Bu büyüyeni gürültüden ayırır. Dump'ı trafikten çıkarılmış bir pod'dan alırım, çünkü dump süresince JVM durur ve liveness probe pod'u öldürebilir. Dump müşteri verisi içerdiği için onu güvenli bir yerde tutar, laptop'a indirmem. Dump almak riskliyse JFR'nin OldObjectSample olayı ve path-to-gc-roots ile hafif bir alternatifim var. MAT'te retained size ve root yolu genellikle kök nedeni doğrudan söylüyor. Düzeltmeden sonra aynı metrikle tabanın düzleştiğini doğrularım ve bir regresyon için alarm koyarım. Önleme tarafında kod review'da her static değiştirilebilir koleksiyon için "bunu ne siler?" diye sorarım, Micrometer'da yüksek kardinaliteli etiketlere izin vermem ve ExitOnOutOfMemoryError kullanırım ki OOM sonrası yarı ölü bir süreç trafik almasın.

Junior cevap "heap'i artırırım" ile başlıyor, bu belirtiyi gizler ve teşhisi geciktirir. Araç adı veriyor ama yöntem yok. Mid cevap doğru araç zincirini ve tipik nedenleri biliyor, bir sızıntıyı bulup düzeltebilir. Senior cevap önce hipotezi doğruluyor (sızıntı mı, yük mü, heap mi native mi), production'da dump almanın risklerini (STW, liveness, veri gizliliği) yönetiyor, düzeltmenin doğrulanmasını ve önleme adımlarını ekliyor. Fark, aracı bilmekte değil, operasyonel riski ve kanıt zincirini yönetmekte.

Takip sorusu: "Heap kullanımı sabit ama pod her gün biraz daha fazla RSS kullanıyor ve sonunda OOMKilled oluyor. Nerelere bakarsın?"

Soru: "G1 ile ZGC arasında nasıl seçim yaparsın?"

G1 varsayılan GC, ZGC ise düşük gecikmeli GC. Latency önemliyse ZGC, değilse G1 kullanırım.

G1 heap'i bölgelere ayırır, young toplamaları ve eşzamanlı işaretleme sonrası mixed toplamalar yapar, MaxGCPauseMillis (varsayılan 200 ms) hedefine göre çalışır. ZGC işaretleme ve sıkıştırmayı eşzamanlı yapar, colored pointer ve load barrier kullanır, duraklamaları heap boyutundan bağımsız olarak milisaniye altında tutar. Bedeli biraz daha fazla CPU ve bellek payı. Java 21'de generational ZGC -XX:+ZGenerational ile açılıyor, Java 23'ten beri varsayılan. Büyük heap ve sıkı latency hedefi varsa ZGC, genel API'ler için G1.

Önce GC'nin gerçekten sorun olduğunu kanıtlarım. p99 problemlerinin çoğu GC değil: downstream çağrılar, pool beklemesi ya da CPU throttling. GC log'unda duraklamaların p99'a gerçekten katkı verdiğini görürsem karar veririm. G1 çoğu e-ticaret API'si için doğru varsayılan: throughput ve latency dengeli, iyi anlaşılmış, tuning'e az ihtiyaç duyuyor. ZGC'yi sıkı p99 hedefi olan, büyük heap'li ve ek CPU'nun kabul edilebildiği servislerde düşünürüm. ZGC'nin riski allocation stall: allocation hızı toplama hızını geçerse thread'ler bekler ve bu pause metriklerinde görünmez, latency'de görünür. Ayrıca compressed oops kullanmadığı için küçük heap'lerde bellek maliyeti biraz artar.

Sürüm de önemli: Java 21'de generational ZGC için bayrak gerekiyor, 25'te varsayılan. Container'da önce JVM'in gerçekten G1 ile çalıştığını doğrularım, çünkü JDK 27 öncesinde 2 CPU'nun ya da yaklaşık 1792 MB belleğin altında sessizce SerialGC seçiliyor. Batch job'lar için Parallel GC'yi de masada tutarım. Değişikliği bir canary pod'da, aynı trafikle, p99, CPU ve RSS'i karşılaştırarak yaparım.

Junior cevap tek boyutlu: "latency için ZGC". Bedelini ve ne zaman gereksiz olduğunu bilmiyor. Mid cevap iki GC'nin mekanizmasını ve sürüm bilgisini doğru veriyor. Senior cevap kararı ölçüme bağlıyor (GC gerçekten sorun mu?), ZGC'nin metriklerde görünmeyen riskini (allocation stall) biliyor, container'da GC'nin sessizce Serial'e düşmesi gibi bir production tuzağını ekliyor ve değişikliği canary ile güvenli yapmayı öneriyor.

Takip sorusu: "GC log'larında sık 'humongous allocation' görüyorsun. Bu ne demek ve ne yaparsın?"

Soru: "Spring Boot servisini Kubernetes'te çalıştırırken JVM'in bellek ve CPU ayarlarını nasıl yaparsın?"

-Xmx'i container'ın memory limitine göre veririm. Resource request ve limit tanımlarım. Bellek yetmezse limiti artırırım.

-Xmx'i limite eşitlemem, çünkü JVM heap dışında da bellek kullanır: metaspace, thread stack'leri, code cache, direct buffer'lar. Bu yüzden -XX:MaxRAMPercentage=70 gibi bir yüzde veririm. JDK 10+ container limitlerini görüyor. Memory request ve limit'i eşit tutarım. CPU limiti çok düşükse throttling olur ve JVM 1 CPU görüp SerialGC seçebilir, bu yüzden gerçekçi bir CPU veririm. -XX:+ExitOnOutOfMemoryError ve heap dump ayarlarını eklerim.

Belleği bir bütçe olarak düşünürüm: limit = heap + metaspace + thread'ler + code cache + direct memory + GC yapıları. Heap'i MaxRAMPercentage ile veririm ki pod boyutu değişince otomatik ölçeklensin. Yüzdeyi container büyüklüğüne göre seçerim, küçük container'da heap dışının payı büyük. Sonra yük altında NMT ve RSS ile ölçüp ayarlarım. MaxMetaspaceSize veririm ki sızıntı OOMKilled yerine okunabilir bir hata olsun.

CPU'da iki tuzak var. Düşük limit throttling yapar, açılışı ve p99'u bozar. Limiti kaldırırsam JDK 19+ JVM request'e bakmadığı için node'un tüm çekirdeklerini görür ve onlarca GC thread'i, büyük bir common pool kurar. Bu yüzden ya gerçekçi bir limit ya da limit yok + -XX:ActiveProcessorCount. GC'yi açıkça seçerim, çünkü Java 21 ve 25'te 2 CPU'nun veya yaklaşık 1792 MB belleğin altında JVM sessizce SerialGC seçiyor. JDK 27'de bu kalktı. Her yeni servisin ilk deploy'unda jcmd <pid> VM.flags ile nihai heap'i, GC'yi ve işlemci sayısını doğrularım. ExitOnOutOfMemoryError, kalıcı volume'a heap dump ve GC log'u da standart. Dump'ların müşteri verisi içerdiğini unutmam.

Junior cevap tam olarak q16'daki hatayı tarif ediyor: limite göre -Xmx vermek pod'u OOMKilled yapar. Mid cevap bu hatayı ve temel düzeltmeleri biliyor, bir servisi güvenle çalıştırır. Senior cevap belleği ölçülebilir bir bütçe olarak ele alıyor, CPU tarafında hem throttling'i hem de limit kaldırılınca ortaya çıkan ters etkiyi (JDK 19+ davranışı) biliyor, GC ergonomisinin sürüme bağlı tuzağını söylüyor ve hepsini bir doğrulama adımıyla (VM.flags) bağlıyor.

Takip sorusu: "Pod OOMKilled oldu ama heap dump yok ve log'da hiçbir hata yok. Neden? Bir sonraki sefere neyi hazırlarsın?"

Sorulmadı ama sorulabilir

JIT, tiered compilation ve warm-up

HotSpot kodu önce yorumlar (interpreter), sık çalışan metotları önce hızlı derleyen C1 ile, sonra profil bilgisiyle agresif optimizasyon yapan C2 ile derler (tiered compilation). Inlining, escape analysis ve devirtualization hep bu profile dayanır. Sonuç: yeni açılan bir pod ilk dakikalarda hem daha yavaştır hem daha fazla CPU harcar ("warm-up"). Rolling deploy sırasında görülen geçici latency artışlarının bir nedeni budur. Önlemler: yeni pod'a trafiği kademeli vermek, açılışta CPU'yu kısmamak, sınıf yükleme maliyetini düşürmek için CDS/AppCDS. JDK 24 ile gelen ahead-of-time sınıf yükleme ve bağlama (JEP 483, Project Leyden'in ilk adımı) ve sonraki sürümlerdeki genişletmeleri açılışı daha da kısaltır.

Mülakat cümlesi: "Yeni pod'un ilk dakikaları JIT warm-up'tır. Trafiği kademeli veririm, açılışta CPU'yu throttling ile boğmam."

Safepoint ve time-to-safepoint

STW bir GC aşaması başlamadan önce tüm uygulama thread'lerinin bir safepoint'e gelmesi gerekir (metot çağrıları, döngü geri dönüşleri gibi noktalar). Bir thread safepoint kontrolü olmayan uzun bir döngüde (JIT'in "sayılı döngü" olarak optimize ettiği bir int döngüsü gibi) ilerliyorsa, diğer tüm thread'ler onu bekler. Bu bekleme süresi GC duraklamasının içinde görünmeyebilir ama uygulama durmuştur. -Xlog:safepoint ile ölçülür. Heap dump, thread dump ve bazı JVM işlemleri de safepoint gerektirir.

Mülakat cümlesi: "Duraklama sadece GC'nin süresi değil, tüm thread'lerin safepoint'e ulaşma süresini de içerir. -Xlog:safepoint ile ölçerim."

System.gc() çağırmalı mıyım?

System.gc() bir istektir, garanti değildir. Çoğu GC'de bir full GC tetikler ve uygulama kodunda çağrılması neredeyse her zaman yanlıştır. -XX:+DisableExplicitGC bu çağrıları yok sayar. Ama dikkat: JDK, direct buffer sınırına ulaşıldığında belleği geri kazanmak için içeride System.gc() kullanır. Bu bayrak, Netty veya NIO yoğun bir uygulamada OutOfMemoryError: Direct buffer memory riskini artırabilir. G1 için -XX:+ExplicitGCInvokesConcurrent, çağrıyı STW full GC yerine eşzamanlı bir döngüye çevirir.

Mülakat cümlesi: "Uygulama kodunda System.gc() çağırmam. DisableExplicitGC'yi de direct memory etkisini bilerek kullanırım."

Class loading ve sınıf yükleyiciler

Sınıflar ilk kullanımlarında tembel (lazy) olarak yüklenir. Yükleme, bağlama (doğrulama, hazırlık, çözümleme) ve ilklendirme (static bloklar) adımlarından geçer. Sınıf yükleyiciler parent delegation modeliyle çalışır: önce üstteki yükleyiciye sorulur (bootstrap → platform → application), bulunamazsa kendi yükler. Bir sınıfın kimliği "sınıf adı + onu yükleyen ClassLoader"dır. Aynı sınıfın iki farklı yükleyiciden yüklenmiş iki kopyası birbirine cast edilemez (ClassCastException: Order cannot be cast to Order). Spring Boot fat jar'ları iç içe jar'ları okuyabilmek için kendi sınıf yükleyicisini kullanır, devtools ise yeniden başlatma için ayrı bir yükleyici kullanır. Bu ikincisi "aynı sınıf cast edilemiyor" hatalarının sık kaynağıdır.

Mülakat cümlesi: "Sınıfın kimliği adı ve yükleyicisidir. ClassLoader'a kalan tek bir referans, yüklediği tüm sınıfları Metaspace'te tutar."

Java Memory Model ile JVM bellek alanları aynı şey değil

"Java memory model" ifadesi iki farklı şey için kullanılır ve mülakatta karıştırılması kötü bir sinyaldir. JVM çalışma zamanı bellek alanları (heap, stack, metaspace) bu bölümün konusudur: veri fiziksel olarak nerede durur. Java Memory Model (JMM, JLS bölüm 17) ise bir thread'in yazdığı değerin başka bir thread tarafından ne zaman ve hangi sırayla görüleceğinin kurallarıdır: happens-before, volatile, synchronized, final alan semantiği. JMM sorusuna heap/stack anlatmak, soruyu yanlış anlamak demektir (JMM için bkz. Bölüm 5).

Mülakat cümlesi: "Heap ve stack verinin nerede durduğu, JMM ise thread'ler arası görünürlük ve sıralama kurallarıdır."

Virtual thread'lerin bellek profili

Platform thread'i bir işletim sistemi thread'idir: rezerve edilmiş bir stack'i ve çekirdek kaynakları vardır, binlercesi pahalıdır. Virtual thread (Java 21, JEP 444) JVM tarafından yönetilir. Bloklayan bir I/O çağrısında taşıyıcı thread'den ayrılır, stack frame'leri heap'e chunk olarak kopyalanır ve taşıyıcı başka bir virtual thread'i çalıştırır. Bellek profili açısından sonuçlar: thread başına maliyet stack'in gerçek derinliği kadardır, ama bu bellek artık heap'te durur ve GC tarafından yönetilir. Yüz binlerce eşzamanlı virtual thread, heap kullanımında ve GC yükünde görünür. Ayrıca ThreadLocal'larda pahalı nesne cache'lemek anlamını yitirir, çünkü thread'ler havuzlanmaz.

Mülakat cümlesi: "Virtual thread'lerin stack'i heap'te yaşar. Thread sayısı ucuzlar ama bellek heap'e ve GC'ye taşınır, ThreadLocal cache'leri anlamını yitirir."

Sık yapılan hatalar

  • Container memory limitini -Xmx'e eşitlemek. Heap dışı alanlar limiti aşar, pod OOMKilled olur. MaxRAMPercentage ile heap'i limitin bir yüzdesi olarak ver, NMT ile ölç.
  • Varsayılan heap boyutuna güvenmek. Container'da varsayılan MaxRAMPercentage %25'tir, belleğin çoğu kullanılmaz. Yüzdeyi açıkça ver.
  • "Primitive'ler stack'te tutulur" demek. Sadece yerel primitive'ler frame'dedir. Nesne alanları ve static alanlar heap'tedir.
  • "JIT nesneleri stack'e koyar" demek. HotSpot kaçmayan nesneyi scalar replacement ile ortadan kaldırır, stack'e taşımaz. Sadece sıcak, C2 ile derlenmiş kodda olur.
  • Sızıntıya heap'i artırarak "çözüm" bulmak. Çöküş sadece ertelenir. GC sonrası tabanı ölç, heap dump ile kök nedeni bul.
  • Static Map'i sınırsız cache olarak kullanmak. GC root'tan erişilebilir, hiç küçülmez. Caffeine ile boyut ve süre sınırı ver.
  • ThreadLocal'ı finally içinde remove etmemek. Havuzlanmış thread'lerde değer yaşar ve bir sonraki isteğe sızar. set'i de try içine al.
  • Micrometer'da id veya ham URL'yi etiket yapmak. Her değer yeni bir meter üretir, registry sınırsız büyür. Etiketleri düşük kardinaliteli tut.
  • Production'da trafik alan pod'dan habersiz heap dump almak. Dump süresince JVM durur, liveness fail edip pod restart olabilir. Pod'u trafikten çıkar, volume hazırla.
  • Heap dump'ları sıradan bir dosya gibi paylaşmak. Müşteri verisi, token ve şifre içerir. Hassas veri gibi sakla.
  • OOM sonrası süreci yaşatmak. Bir thread ölmüş olabilir, servis yarı çalışır. -XX:+ExitOnOutOfMemoryError ile restart ettir.
  • Düşük CPU limitiyle (250m) JVM çalıştırmak. Throttling açılışı ve p99'u bozar, JVM 1 işlemci görüp SerialGC seçer.
  • CPU limitini kaldırıp işlemci sayısını unutmak. JDK 19+ JVM node'un tüm çekirdeklerini görür. -XX:ActiveProcessorCount ver.
  • G1 ile çalıştığını varsaymak. JDK 27 öncesinde 2 CPU'nun ya da yaklaşık 1792 MB belleğin altında SerialGC seçilir. GC'yi açıkça seç ve jcmd <pid> VM.flags ile doğrula.
  • -XX:+DisableExplicitGC'yi NIO yoğun uygulamada etkisini bilmeden açmak. Direct buffer geri kazanımını engelleyip Direct buffer memory OOM'una yol açabilir.

Hızlı tekrar

S: Bir Java sürecinin belleği -Xmx'ten neden büyüktür?

C: -Xmx sadece heap'i sınırlar. Üstüne metaspace, thread stack'leri, code cache, direct buffer'lar, GC yapıları ve diğer native bellek eklenir. Container limiti bu toplamı karşılamalıdır.

S: Bir nesnenin int alanı nerede tutulur? Static bir alanın değeri?

C: İkisi de heap'te: nesne alanı nesnenin içinde, static alan değeri (Java 7'den beri) sınıfın heap'teki Class nesnesinde. Sadece yerel primitive'ler stack frame'indedir.

S: Young GC neden ucuzdur?

C: Kopyalayan bir toplayıcıdır: sadece canlı nesneleri survivor'a veya old'a kopyalar, Eden'i bütünüyle boş kabul eder. Maliyeti canlı nesne sayısıyla orantılıdır, çöp miktarıyla değil.

S: Escape analysis HotSpot'ta ne yapar?

C: C2, metottan kaçmayan bir nesneyi scalar replacement ile hiç oluşturmaz, alanlarını register/yerel değişken olarak tutar; kilitlerini de kaldırabilir. Nesneyi stack'e taşımaz ve sadece sıcak, derlenmiş kodda olur.

S: Java'da bellek sızıntısı nedir?

C: Artık ihtiyaç duyulmayan ama bir GC root'tan (stack, static alan) hâlâ erişilebilen nesnelerdir. Belirtisi, GC sonrası heap tabanının zamanla yükselmesidir.

S: ThreadLocal neden thread havuzlarında sızıntı ve veri ihlali yaratır?

C: Değer, thread'in ThreadLocalMap'inde strong referansla durur; havuzlanmış thread süreç boyunca yaşar. remove edilmezse değer kalır ve aynı thread'i alan bir sonraki isteğe görünebilir. finally içinde remove gerekir.

S: Heap dump'ta retained size ile shallow size farkı?

C: Shallow nesnenin kendi boyutu, retained ise o nesne silinseydi serbest kalacak toplam bellek. Sızıntı avında dominator tree'de retained size'a ve path to GC roots'a bakılır.

S: Production'da heap dump alırken üç risk?

C: JVM dump süresince durur (liveness fail edip restart olabilir), dosya heap kadar büyüktür (volume gerekir) ve dump müşteri verisi içerir (hassas veri olarak saklanmalı).

S: Pod OOMKilled oldu, log'da hata ve heap dump yok. Neden?

C: Container memory limiti aşıldı ve kernel süreci SIGKILL ile öldürdü (exit 137). Bu bir Java OOM değildir; JVM hiçbir kod çalıştıramaz. Genelde heap + heap dışı toplamı limitten büyüktür.

S: Container'da -Xmx verilmezse heap ne olur?

C: Kullanılabilir belleğin %25'i (MaxRAMPercentage varsayılanı); yaklaşık 250 MB altında MinRAMPercentage (%50) kullanılır. Çoğu servis için azdır; yüzde açıkça verilir.

S: CPU limiti 250m olan pod'da JVM kaç işlemci görür ve ne olur?

C: Kota yukarı yuvarlanır: 1 işlemci. GC thread'leri ve common pool küçülür, JDK 27 öncesinde SerialGC seçilir, CFS throttling açılışı ve p99'u bozar.

S: CPU limiti olmayan bir pod'da JDK 19+ JVM kaç işlemci görür?

C: Node'un tüm çekirdeklerini, çünkü CPU shares (request) artık hesaba katılmaz (JDK-8281181). Çözüm -XX:ActiveProcessorCount ile gerçekçi bir sayı vermek.

S: JVM hangi durumda G1 yerine SerialGC seçer?

C: JDK 26 ve öncesinde, GC açıkça seçilmemişse ve makine 2 işlemcinin ya da yaklaşık 1792 MB belleğin altındaysa. JDK 27 ile (JEP 523) G1 her ortamda varsayılan oldu.

S: Generational ZGC hangi sürümde nasıl açılır?

C: Java 21'de -XX:+UseZGC -XX:+ZGenerational ile (JEP 439). JDK 23'ten beri generational mod varsayılan (JEP 474), JDK 24'te nesilsiz mod kaldırıldı (JEP 490); Java 25'te -XX:+UseZGC yeterli.

S: OOM sonrası neden süreci sonlandırmak daha güvenlidir?

C: OOM herhangi bir thread'i öldürebilir (ör. Kafka consumer thread'i); uygulama ayakta görünür ama işini yapmaz. -XX:+ExitOnOutOfMemoryError restart ettirir, HeapDumpOnOutOfMemoryError kanıtı saklar.

Kaynaklar