#java #architecture #exeris #jvm-performance #performance

Twój stos TLS kłamie w kwestii Zero-Copy

W runtime'ach budowanych pod zero-allocation, SSLEngine staje się problemem strukturalnym. Utrzymuje TLS po stronie sterty (heap), wymusza niedeterministyczne czyszczenie i łamie model własności pamięci off-heap.

Ograniczenie “No Waste Compute”

Kiedy zaczynałem projektować Exeris Kernel, bardzo wcześnie ustaliłem jedną, nienegocjowalną regułę: żadnego marnowania cykli procesora (no waste compute). Ta reguła brzmi jak marketingowy slogan, dopóki nie zacznie zabijać standardowych decyzji projektowych.

Zdążyłem już zbanować ThreadLocal, przenieść propagację kontekstu do Scoped Values i wypchnąć większą część runtime’u w stronę jawnego zarządzania pamięcią off-heap. Idea była prosta: jeśli hot path ma pozostać wolny od presji na Garbage Collector (GC), to kształt pamięci i czas jej życia nie mogą być traktowane jako przypadkowe detale.

A potem doszedłem do TLS.

Jedno zastrzeżenie na wejściu: to nie jest uniwersalny argument przeciwko SSLEngine. W standardowej usłudze Javowej to wciąż właściwy wybór — sprawdzony w boju i głęboko zintegrowany z ekosystemem. Ten artykuł dotyczy węższego problemu: co się dzieje, gdy TLS siedzi bezpośrednio na hot path runtime’u, który traktuje własność off-heap, deterministyczne zwalnianie i zero alokacji jako twarde warunki architektoniczne.

W większości aplikacji pisanych w Javie, warstwa TLS jest po prostu fragmentem stosu. Szyfruje bajty, przekazuje je dalej i zazwyczaj dyskutuje się o niej tylko wtedy, gdy psują się certyfikaty lub gdy opóźnienia (latency) nagle stają się widoczne na produkcji. Jednak w runtime’ie, w którym liczy się każdy bajt na ścieżce transportowej, TLS nie jest sprawą poboczną. Jest jedną z definiujących granic wykonania (execution boundaries). Każdy request przez nią przechodzi. Każdy response przez nią przechodzi. Jeśli ta granica wciąż operuje na kontraktach opartych o stertę (heap-facing), to cała reszta runtime’u musi się naginać do błędnego modelu.

Prawdziwym problemem, który zauważyłem w SSLEngine, nie było to, że jest abstrakcyjnie wolny, stary, czy nawet to, że alokuje pamięć. Znacznie głębszym problemem jest to, że SSLEngine wyraża granicę TLS w kategoriach zarządzanych przez JVM obiektów buforowych oraz widocznego dla sterty przepływu kontroli (control flow), podczas gdy reszta runtime’u bardzo stara się robić dokładnie odwrotnie.

Niedopasowanie w Kwestii Własności Pamięci (Ownership)

To niepowodzenie uwidoczniło się na poziomie kontraktu na długo przed pierwszym odpaleniem benchmarków.

SSLEngine jest zbudowany wokół ByteBuffer. Wołasz wrap(src, dst) i unwrap(src, dst). Z powrotem dostajesz obiekt SSLEngineResult. Zostajesz zamknięty w modelu, w którym granica TLS wyrażana jest przez obiekty API należące do JVM, nawet jeśli część pamięci operacyjnej używa pamięci bezpośredniej (direct memory).

To ma znaczenie, ponieważ w Exeris nie próbuję redukować presji na stertę (heap) tylko statystycznie. Staram się zdefiniować ścisłą, jawną własność pamięci przez cały hot path. To, czego oczekiwałem od tej granicy, to absolutna kontrola: kernel posiada pamięć wejściową, kernel posiada pamięć wyjściową, kernel kontroluje jej czas życia (lifecycle) i kernel może zwolnić natywny stan dokładnie wtedy, kiedy uzna pracę za zakończoną.

Tymczasem SSLEngine narzuca coś innego. Opiera się na wymianie buforów przez kontrakt obiektowy JDK oraz na przejściach stanów (state transitions) wyrażanych przez zwracane do JVM obiekty. Jego proces czyszczenia nie opiera się na tym samym jawnym modelu własności, na którym bazuje reszta kernela.

W tradycyjnym stosie (stack), odroczone zwalnianie pamięci (delayed cleanup) jest zazwyczaj akceptowalne, bo cały system i tak toleruje sporą ilość odroczonej pracy. W architekturze zorientowanej na off-heap “zwalnianie później” nie jest bez znaczenia. Oznacza to, że natywny stan TLS może przetrwać moment, w którym runtime logicznie zakończył już z nim pracę. Kiedy wyraźnie dostrzegłem to niedopasowanie w semantyce własności, przestałem patrzeć na SSLEngine jako na komponent do zoptymalizowania i zacząłem widzieć go jako granicę, która należała do błędnej architektury.

Figure 1: SSLEngine memory contract vs. Exeris Arena ownership model.

Kwestia Netty

Zanim zdecydowałem się na ścieżkę FFM, sprawdziłem OpenSslEngine od Netty bezpośrednio. To naprawdę szybki i sprawdzony kawałek inżynierii — i dla wielu systemów jest właściwą odpowiedzią. Operuje jednak w zupełnie innym paradygmacie architektonicznym.

Netty rozwiązuje problem off-heapu przez pule buforów (pooled buffers) i ręczne zliczanie referencji (reference counting - retain() i release()). To potężny model, ale nakłada strukturalny podatek: semantyka własności nieuchronnie przecieka do kodu biznesowego aplikacji, a zapomnienie o zwolnieniu bufora powoduje niesławne wycieki pamięci (memory leaks). To wciąż jest model spinający obiekty JVM i pamięć natywną przy pomocy ciężkiej warstwy abstrakcji we frameworku.

Dzięki Panama FFM w Exeris nie potrzebuję reference counting. Otrzymuję deterministyczną, rygorystyczną własność (ownership). Granice pamięci powiązane są z zakresami czasu życia (jak Arena), co oznacza, że cykl życia bufora TLS jest statycznie gwarantowany przez runtime, a nie zarządzany dynamicznie przez programistów zliczających referencje. Sama granica (boundary) jest czystsza, a koszt jej utrzymania spada.

Jawny Stan i FFM

Żeby zrozumieć, dlaczego to zmienia architekturę, spójrz na faktyczną implementację w Exeris Kernel.

Po pierwsze, odebrałem silnikowi TLS prawo do cichego zarządzania własnym cyklem życia. W TlsStateMachine przejścia między stanami są deterministyczne i powiązane bezpośrednio z kontekstem wykonawczym kernela, a nie zostawione na łaskę Garbage Collectora.

// Snippet from TlsStateMachine.java (Exeris Kernel)
// State transitions are explicitly modeled and bound to the off-heap lifecycle.

public void advanceState(TlsEvent event) {
    // I enforce strict state progression before any native call is made.
    // There is no ambiguous "maybe it's closed" state lingering on the heap.
    if (currentState == TlsState.HANDSHAKE && event == TlsEvent.APP_DATA) {
        throw new IllegalStateException("Cannot process application data during handshake");
    }
    // ... explicit state handling
}

Po drugie, przypiąłem samą operację kryptograficzną bezpośrednio do Panama FFM w OffHeapTlsEngine. Zwróć uwagę, że nie opakowuję tutaj (wrap) żadnych tablic ze sterty. Przekazuję wprost segmenty surowej pamięci lub oddelegowuję deskryptory plików bezpośrednio do natywnych funkcji OpenSSL.

// Snippet from OffHeapTlsEngine.java (Exeris Kernel)
// Zero-allocation FFM call: the raw off-heap address is passed directly —
// no MemorySegment wrapper object is created on the hot path.

public int writeRaw(MemorySegment sourceSegment) {
    // 1. The memory is off-heap and strictly owned by an Arena.
    // 2. Pass the raw native address (a long) directly via FFM downcall.
    try {
        long srcAddr = sourceSegment.address();
        return SSL_write(sslHandle, srcAddr, (int) sourceSegment.byteSize());
    } catch (Throwable t) {
        throw new TlsNativeException("FFM downcall to SSL_write failed", t);
    }
}

Koszty takiego podejścia (trade-off) są ewidentne: tracę siatkę bezpieczeństwa (safety net) w postaci walidacji granic bufora z ByteBuffer oraz czyszczenie z GC. W zamian zyskuję jednak absolutną kontrolę nad ścieżką danych (data path).

Czego dowiodły eksploracyjne pomiary

Zamiast przedwcześnie rzucać optymalizacyjnymi twierdzeniami, wolę brutalną transparentność. Natywna ścieżka TLS za pomocą FFM w Exeris wciąż nabiera ostatecznego kształtu, ale bardzo wczesne wyniki testów JMH dokładnie potwierdzają moje założenia strukturalne.

Przetestowałem cztery wyraźne modele architektoniczne:

  1. JDK SSLEngine: Standardowa granica oparta o stertę (tylko w obszarze pamięci).
  2. Netty tcnative: Off-heap przez JNI i zliczanie referencji w ByteBuf (wewnątrz channel pipeline).
  3. Exeris FFM (Memory BIO): Natywny TLS przez Panamę, gdzie runtime ma absolutną własność pamięci off-heap (in-process).
  4. Exeris FFM (FD Owner): Bezwzględny hot path. OpenSSL zapięty bezpośrednio pod deskryptor pliku gniazda (socket file descriptor), całkowicie z pominięciem pośrednich buforów pamięci — benchmark oparty o zapis przez loopback.
ArchitekturaGranica PamięciPrzepustowość (Throughput)Alokacja (Na rekord 1KB)
JDK SSLEngineHeap (ByteBuffer)~905k ops/s~2,528 B/op
Netty tcnativeOff-heap (ByteBuf)~856k ops/s~560 B/op
Exeris Memory BIOOff-heap (Panama Arena)~922k ops/s0 B/op
Exeris FD OwnerBezpośrednio na OS socket~367k ops/s0 B/op

(Metodologia: JMH faza gc, Oracle JDK 26 GA, ZGC, commit f778683, 2026-05-01. Faza profile dla Memory BIO dodatkowo potwierdzona przez JFR: zero zdarzeń jdk.GarbageCollection — ZGC nie odpalił ani jednej kolekcji przez cały przebieg benchmarku. Pełen zestaw pomiarów w exeris-benchmarks.)

Figure 2: The data path of Memory BIO vs FD Owner directly binding to the socket descriptor.

Rozpakujmy te liczby, bo kontekst ma tu większe znaczenie niż surowe cyfry.

W czystym pomiarze in-process (w pamięci), implementacja Exeris Memory BIO jest szybsza niż standardowy SSLEngine czy Netty tcnative. Runtime uciąga ~922,000 ops/s bez płacenia podatku strukturalnego za przepychanie obiektów buforowych z i na stertę.

Jednak absolutnie najważniejszą metryką architektoniczną jest ostatni wiersz: Exeris FD Owner.

Naiwna interpretacja mogłaby brzmieć: dlaczego przepustowość spadła do ~367,000 ops/s? Odpowiedź brzmi: ponieważ benchmark FD Owner całkowicie opuszcza syntetyczną arenę pamięci in-process. Zapisuje dane bezpośrednio do interfejsu pętli zwrotnej (loopback interface) systemu operacyjnego za pomocą deskryptorów plików gniazda. Na tym etapie nie mierzę już operacji kopiowania pamięci; uderzam w limity stosu sieciowego systemu operacyjnego i wywołań systemowych (syscalls).

Warstwa GC i Prawdziwy Koszt Abstrakcji

To, co zmieniło moje myślenie, nie była liczba ops/s. Zmieniło ją to, co pokazał -prof gc pod spodem.

Do przetworzenia standardowego payloadu 1024 bajtów, SSLEngine alokuje ponad 2.5 kilobajta śmieci (gc.alloc.rate.norm). Warstwa TLS generuje więcej odpadów heap niż szyfruje danych. Zdążyłem już wypchnąć resztę hot path poza stertę — a profiler GC mówił mi, że granica TLS po cichu cofa tę pracę przy każdym rekordzie.

Z kolei ścieżki oparte o Exeris FFM redukują znormalizowany wskaźnik alokacji do absolutnego zera. (Profiler rejestruje ~0.01 B/op przy zerowej rzeczywistej liczbie cykli GC, co w JMH jest standardowym szumem pomiarowym przy zjawisku kompletnego braku alokacji).

To jest rdzenna definicja “No Waste Compute”. Poprzez całkowite wyeliminowanie pośredniej warstwy buforów, kernel fundamentalnie zmienia pracę Garbage Collectora. GC po prostu przestaje sprzątać po TLS. ZGC nie jest już w ogóle zmuszane do porządkowania warstwy kryptograficznej.

Figure 3: Allocation rate (garbage generated) per 1KB payload across different TLS architectures.

Kiedy SSLEngine wciąż wygrywa

Kilka rzeczy pozostaje jednak prawdą nawet po tej architektonicznej zmianie.

Po pierwsze, SSLEngine wciąż jest właściwą odpowiedzią dla przytłaczającej większości systemów. Gdybym budował standardową aplikację w Spring Boot, usługę w Netty, czy cokolwiek, gdzie priorytetem jest ogromna prostota operacyjna przy standardowych kompromisach w JVM, w ogóle nie wpychałbym na siłę natywnej ścieżki TLS do projektu.

Po drugie, bufory bezpośrednie (direct buffers) i ich pule wciąż mają kolosalne znaczenie. To nie jest artykuł, który próbuje udawać, że cały dotychczasowy ekosystem Javy jest naiwny.

Ostatecznie, Panama FFM i natywny TLS wcale nie usuwają złożoności — one po prostu zmieniają jej położenie. Otrzymujesz absolutną kontrolę, ale dziedziczysz też absolutną odpowiedzialność za cykl życia pamięci, poprawność wykonania i zarządzanie błędami (failure modes). To jest ostra, architektoniczna decyzja pod wysoce wyspecjalizowany kernel, a nie uniwersalna rekomendacja dla całej branży.

Co zmieniłem i co oddałem

Dużo wysiłku optymalizacyjnego w JVM wciąż opiera się na założeniu, że sterta (heap) jest centrum wszechświata, a jedynym celem inżyniera jest sprawienie, by bolała jak najmniej. To ważny i poprawny sposób na projektowanie oprogramowania, ale nie taki design zaplanowałem dla Exeris.

Kiedy mój runtime zaczął migrować w stronę restrykcyjnej własności pamięci off-heap, SSLEngine przestał wyglądać jak niegroźna abstrakcja, a zaczął jawić się jako jedyna granica, która mogłaby po cichu wciągnąć całą ścieżkę transportową z powrotem w błędny model.

Zrezygnowałem z niego, ponieważ dla mojego kernela, ten kontrakt mówi po prostu złym językiem. Jeśli hot path jest zaprojektowany jako deterministyczny proces poza stertą, to TLS też musi się posługiwać tym językiem.


Natywna implementacja FFM TLS oraz bezwzględny model własności (ownership) są obecnie budowane całkowicie off-heap wewnątrz Exeris Kernel. Jeśli chcesz zweryfikować te liczby, uruchomić kod lub zgłębić architekturę zero-allocation: