#java #memory #panama-ffm #exeris #jvm-performance #off-heap

ByteBuffer rozwiązuje połowę problemu: wzorzec LoanedBuffer

Direct ByteBuffer daje ci zero-copy. Nie daje ci deterministycznego czyszczenia. W runtime'ie, w którym własność pamięci jest częścią architektury, ta luka jest strukturalna - nie kwestią tuningu.

To jest siódmy artykuł w serii Exeris Kernel.

*TL;DR: Direct ByteBuffer daje ci zero-copy, ale czyszczenie deleguje do GC. Arena daje deterministyczne czyszczenie, ale ogranicza własność do pojedynczego regionu. Żaden z nich nie modeluje bufora dzielonego między wątkami z czasem życia dłuższym niż pojedynczy fork. LoanedBuffer to trzecia opcja której Exeris potrzebował: jawne zliczanie referencji, try-with-resources dla nudnego przypadku, i EX-MEM-1003 gdy dyscyplina zawodzi. Koszt jest uczciwy

  • kompilator nie złapie zapomnianego retain() przed fork().*

Możesz wypchnąć TLS poza stertę. Możesz zbanować ThreadLocal i zastąpić go przez ScopedValue. Możesz ustrukturyzować współbieżność przez StructuredTaskScope. Możesz zepchnąć granicę TLS do Panama FFM tak żeby operacje kryptograficzne przestały alokować. I dalej będziesz znajdował presję alokacyjną na hot path - bo gdzieś, coś przenosi bajty przez byte[].

Ten artykuł jest o tej granicy.

Jest też miejscem w którym jedno konkretne założenie z ery JVM ostatecznie się łamie: że direct ByteBuffer to “ten off-heapowy”. Direct ByteBuffer rozwiązuje zero-copy. Nie rozwiązuje własności (ownership). W runtime’ie w którym cykl życia pamięci ma być częścią architektury, to nie jest ten sam problem.


Ograniczenie

Kiedy zaczynałem projektować subsystem pamięci dla Exeris, miałem dwa ograniczenia które musiały zachodzić jednocześnie na hot path żądania:

  1. Zero-copy. Bajty zchodzące z socketa, przez TLS, przez framing HTTP, do handler’a żądania - żaden z tych kroków nie może alokować nowej tablicy i kopiować.
  2. Deterministyczne czyszczenie. Gdy praca na buforze się skończy, pamięć natywna musi być zwolniona teraz, a nie wtedy gdy GC sobie przypomni.

Większość abstrakcji pamięciowych w JVM daje ci dokładnie jedno z tych dwóch.

byte[] nie daje żadnego. Alokacja na stercie, cykl życia zarządzany przez GC, i kopia za każdym razem gdy przekraczasz granicę natywną.

ByteBuffer.wrap(byte[]) to bufor na stercie pod inną nazwą. Te same problemy.

ByteBuffer.allocateDirect(n) daje ci (1) ale nie (2). Segment jest off-heap, więc przejście do kodu natywnego nie wymaga kopii. Ale leżąca u podstaw pamięć jest zwalniana dopiero gdy wątek Cleaner zauważy że referencja do ByteBuffer stała się nieosiągalna. Pod obciążeniem oznacza to że bufory mogą przetrwać arbitralnie długo po zakończeniu pracy. Nie kontrolujesz kiedy. Nie możesz zapytać. Nie ma close().

Arena.allocate(layout) z Panama FFM daje (2) ale z grubszym modelem własności. Arena posiada region pamięci; zamknięcie areny zwalnia wszystko co w niej jest. To jest OK dla czasu życia żądania. Jest mniej OK gdy bufor jest dzielony między wątkami, albo przenoszony z jednego zadania do drugiego i zwalniany przez to drugie, albo jest częścią kolejki in-flight.

Potrzebowałem obu. I potrzebowałem żeby się komponowały.


ByteBuffer rozwiązuje połowę problemu

To co odwiodło mnie od bezpośredniego użycia ByteBuffer to nie była ergonomia API. To że ByteBuffer nie ma modelu własności.

Ma model dostępu - position, limit, capacity, slice, duplicate - ale nic co odpowiada na pytanie “kto jest odpowiedzialny za zwolnienie tej pamięci i kiedy?”. Bufory direct delegują to pytanie do GC. Bufory z backing’iem na stercie delegują je do GC dwukrotnie - raz dla samego obiektu bufora, raz dla tablicy którą opakowują.

To działa dobrze gdy alokacje są rzadkie a czasy życia oczywiste. Łamie się gdy bajty płyną przez współbieżność 1-VT-per-stream przy prędkości sieci a wątek Cleaner jest jedyną rzeczą stojącą między tobą a powolnym, cichym wyciekiem pamięci natywnej.

Standardowy workaround w JVM to pulowanie buforów: trzymaj ConcurrentLinkedQueue z instancjami direct ByteBuffer, wydawaj je, i ufaj wywołującym że je oddadzą. To działa w praktyce i podpiera frameworki takie jak Netty. Re-wprowadza też dokładnie ten problem który GC próbował rozwiązać: jawne zarządzanie cyklem życia, z dodatkowym twistem - zapomnienie o zwrocie jest teraz ciche, bufor po prostu wsiąka w pamięć-sierotę bez zdarzenia Cleaner którym można by to zauważyć.

To czego chciałem to dyscyplina cyklu życia jak w Arena w połączeniu z elastycznością pulowanego bufora - i sposób na dzielenie własności między wątkami bez locka ani wycieku.

To jest czym LoanedBuffer jest. Reszta tego artykułu to ile to kosztowało.

Rysunek 1: Trzy modele własności - ByteBuffer (sprzężony z GC), Arena (ograniczony scope'em), LoanedBuffer (reference-counted z jawnym close).

Wzorzec Loan

Podstawowy kształt nie zaskakuje. LoanedBuffer implementuje AutoCloseable. Alokujesz przez SPI. Używasz try-with-resources:

try (LoanedBuffer buffer = allocator.allocate(AllocationHint.MEDIUM)) {
    buffer.writeBytes(payload, 0, payload.length);
    transport.send(buffer);
}

Trzy rzeczy dzieją się tutaj których ByteBuffer ci nie daje.

Po pierwsze, allocator jest wstrzykiwany przez SPI. Kod aplikacji nie wie czy allocator jest pulą slabów, partycjonowaną areną, czy wyspecjalizowaną pulą natywną zoptymalizowaną pod konkretny transport. Implementation-blindness jest zachowane - Core operuje wyłącznie na kontrakcie MemoryAllocator, rozwiązanym przy bootstrap’ie przez ServiceLoader:

MemoryAllocator allocator = ServiceLoader.load(MemoryAllocator.class)
        .findFirst()
        .orElseThrow(() -> new KernelBootstrapException(KernelErrorCodes.EX_BOOT_0002));

Po drugie, AllocationHint jest typowanym enum’em, nie surowym byte count’em. Hint mówi allocator’owi której klasy rozmiaru się oczekuje (SMALL, MEDIUM, LARGE, NETWORK_FRAME). Allocator wybiera slab z dopasowanej puli. Nie ma matmy w miejscu wywołania, nie ma zaokrąglania, nie ma “czy właśnie odpaliłem slow path?”.

Po trzecie, close() jest deterministyczne i natychmiastowe. Gdy blok try kończy działanie, slab wraca do swojej puli teraz. Watermark manager aktualizuje się teraz. Nie ma wątku Cleaner, nie ma PhantomReference, nie ma czekania na GC.

To jest nudny, single-owner case. Wzorzec zarabia na swoją nazwę w następnym przypadku - tym którego ByteBuffer nie umie w ogóle zamodelować.


Zliczanie referencji przez VarHandle

Wewnątrz kernela, bufor często ma więcej niż jednego logicznego właściciela. Warstwa transport chce go trzymać podczas gdy handler żądania czyta. Handler chce go trzymać podczas gdy async’owa praca jest w locie. Async’owa praca może chcieć go zatrzymać dla operacji follow-up.

LoanedBuffer rozwiązuje to przez jawne zliczanie referencji (reference counting). Alokacja zaczyna licznik od jednego. retain() inkrementuje. close() dekrementuje. Gdy licznik osiągnie zero, slab wraca do puli.

Implementacja używa VarHandle dla ścieżki CAS. Bez synchronized, bez alokacji AtomicInteger per bufor, bez monitor inflation. Licznik referencji jest prymitywnym polem int na samym buforze, dostępnym przez VarHandle klasowy:

public final void retain() {
    REF_COUNT_HANDLE.getAndAdd(this, 1);
}

public final void close() {
    int previous = (int) REF_COUNT_HANDLE.getAndAdd(this, -1);
    if (previous == 1) {
        fireCloseActions();
    }
}

Wywołanie close() więcej razy niż bufor był retain’owany to fatalne naruszenie kontraktu. Wywołanie retain() na widoku który nie posiada własności - na przykład slice’ie peek() który eksponuje region pamięci bez przekazania własności - to też naruszenie kontraktu. Kernel emituje EX-MEM-1003 (Peek View Ownership Misuse) jako glass-box telemetry event gdy to się stanie, z metodą wywołującą zapisaną w rawArgs[0]. Samo wywołanie jest no-op’em: ani nie inkrementuje licznika, ani nie wraca po cichu. Jest logowane i odmawiane.

Sensem odmowy nie jest karanie. Sensem jest to że nieobserwowany bug typu retain()-na-widoku staje się use-after-free gdzieś indziej, na innym wątku, w nieprzewidywalnym momencie. Failing fast i głośno w miejscu nadużycia czyni buga lokalnym zamiast rozproszonym.


Problem transferu async’owego

To jest przypadek który motywował projekt. Nie musiałem go debugować na produkcji - wyłapałem to na etapie szkicowania modelu własności, i projekt poszedł od tego dalej.

Przychodzi żądanie. Handler wczytuje je do bufora. Handler następnie forkuje dwa async’owe sub-taski pod StructuredTaskScope: jeden do walidacji, jeden do enrichment’u. Oba sub-taski muszą czytać ten sam bufor. Handler joinuje oba, potem serializuje response.

W standardowym modelu JVM to jest problem dzielenia bez dobrej odpowiedzi. Jeśli przekażesz ByteBuffer do dwóch Virtual Thread’ów, właśnie stworzyłeś zagrożenie aliasingu bez modelu współbieżności. Jeśli skopiujesz bufor dwukrotnie, właśnie pokonałeś zero-copy.

W modelu LoanedBuffer dzielenie jest jawne:

try (var scope = StructuredTaskScope.open(Joiner.awaitAllSuccessfulOrThrow())) {
    try (LoanedBuffer buffer = allocator.allocate(AllocationHint.NETWORK_FRAME)) {
        buffer.retain();

        scope.fork(() -> {
            try {
                return processAsync(buffer);
            } finally {
                buffer.close();
            }
        });

        scope.join();
    }
}

Wzorzec to:

  1. Allocator zwraca bufor z refCount = 1.
  2. Przed forkiem rodzic woła retain(). Licznik to teraz 2.
  3. Rodzic forkuje sub-task. Sub-task działa współbieżnie.
  4. Sub-task zamyka bufor gdy skończy. Licznik spada do 1.
  5. try-with-resources rodzica zamyka się gdy zewnętrzny blok wychodzi. Licznik spada do 0. Slab wraca do puli.

To jest dependency-safe bo retain() wydarzyło się przed fork’iem. Jeśli wywołujący zapomni retain(), close() rodzica może się ścigać z odczytem sub-task’a, a sub-task obserwuje slab który został już zrecyklingowany. Kernel łapie to w swoim TCK suite, ale honorowanie kontraktu jest po stronie wywołującego

  • nie ma automatycznego retain przy fork’u. Rozważałem zrobienie tego żeby scope.fork() automatycznie retain’ował bufor jeśli przekazano specjalny typ wrappera, ale koszt to było wprowadzenie równoległej powierzchni API dla czegoś co fundamentalnie jest kwestią dyscypliny. Obecny projekt trzyma regułę widoczną w miejscu wywołania: jeśli forkujesz, najpierw retain’uj.

To jest też miejsce gdzie wzorzec z artykułu o STS-bootstrap zarabia po raz kolejny. Tam, structured scope posiadał startup round - ograniczoną jednostkę pracy z jasnym czasem życia. Tutaj, structured scope posiada fan-out unit z tą samą jasnością, ale z dodatkowym zasobem - buforem - którego czas życia jest dłuższy niż dowolny pojedynczy fork i krótszy niż otaczający scope. Model własności musi to wspierać.

StructuredTaskScope nie wspiera. LoanedBuffer wspiera.

Rysunek 2: Async'owy transfer własności między dwoma wątkami. Licznik referencji śledzi żywą własność; close actions odpalają się tylko przy zerze.

Kontrakt JMM pod tym wszystkim warto wyartykułować bezpośrednio bo łatwo go źle zrozumieć. Nie ma jawnych memory fence’ów na ścieżce close-action. Widoczność slotów close-action zapisanych przez wątek alokujący dla wątku zwalniającego jest gwarantowana przez bezpieczną publikację (safe publication) samej referencji do bufora. Gdy rodzic przekazuje bufor do scope.fork(), implementacja structured-scope w JDK publikuje referencję bezpiecznie - ta publikacja to to co czyni wszystkie pola bufora widocznymi dla sub-task’a, łącznie z chain’em close-action. Jeśli ominiesz scope.fork() i przekażesz bufor do innego wątku przez, powiedzmy, pole non-volatile, model się łamie.

To jest też powód dla którego allocator transport Community używa semantyki shared-arena dla wszystkich alokacji zamiast Arena.ofConfined(). Carrier thread alokuje, ale per-stream Virtual Thread zamyka - różne wątki, ten sam bufor. Confined arena odmówiłaby cross-thread’owego close(). Shared arena pozwala na to, z safe-published referencją do bufora niosącą widoczność z JMM.


Watermarki i granica presji

LoanedBuffer rozwiązuje per-buffer ownership. Nie rozwiązuje presji agregatowej.

Gdy pule slabów zaczynają się kończyć, kernel musi wiedzieć - i zdecydować co z tym zrobić - zanim EX-MEM-1001 (Off-heap Exhausted) zostanie rzucony na hot path żądania. To jest zadanie WatermarkManager.

Manager eksponuje cztery poziomy progowe:

PoziomWykorzystanie off-heapDecyzja ResourceArbiter
NORMAL< 70%ALLOW - alokacje przechodzą
WARNING70–85%THROTTLE - duże alokacje odrzucane
CRITICAL85–95%REJECT - tylko ruch krytyczny
SHEDDING≥ 95%SHED_LOAD - EX-MEM-1001 rzucany

ResourceArbiter czyta bieżący poziom przy każdym żądaniu alokacji:

public LoanedBuffer tryAllocate(AllocationHint hint) {
    if (watermark.isHighWatermarkBreached()) {
        throw new MemoryExhaustedException(hint.bytes(), watermark.availableBytes());
    }
    return allocator.allocate(hint);
}

To jest miejsce w którym LoanedBuffer łączy się z następną warstwą architektoniczną. Poziomy watermark’u to nie tylko wewnętrzna księgowość - eksponują presję jako typowany sygnał (WatermarkLevel), na który reszta kernela - scheduling, admission, logika biznesowa - może reagować bez inspekcji liczników GC ani parsowania zdarzeń JFR w runtime’ie. Jak transport edge używa tego sygnału żeby zrzucić obciążenie zanim praca trafi do kernela, to osobna decyzja i należy do swojego artykułu.


Wykrywanie wycieków: gdy dyscyplina zawodzi

Wzorzec Loan opiera się na dyscyplinie. Każda alokacja musi być sparowana z close(). Każdy retain() musi być sparowany z kolejnym close(). Nie ma fallback’u na GC.

Na produkcji ta dyscyplina jest egzekwowana przez powierzchnię API - try-with-resources, sealed types, glass-box telemetry z EX-MEM-1003. W development i testach jest egzekwowana przez LeakTracker, który integruje java.lang.ref.Cleaner żeby wykryć bufory które stały się nieosiągalne bez bycia zamkniętymi.

Gdy LeakTracker działa w trybie PARANOID i obserwuje LoanedBuffer którego licznik referencji jest niezerowy w momencie GC, emituje EX-MEM-1002 (Arena Leak Detected):

KodZnaczenieGlass-Box payload (rawArgs)
EX-MEM-1001Off-heap Exhausted[0] long requestedBytes, [1] long availableBytes
EX-MEM-1002Arena Leak Detected[0] long segmentAddress, [1] long segmentByteSize
EX-MEM-1003Peek View Ownership Misuse[0] String callerMethod

Wyciek jest logowany z adresem segmentu i rozmiarem, i emitowane jest zdarzenie JFR. W długo działającym teście to zamienia “zapomniałem o close gdzieś” w konkretny, actionable sygnał ze stack trace’em.

To nie łapie wszystkich wycieków. Referencja trzymana przez długożyjącą strukturę danych nie zostanie zGC’owana, a LeakTracker nie odpali. Dyscyplina terminalStateCatalog którą opisałem w artykule o Flow stosuje się też tutaj: długożyjące cache’e in-memory potrzebują własnej polityki ograniczonej retencji. Wzorzec łapie zapomniane referencje, nie celowo zatrzymane.

Trade-off jest uczciwy. LeakTracker jest narzędziem development i staging, nie production safety net. Na produkcji powierzchnia API i code review są podstawową obroną. W development tryb PARANOID to różnica między “jest gdzieś wyciek w 50k LOC” a “wyciek jest w OrderHandler.java linia 142, alokowany z NetworkFrameSlabPool, 4096 bajtów.”


Co dalej pozostaje prawdą

Kilka rzeczy zostaje prawdą nawet po tym jak ten model jest na miejscu. Niektóre z nich to powody żeby go nie adoptować.

ByteBuffer wciąż jest właściwą odpowiedzią dla większości aplikacji Java. Jeśli budujesz normalny serwis HTTP a twoim wąskim gardłem nie jest presja alokacyjna na hot path żądania, wzorzec Loan jest over-engineering’iem. Kosztuje obciążenie kognitywne na każdej ścieżce odczytu, każdym forku, każdym cross-thread’owym handoff’ie. Ten koszt jest uzasadniony przez ograniczenie, nie przez estetykę.

Arena wciąż jest użyteczna dla alokacji o czasie życia żądania które nie potrzebują dzielenia. Wewnątrz pojedynczego Virtual Thread’a, z jasną granicą scope, Arena.ofConfined() jest prostszą rzeczą niż refcounted buffer. Kernel używa obu wzorców tam gdzie każdy pasuje.

GC wciąż jest twoim przyjacielem dla grafów obiektów. Nic we wzorcu Loan nie mówi “nigdy nie alokuj na stercie”. Wzorzec jest specyficzny dla pamięci off-heap na hot path żądania. Obiekty domenowe, instancje planów, rekordy logów - wszystkie żyją tam gdzie Java zawsze je trzymała.

Wzorzec nie rozwiązuje IPC międzyprocesowego. Jeśli bufor opuszcza proces - wysyłany przez sieć, zapisany do pliku shared memory, przekazany do innej JVM - licznik referencji przestaje być znaczący. Model LoanedBuffer jest poprawny tylko dla czasu życia in-process. Handoff do innego procesu to jego własny problem granic z własną semantyką własności.

W końcu, wzorzec Loan nie zmiękcza kosztu dyscypliny. Każdy fork musi retain’ować. Każde dzielenie musi retain/close. Każda ścieżka async musi zamknąć w finally. Kompilator nie złapie pominięć za ciebie. Code review złapie. TCK złapie. LeakTracker złapie, w development. Runtime - nie.

Rozważałem zrobienie tego mniej jawnym - typ wrappera który auto-retain’uje przy ucieczce z metody, adnotacja która zmusza kompilator do egzekwowania sparowanych close(). Każde z tych dodałoby albo runtime overhead, albo równoległe API, albo wymagałoby statycznego analyzera który nie istnieje. Obecny projekt akceptuje koszt dyscypliny jako cenę modelu architektonicznego.


Do czego ciągle wracam to fakt że to nie jest sprytna struktura danych. To jest kontrakt - kto posiada tę pamięć, kto ma prawo przedłużyć jej czas życia, i co jest obserwowalne gdy ktoś się pomyli. Implementacja jest nieefektowna: VarHandle, int, chain close-action. Praca to decyzja że własność należy w ogóle do systemu typów, i zaakceptowanie że koszt dyscypliny to cena modelu.

Następna decyzja architektoniczna w serii to co kernel robi z sygnałem presji gdy go już ma - jak WatermarkLevel staje się decyzją o shed’zie na edge’u sieci, i jedyne miejsce w kernelu gdzie nieustrukturyzowane Virtual Thread’y są celowo dozwolone.


Zbadaj Exeris Kernel - architektura zero-allocation w działającym kodzie: 🔗 exeris-systems/exeris-kernel

Subsystem pamięci żyje w exeris-kernel-spi (MemoryAllocator, LoanedBuffer, AllocationHint) i exeris-kernel-core (AbstractLoanedBuffer, WatermarkManager, ResourceArbiter, LeakTracker). Jeśli chcesz zobaczyć jak kontrakt refcount / retain() / close() zachowuje się przy cross-thread’owym fork-and-join, suite TCK w exeris-kernel-tck to najszybsze wejście.