Начиная с JDK 28, API Foreign Function and Memory (FFM) сможет выделять небольшие объёмы памяти из повторно используемых пулов нативной памяти, если приложение использует Arena.ofConfined().Главное преимущество этого нововведения — отсутствие необходимости изменять исходный код при существенном сокращении накладных расходов в распространённых сценариях использования FFM.Разбираемся в новом переводе от команды Spring АйО Читать далее
Уровень сложностиПростой
Время на прочтение7 мин
Охват и читатели3.4K
Обзор
Перевод
Начиная с JDK 28, API Foreign Function and Memory (FFM) сможет выделять небольшие объёмы памяти из повторно используемых пулов нативной памяти, если приложение использует Arena.ofConfined().
Главное преимущество этого нововведения — отсутствие необходимости изменять исходный код при существенном сокращении накладных расходов в распространённых сценариях использования FFM.
Выделение небольших объёмов нативной памяти — распространённая операцияАрены, привязанные к одному потоку (confined arenas), часто используются для временного хранения данных при вызовах нативных функций. Типичный пример:
try (Arena arena = Arena.ofConfined()) {
MemorySegment result = arena.allocate(ValueLayout.JAVA_INT);
nativeFunction.invokeExact(result); // Writes a result in the provided segment
return result.get(ValueLayout.JAVA_INT, 0);
}Этот подход обеспечивает детерминированное управление временем жизни памяти и строгие гарантии её доступности только одному потоку. После закрытия арены связанные с ней сегменты памяти немедленно становятся недоступными. В частности, такой шаблон часто применяется при обработке errno в системных вызовах.
До JDK 28 даже для выделения крошечного участка памяти мог потребоваться вызов стандартного нативного аллокатора и регистрация соответствующей операции очистки. Если нативной функции нужен лишь указатель, значение типа long или int либо короткая строка, накладные расходы на управление памятью могут оказаться выше затрат на непосредственный доступ к выделенному участку.
Анализ исходного кода и поведения среды выполнения, проведённый в ходе разработки этой оптимизации, подтвердил распространённость подобных сценариев. В одном из запусков тестового набора с инструментированием часть изолированных арен вообще не выделяла нативную память, а более 99,99% арен, выполнявших выделение, использовали менее 64 байт. Хотя нагрузку тестового набора нельзя считать репрезентативной для всех приложений, полученные результаты убедительно свидетельствовали в пользу небольшого пула с ленивой инициализацией.
Как работает пулингКаждый платформенный поток может лениво создавать и поддерживать небольшой кеш пулов нативной памяти. По умолчанию такой кеш способен хранить четыре пула по 64 байта каждый.
При создании арена не получает пул автоматически. Его получение откладывается до первого выделения памяти, для которого пулинг может оказаться полезным. Это важно, поскольку арены, не выделяющие память, не приводят к созданию нативных пулов.
После получения пул закрепляется исключительно за соответствующей ареной. Подходящие участки памяти последовательно выделяются из этого пула. Схематично процесс выглядит так:
64-byte pool
0 8 16 20 63
+----------+----------+-----+-------------------- ... --+
| pointer | long | int | remaining capacity ... |
+----------+----------+-----+-------------------- ... --+
Рисунок 1. Выделение небольших участков памяти из пула.
Если запрошенный участок не помещается в пул или для него требуется выравнивание, которое пул не может гарантировать, используется стандартный механизм выделения памяти. Таким образом, пулинг представляет собой оптимизацию, применяемую при подходящих условиях: большие блоки и участки с нестандартными требованиями к выравниванию продолжают выделяться прежним способом.
При закрытии арены использованная часть её пула очищается, после чего пул возвращается в кеш потока. Если кеш уже заполнен, пул освобождается через нативный аллокатор.
Каждая арена, получившая пул, владеет им эксклюзивно. Поэтому вложенные изолированные арены — например, когда внутри блока try-with-resources одной арены создаётся ещё один блок try-with-resources со второй ареной — остаются независимыми друг от друга.
Если свободного пула в кеше нет, арена пытается выделить собственный локальный пул. При закрытии арены этот пул либо добавляется в кеш, либо освобождается.
Виртуальные потокиСоздание отдельного кеша нативной памяти для каждого виртуального потока свело бы на нет одно из их главных преимуществ — минимальное потребление ресурсов. Поэтому виртуальный поток временно получает пул из кеша платформенного потока-носителя (carrier thread), на котором он в данный момент выполняется.
Виртуальный поток закрепляется за потоком-носителем (pinned) лишь на короткое время передачи пула. Затем пул отсоединяется от кеша потока-носителя и переходит в исключительное владение арены.
Благодаря этому виртуальный поток может безопасно мигрировать между потоками-носителями, пока арена остаётся открытой. После закрытия арены пул возвращается в кеш того потока-носителя, на котором в этот момент выполняется виртуальный поток. Это может быть уже другой поток-носитель.
Такая архитектура позволяет избежать создания кешей нативной памяти для потенциально миллионов виртуальных потоков, одновременно сохраняя эксклюзивное владение каждым пулом.
Сохранение гарантий безопасностиПулинг не меняет правила времени жизни и доступности памяти, установленные API Arena.
После закрытия арены по-прежнему выполняются следующие условия:
Область действия арены больше не активна.
Доступ к её сегментам памяти невозможен.
Прежде чем пул станет доступен для повторного использования, его часть, задействованная предыдущей ареной, заполняется нулями. Вновь выделяемые пулы также изначально заполнены нулями.
Это сохраняет требование API, согласно которому нативные сегменты памяти, возвращаемые ареной, должны быть инициализированы нулями, и предотвращает раскрытие данных, записанных одной ареной, через другую арену, получившую тот же пул позднее.
Стандартные операции очистки выполняются до обнуления и освобождения пула. Такой порядок важен, поскольку операция очистки может на законных основаниях обращаться к нативной области памяти через сегмент, используемый для очистки.
Кешированные пулы, доступные для повторного использования, гарантированно освобождаются при завершении соответствующего платформенного потока или потока-носителя. Пулы, принадлежащие открытым аренам, отсоединены от кеша, поэтому завершение потока-носителя или миграция виртуального потока не приведут к их случайному освобождению.
Изменения в наблюдаемом поведенииХотя время жизни сегментов памяти не меняется, время существования лежащих в их основе нативных выделений может измениться.
При закрытии арены, использующей пулинг, её память может возвращаться в кеш JDK, а не немедленно освобождаться через нативный аллокатор.
Поэтому приложения и диагностические инструменты не должны рассчитывать на однозначное соответствие между выделениями памяти ареной и нативными вызовами malloc или free.
ПроизводительностьМикробенчмарки, проведённые в ходе разработки, показали значительное сокращение задержек при выделении небольших участков памяти в изолированных аренах.
В следующей таблице представлено ускорение при выделении 5 и 20 байт:
Платформа | Выделение 5 байт | Выделение 20 байт |
Linux AArch64 | 12,3× | 8,6× |
Linux x64 | 6,8× | 7,8× |
macOS AArch64 | 18,6× | 16,8× |
Windows x64 | 16,8× | 18,9× |
Таблица 1. Ускорение на протестированных платформах.
Каждое значение представляет собой отношение среднего времени выполнения операции в JMH без пулинга к времени выполнения с пулингом. Чем выше показатель, тем лучше. Конфигурация бенчмарков и исходные результаты доступны в запросе на включение изменений OpenJDK PR #31365.
Отдельный запуск бенчмарков на компьютере Apple M4 под управлением macOS дал следующие результаты:
Benchmark (size) Mode Cnt Score Error Units
AllocTest.alloc_confined 5 avgt 30 1.052 ± 0.027 ns/op
AllocTest.alloc_confined 20 avgt 30 1.138 ± 0.023 ns/op
AllocTest.alloc_confined 100 avgt 30 19.044 ± 0.322 ns/op
AllocTest.alloc_confined 500 avgt 30 26.383 ± 0.714 ns/op
AllocTest.alloc_confined 2000 avgt 30 31.292 ± 0.253 ns/op
AllocTest.alloc_confined 8000 avgt 30 81.119 ± 0.957 ns/op
AllocTest.alloc_confined_no_pool 5 avgt 30 15.862 ± 0.226 ns/op
AllocTest.alloc_confined_no_pool 20 avgt 30 18.139 ± 0.262 ns/op
AllocTest.alloc_confined_no_pool 100 avgt 30 18.856 ± 0.281 ns/op
AllocTest.alloc_confined_no_pool 500 avgt 30 26.168 ± 0.232 ns/op
AllocTest.alloc_confined_no_pool 2000 avgt 30 31.209 ± 0.314 ns/op
AllocTest.alloc_confined_no_pool 8000 avgt 30 80.840 ± 0.804 ns/op
Результаты бенчмарков на Mac с процессором M4.
При выделении участков памяти, превышающих стандартный размер пула в 64 байта, производительность в целом оставалась на прежнем уровне, поскольку для таких операций используется стандартный механизм выделения.
Виртуальные потоки несут дополнительные накладные расходы на взаимодействие с потоками-носителями, однако и в этом случае пулинг обеспечивает существенное ускорение по сравнению с выделением памяти без использования пулов:
Benchmark (size) Mode Cnt Score Error Units
AllocTest.OfVirtual.alloc_confined 5 avgt 30 3.222 ± 0.087 ns/op
AllocTest.OfVirtual.alloc_confined 20 avgt 30 2.737 ± 0.021 ns/op
AllocTest.OfVirtual.alloc_confined 100 avgt 30 18.868 ± 0.199 ns/op
AllocTest.OfVirtual.alloc_confined 500 avgt 30 26.117 ± 0.240 ns/op
AllocTest.OfVirtual.alloc_confined 2000 avgt 30 31.407 ± 0.508 ns/op
AllocTest.OfVirtual.alloc_confined 8000 avgt 30 80.474 ± 0.676 ns/op
AllocTest.OfVirtual.alloc_confined_no_pool 5 avgt 30 15.672 ± 0.090 ns/op
AllocTest.OfVirtual.alloc_confined_no_pool 20 avgt 30 18.109 ± 0.250 ns/op
AllocTest.OfVirtual.alloc_confined_no_pool 100 avgt 30 18.791 ± 0.163 ns/op
AllocTest.OfVirtual.alloc_confined_no_pool 500 avgt 30 25.947 ± 0.174 ns/op
AllocTest.OfVirtual.alloc_confined_no_pool 2000 avgt 30 31.206 ± 0.440 ns/op
AllocTest.OfVirtual.alloc_confined_no_pool 8000 avgt 30 79.711 ± 0.576 ns/op
Результаты бенчмарков виртуальных потоков на Mac с процессором M4.
Как всегда, ускорение, продемонстрированное микробенчмарками, не означает, что производительность приложения в целом вырастет в той же пропорции.
Наибольший выигрыш ожидается в сценариях, где создание арены, выделение нативной памяти и её очистка составляют значительную часть затрат на выполнение короткой нативной операции.
Кто получит преимущества?Эта оптимизация особенно актуальна для следующих сценариев:
Привязки FFM, создающие изолированную арену при каждом вызове нативной функции.
Автоматически генерируемые обёртки, подобные создаваемым с помощью jextract.
Небольшие значения и структуры языка C.
Нативные дескрипторы и выходные параметры, передаваемые через указатели.
Короткие строки и массивы байтов, передаваемые в нативный код.
Чувствительный к задержкам код, часто выполняющий недорогие нативные вызовы.
Следует учитывать, что преимущества распространяются только на арены, привязанные к одному потоку, и не затрагивают другие типы арен.
Прозрачная оптимизацияНаиболее полезные улучшения производительности нередко оказываются теми, ради которых разработчикам не приходится осваивать новый API или переписывать уже работающий код.
Изолированные арены с поддержкой пулинга сохраняют свойства Arena.ofConfined(): детерминированное управление временем жизни памяти, инициализацию нулями и ограничение доступа одним потоком.
Изменения происходят на более низком уровне: теперь при множестве небольших кратковременных выделений памяти можно избежать повторных обращений к нативному аллокатору.
Для FFM-приложений, использующих изолированные арены в качестве временного рабочего пространства с ограниченным временем жизни, это означает, что существующий код сохраняет все гарантии безопасности, но работает значительно быстрее.
Дополнительные материалыЕсли вы хотите узнать больше о пулинге памяти в изолированных аренах, ознакомьтесь со следующими материалами:

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Ваш кэш в Redis неэффективен, что с этим делать? | -1 | 7.96 | 10-08-2026 |
| 2 | In-memory база врёт: 5 расхождений с продовой БД | 0 | 16.95 | 07-07-2026 |
| 3 | [Перевод] Паттерны доступа к данным, которые выбесят ваш процессор | 0 | 8.55 | 03-07-2026 |
| 4 | Управление памятью в контейнерах STL. От абстракций к реализации | 0 | 7.69 | 23-10-2026 |
| 5 | Конвейеры на Channels: как не получить тихую утечку памяти и вечно висящий воркер | 0 | 8.4 | 27-07-2026 |
| 6 | Можно ли доверить ИИ миграцию Spring Boot 2 → 3? | 0 | 17.72 | 23-10-2026 |
| 7 | Пишем логи в journald с помощью Logback и FFM API | -1 | 7.89 | 10-08-2026 |
| 8 | API быстрее UI — не закон: замеры на 400+ Android UI-тестах | 0 | 13.39 | 23-10-2026 |
| 9 | EmbeddingGemma 2: как перейти с 768 на 512 координат и сэкономить память на RTX 5090 | 0 | 16.42 | 23-10-2026 |