Problem: dönə-dönə dayandırılan pod

Spring Boot servisi iki gün problemsiz işləyir, sonra container platforması onu yenidən başladır. Səbəb olaraq prosesin yaddaş limitini aşdığı və dayandırıldığı göstərilir. Komanda heap ölçüsünü artırır, servis bir az daha uzun yaşayır, sonra eyni hadisə təkrarlanır. Heç kim deyə bilmir: tətbiq yaddaş sızdırır, heap container üçün sadəcə çox böyükdür, yoxsa yaddaş ümumiyyətlə heap-də deyil.

Bu suallara cavab vermək üçün JVM-in məlumatı harada saxladığını göstərən xəritə və garbage collector-un nə etdiyinə dair sadə model lazımdır. Bu yazı ikisini də verir və JVM-in artıq özü yaratdığı sübutları necə oxumağı göstərir.

Yaddaş harada yaşayır

İşləyən JVM prosesi bir neçə ayrı bölgədən istifadə edir. Onlardan yalnız biri -Xmx ilə idarə olunur.

  • Heap. new ilə yaradılan bütün obyektlər: massivlər, string-lər və onları saxlayan kolleksiyalar. Bu bölgəni garbage collector idarə edir. "Java yaddaşı" deyəndə çox vaxt məhz bu nəzərdə tutulur.
  • Thread stack-ləri. Hər platform thread-in öz stack-i var və orada frame-lər saxlanılır: lokal dəyişənlər, parametrlər, qayıdış ünvanları. Stack ölçüsü thread başına təyin olunur (-Xss), yəni çox thread çox cəm deməkdir. StackOverflowError bir stack-in dolduğunu göstərir, adətən nəzarətsiz rekursiya səbəbindən.
  • Metaspace. Class metadata: yüklənmiş class-lar, metod məlumatları, constant pool-lar. Native yaddaşda yerləşir və daha çox class yükləndikcə böyüyür. İcra zamanı class generasiya edən və ya class loader-ləri dəfələrlə yenidən yükləyən tətbiqlərdə tükənə bilər.
  • Digər native yaddaş. JIT kompilyatorunun code cache-i, direct byte buffer-lər (Netty kimi şəbəkə kitabxanaları istifadə edir), memory-mapped fayllar, GC-nin daxili strukturları və native kitabxanalar.

Nəticə sadədir, amma tez-tez unudulur: əməliyyat sisteminin limiti bütün prosesə şamil olunur, yəni heap üstəgəl qalan hər şey limitə sığmalıdır. 1 GiB limitli container-də -Xmx1g artıq yanlış konfiqurasiyadır, çünki heap tək başına bütün limiti doldura bilər.

+------------------------- process yaddaşı ------------------------+
| heap (-Xmx)  | metaspace | thread stack  | code cache | direct    |
| obyektlər    | class-lar | N thread x    | JIT kodu   | buffer,   |
| GC idarə edir| native    | -Xss          |            | native    |
+------------------------------------------------------------------+
        container limiti yuxarıdakı bütün qutunu əhatə etməlidir

Generational ideya

Obyektlərin çoxu tez ölür: request DTO-su, müvəqqəti string və ya iterator request bitən kimi lazımsız olur. Generational collector-lar bu müşahidədən istifadə edir. Heap məntiqi olaraq young generation və old generation hissələrinə bölünür.

Yeni obyektlər young generation-da yaradılır. O dolduqda young collection sağ qalanları köçürür, qalanını təmizləyir. Az obyekt sağ qaldığı üçün bu ucuzdur. Bir neçə young collection-dan sağ çıxan obyektlər old generation-a keçirilir. Old generation daha az toplanır və bu iş daha bahalıdır, çünki orada cache-lər, connection pool-lar və singleton-lar kimi uzunömürlü məlumat durur.

Buna görə allocation rate heap ölçüsü qədər vacibdir. Çoxlu qısaömürlü obyekt yaradan servis tez-tez young collection-ı işə salır. Məlumatı lazımından uzun saxlayan servis old generation-ı doldurur və bahalı collection-lara səbəb olur.

G1 və ZGC ümumi çərçivədə

Collector JVM flag-i ilə seçilir və düzgün seçim nəyi optimallaşdırdığınızdan asılıdır.

  • G1 müasir JDK-larda əksər server səviyyəli maşınlarda default collector-dur. Heap-i çoxlu region-a bölür, young region-ları birgə toplayır, old region-ları isə mərhələli təmizləyir. Konfiqurasiya olunan pauza hədəfi (-XX:MaxGCPauseMillis) var, amma bu söz deyil, hədəfdir. Yaxşı ümumi seçimdir.
  • ZGC işinin əksər hissəsini tətbiq thread-ləri ilə paralel görür, buna görə pauzalar çox qısa qalır və heap böyüdükcə artmır. JDK 21-də generational rejim var (-XX:+UseZGC -XX:+ZGenerational). Aşağı gecikmə üçün müəyyən CPU və yaddaş əlavə xərci ödəyir; böyük heap-i və ya sərt gecikmə tələbi olan servislərə uyğundur.

Collector-u bir blog yazısına görə dəyişməyin. Yalnız ölçmələr pauzaların latency hədəflərinizə zərər verdiyini göstərəndə və hər iki collector-u öz trafikinizlə müqayisə edəndə dəyişin.

Container daxilində JVM ölçüsü

Müasir JVM container limitini oxuyur. Sabit -Xmx əvəzinə heap-i limitin payı kimi ifadə edə bilərsiniz:

java -XX:MaxRAMPercentage=65.0 -XX:+ExitOnOutOfMemoryError -jar app.jar

1 GiB limitdə bu təxminən 650 MiB heap verir, qalanı metaspace, stack-lər, code cache və direct buffer-lər üçün qalır. Default faiz qəsdən mühafizəkardır və onu artırmaq non-heap istifadənin ölçülməsinə söykənməlidir. -XX:+ExitOnOutOfMemoryError OutOfMemoryError zamanı prosesi bağlayır ki, orchestrator yarımçıq işləyən instance saxlamaq əvəzinə onu təmiz şəkildə yenidən başlatsın.

İki əlavə qeyd. Birincisi, platforma tərəfindən limiti aşdığına görə dayandırılan container ilə JVM-in OutOfMemoryError: Java heap space xətası fərqli hadisələrdir. Birincisi adətən non-heap və ya ümumi yaddaşa, ikincisi heap-ə aiddir. İkincisi, CPU limiti qoysanız, JVM GC və kompilyator thread sayını ondan çıxarır, buna görə çox kiçik CPU limitləri collection-ı ləngidə bilər.

GC log sətrini oxumaq

Unified logging-i -Xlog:gc ilə (ətraflı üçün -Xlog:gc*) aktiv edin. Tipik G1 sətri belə görünür (rəqəmlər nümunə xarakterlidir):

[10:15:32.123][info][gc] GC(42) Pause Young (Normal) (G1 Evacuation Pause) 612M->198M(1024M) 14.532ms

Soldan sağa oxuyun:

  • GC(42) collection-ın sıra nömrəsidir.
  • Pause Young (Normal) tətbiq thread-lərini dayandıran young collection olduğunu bildirir.
  • 612M->198M(1024M) heap doluluğunun əvvəl və sonrakı halıdır, mötərizədə cari ayrılmış heap var.
  • 14.532ms pauza müddətidir.

Axtardığınız tək sətir yox, tendensiyadır. Young collection-lardan sonra doluluq oxşar səviyyəyə qayıtmalıdır. Collection-dan sonrakı səviyyə durmadan artırsa və full collection-lar getdikcə az yer qaytarırsa, canlı çoxluq böyüyür; bu, sızmaya və ya limitsiz cache-ə işarədir. Doluluq sabit səviyyəyə qayıdırsa, amma collection-lar çox tez-tez baş verirsə, allocation rate yüksəkdir və bu qədər obyekti kimin yaratdığına baxmaq lazımdır.

Servislərdə sızma nümunələri

Java-da asılı qalmış pointer yoxdur, amma istənilməyən əlçatanlıq var. Geniş yayılmış nümunələr:

  • Limitsiz cache-lər. Heç vaxt çıxarma etməyən HashMap cache kimi istifadə olunur. Ölçü limiti və expiry olan cache işlədin.
  • Static kolleksiyalar. Listener, session və ya request məlumatı yığan və heç vaxt təmizlənməyən static List və ya Map.
  • Thread-local dəyərlər. Pool-dakı thread-də set edilib heç vaxt silinməyən ThreadLocal dəyəri thread yaşadıqca saxlayır.
  • Bağlanmamış resurslar. Açıq qalan stream-lər, HTTP response body-ləri və JDBC result set-ləri buffer-ləri saxlayır. try-with-resources istifadə edin.
  • Listener və callback-lər. Uzunömürlü publisher-də qeydiyyatdan keçib heç vaxt çıxarılmayan obyekt əlçatan qalır.
  • Böyük nəticə çoxluqları. Səhifələmə və ya streaming əvəzinə bütün cədvəli list-ə yükləmək.
// Sızma: map hər fərqli user id ilə böyüyür və heç vaxt kiçilmirprivate static final Map<String, UserProfile> CACHE = new HashMap<>();UserProfile profile(String userId) {    return CACHE.computeIfAbsent(userId, this::loadFromDatabase);}

Araşdırmaq üçün yaddaş yüksək olanda heap dump alın (jcmd <pid> GC.heap_dump file.hprof və ya avtomatik dump üçün -XX:+HeapDumpOnOutOfMemoryError) və profiler-də açın. Dominator tree-yə baxın: ən çox yaddaş saxlayan obyektlər və GC root-dan onlara gedən yol niyə təmizlənmədiklərini izah edir. Heap dump şəxsi məlumat ehtiva edə bilər, ona görə həssas fayl kimi saxlayın.

Tez-tez edilən səhvlər

  1. -Xmx-i container limitinə bərabər qoymaq.
  2. Böyüyən heap qrafikini yoxlamadan sızma saymaq. Collection-dan sonrakı doluluğa baxın.
  3. Hər OutOfMemoryError-a ilk reaksiya olaraq heap-i böyütmək. Böyük heap sızmanı gecikdirir və sonrakı dump-u böyüdür.
  4. İnternetdən kopyalanmış onlarla GC flag-i ilə tuning etmək. Default-larla başlayın və bir dəfədə bir şeyi dəyişin.
  5. Thread sayının yaddaş olduğunu unutmaq. Minlərlə platform thread-in hər biri stack ayırır.

Trade-off-lar

Böyük heap daha az collection deməkdir, amma stop-the-world collector üçün ən pis hal işi uzadır və yaddaş xərcini artırır. Kiçik heap yaddaşa qənaət edir, amma tez-tez collection riski yaradır. Aşağı pauzalı collector-lar CPU və yaddaş xərci tələb edir. Universal parametr yoxdur; yalnız latency hədəfinizə, trafik formanıza və container büdcənizə uyğun, ölçmə ilə təsdiqlənmiş parametr var.

Yoxlama siyahısı

  • Container limiti heap-dən böyükdür və non-heap yaddaş üçün yer var.
  • MaxRAMPercentage (və ya -Xmx) təsadüfən default qalmayıb, şüurlu seçilib.
  • GC logging aktivdir və tətbiq log-ları ilə birlikdə göndərilir.
  • Heap və non-heap yaddaş metrik kimi ixrac olunur və tendensiyaya görə alert qurulub.
  • Cache-lərin ölçü limiti və expiry siyasəti var.
  • Servis OutOfMemoryError zamanı çıxır ki, yenidən başladıla bilsin.

Sonra nə oxumalı