Java в SDLC: CI/CD, зависимости, безопасность и лучшие ресурсы
Четырнадцать статей назад мы начинали с public static void main. К этому моменту вы умеете
писать код, который компилируется, тестируется, работает под нагрузкой и отдаёт метрики.
Осталось то, что отличает работающий код от живой системы: процесс, в котором этот код
меняется годами руками десятка человек и при этом не разваливается.
Эта статья — не про «а ещё бывает CI». Она про то, что в жизненном цикле Java-проекта устроено иначе, чем где бы то ни было, и почему привычки из других экосистем здесь работают плохо. Четыре вещи задают всю специфику:
- Java-код живёт долго. Средний возраст продакшн-кодовой базы на Java — 8–15 лет. Люди меняются, код остаётся. Всё, что вы решите про версии и совместимость, аукнется не вам.
- Дерево зависимостей глубокое и чужое. Пять строк в
pom.xmlразворачиваются в две сотни артефактов. Уязвимость приходит с уровня, который вы не выбирали. - Артефакт — JAR, а не исходник. Потребитель линкуется с уже скомпилированным байткодом. Отсюда понятие бинарной совместимости, которого нет в мире, где всё собирается из исходников на каждой машине.
- Платформа обновляется по расписанию. Каждые полгода — релиз, каждые два года — 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 разные ответы на один вопрос
Картинка выше показывает две вещи сразу. Первая: уязвимость почти никогда не в том, что вы подключили. Вторая, менее известная и куда более коварная: когда один артефакт приходит двумя путями в разных версиях, 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». Порядок действий, проверенный на многих миграциях:
- Сначала соберите старым компилятором на новой JVM. Байткод Java 8 отлично исполняется на JDK 21. Так вы отделяете проблемы рантайма от проблем компиляции.
- Найдите использование внутренних API:
Типичные находки:
jdeps --multi-release 21 --jdk-internals -R --class-path 'libs/*' target/app.jarsun.misc.Unsafe,sun.reflect, старые версии Lombok, cglib, ASM, Groovy, Kryo, mockito-inline. Обычно лечится обновлением библиотеки. - Ожидайте боли от strong encapsulation. JEP 403 (JDK 17) закрыл внутренности JDK.
Рефлексия в чужие пакеты требует явного
--add-opens java.base/java.lang=ALL-UNNAMED. Флаги — временный костыль, а не решение: они переживают релизы плохо. - Проверьте GC-настройки. Дефолты меняются между версиями; флаги, скопированные из статьи 2015 года, часть которых уже удалена, не дадут JVM стартовать вовсе.
- Только потом поднимайте
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, конкатенации нет никогда (разбор в работе с данными). Random≠SecureRandom. Токены, соли, идентификаторы сессий — только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 в логи. У recordstoString()генерируется автоматически и печатает всё; переопределяйте его в тех, что содержат персональные данные или токены.- 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>и правилом enforcerbanDuplicateClasses. @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, как несколько месяцев в языке с другой моделью памяти и конкурентности.
Спасибо, что дошли до конца. Дальше — только практика: возьмите реальную задачу, доведите её до прода и посмотрите, что сломается. Именно там начинается настоящее обучение.