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()przedfork().*
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:
- 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ć.
- 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.

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:
- Allocator zwraca bufor z
refCount = 1. - Przed forkiem rodzic woła
retain(). Licznik to teraz 2. - Rodzic forkuje sub-task. Sub-task działa współbieżnie.
- Sub-task zamyka bufor gdy skończy. Licznik spada do 1.
try-with-resourcesrodzica 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.

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:
| Poziom | Wykorzystanie off-heap | Decyzja ResourceArbiter |
|---|---|---|
NORMAL | < 70% | ALLOW - alokacje przechodzą |
WARNING | 70–85% | THROTTLE - duże alokacje odrzucane |
CRITICAL | 85–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):
| Kod | Znaczenie | Glass-Box payload (rawArgs) |
|---|---|---|
EX-MEM-1001 | Off-heap Exhausted | [0] long requestedBytes, [1] long availableBytes |
EX-MEM-1002 | Arena Leak Detected | [0] long segmentAddress, [1] long segmentByteSize |
EX-MEM-1003 | Peek 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.