Java Java в SDLC: CI/CD, зависимости, безопасность и лучшие ресурсы
0%

Java в SDLC: CI/CD, зависимости, безопасность и лучшие ресурсы

Java в SDLC: CI/CD, зависимости, безопасность и лучшие ресурсы

Четырнадцать статей назад мы начинали с public static void main. К этому моменту вы умеете писать код, который компилируется, тестируется, работает под нагрузкой и отдаёт метрики. Осталось то, что отличает работающий код от живой системы: процесс, в котором этот код меняется годами руками десятка человек и при этом не разваливается.

Эта статья — не про «а ещё бывает CI». Она про то, что в жизненном цикле Java-проекта устроено иначе, чем где бы то ни было, и почему привычки из других экосистем здесь работают плохо. Четыре вещи задают всю специфику:

  1. Java-код живёт долго. Средний возраст продакшн-кодовой базы на Java — 8–15 лет. Люди меняются, код остаётся. Всё, что вы решите про версии и совместимость, аукнется не вам.
  2. Дерево зависимостей глубокое и чужое. Пять строк в pom.xml разворачиваются в две сотни артефактов. Уязвимость приходит с уровня, который вы не выбирали.
  3. Артефакт — JAR, а не исходник. Потребитель линкуется с уже скомпилированным байткодом. Отсюда понятие бинарной совместимости, которого нет в мире, где всё собирается из исходников на каждой машине.
  4. Платформа обновляется по расписанию. Каждые полгода — релиз, каждые два года — LTS. Обновление JDK — это плановая инженерная работа, а не «когда-нибудь потом».

По нише Java ближе всего к .NET (см. трек C#/.NET), и процессы там похожи по форме. Но есть принципиальная разница: в .NET один вендор контролирует язык, рантайм, сборщик, менеджер пакетов и IDE, а в Java всё это разные организации. Отсюда и сильные стороны (можно взять JDK от Amazon, сборщик от Gradle Inc., репозиторий от Sonatype и всё это будет работать вместе), и слабые: нет одной команды, которая гарантирует, что сегодняшний mvn verify совпадёт с завтрашним. Согласованность приходится строить самому — и в этом смысл всей статьи.

Ритм платформы: почему обновление JDK — это процесс, а не задача

С JDK 9 (2017) Oracle перешла на строгий шестимесячный цикл релизов. Раз в два года (с JDK 21 — раз в два года, ранее раз в три) выходит LTS, который вендоры поддерживают годами. Это изменило SDLC сильнее, чем любая языковая фича.

Три следствия для процесса:

  • Всегда есть «где мы и куда следующий шаг». Проект должен знать свою целевую LTS и дату ухода с текущей. Расстояние 8 → 17 — это месяцы работы; 17 → 21 — обычно дни. Копить долг дорого нелинейно.
  • Выбор дистрибутива JDK — бизнес-решение. OpenJDK — это исходники; собранные бинарники дают разные вендоры: Eclipse Temurin (нейтральный, самый частый выбор), Amazon Corretto, Azul Zulu, BellSoft Liberica, Red Hat build of OpenJDK, Oracle JDK. Лицензии Oracle менялись трижды за десятилетие; если вы не читали текущую NFTC — берите Temurin и не создавайте юристам работу.
  • CI обязан фиксировать версию. Не «JDK 21», а конкретный дистрибутив и major-версия в одном месте, откуда её читают и сборка, и Dockerfile, и локальные машины разработчиков.

Карта: где в жизненном цикле стоят ворота

Прежде чем разбирать инструменты, полезно увидеть весь конвейер и понять, какая проверка на каком этапе дешевле всего. Правило простое: чем левее найдена проблема, тем дешевле она стоит, но чем левее проверка, тем меньше у неё контекста.

Обратите внимание на две пунктирные стрелки — это и есть цикл, а не конвейер. Инцидент, который не превратился в тест или правило статического анализатора, повторится. И отдельно про D3: артефакт не меняется, а его уязвимость появляется. Вчера собранный образ сегодня стал дырявым, потому что кто-то опубликовал CVE. Поэтому сканирование — не однократный шаг сборки, а повторяющаяся операция над тем, что уже в проде.

Сборка в CI: воспроизводимость важнее скорости

Задача CI формулируется не как «прогнать тесты», а как дать однозначный ответ: годится этот коммит в прод или нет, и дать его так, чтобы ответ не зависел от машины, дня недели и настроения сети. В Java это требует явных усилий, потому что и Maven, и Gradle по умолчанию тянутся в интернет и подстраиваются под окружение.

Вот рабочий пайплайн для Maven-проекта на GitHub Actions. Он короткий, но каждая строка здесь неслучайна.

name: ci

on:
  push:
    branches: [main]
  pull_request:

# Минимальные права по умолчанию: расширяем только там, где действительно нужно
permissions:
  contents: read

concurrency:
  # Новый пуш в ту же ветку отменяет предыдущий прогон — экономия минут раннера
  group: ci-${{ github.ref }}
  cancel-in-progress: true

env:
  # -B: batch mode (без интерактива), -ntp: не печатать прогресс скачивания —
  # иначе логи CI на 80% состоят из точек загрузки
  MAVEN_ARGS: "-B -ntp -Dstyle.color=always"

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          distribution: temurin       # фиксируем вендора, а не просто версию
          java-version: '21'
          cache: maven                # кэш ~/.m2/repository по хэшу pom.xml

      # verify, а не install: install кладёт артефакт в локальный ~/.m2 и создаёт
      # иллюзию успеха на машине, где предыдущая сборка что-то там оставила
      - name: Build and test
        run: mvn ${MAVEN_ARGS} verify

      # Тесты падают — отчёты всё равно нужны. if: always() обязателен
      - name: Publish test report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: surefire-reports
          path: '**/target/*-reports/'

  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: '21', cache: maven }

      # Профиль с медленными проверками не должен тормозить основную сборку
      - run: mvn ${MAVEN_ARGS} -P quality verify -DskipTests

  compat:
    # Только для библиотек: проверяем, что не сломали чужой байткод
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: '21', cache: maven }
      - run: mvn ${MAVEN_ARGS} japicmp:cmp

Для Gradle идея та же, но рычаги другие:

      - uses: gradle/actions/setup-gradle@v4
        with:
          # Кэш пишется только с main — ветки его читают, но не отравляют
          cache-read-only: ${{ github.ref != 'refs/heads/main' }}

      # --no-daemon в CI больше не рекомендуется: демон живёт в пределах джобы и
      # ускоряет многомодульную сборку. Зато обязателен configuration cache.
      - run: ./gradlew build --configuration-cache

Отдельно про Gradle Wrapper: gradlew — это скрипт, который скачивает и запускает нужную версию Gradle. Обязательно коммитьте gradle/wrapper/gradle-wrapper.properties с полем distributionSha256Sum — иначе вы выполняете на CI бинарник, целостность которого никто не проверил. Аналог для Maven — Maven Wrapper (./mvnw), и он тоже поддерживает контрольные суммы.

Java-специфичные грабли CI

  • mvn clean install вместо verify. install публикует артефакт в локальный репозиторий раннера. На чистой машине разницы нет, на переиспользуемом раннере — сборка «зелёная», потому что подцепила вчерашний JAR соседнего модуля.
  • -U в каждом запуске. Флаг «обнови SNAPSHOT» превращает сборку в лотерею: результат зависит от того, что кто-то только что опубликовал. SNAPSHOT-зависимостей в релизной сборке быть не должно вовсе.
  • Локаль и часовой пояс раннера. Классика: тест форматирования даты проходит локально (ru_RU, MSK) и падает в CI (en_US, UTC) — или наоборот. Фиксируйте явно в maven-surefire-plugin: <argLine>-Duser.language=en -Duser.country=US -Duser.timezone=UTC</argLine>, а бизнес-логику пишите на Clock, который можно подменить (см. тестирование).
  • Память раннера. У контейнерного раннера cgroup-лимит, и JVM видит его корректно только с современными JDK. Форкнутые JVM Surefire наследуют не те настройки, что вы думаете; если тесты падают по OOM — начните с явного -Xmx в argLine (подробности про эргономику — в JVM изнутри).
  • Testcontainers требуют Docker. На части раннеров его нет или он rootless. Выделяйте интеграционные тесты в отдельный профиль/джобу, чтобы отсутствие Docker не блокировало всё.

Воспроизводимая сборка

Идеал: два человека на разных машинах собирают один коммит и получают побайтово одинаковый JAR. В Java это достижимо, и Maven это поддерживает:

<properties>
  <!-- Фиксированная временная метка внутри JAR: без неё каждый пересбор даёт
       новый файл, отличающийся только временем — и любые проверки хэша бессмысленны -->
  <project.build.outputTimestamp>2026-07-16T10:00:00Z</project.build.outputTimestamp>
  <maven.compiler.release>21</maven.compiler.release>
</properties>

Проверить можно плагином maven-artifact-plugin (mvn artifact:compare). Зачем это нужно практически: воспроизводимость — единственный способ доказать, что бинарник в проде собран именно из того исходника, на который вы смотрите. Всё остальное — вера.

Ключевая деталь тут — maven.compiler.release, а не пара source/target. Разница принципиальная и это одна из самых дорогих ловушек Java: -source 17 -target 17 на JDK 21 разрешит компилятору линковаться с API, которого в JDK 17 нет. Код соберётся, а в рантайме на 17-й JVM вы получите NoSuchMethodError. Флаг --release (JEP 247) подключает настоящие сигнатуры нужной версии платформы и не даёт этому случиться.

Зависимости: почему у Maven и Gradle разные ответы на один вопрос

Радиус поражения CVE в дереве зависимостей и правила разрешения конфликта версий

Картинка выше показывает две вещи сразу. Первая: уязвимость почти никогда не в том, что вы подключили. Вторая, менее известная и куда более коварная: когда один артефакт приходит двумя путями в разных версиях, Maven и Gradle решают конфликт противоположным образом.

  • Maven: «nearest wins» — побеждает версия, ближе к корню дерева. При равной глубине — та, что объявлена раньше. Это значит, что транзитивная старая версия может вытеснить свежую, если пришла коротким путём.
  • Gradle: «highest wins» — побеждает наибольшая версия. Безопаснее по умолчанию, но strictly и resolutionStrategy.force умеют откатывать назад, и тогда всё то же самое.

Отсюда практическое правило: конвергенцию версий должна проверять машина.

<!-- pom.xml: ворота на конвергенцию. Падает сборка, а не прод -->
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-enforcer-plugin</artifactId>
  <version>3.5.0</version>
  <executions>
    <execution>
      <id>enforce-deps</id>
      <goals><goal>enforce</goal></goals>
      <configuration>
        <rules>
          <!-- Ни одна выбранная версия не должна быть ниже той,
               которую требует хоть кто-то в дереве -->
          <requireUpperBoundDeps/>
          <!-- Никаких диапазонов и SNAPSHOT в релизе -->
          <requireReleaseDeps>
            <onlyWhenRelease>true</onlyWhenRelease>
          </requireReleaseDeps>
          <banDuplicatePomDependencyVersions/>
          <requireJavaVersion><version>[21,)</version></requireJavaVersion>
        </rules>
        <fail>true</fail>
      </configuration>
    </execution>
  </executions>
</plugin>

Разбор конкретного конфликта — две команды, которые стоит знать наизусть:

# Maven: откуда взялся log4j-core и какие версии были отвергнуты
mvn dependency:tree -Dverbose -Dincludes=org.apache.logging.log4j:log4j-core

# Gradle: то же самое, с объяснением правила выбора
./gradlew dependencyInsight --dependency log4j-core --configuration runtimeClasspath

Единая точка правды о версиях

Прямое указание версии в каждом модуле — гарантированный дрейф. Правильный способ:

<!-- Родительский pom: BOM импортируется, версии не дублируются нигде -->
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-dependencies</artifactId>
      <version>3.3.4</version>
      <type>pom</type>
      <scope>import</scope>   <!-- импорт BOM: сотни согласованных версий одной строкой -->
    </dependency>
  </dependencies>
</dependencyManagement>

В Gradle тот же механизм — version catalog:

# gradle/libs.versions.toml — версии живут здесь и только здесь
[versions]
jackson = "2.17.2"
junit = "5.11.0"

[libraries]
jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" }
junit-jupiter    = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }

[bundles]
jackson = ["jackson-databind"]

Честно про lock-файлы

Здесь Java уступает и Node, и Rust, и .NET. У Maven нет встроенного lock-файла. Иллюзия детерминизма создаётся тем, что все версии зафиксированы через BOM, но транзитивные зависимости зависимостей всё равно резолвятся заново. Варианты:

  • Gradle умеет настоящий dependencyLocking — включайте, если вы на Gradle:
    dependencyLocking { lockAllConfigurations() }   // затем ./gradlew dependencies --write-locks
    
  • Для Maven — сторонний maven-lockfile или дисциплина: приватный репозиторий-прокси (Nexus/Artifactory) с политикой «внешние артефакты кэшируются навсегда, удаление запрещено». Это заодно защищает от исчезновения артефакта из Central и от dependency confusion: прокси знает, какие groupId внутренние, и не ходит за ними наружу.
  • Минимум минимумов: mvn dependency:go-offline в отдельном шаге CI, чтобы поломка сети проявлялась как понятная ошибка, а не как загадочно упавший тест.

Автообновления

Обновлять зависимости вручную раз в квартал — плохо: набирается тридцать версий разом, и никто не знает, какая из них сломала прод. Обновлять непрерывно роботом — хорошо.

// renovate.json — группируем связанное, чтобы не утонуть в PR
{
  "extends": ["config:recommended"],
  "packageRules": [
    { "matchPackagePatterns": ["^org.springframework"], "groupName": "spring" },
    { "matchPackagePatterns": ["^com.fasterxml.jackson"], "groupName": "jackson" },
    { "matchUpdateTypes": ["patch"], "automerge": true }
  ],
  "vulnerabilityAlerts": { "labels": ["security"], "schedule": ["at any time"] }
}

Автомёрж патчей безопасен ровно настолько, насколько хороши ваши тесты. Если после автомёржа страшно — проблема не в автомёрже.

Совместимость: три разных вопроса, которые все путают

Матрица совместимости: исходная, бинарная, поведенческая

Если вы публикуете библиотеку (в том числе внутреннюю — общий common-utils внутри компании это ровно тот же случай), вы обязаны различать три совместимости. Самый неочевидный пример — третья строка матрицы:

// Версия 1.0 вашей библиотеки
public final class Limits {
    public static final int MAX_RETRIES = 3;   // compile-time constant!
}

// Потребитель компилируется с 1.0. В его .class попадёт ЧИСЛО 3, а не ссылка на поле:
//   bipush 3
// В версии 1.1 вы меняете константу на 5, подкладываете новый JAR — и у потребителя
// по-прежнему 3. Ни ошибки, ни предупреждения. Просто другое поведение.

Лечится тем, что «настраиваемые» значения не делают компилируемыми константами: public static final int maxRetries() { return 5; } или обычное непримитивное поле. Полный перечень того, что считается бинарно совместимым, — в JLS, глава 13 «Binary Compatibility»; это редкий случай, когда спецификацию действительно стоит прочитать целиком, она короткая.

В CI это проверяется автоматически:

<!-- japicmp сравнивает собранный JAR с последней релизной версией из репозитория -->
<plugin>
  <groupId>com.github.siom79.japicmp</groupId>
  <artifactId>japicmp-maven-plugin</artifactId>
  <version>0.23.1</version>
  <configuration>
    <parameter>
      <onlyModified>true</onlyModified>
      <breakBuildOnBinaryIncompatibleModifications>true</breakBuildOnBinaryIncompatibleModifications>
      <!-- внутренние пакеты не являются публичным API и меняются свободно -->
      <excludes><exclude>com.acme.internal</exclude></excludes>
    </parameter>
  </configuration>
</plugin>

Альтернатива — Revapi, он умеет больше и настраивается сложнее.

Обновление JDK как отдельный вид работ

Переезд между LTS — не «поменять цифру в CI». Порядок действий, проверенный на многих миграциях:

  1. Сначала соберите старым компилятором на новой JVM. Байткод Java 8 отлично исполняется на JDK 21. Так вы отделяете проблемы рантайма от проблем компиляции.
  2. Найдите использование внутренних API:
    jdeps --multi-release 21 --jdk-internals -R --class-path 'libs/*' target/app.jar
    
    Типичные находки: sun.misc.Unsafe, sun.reflect, старые версии Lombok, cglib, ASM, Groovy, Kryo, mockito-inline. Обычно лечится обновлением библиотеки.
  3. Ожидайте боли от strong encapsulation. JEP 403 (JDK 17) закрыл внутренности JDK. Рефлексия в чужие пакеты требует явного --add-opens java.base/java.lang=ALL-UNNAMED. Флаги — временный костыль, а не решение: они переживают релизы плохо.
  4. Проверьте GC-настройки. Дефолты меняются между версиями; флаги, скопированные из статьи 2015 года, часть которых уже удалена, не дадут JVM стартовать вовсе.
  5. Только потом поднимайте maven.compiler.release и начинайте пользоваться новыми возможностями языка.

Ветвление и релизы

Для сервиса, который вы выкатываете сами, — trunk-based: короткие ветки, быстрый мёрж, флаги вместо долгоживущих веток. Для библиотеки, у которой есть внешние потребители, — release-ветки и бэкпорты. В Java второй случай встречается чаще, чем в других экосистемах: enterprise-потребители не обновляют мажорные версии по вашему расписанию.

Ключевое правило: исправление безопасности делается в самой старой поддерживаемой ветке и черри-пикается вперёд, а не наоборот. Обратный порядок означает, что чинить придётся дважды и один раз обязательно забудут. Подробнее о моделях ветвления — в треке Git.

Версионирование: SemVer для библиотек, где major действительно означает «мы сломали бинарную совместимость». Для сервисов SemVer бессмысленен — там уместнее <дата>.<номер сборки>.<короткий SHA>, потому что единственный интересный вопрос — «какой именно коммит сейчас в проде».

И положите ответ на этот вопрос внутрь артефакта:

<!-- Версия и коммит попадают в MANIFEST.MF; Spring Boot Actuator отдаёт их в /actuator/info -->
<plugin>
  <artifactId>maven-jar-plugin</artifactId>
  <configuration>
    <archive>
      <manifest><addDefaultImplementationEntries>true</addDefaultImplementationEntries></manifest>
      <manifestEntries>
        <Git-Commit>${git.commit.id.abbrev}</Git-Commit>
        <Build-Time>${maven.build.timestamp}</Build-Time>
      </manifestEntries>
    </archive>
  </configuration>
</plugin>

Безопасность цепочки поставок

Java пережила самый известный инцидент цепочки поставок в истории отрасли, и разобрать его стоит по шагам — он объясняет, почему все последующие практики выглядят именно так.

Что из этого следует помнить навсегда:

  • Опасность возникла из сочетания трёх «удобных» фич: подстановки в сообщениях, JNDI как универсального механизма поиска и загрузки классов по сети. Каждая по отдельности выглядела разумно. Это типичная форма уязвимостей в Java: не переполнение буфера, а слишком мощная абстракция, до которой дотягиваются недоверенные данные.
  • Дыра была в зависимости зависимости. Компании неделями искали, есть ли у них log4j вообще. Тот, у кого был SBOM, ответил за минуту.
  • Реакция стоила дороже уязвимости. Патч вышел быстро, а вот выяснение «где именно у нас этот JAR» — вот где сгорели человеко-месяцы.

Жизненный цикл уязвимости в проекте

Подавление без срока — главный организационный антипаттерн в этой области. Через год в suppressions.xml лежат сорок записей, никто не помнит их причин, и сканер бесполезен.

Приоритизация: не все CVE одинаково срочны

CVSS-балл — плохая мера срочности сам по себе. Критичная уязвимость в парсере формата, который вы не разбираете, менее опасна, чем средняя в коде, обрабатывающем каждый запрос.

Ориентир для оси Y — каталог CISA KEV (уязвимости, эксплуатация которых наблюдалась в реальности) и метрика EPSS. Ориентир для оси X — анализ достижимости: его умеют делать современные сканеры, и он снимает 60–80% шума.

Инструменты

<!-- OWASP Dependency-Check: сопоставляет артефакты с базой NVD -->
<plugin>
  <groupId>org.owasp</groupId>
  <artifactId>dependency-check-maven</artifactId>
  <version>10.0.4</version>
  <configuration>
    <!-- ВАЖНО: без ключа NVD API обновление базы занимает часы из-за rate limit.
         Ключ бесплатный, получается за минуту, кладётся в секреты CI. -->
    <nvdApiKey>${env.NVD_API_KEY}</nvdApiKey>
    <failBuildOnCVSS>7</failBuildOnCVSS>
    <suppressionFiles><suppressionFile>owasp-suppressions.xml</suppressionFile></suppressionFiles>
  </configuration>
  <executions><execution><goals><goal>check</goal></goals></execution></executions>
</plugin>

<!-- CycloneDX: SBOM как обязательный артефакт сборки -->
<plugin>
  <groupId>org.cyclonedx</groupId>
  <artifactId>cyclonedx-maven-plugin</artifactId>
  <version>2.9.0</version>
  <executions>
    <execution>
      <phase>package</phase>
      <goals><goal>makeAggregateBom</goal></goals>
    </execution>
  </executions>
</plugin>

Практический набор, который закрывает большую часть риска:

Слой Инструмент Что ловит
Зависимости, локально и в CI OWASP Dependency-Check, OSV-Scanner известные CVE в JAR
Зависимости, непрерывно Dependabot / Renovate / Snyk новые CVE в уже выкаченном
Состав артефакта CycloneDX SBOM «есть ли у нас log4j» за секунды
Образ Trivy, Grype CVE базового образа и системных пакетов
Происхождение actions/attest-build-provenance, cosign, SLSA «этот JAR собран из этого коммита этим пайплайном»
Публикация в Central GPG-подпись, Central Portal подмена артефакта в репозитории

Полное погружение в тему — Безопасность цепочки поставок и Безопасность в пайплайне.

Безопасность самого Java-кода

Есть класс уязвимостей, специфичный именно для Java, — и он же самый недооценённый.

Десериализация: главная историческая рана Java

ObjectInputStream.readObject() восстанавливает произвольный объектный граф, вызывая при этом чужой код: конструкторы, readObject, readResolve, finalize. Атакующий, контролирующий байты, собирает «gadget chain» из классов, уже лежащих у вас на classpath (классика — commons-collections), и получает выполнение кода. Ни один из этих классов не «уязвим» сам по себе — уязвима сама модель.

// Правило номер один: НЕ десериализуйте недоверенные данные через Java-сериализацию.
// Для внешних данных — JSON/Protobuf с явной схемой.

// Если совсем нельзя убрать (legacy-протокол) — включите фильтр (JEP 290/415):
var filter = ObjectInputFilter.Config.createFilter(
        "com.acme.dto.*;"       // разрешён только наш пакет DTO
      + "java.base/java.util.ArrayList;java.base/java.lang.String;"
      + "maxdepth=8;maxrefs=1000;maxbytes=100000;"  // ограничения ресурсов
      + "!*");                  // всё остальное запрещено

try (var ois = new ObjectInputStream(in)) {
    ois.setObjectInputFilter(filter);   // фильтр ДО первого readObject
    var dto = (OrderDto) ois.readObject();
}

Глобально то же самое задаётся системным свойством -Djdk.serialFilter=... — полезно, когда десериализацию делает не ваш код, а библиотека. Документация: Serialization Filtering.

Отдельная и очень частая форма той же проблемы — полиморфная десериализация в Jackson:

// ОПАСНО: позволяет клиенту указать любой класс в поле @class и заставить
// Jackson его инстанцировать. Это удалённое выполнение кода в одну строку.
mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance);

// БЕЗОПАСНО: явный allow-list допустимых подтипов
PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
        .allowIfSubType("com.acme.events.")     // только наши события
        .build();
mapper.activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.NON_FINAL);

// Ещё лучше — вообще без default typing, а через явные @JsonSubTypes на sealed-иерархии
// (см. статью про ООП: sealed + records дают закрытое множество вариантов бесплатно)

XXE: парсеры XML по умолчанию доверчивы

// Стандартные фабрики JAXP исторически включают внешние сущности.
// Один XML — и атакующий читает /etc/passwd или ходит из вашей сети наружу (SSRF).
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);  // самое надёжное
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);

То же нужно делать для SAXParserFactory, XMLInputFactory, TransformerFactory, SchemaFactory — у каждой свой набор ключей. Это ровно тот случай, когда стоит написать один утилитный класс SafeXml и запретить прямое создание фабрик правилом ArchUnit.

Короткий чек-лист прикладной безопасности Java

  • SQL — только PreparedStatement/параметры JPA, конкатенации нет никогда (разбор в работе с данными).
  • RandomSecureRandom. Токены, соли, идентификаторы сессий — только SecureRandom. Math.random() предсказуем.
  • Zip Slip. При распаковке архива проверяйте, что нормализованный путь остаётся внутри целевого каталога: if (!target.normalize().startsWith(base)) throw ....
  • SpEL и EL. @Value("#{...}") и любые выражения от пользователя — прямой путь к RCE.
  • ProcessBuilder — список аргументов, не строка, и никакой оболочки.
  • Секреты не в pom.xml и не в application.yml. Переменные окружения или хранилище (см. управление секретами). Отдельно: settings.xml с паролями от Nexus не коммитится, значения подставляются из переменных окружения через env.-плейсхолдеры Maven.
  • toString() доменных объектов — самая частая утечка PII в логи. У records toString() генерируется автоматически и печатает всё; переопределяйте его в тех, что содержат персональные данные или токены.
  • Security Manager мёртв. JEP 411 объявил его устаревшим в 17, JEP 486 отключил окончательно. Если в старом коде на него была ставка — изоляция теперь строится на уровне процессов и контейнеров, а не внутри JVM.

Ворота качества: что стоит включить, а что — карго-культ

Java даёт очень мощный статический анализ, и его дешевле включить сразу, чем внедрять потом.

<!-- Error Prone встраивается прямо в javac: ошибки видны при компиляции, а не в отчёте -->
<plugin>
  <artifactId>maven-compiler-plugin</artifactId>
  <configuration>
    <release>21</release>
    <compilerArgs>
      <arg>-Xlint:all</arg>
      <arg>-Werror</arg>                  <!-- предупреждение = ошибка сборки -->
      <arg>-XDcompilePolicy=simple</arg>
      <arg>--should-stop=ifError=FLOW</arg>
      <arg>-Xplugin:ErrorProne -XepOpt:NullAway:AnnotatedPackages=com.acme</arg>
    </compilerArgs>
    <annotationProcessorPaths>
      <path>
        <groupId>com.google.errorprone</groupId>
        <artifactId>error_prone_core</artifactId>
        <version>2.31.0</version>
      </path>
      <path>
        <groupId>com.uber.nullaway</groupId>
        <artifactId>nullaway</artifactId>
        <version>0.11.3</version>
      </path>
    </annotationProcessorPaths>
  </configuration>
</plugin>

Что реально окупается:

  • Error Prone — ловит настоящие баги: equals на разных типах, Optional.get() без проверки, потерянный результат, неверный формат.
  • NullAway — практическая замена отсутствующей в Java системе nullability. Даёт 90% пользы Kotlin-овских ? за один флаг.
  • SpotBugs + find-sec-bugs — анализ байткода, находит то, чего не видно в исходнике; find-sec-bugs добавляет security-детекторы.
  • ArchUnit — правила архитектуры как тесты («домен не импортирует Spring», «контроллеры не ходят в репозитории напрямую»). Единственный способ сохранить слои живыми (подробнее — архитектура прод-приложений).
  • Spotless + google-java-format — стиль перестаёт быть темой ревью. Один раз, mvn spotless:apply, больше не обсуждается.
  • PIT — мутационное тестирование. Отвечает на вопрос, который не отвечает покрытие: «а тесты вообще что-то проверяют?».

Чего лучше избегать: порог покрытия как самоцель. JaCoCo с <minimum>0.80</minimum> в проекте без культуры тестирования порождает тесты без ассертов. Полезнее ставить порог на изменённый код и следить за трендом, а не за абсолютным числом.

Типичные ошибки процесса в Java-проектах

  • Версия JDK в четырёх местах (CI, Dockerfile, pom.xml, README) и все разные. Заводите один источник: Maven Toolchains или .sdkmanrc, а остальное читает оттуда.
  • Uber-JAR через maven-shade-plugin без relocation. Две библиотеки приносят одноимённые классы, побеждает случайная. Симптом — NoSuchMethodError в проде при зелёном CI. Лечится <relocations> и правилом enforcer banDuplicateClasses.
  • @Transactional на приватном методе, тесты на моках всё пропускают. Класс ошибок «прокси не сработал» ловится только интеграционным тестом с реальным контекстом.
  • Мониторинг только по HTTP-метрикам. Java-специфичное надо смотреть отдельно: паузы GC, занятость пула соединений, размер очереди executor’а, метаспейс.
  • Игнор -Werror, потому что «много warning’ов». Один раз почистить дешевле, чем годами не замечать реальные предупреждения в потоке шума.
  • Библиотека без -parameters. Флаг компилятора, сохраняющий имена параметров в байткоде; без него часть фреймворков (в том числе Spring в ряде сценариев) требует дублировать имена в аннотациях.
  • Прод собирается на машине разработчика. «У меня локально работает» — это не шутка, а описание процесса, где нет воспроизводимости.

Чек-лист production-ready Java-сервиса

Сборка и CI:

  • Wrapper (mvnw/gradlew) с проверкой контрольной суммы, версия JDK зафиксирована.
  • mvn verify, а не install; сборка одна, артефакт продвигается по средам без пересборки.
  • maven.compiler.release, а не source/target.
  • project.build.outputTimestamp — сборка воспроизводима.
  • Локаль и таймзона тестов зафиксированы явно.
  • Enforcer: конвергенция версий, запрет SNAPSHOT и диапазонов в релизе.

Зависимости и безопасность:

  • Версии — только через BOM/version catalog, ни одной версии в модулях.
  • Renovate/Dependabot включён, патчи автомёржатся.
  • Скан CVE в PR + повторный скан прода по расписанию.
  • SBOM генерируется и хранится вместе с артефактом.
  • Файл подавлений — с причиной и сроком пересмотра у каждой записи.
  • Внешние артефакты идут через прокси-репозиторий.

Качество и совместимость:

  • -Xlint:all -Werror, Error Prone, SpotBugs, Spotless в сборке.
  • ArchUnit защищает границы слоёв.
  • Для библиотек — japicmp/revapi в PR.
  • Интеграционные тесты на Testcontainers, а не на моках инфраструктуры.

Эксплуатация:

  • Версия и SHA коммита доступны из работающего приложения.
  • Миграции БД — expand/contract, откат приложения не ломает схему.
  • Настроены таймауты на всех внешних вызовах, есть graceful shutdown.
  • Логи структурные, дампы heap и JFR-запись можно снять с прода без перезапуска.

Карта ресурсов: куда идти дальше

Java — экосистема с огромным количеством материалов, и большая их часть устарела. Главный навык — отличать источники, которые обновляются, от тех, что застыли на Java 8. Ниже — то, что действительно стоит читать в 2026 году.

Официальные первоисточники

  • Java SE Documentation — Javadoc и руководства по текущему LTS. Начинайте здесь, а не с блогов.
  • The Java Language Specification — JLS и JVMS. Тяжело, но глава 13 (бинарная совместимость) и глава 17 (модель памяти) обязательны для любого, кто пишет библиотеки или конкурентный код.
  • JEP Index — все изменения платформы с обоснованием. Лучший способ понять, куда движется Java и почему.
  • Inside Java — официальный блог команды OpenJDK, плюс подкаст и видео-новостник Inside Java Newscast.
  • dev.java — официальные обучающие материалы, переписанные под современную Java (наконец-то без апплетов).
  • OpenJDK mailing lists — если хотите видеть, как решения принимаются в реальном времени: amber-dev, loom-dev, hotspot-gc-dev.

Книги

  • Joshua Bloch, Effective Java, 3-е изд. — безусловный номер один. 90 пунктов о том, как писать Java правильно. Устарела лишь частично (нет records и sealed), принципы — нет.
  • Brian Goetz и др., Java Concurrency in Practice — книга 2006 года, которая до сих пор лучшая по модели памяти и безопасной публикации. Читать вместе с современными материалами про виртуальные потоки.
  • Benjamin Evans, James Gough, Chris Newland, Optimizing Java — JIT, GC, методология измерения. Лучшая книга по производительности JVM.
  • Scott Oaks, Java Performance, 2-е изд. — практичнее и прикладнее, много про настройку сборщиков.
  • Raoul-Gabriel Urma и др., Modern Java in Action — Stream API, лямбды, CompletableFuture подробно и с толком.
  • Vlad Mihalcea, High-Performance Java Persistence — обязательна для всех, кто трогает JPA.
  • Cay Horstmann, Core Java — справочник-энциклопедия, регулярно обновляется под новые LTS.
  • Bill Venners, Inside the Java Virtual Machine — старая, но лучшая для понимания устройства байткода и загрузчиков классов.

Блоги и люди

  • Nicolai Parlog (nipafx) — самое понятное объяснение новых фич языка; он же ведёт Inside Java Newscast.
  • Aleksey Shipilëv — эталонные разборы GC, JMH и производительности JVM. Статья «Java Memory Access Poking» и цикл про GC — обязательное чтение.
  • Vlad Mihalcea — JPA/Hibernate, транзакции, SQL.
  • Gunnar Morling — CDC, Debezium, инженерная культура, JFR.
  • Baeldung — гигантская база рецептов. Полезен для быстрого старта, но проверяйте дату: там много материалов, устаревших на несколько LTS.
  • foojay.io — агрегатор блогов сообщества, хорошая лента.
  • InfoQ Java — новости и глубокие статьи, без hype.
  • JetBrains Java Annotated Monthly — ежемесячная подборка лучшего за месяц; один из немногих способов не утонуть.

Видео, подкасты, конференции

  • Inside Java Newscast и JEP Café на YouTube-канале Java — короткие разборы от команды платформы.
  • Devoxx и JavaZone — записи докладов в свободном доступе, уровень выше среднего.
  • JUG Ru / Joker / JPoint — русскоязычные конференции с сильной программой по внутренностям JVM.
  • Подкаст Inside Java Podcast — интервью с авторами JEP.

Что изучать параллельно

Java редко существует в вакууме. Естественные соседи:

  • Kotlin — тот же байткод, другая эргономика; если Java-проект уже большой, Kotlin добавляется в него по одному файлу.
  • GraalVM native image — компиляция в нативный бинарник, время старта в миллисекундах; цена — рефлексия требует конфигурации, а пиковая производительность ниже (см. деплой и наблюдаемость).
  • Kafka, Spark, Flink, Elasticsearch — вся эта инфраструктура написана на JVM, и знание внутренностей JVM здесь превращается в прямое преимущество.
  • Треки портала: DevOps, Безопасность, Базы данных, Распределённые системы.

Итог трека

Мы прошли путь от main до продакшн-системы. Если сжать шестнадцать статей до нескольких предложений, получится вот что.

Java — это платформа, а не язык. Язык за последние годы изменился радикально: records, sealed-типы, pattern matching и виртуальные потоки сделали код, который в 2015 году занимал страницу, десятью строками. Но главная ценность по-прежнему в JVM: адаптивный JIT, который оптимизирует по факту исполнения, сборщики мусора с паузами меньше миллисекунды на многогигабайтных кучах, лучшая в отрасли наблюдаемость рантайма через JFR.

Где Java выигрывает: долгоживущие серверные системы с большой командой, где важнее предсказуемость и поддерживаемость, чем скорость первого прототипа; высоконагруженный бэкенд, где нужны и производительность, и безопасность памяти; интеграция с необъятной экосистемой, в которой библиотека есть под всё.

Где проигрывает: быстрый скриптинг и разовые задачи (Python удобнее); serverless с холодными стартами (без native image старт JVM — вечность); системы с жёстким контролем латентности в хвосте, где недопустима ни одна GC-пауза (Rust, C++); окружения с жёстким лимитом памяти в десятки мегабайт; фронтенд.

Куда её не стоит тащить: в проект из трёх скриптов; в команду из двух человек, которой надо проверить гипотезу за неделю; в задачу, где ценность в математике, а не в инфраструктуре. Соседний трек C#/.NET занимает почти ту же нишу и во многом устроен удобнее — с оговоркой, что экосистема Java шире, а выбор вендоров свободнее. Выбор между ними чаще диктуется командой и рынком труда, чем техникой.

И главное: вы теперь понимаете, что происходит под капотом. Байткод, области памяти, разрешение зависимостей, прокси Spring, persistence context Hibernate, планировщик виртуальных потоков — это не магия, а конкретные механизмы с конкретными правилами. Это и отличает инженера, которого зовут разбираться в проде, от того, кто ждёт, пока разберутся другие.

Что дальше

Трек по Java закончен. Естественные продолжения:

  • Дорожная карта портала — как связаны все треки и в каком порядке их проходить в зависимости от вашей цели.
  • DevOps — контейнеры, Kubernetes, IaC: логичный следующий шаг, если вы отвечаете за то, как ваш сервис живёт в проде.
  • Безопасность — от моделирования угроз до прикладной криптографии; всё, что в этой статье было конспектом, там разобрано подробно.
  • Архитектурные паттерны и DDD — следующий уровень после «умею писать сервисы».
  • Распределённые системы — когда сервисов становится много и начинается настоящая сложность.
  • C#/.NET, Go, Rust или Elixir — если хочется посмотреть, как те же задачи решают другие среды выполнения. Ничто так не углубляет понимание Java, как несколько месяцев в языке с другой моделью памяти и конкурентности.

Спасибо, что дошли до конца. Дальше — только практика: возьмите реальную задачу, доведите её до прода и посмотрите, что сломается. Именно там начинается настоящее обучение.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов