Всем привет, меня зовут Сергей Прощаев, и в этой статье расскажу про Java 27, но не про синтаксис, а про то, что меняется в работающем сервисе.
Я Tech Lead и руководитель направления Java | Kotlin разработки в FinTech & E‑commerce и преподаю на курсах разработки и архитектуры в ОТУС.

15 сентября вышла Java 27, и её главное изменение нельзя увидеть в IDE. Нового синтаксиса для продакшена в ней почти нет, зато поменялись дефолты самой JVM:
сборщик мусора в маленьких контейнерах;
раскладка объектов в памяти;
набор допустимых флагов запуска;
поведение TLS.
Если сервис не задаёт эти настройки явно, после смены базового образа у него может измениться профиль памяти и пауз, а старый стартовый скрипт может просто перестать запускаться.
Ниже разбираю пять таких изменений, считаю, где память действительно экономится, и показываю, какую часть этих изменений можно проверить на Java 25 LTS уже сейчас.
Что вообще вышло
В JDK 27 девять JEP, из них четыре в статусе preview и один incubator. Это не LTS: обновления будут до марта 2027 года.
Текущий LTS — Java 25, следующий по дорожной карте Oracle планируется в сентябре 2027 года, это будет Java 29.
Честно скажу: когда я первый раз пролистал список, реакция была «и это всё?». Но я перечитал описания JEP внимательнее, и, как и предполагал, самое интересное оказалось в дефолтах:
G1 по умолчанию во всех окружениях (JEP 523);
компактные заголовки объектов по умолчанию (JEP 534);
маскирование секретов в JFR (JEP 536);
гибридный постквантовый обмен ключами в TLS 1.3 (JEP 527).
Одна оговорка перед разбором. Смена
FROM eclipse-temurin:25-jreна образ с JDK 27 меняет не только JVM: вместе с ней могут обновиться базовая ОС, libc, CA‑сертификаты, tzdata.
В части примеров ниже я сравниваю JDK 25 и JDK 27 при одинаковых лимитах ресурсов. Это показывает совокупный эффект обновления, а не изолированное влияние одного изменения.
Для чистого A/B отдельной фичи я сравниваю флаги на одной и той же JVM.
Для воспроизводимости фиксируйте не только версию JDK, но и digest образа.
Что происходит с сервисом, который просто пересобрали на новом образе, показано на схеме (Рис. 2).

Главная мысль схемы: у Java 27 нет «нейтрального» обновления для сервиса, который полагается на дефолты. Часть изменений может улучшить одни метрики ценой других, часть изменит поведение под нагрузкой, а один оставшийся в скрипте удалённый флаг способен вообще сорвать запуск.
Изменение 1. Serial GC, о котором вы не знали
До Java 27 JVM выбирала Serial GC вместо G1, если ей была доступна только одна CPU или меньше 1792 МБ памяти. JVM в контейнере учитывает лимиты cgroup, поэтому под с limits.cpu: 1 или memory: 1Gi попадал под это правило. Так бывает у небольших вспомогательных сервисов: нотификаций, адаптеров, консьюмеров.
Помню, как однажды мы разбирали жалобы на периодические «залипания» сервиса уведомлений: p99 раз в несколько минут прыгал почти до секунды. CPU спокойный, heap в норме.
Полдня мы искали проблему в пуле соединений к базе, пока не включили -Xlog:gc: паузы сборщика совпадали по времени со всплесками p99. Дальше всё объяснила одна команда: UseSerialGC = true с пометкой {ergonomic}. Сборщик никто не выбирал, JVM выбрала его сама по лимиту в один CPU.
Проверить, что реально работает у вас, можно за минуту:
# (Bash) Какой GC выбирает JVM в «маленьком» контейнере for tag in 25-jre 27-jre; do # 27-jre — если образ вашего дистрибутива уже опубликован echo "== JDK $tag ==" docker run --rm --cpus=1 --memory=1g eclipse-temurin:$tag \ java -XX:+PrintFlagsFinal -version 2>/dev/null \ | grep -E ' Use(Serial|Parallel|G1|Z)GC ' | grep '= true' done
# (Text) Ожидаемый вывод == JDK 25 == bool UseSerialGC = true {product} {ergonomic} == JDK 27 == bool UseG1GC = true {product} {ergonomic}
Пометка
{ergonomic}означает, что JVM выбрала этот сборщик автоматически.
Что изменится после перехода?
По данным OpenJDK, в таких окружениях G1 даёт сопоставимые накладные расходы по native‑памяти, немного меньшую пропускную способность и меньшие максимальные паузы, чем Serial.
Для latency‑сервиса это скорее плюс, для однопоточной пакетной задачи — не факт. Команда OpenJDK рекомендует сравнить сервис на разных сборщиках.
Если такой возможности нет, остаётся либо принять G1, либо явно указать Serial и сохранить прежнее поведение.
Мой вариант, который я обычно использую: сборщик всегда указан явно, по результатам замера.
Размер кучи выбираю не «магическим процентом», а из бюджета памяти конкретного сервиса. По умолчанию MaxRAMPercentage равен 25%.
Любое другое значение — решение под конкретный сервис, потому что кроме кучи память контейнера нужна metaspace, стекам потоков, code cache, direct buffers и служебным структурам GC.
Если лимит памяти сервиса известен и стабилен, фиксированный -Xmx даёт более предсказуемый бюджет кучи. MaxRAMPercentage удобнее там, где размер контейнера меняется.
Изменение 2. Минус четыре байта на объект, но не на каждом
Для обычного объекта (не массива) на 64-битной HotSpot со сжатыми указателями на класс классический заголовок занимает 12 байт: mark word и указатель на класс. Compact Object Headers ужимают его до 8 байт. Фича появилась экспериментальной в JDK 24, стала полноценной в JDK 25 (JEP 519), а в 27 включена по умолчанию.
По данным JEP 534, в одной конфигурации SPECjbb2015 использует на 22% меньше кучи и на 8% меньше CPU, в другой число сборок мусора падает на 15%. Документация JDK 27 оценивает экономию скромнее и честнее: в среднем 4 байта на объект.
Почему «в среднем»? Мне как‑то попалась статья, где уверенно писали, что Integer станет вдвое меньше. Я сел и посчитал. Объекты выравниваются по 8 байт, и это съедает часть выигрыша:
Объект | Классический заголовок (12 байт) | Компактный (8 байт) | Выигрыш |
|---|---|---|---|
| 12 → 16 | 8 | 8 байт |
| 12 + 4 = 16 | 8 + 4 = 12 → 16 | 0 |
| 12 + 8 = 20 → 24 | 8 + 8 = 16 | 8 байт |
| 12 + 8 = 20 → 24 | 8 + 8 = 16 | 8 байт |
| 12 + 16 = 28 → 32 | 8 + 16 = 24 | 8 байт |
HashMap.Node — внутренняя реализация JDK, а не API‑контракт, и её раскладка может измениться между версиями. Здесь она только иллюстрирует принцип; бизнес‑код лучше проверять на собственных классах.
То есть Integer не выигрывает ничего, а небольшой объект из двух полей уменьшается на треть. Проверить это можно на своём классе:
// (Java) Зависимость: org.openjdk.jol:jol-core:0.17 (последняя опубликованная версия на момент написания) import org.openjdk.jol.info.ClassLayout; public class HeaderCheck { static final class Event { int code; Object payload; } public static void main(String[] args) { print("Object", new Object()); print("Integer", Integer.valueOf(1000)); print("Long", Long.valueOf(1000L)); print("Event", new Event()); } static void print(String name, Object o) { System.out.printf("%-8s %d bytes%n", name, ClassLayout.parseInstance(o).instanceSize()); } }
# (Bash) JDK 25: одна и та же программа с фичей и без неё CP="classes:jol-core-0.17.jar" java -XX:-UseCompactObjectHeaders -cp "$CP" HeaderCheck java -XX:+UseCompactObjectHeaders -cp "$CP" HeaderCheck
Если расчёт верен, в первом запуске вы увидите 16/16/24/24, во втором — 8/16/16/16. Но микропример — это не экономика приложения. На реальном сервисе я сравниваю гистограмму кучи под одинаковой нагрузкой с флагом и без:
# (Bash) Распределение shallow heap по классам в процессе. # Только на стенде или в контролируемом диагностическом окне: # у GC.class_histogram высокое влияние, зависящее от размера и содержимого кучи. jcmd <pid> GC.class_histogram | head -25
Гистограмма показывает собственный (shallow) размер экземпляров по классам, но сама по себе не отвечает на вопрос, уменьшился ли RSS контейнера и выросла ли производительность. Между заполненностью кучи и RSS есть ещё несколько уровней, поэтому итог проверяется отдельными замерами.
Вывод отсюда практический. Больше всего выигрывают сервисы, где много мелких объектов со ссылками: коллекции, DTO, события, кэши, всё, что гоняет JSON туда‑обратно. Сервис, который в основном держит большие byte[], заметит разницу слабо.
История, которая меня зацепила. В текстах JEP 519 и JEP 534 указано, что Amazon проверял компактные заголовки на сотнях продакшен‑сервисов, причём в основном через бэкпорты фичи в JDK 21 и JDK 17. Фичу также тестировали в SAP. Для меня это главный практический урок: команды, у которых налажены замеры, забирают оптимизации JVM задолго до того, как их включают по умолчанию.
Изменение 3. То, что сломается на старте
В JDK 27 удалены флаги -noclassgc, -noverify, -verifyremote и -Xverify:none.
У первого и третьего есть замены (-Xnoclassgc и -Xverify:remote), у -noverify и -Xverify:none замены нет. Удалённая опция приводит к ошибке запуска. В Kubernetes это выглядит как бесконечный рестарт пода, при этом CI может оставаться зелёным, если тесты запускались с другими параметрами.
Там же удалён экспериментальный интерфейс JVMCI вместе со встроенным в JDK компилятором Graal и флагом -XX:+UseGraalJIT (подробности в обзоре runtime‑изменений).
Это не означает удаления GraalVM как отдельного продукта, но совместимость конкретной версии GraalVM, native‑image и инструментов, которые используют Graal или JVMCI, с JDK 27 нужно проверять отдельно.
Менее заметные изменения:
-XX:InitiatingHeapOccupancyPercentпереименован в-XX:G1IHOP. Старое имя пока работает, но объявлено устаревшим.-XX:[+|-]UseCompressedClassPointersстал obsolete, JVM выдаст предупреждение.JSON‑дамп потоков получил
formatVersion: 2, идентификаторы процесса и потоков стали числами. Если внутренний tooling строго валидирует схему, его нужно проверить.
-noverify лет десять кочевал по «ускорителям старта» и конфигам профилировщиков, так что начать стоит с поиска по репозиториям и чартам:
# (Bash) Первый проход: флаги, которые в JDK 27 удалены или устарели grep -rnE -- '-noverify|-Xverify:none|-noclassgc|-verifyremote|UseGraalJIT|EnableJVMCI|InitiatingHeapOccupancyPercent|UseCompressedClassPointers' \ --include='*.sh' --include='*.yaml' --include='*.yml' --include='Dockerfile*' \ --include='*.properties' --include='*.gradle*' --include='pom.xml' .
Но grep — только первый проход. Аргументы JVM приходят ещё из JAVA_TOOL_OPTIONS, JDK_JAVA_OPTIONS, JAVAOPTIONS, entrypoint‑скриптов базового образа и переменных окружения в манифестах. Поэтому я всегда сверяюсь с тем, что реально получила работающая JVM:
# (Bash) Эффективная конфигурация JVM в поде (нужен jcmd в образе; PID 1 — если java главный процесс) kubectl exec <pod> -- sh -c 'env | grep -E "JAVA_TOOL_OPTIONS|JDK_JAVA_OPTIONS|_JAVA_OPTIONS"' kubectl exec <pod> -- jcmd 1 VM.command_line kubectl exec <pod> -- jcmd 1 VM.flags
Изменение 4. JFR прячет секреты, но не все
JFR в JDK 27 по умолчанию маскирует значения по типовым шаблонам вроде password, token, secret. Опция redact-key отвечает за переменные окружения и системные свойства, redact-argument — за аргументы командной строки. Свои фильтры добавляются с префиксом +, чтобы не затереть стандартные: -XX:FlightRecorderOptions:redact-key=+dburl.
Важно понимать границы. По документации механизм работает по принципу best effort и применяется только к конкретным событиям: jdk.InitialSystemProperty, jdk.InitialEnvironmentVariable и jdk.JVMInformation.
Например, jdk.ProcessStart и jdk.InitialSecurityProperty этим механизмом не редактируются. Переменная с «нетиповым» именем проскочит фильтр. А всё, что приложение само пишет в пользовательские события, JFR не трогает вообще.
Однажды я видел JFR‑запись, приложенную к тикету для подрядчика, в которой лежал пароль от базы прямо в аргументах запуска. В JDK 27 стандартный фильтр, скорее всего, скрыл бы такое значение, если имя параметра попадает под шаблон.
Но правильный вывод из той истории другой: пароль вообще не должен передаваться через аргументы JVM, а JFR‑файл остаётся диагностическим артефактом с ограниченным доступом.
Изменение 5. Постквантовый TLS без изменений в коде
JDK 27 добавляет гибридный обмен ключами для TLS 1.3: классический ECDHE плюс ML‑KEM.
Приложения на стандартном javax.net.ssl получают его по умолчанию, если не переопределяют TLS named groups (через jdk.tls.namedGroups или SSLParameters.setNamedGroups).
Для FinTech это заметно снижает риск сценария «перехватить сейчас, расшифровать потом». Реальная защита, правда, зависит от того, поддерживает ли гибридную схему вторая сторона.
Практическая оговорка: по умолчанию клиент предлагает в ClientHello key share для гибридной схемы X25519MLKEM768 и отдельный классический x25519, поэтому сообщение становится заметно больше.
Если между сервисами стоит старый балансировщик или DPI‑коробка, прогоните интеграционные тесты через реальный сетевой путь, а не только через localhost.
В регулируемых окружениях дополнительно проверьте совместимость с используемым crypto provider и FIPS‑политикой.
Preview‑фичи: что я бы не нёс в прод
Structured concurrency (JEP 533) появилась как incubator ещё в JDK 19, а в JDK 27 вышла в седьмом превью. Примитивные типы в паттернах (JEP 532) проходят уже пятый preview‑цикл подряд, с Java 23 по Java 27. Вот как это выглядит:
// (Java 27, preview) Запуск: java --enable-preview --source 27 Describe.java public class Describe { static String describe(double v) { return switch (v) { case int i -> "точно представимо как int: " + i; case long l -> "точно представимо как long: " + l; default -> "дробное или слишком большое: " + v; }; } public static void main(String[] args) { System.out.println(describe(42.0)); // точно представимо как int: 42 System.out.println(describe(1e12)); // точно представимо как long: 1000000000000 System.out.println(describe(0.5)); // дробное или слишком большое: 0.5 } }
Здесь важно не то, что число «целое», а то, что преобразование double → int для конкретного значения проходит без потери информации.
Именно эта «точность» преобразования решает, сработает ли case int i.
В Java 27 нормативных изменений по сравнению с предыдущим превью нет, но статус preview по‑прежнему означает, что финальная версия может отличаться.
Позиция у меня простая: превью‑фичи в продакшен‑коде я бы не использовал, а для pet‑проекта и подготовки команды они подходят отлично. Первое время меня раздражали бесконечные превью, сейчас я вижу в этом зрелость: API или языковую возможность, которая может стать частью платформы на десятилетия, выгоднее несколько раз проверить в preview, чем стабилизировать слишком рано. Для JDK 28 уже намечены Value Classes and Objects в превью и Simple JSON API в инкубаторе.
Как я предлагаю переходить
Сначала про Spring. Spring Boot 4.1.1 официально совместим с Java до 26 включительно. Spring Framework 7.x полноценно тестируется на LTS‑версиях JDK и рекомендует для продакшена JDK 25 и выше; для non‑LTS релизов поддержка может быть best effort, поэтому проверять нужно конкретную ветку Spring.
В итоге вопрос «можно ли в прод на 27» решается на уровне вашей версии Spring Boot и всего стека зависимостей:
JDBC‑драйверов;
APM‑агентов;
инструментирования байткода;
crypto provider;
native‑библиотек.
Сегодня разумная роль для 27-й версии — тестовый контур.
В своих командах я придерживаюсь такой схемы:
Прод остаётся на LTS (Java 25), свежий feature release живёт в CI‑матрице. Так проблемы обнаруживаются задолго до следующего LTS, и миграция не превращается в одномоментный проект.
Кроме юнит‑тестов, в CI есть smoke‑тест старта с реальным entrypoint, набором JVM‑флагов,
JAVA_TOOL_OPTIONSи-javaagent../gradlew testможет быть зелёным, а продовый запуск с агентом — упасть.Два изменения runtime, ставшие заметными в Java 27, проверяем заранее на Java 25: явно выбираем G1 там, где раньше работал Serial, и включаем Compact Object Headers. Остальное (флаги, JFR, TLS) проверяется только на самой 27-й версии.
Меряем бюджет памяти целиком, а не только heap: heap + metaspace + стеки потоков + code cache + direct buffers + структуры GC. На время замеров помогает
-XX:NativeMemoryTracking=summaryиjcmd <pid> VM.native_memory summary. Но NMT показывает только ту память, которую отслеживает сама JVM, поэтому итоговый RSS контейнера я всё равно проверяю отдельно.Сравнение идёт по протоколу: одинаковые лимиты пода, одинаковый профиль нагрузки, прогрев, установившийся режим. Смотрим p50/p95/p99, RSS, allocation rate, паузы GC и пропускную способность. Фиксируем вендора, точную сборку JDK и digest образа.
# (YAML, GitHub Actions) Прод-версия + «разведка» на свежем JDK jobs: test: runs-on: ubuntu-latest strategy: fail-fast: false matrix: java: [ '25', '27' ] continue-on-error: ${{ matrix.java == '27' }} # 27 не блокирует релиз, но сигналит steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: ${{ matrix.java }} - run: ./gradlew test
# (Bash) entrypoint.sh для замера на JDK 25 с «дефолтами из будущего» # HEAP_PCT подбирается из бюджета памяти вашего сервиса, 55 — лишь пример HEAP_PCT="${HEAP_PCT:-55}" exec java \ -XX:+UseG1GC \ -XX:+UseCompactObjectHeaders \ -XX:MaxRAMPercentage="$HEAP_PCT" \ -XX:NativeMemoryTracking=summary \ -jar app.jar
Последовательность шагов сведена в план перехода (Рис. 3). Его удобно положить в тикет и идти по пунктам.

Главная мысль этого плана: переход на новый JDK — это эксперимент с метриками, а не смена цифры в Dockerfile. Сначала убираем то, что гарантированно сломает старт, потом включаем полезные дефолты на LTS и меряем. Саму версию 27 оставляем в роли разведчика, пока весь ваш стек официально её не поддержит.
Ограничения
Это не полный каталог runtime‑изменений JDK 27. Например, у G1 изменились значения по умолчанию для
MinHeapFreeRatioиMaxHeapFreeRatio, и изменение размера кучи после Full GC по умолчанию фактически отключено. Полный список — в обзоре runtime‑изменений и release notes.Расчёты в таблице верны для обычных объектов в стандартной 64-битной конфигурации со сжатыми указателями. 32 ГБ — типичная граница их адресного диапазона по умолчанию; при другом выравнивании (
ObjectAlignmentInBytes) и у массивов раскладка отличается.Цифры из JEP получены на бенчмарках, а не на вашем сервисе. Ваш результат может быть заметно скромнее.
G1 не универсален. Для пакетных задач Serial или Parallel могут быть предпочтительнее по пропускной способности.
Смена базового образа меняет не только JDK. Без контроля ОС, сертификатов и библиотек замер не изолирует влияние JVM.
Дистрибутивы от других вендоров выходят с небольшой задержкой после GA, поэтому образа 27-й версии вашего поставщика может ещё не быть.
Что проверить у себя уже сегодня
Какой GC реально работает в ваших самых маленьких подах и стоит ли там пометка
{ergonomic}?Нет ли в скриптах, чартах и env‑переменных
-noverify,-Xverify:noneи других удалённых флагов?Сколько кучи сэкономит
-XX:+UseCompactObjectHeadersна JDK 25 под вашей нагрузкой?Не парсит ли ваш tooling JSON‑дампы потоков по строгой схеме?
Есть ли в CI smoke‑тест старта с реальными флагами и агентами на свежем feature release?
Мой вывод такой: Java 27 — не повод срочно обновлять прод. Это повод посмотреть, какие настройки JVM у вас на самом деле работают. Вполне возможно, что ни одну из них вы не выбирали сами.
А вы уже знаете, какой сборщик работает у вас в проде? Пишите в комментариях, особенно если ловили похожие истории с эргономикой JVM в контейнерах.

После обновления JDK даже привычный сервис может вести себя иначе: меняются GC, расход памяти и параметры запуска. Важно понимать, как находить такие изменения и проверять их до выхода в прод.
На открытых уроках разберём подходы к стабильной работе Java‑сервисов и проектированию микросервисов.
22 октября, 20:00. «Spring Boot под нагрузкой: почему сервис работает локально и падает в production». Записаться
22 октября, 19:00. «Основы проектирования бизнес‑логики в микросервисной архитектуре». Записаться

