Установка и инструментарий: JDK, Maven, Gradle, структура проекта
В большинстве языков «установка» — это скучный первый шаг, который можно пролистать.
В Java пролистывать нельзя: половина проблем, с которыми новичок сидит по три часа
(UnsupportedClassVersionError, ClassNotFoundException, «у меня собирается, на CI нет»),
растут ровно отсюда — из непонимания, что именно установлено, кто и во что компилирует
код и откуда JVM берёт классы в момент запуска.
Поэтому статья построена не как список команд, а как модель. Сначала разберёмся, что физически лежит в каталоге JDK и почему инструментов там так много. Потом соберём и запустим программу голыми руками, без сборщика, чтобы увидеть механику. И только после этого перейдём к Maven и Gradle — станет понятно, какую именно работу они за нас делают и почему их конфигурация выглядит именно так.
Модель, из которой всё следует
Java — язык с промежуточным представлением. Компилятор javac переводит текст не
в машинный код, а в байткод — компактную инструкцию для стековой виртуальной машины.
Байткод кладётся в .class-файлы, .class-файлы упаковываются в .jar (обычный ZIP
с манифестом), а .jar уже исполняет JVM, которая в рантайме дожимает горячие
участки в машинный код через JIT-компилятор.
Из этой схемы следуют три практических вывода, которые стоит принять сразу:
- Переносимость есть, но она версионная.
.classне зависит от ОС и процессора, зато жёстко помечен версией формата. JVM 17 не запустит класс, собранный под 21. Обратное работает: JVM 21 запустит класс от Java 8. - Компиляция и запуск — два разных мира с двумя разными наборами классов. То, что видел компилятор, и то, что найдёт JVM, — не одно и то же. Отсюда весь класс ошибок «компилируется, но падает в рантайме».
- Производительность появляется не сразу. Первые тысячи вызовов метода интерпретируются, дальше подключается C1, потом C2. Поэтому Java стартует медленнее Go или C#-AOT, но на длинной дистанции догоняет и часто обгоняет. Детали — в статье про JVM.
Что лежит в JDK
JDK — это не «компилятор Java», это набор из нескольких десятков утилит. Полезно знать хотя бы карту: она пригодится, когда прод начнёт вести себя странно.
Отдельно отметим jdeps — он показывает, от каких модулей и пакетов зависит ваш
артефакт, и это единственный вменяемый способ подготовиться к jlink. И jfr
с jcmd — штатный профилировщик, встроенный в JVM; ими мы займёмся
в статье про производительность.
Чего в JDK нет — сборщика проектов. Здесь принципиальная разница с .NET, где
dotnet CLI закрывает всё: создание проекта, сборку, тесты, публикацию, работу
с пакетами. В Java сборщик — сторонний инструмент, и их два конкурирующих. Плюс:
конкуренция дала действительно мощные системы сборки. Минус: два разных языка
конфигурации, две модели зависимостей и вечный спор в команде. Сравнить с подходом
.NET можно в тулчейне C#.
Какую версию и какой дистрибутив ставить
Java с 2018 года выпускается каждые полгода, но LTS-версии (Long Term Support) выходят раз в два года — именно на них живёт продакшн.
Практическая рекомендация на 2026 год: новый проект — Java 21 или 25. Java 21 даёт виртуальные потоки и полноценный pattern matching и уже поддержана всей экосистемой; Java 25 добавляет удобства и свежие GC-улучшения. Java 17 — разумный минимум, если вас держит корпоративный стандарт. Java 8 в 2026 году — это техдолг, а не выбор, хотя её всё ещё много в банковском и телеком-легаси.
Дистрибутив — это сборка одного и того же OpenJDK разными вендорами:
| Дистрибутив | Кто собирает | Когда брать |
|---|---|---|
| Eclipse Temurin | Adoptium | Значение по умолчанию, если нет причин иначе |
| Amazon Corretto | AWS | Если деплой в AWS, есть свои патчи и долгая поддержка |
| Azul Zulu | Azul | Широкий выбор старых версий и платформ |
| BellSoft Liberica | BellSoft | Есть сборки с JavaFX и мелкие образы для контейнеров |
| Oracle JDK | Oracle | Только если сознательно приняли лицензию NFTC/подписку |
| GraalVM | Oracle Labs | Нужен native image или полиглот |
Скачать Temurin: adoptium.net. Сравнить, что в какой версии появилось и чем отличаются сборки: javaalmanac.io и foojay.io/almanac.
Про лицензии коротко и честно: OpenJDK-сборки (Temurin, Corretto, Zulu, Liberica) бесплатны для любого использования под GPLv2+CPE. Oracle JDK распространяется под NFTC — бесплатен для разработки и продакшна текущих версий, но с ограничениями по срокам поддержки старых релизов. Условия читаем у первоисточника: oracle.com/java/technologies/javase/jdk-faqs.html. Юридически безопасный дефолт — Temurin.
Установка и переключение версий
На любом проекте старше года вам понадобится держать несколько JDK одновременно.
Ставить их вручную и править PATH — путь к боли. Правильный инструмент —
менеджер версий.
# --- Linux / macOS: SDKMAN! (https://sdkman.io/) ---
curl -s "https://get.sdkman.io" | bash
source "$HOME/.sdkman/bin/sdkman-init.sh"
sdk list java # весь каталог: версии и вендоры
sdk install java 21.0.6-tem # Temurin 21
sdk install java 25.0.1-tem # Temurin 25
sdk default java 21.0.6-tem # версия по умолчанию в системе
sdk use java 25.0.1-tem # только в текущей сессии оболочки
# SDKMAN умеет ставить и сами сборщики — это удобно
sdk install maven
sdk install gradle
Фиксируем версию на уровне репозитория, чтобы у всех совпадало:
# .sdkmanrc в корне проекта — коммитим в git
java=21.0.6-tem
maven=3.9.9
Теперь sdk env в каталоге проекта переключит всё разом, а sdk env install
доставит недостающее. Альтернативы: jenv (только переключение, установка отдельно),
mise, asdf. На Windows — winget install EclipseAdoptium.Temurin.21.JDK или
scoop; на macOS ещё brew install --cask temurin@21.
Проверяем результат:
java -version
# openjdk version "21.0.6" 2025-01-21 LTS
# OpenJDK Runtime Environment Temurin-21.0.6+7 (build 21.0.6+7-LTS)
# OpenJDK 64-Bit Server VM Temurin-21.0.6+7 (build 21.0.6+7-LTS, mixed mode, sharing)
javac -version # javac 21.0.6
echo $JAVA_HOME # /home/user/.sdkman/candidates/java/current
Грабля номер один в карьере джуна: java и javac в PATH могут указывать на
разные JDK, а Maven и Gradle смотрят вообще на JAVA_HOME, а не на PATH. Если
что-то ведёт себя необъяснимо — сверьте все три:
which java, which javac, echo $JAVA_HOME.
Первый запуск без сборщика
Прежде чем прятать механику за Maven, соберём программу руками. Это пятиминутное упражнение экономит потом дни отладки.
// Файл: Hello.java
public class Hello {
public static void main(String[] args) {
// Runtime.version() — та самая JVM, что нас запустила
System.out.println("Привет, JVM " + Runtime.version());
}
}
javac Hello.java # получили Hello.class
java Hello # запускаем КЛАСС, без расширения .class
# Привет, JVM 21.0.6+7-LTS
# С Java 11 однофайловые программы можно запускать напрямую, без javac (JEP 330)
java Hello.java
С Java 25 стал стабильным «компактный исходник» — программа без класса-обёртки (JEP 512), удобный для обучения и скриптов:
// Файл: hello.java — метод main без класса и без static
void main() {
IO.println("Привет из компактного исходника");
}
А теперь заглянем внутрь .class. Это одна из самых недооценённых команд в тулчейне:
javap -c Hello.class
public class Hello {
public Hello();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #13 // String Привет, JVM
5: invokedynamic #17, 0 // InvokeDynamic #0:makeConcatWithConstants
...
13: invokevirtual #23 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
16: return
}
Обратите внимание на invokedynamic вместо ожидаемого StringBuilder: с Java 9
конкатенация строк собирается на лету через StringConcatFactory. Это первый намёк,
что «идиоматичный код» в Java определяется не только языком, но и тем, во что его
превращает компилятор и JIT.
Ещё один инструмент для экспериментов — REPL:
jshell
jshell> var xs = List.of(3, 1, 2);
xs ==> [3, 1, 2]
jshell> xs.stream().sorted().toList()
$2 ==> [1, 2, 3]
jshell> /exit
jshell бесценен, когда надо быстро проверить поведение API, не заводя проект.
Classpath: где JVM берёт классы
Пока файл один, всё просто. Как только появляются зависимости, включается механизм, который и порождает большинство рантайм-ошибок новичка.
JVM ищет класс по имени, обходя загрузчики по цепочке, и первый нашедший побеждает:
java.base и модули JDK"} B -- найден --> Z["Класс загружен,
дальше верификация байткода"] B -- нет --> C{"Platform loader:
остальные модули платформы"} C -- найден --> Z C -- нет --> D{"Application loader:
-cp, -p, MANIFEST Class-Path"} D -- найден --> Z D -- нет --> E{"Кто спрашивал?"} E -- "Class.forName / рефлексия" --> F["ClassNotFoundException"] E -- "код скомпилирован против класса" --> G["NoClassDefFoundError"] Z --> H{"Версия class-файла
больше версии JVM?"} H -- да --> I["UnsupportedClassVersionError"] H -- нет --> J["Работает"]
Разница между двумя исключениями — важный диагностический сигнал.
ClassNotFoundException означает «класс искали по имени в рантайме и не нашли»
(типично для рефлексии, драйверов, DI-контейнеров). NoClassDefFoundError —
«при компиляции класс был, при запуске исчез», то есть classpath сборки и classpath
запуска разъехались. Второе почти всегда означает неверно выбранный scope зависимости.
Собираем вручную многофайловый проект с внешней библиотекой:
# Компиляция: куда класть результат и где искать зависимости
javac -d target/classes -cp "lib/slf4j-api-2.0.16.jar" $(find src -name "*.java")
# Запуск: classpath = наши классы + библиотеки. Разделитель ':' (Windows — ';')
java -cp "target/classes:lib/*" com.example.App
# Упаковка в jar с точкой входа
jar --create --file app.jar --main-class com.example.App -C target/classes .
java -jar app.jar
Тут же становится очевидно, зачем нужен сборщик: перечислять jar-файлы руками, скачивать их, следить за их зависимостями и версиями — работа, которую нельзя делать вручную дольше одного дня.
Maven: декларация вместо скрипта
Maven исходит из идеи соглашения над конфигурацией: если вы разложили файлы
так, как принято, конфигурировать почти нечего. Проект описывается файлом pom.xml,
а сборка — это проход по фиксированному жизненному циклу фаз, к которым
привязаны цели плагинов.
Ключевое, что путает всех: команда mvn package выполняет не фазу package,
а все фазы до неё включительно. Отсюда правило: в CI пишите mvn verify, а не
привычное по туториалам mvn clean install — install захламляет локальный
репозиторий артефактами и, что хуже, маскирует ошибки «забыли объявить зависимость»,
потому что нужный jar уже лежит в ~/.m2.
Боевой pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- Координаты артефакта: groupId:artifactId:version — глобальный адрес в экосистеме -->
<groupId>com.example</groupId>
<artifactId>orders</artifactId>
<version>0.1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<properties>
<!-- release, а НЕ пара source/target: компилятор увидит API ровно целевой версии -->
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<junit.version>5.11.3</junit.version>
</properties>
<!-- BOM: один источник правды по версиям семейства библиотек -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>${junit.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.16</version>
</dependency>
<dependency>
<!-- версия не указана: приходит из BOM выше -->
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<compilerArgs>
<arg>-Xlint:all</arg> <!-- все предупреждения компилятора -->
<arg>-Werror</arg> <!-- предупреждение = ошибка сборки -->
<arg>-parameters</arg> <!-- имена параметров в байткод: нужно Spring и Jackson -->
</compilerArgs>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<id>enforce</id>
<goals><goal>enforce</goal></goals>
<configuration>
<rules>
<requireMavenVersion><version>[3.9,)</version></requireMavenVersion>
<requireJavaVersion><version>[21,)</version></requireJavaVersion>
<!-- падать, если одна библиотека пришла в двух версиях -->
<dependencyConvergence/>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
Scope зависимости — это про два разных classpath
| Scope | Компиляция | Тесты | Рантайм | Пример |
|---|---|---|---|---|
compile (по умолчанию) |
да | да | да | Jackson, Guava |
provided |
да | да | нет | Servlet API, Lombok |
runtime |
нет | да | да | JDBC-драйвер, logback |
test |
нет | да | нет | JUnit, Mockito, AssertJ |
import |
— | — | — | только для BOM в dependencyManagement |
Классическая ошибка: поставить provided библиотеке, которую контейнер на самом деле
не предоставляет, — сборка зелёная, приложение падает на старте
с NoClassDefFoundError. Ровно та ситуация из схемы выше.
Wrapper: воспроизводимость сборки
Версия самого Maven — тоже зависимость проекта. Фиксируем её врапером:
mvn wrapper:wrapper -Dmaven=3.9.9 # создаст mvnw, mvnw.cmd и .mvn/wrapper/
./mvnw -v # скачает нужный Maven при первом запуске
Коммитим mvnw, mvnw.cmd и .mvn/ в репозиторий. Теперь на CI не нужен
предустановленный Maven, а «у меня другая версия» перестаёт быть аргументом.
Полезные команды на каждый день:
./mvnw verify # компиляция + все тесты, ничего не ставит в ~/.m2
./mvnw -o verify # offline: только из локального кэша
./mvnw dependency:tree # дерево зависимостей с пометками конфликтов
./mvnw dependency:analyze # объявлено, но не используется / используется, но не объявлено
./mvnw versions:display-dependency-updates
./mvnw -pl orders-api -am test # только модуль и то, от чего он зависит
Отдельно про dependency:analyze — это редко используемая и очень полезная цель:
она ловит «случайные» транзитивные зависимости, на которые вы опираетесь, не объявив
их. Такой код ломается ровно в тот день, когда апстрим уберёт свою зависимость.
Gradle: сборка как программа
Gradle решает ту же задачу иначе. Вместо фиксированного жизненного цикла — граф задач (DAG), который вы описываете на Kotlin DSL (или Groovy). Каждая задача знает свои входы и выходы, поэтому Gradle умеет пропускать то, что не изменилось, кэшировать результаты между сборками и параллелить независимые ветки.
// build.gradle.kts
plugins {
`java-library`
id("com.diffplug.spotless") version "6.25.0"
}
group = "com.example"
version = "0.1.0-SNAPSHOT"
// Toolchain: Gradle сам скачает нужный JDK, независимо от того, чем запущен сам Gradle
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories { mavenCentral() }
dependencies {
// api — утечёт в публичный API и в compile classpath потребителей
api(libs.slf4j.api)
// implementation — деталь реализации, потребители её не увидят: быстрее пересборка
implementation(libs.jackson.databind)
// compileOnly — только компиляция, в рантайме класса не будет
compileOnly(libs.jspecify)
testImplementation(platform(libs.junit.bom)) // BOM подключается как platform
testImplementation(libs.junit.jupiter)
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.withType<JavaCompile>().configureEach {
options.encoding = "UTF-8"
options.compilerArgs.addAll(listOf("-Xlint:all", "-Werror", "-parameters"))
}
tasks.test {
useJUnitPlatform()
maxParallelForks = Runtime.getRuntime().availableProcessors() / 2
}
Разделение api / implementation — главное функциональное преимущество Gradle перед
Maven. В Maven любая compile-зависимость транзитивно попадает в classpath всех, кто
вас использует; изменение её версии заставляет пересобирать весь граф модулей.
В Gradle implementation обрывает эту цепочку: инкрементальная сборка большого
многомодульного проекта ускоряется в разы.
Подробности: docs.gradle.org — Java Library Plugin.
Version catalog: версии в одном файле
# gradle/libs.versions.toml
[versions]
slf4j = "2.0.16"
jackson = "2.18.2"
junit = "5.11.3"
[libraries]
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" }
jspecify = { module = "org.jspecify:jspecify", version = "1.0.0" }
junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter" }
[bundles]
jackson = ["jackson-databind"]
Каталог доступен во всех модулях как typesafe-акцессоры libs.slf4j.api — с
автодополнением в IDE. Это прямой аналог Central Package Management в .NET и
рекомендованный способ жить в многомодульном репозитории:
docs.gradle.org — Version Catalogs.
Ускорение, которое включается двумя строками
# gradle.properties
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=768m
configuration-cache кэширует результат фазы конфигурации — на большом проекте это
экономит секунды на каждой сборке
(документация).
Врапер обязателен так же, как в Maven:
gradle wrapper --gradle-version 8.12 # создаст gradlew, gradlew.bat, gradle/wrapper/
./gradlew build --scan # сборка + ссылка на разбор, куда ушло время
./gradlew dependencies --configuration runtimeClasspath
./gradlew dependencyInsight --dependency jackson-databind --configuration runtimeClasspath
Важно про безопасность: gradle/wrapper/gradle-wrapper.jar — это исполняемый код
в репозитории. Проверяйте его подлинность (GitHub Action gradle/actions/wrapper-validation),
иначе получаете идеальный вектор атаки на цепочку поставок.
Как приезжают зависимости и почему они конфликтуют
Оба сборщика работают одинаково по схеме «сначала локальный кэш, потом сеть»:
Теперь самое коварное. Maven и Gradle разрешают конфликты версий по разным правилам, и это не настройка, а фундаментальное различие.
Ситуация: ваш модуль тянет библиотеку A, которая требует jackson 2.15, и
библиотеку B, которая требует jackson 2.18.
- Maven — «ближайший в дереве побеждает» (nearest-wins). Побеждает та версия,
что объявлена ближе к корню, а при равной глубине — объявленная выше в
pom.xml. То есть результат зависит от порядка строк в файле. Если победит 2.15, библиотека B упадёт сNoSuchMethodErrorв рантайме. - Gradle — «наибольшая побеждает» (highest-wins). Возьмётся 2.18, что обычно безопаснее из-за обратной совместимости, но не гарантированно: мажорные версии ломают API.
Лечение одинаково в обеих системах: явно зафиксировать версию (dependencyManagement
в Maven, constraints или BOM в Gradle) и включить проверку конвергенции
(dependencyConvergence у enforcer-плагина). И регулярно смотреть дерево:
./mvnw dependency:tree -Dincludes=com.fasterxml.jackson.core
# [INFO] +- com.example:lib-a:jar:1.4.0:compile
# [INFO] | \- com.fasterxml.jackson.core:jackson-databind:jar:2.15.2:compile
# [INFO] \- com.example:lib-b:jar:2.0.0:compile
# [INFO] \- (com.fasterxml.jackson.core:jackson-databind:jar:2.18.2:compile
# [INFO] - omitted for conflict with 2.15.2)
Строка omitted for conflict with — это ровно то место, где сборка внешне зелёная,
а прод падает. Читайте вывод dependency:tree внимательно.
Maven или Gradle: честный выбор
| Критерий | Maven | Gradle |
|---|---|---|
| Формат | XML, декларативный | Kotlin/Groovy DSL, императивный |
| Порог входа | ниже: похожие pom во всех проектах | выше: сборка — это код, который надо читать |
| Скорость на большом репозитории | средняя | заметно выше: инкрементальность и кэш |
| Предсказуемость | высокая: фазы жёстко заданы | ниже: любой плагин может всё переписать |
| Гибкость | через плагины, нестандартное — больно | почти безграничная |
| Разделение api/implementation | нет | есть |
| Android | не поддерживается | штатный сборщик |
| Многомодульность на 50+ модулей | тяжело | родная задача |
Правило, которое работает: Maven — для сервисов и библиотек с обычной структурой, особенно если в команде нет выделенного человека под сборку. Gradle — для больших монорепозиториев, Android и там, где сборка нетривиальна. Худший вариант — Gradle, в котором никто не разбирается: он превращается в четыреста строк скопированного кода, который боятся трогать.
Есть и третий путь — Bazel — но он оправдан только на очень крупных полиязычных монорепозиториях и требует отдельной команды сопровождения.
Структура проекта
Обе системы по умолчанию используют один и тот же Maven Standard Directory Layout. Он не обсуждается — просто следуйте ему, и все инструменты заработают из коробки.
orders/
├── .mvn/wrapper/ # врапер: коммитим
├── mvnw, mvnw.cmd
├── pom.xml
├── .editorconfig
├── .gitignore # target/, build/, .idea/, *.iml
├── src/
│ ├── main/
│ │ ├── java/
│ │ │ └── com/example/orders/ # пакеты = каталоги, обратный DNS
│ │ │ ├── OrdersApplication.java
│ │ │ ├── domain/ # модель, без зависимостей наружу
│ │ │ ├── application/ # сценарии использования
│ │ │ └── infrastructure/ # БД, HTTP, очереди
│ │ └── resources/ # application.yaml, миграции, шаблоны
│ └── test/
│ ├── java/ # зеркалит структуру main
│ └── resources/ # фикстуры, тестовые конфиги
└── target/ # артефакты сборки, в git не попадает
Три жёстких правила, которые ловят почти все ошибки компоновки:
- Пакет обязан совпадать с путём каталога.
package com.example.orders.domain;лежит вsrc/main/java/com/example/orders/domain/. Иначе — «class X is public, should be declared in a file named X.java» или невидимые классы. - Имя пакета — обратный доменный адрес вашей организации:
com.example,ru.company.project. Это не эстетика — это защита от коллизий в общем пространстве имён экосистемы. - Тесты зеркалят основной код по пакетам. Тогда тест видит package-private
классы соседа без ухищрений, а Surefire находит его по соглашению об именах
(
*Test.java).
Многомодульный проект
Как только появляется больше одного деплоя или хочется физически запретить домену знать про Hibernate, проект разбивают на модули:
<!-- Корневой pom.xml: packaging=pom, только агрегирует -->
<packaging>pom</packaging>
<modules>
<module>orders-domain</module>
<module>orders-application</module>
<module>orders-infrastructure</module>
<module>orders-api</module>
</modules>
Границы модулей — это уже архитектурное решение, а не техническое, и о нём подробно в статье про архитектуру прод-приложений. Пока — принцип: зависимости направлены внутрь, к домену, и никогда наоборот. Сборщик здесь работает как компилятор архитектуры: нарушение направления просто не соберётся.
Про JPMS отдельно
Модульная система Java (module-info.java, JEP 261) — не то же самое, что модули
Maven/Gradle. Честный совет для 2026 года: в обычном сервисе она вам не нужна.
JPMS даёт настоящую инкапсуляцию пакетов и работает под jlink, но конфликтует с
半 экосистемой (реФлексия, agent-библиотеки, автоматические модули) и добавляет
класс новых ошибок. Оправдана для публичных библиотек и для минимизации рантайма
через jlink. Внутри приложения хватает classpath.
Формы поставки артефакта
Что именно вы отдадите в прод — тоже часть тулчейна, и решение принимается рано.
# Fat jar руками — Maven Shade
./mvnw package shade:shade
# Spring Boot делает это своим плагином и раскладывает слои для Docker
./mvnw spring-boot:build-image
# Минимальный рантайм: сначала узнаём список модулей, потом собираем
jdeps --print-module-deps --ignore-missing-deps target/app.jar
jlink --add-modules java.base,java.logging,java.sql \
--strip-debug --no-header-files --no-man-pages --compress=2 \
--output custom-runtime
Начинайте с fat jar — это дефолт индустрии. Переходите на jlink или native image,
только когда измерили конкретную боль: размер образа, время холодного старта,
потребление памяти. Подробно — в статье про
деплой и наблюдаемость.
Качество кода на этапе сборки
Java-компилятор довольно мягок, поэтому индустрия обвешивает сборку статическим анализом. Минимальный разумный набор:
- Spotless + google-java-format или palantir-java-format — автоформат, снимающий споры о стиле: github.com/diffplug/spotless.
- Error Prone от Google — ловит реальные баги на этапе компиляции
(
==для строк, потерянный результат, неверные форматные строки): errorprone.info. - NullAway поверх Error Prone — дешёвая проверка null-безопасности, отчасти закрывающая отсутствие nullable-типов, которые есть в C#.
- SpotBugs (spotbugs.github.io) и Checkstyle (checkstyle.org) — байткод-анализ и стиль.
// build.gradle.kts — форматирование как часть сборки
spotless {
java {
googleJavaFormat("1.24.0")
removeUnusedImports()
trimTrailingWhitespace()
endWithNewline()
}
}
// ./gradlew spotlessApply — исправить, spotlessCheck — упасть в CI
Плюс .editorconfig в корне, который понимают IDEA, VS Code и Spotless:
root = true
[*.java]
indent_style = space
indent_size = 4
max_line_length = 120
charset = utf-8
end_of_line = lf
insert_final_newline = true
[*.{xml,yaml,yml,toml}]
indent_size = 2
Что ставить из IDE: IntelliJ IDEA — фактический стандарт, Community-редакции
хватает для всего, кроме Spring-специфики и профилировщика. Альтернативы —
VS Code с Extension Pack for Java (внутри Eclipse JDT Language Server),
Eclipse, Neovim с jdtls. Все они опираются на один и тот же LSP-сервер
компании Eclipse, так что автодополнение везде примерно одинаковое; отличия — в
рефакторингах и отладчике.
Грабли, на которые наступают все
1. UnsupportedClassVersionError. Самое частое сообщение первого месяца:
java.lang.UnsupportedClassVersionError: com/example/App has been compiled by a more
recent version of the Java Runtime (class file version 65.0), this version of the
Java Runtime only recognizes class file versions up to 61.0
Читается по таблице (major = версия Java + 44):
| Java | 8 | 11 | 17 | 21 | 25 |
|---|---|---|---|---|---|
| major | 52 | 55 | 61 | 65 | 69 |
То есть класс собран под 21, а запущен на 17. Лечится либо обновлением рантайма,
либо --release нужной версии при сборке.
2. -source/-target вместо --release. Со старой парой флагов компилятор
использует библиотеку классов текущего JDK. Код "".isBlank() (API Java 11)
успешно скомпилируется с -target 8 и упадёт NoSuchMethodError на Java 8.
--release 8 подсовывает компилятору настоящий API восьмёрки и ловит это сразу.
3. Тихая порча кэша. Оборванная закачка оставляет в ~/.m2/repository файлы
*.lastUpdated, после чего Maven упорно «не находит» артефакт. Лечение:
find ~/.m2/repository -name "*.lastUpdated" -delete
./mvnw -U verify # -U: принудительно перепроверить SNAPSHOT и обновления
4. Кодировка. Начиная с Java 18 (JEP 400) дефолтная кодировка файлов —
UTF-8 независимо от локали ОС, но сборка старых проектов и Windows-машины всё ещё
преподносят сюрпризы. Явно указывайте project.build.sourceEncoding и
options.encoding, иначе русские строки в ресурсах превратятся в вопросительные знаки.
5. Несколько реализаций SLF4J в classpath. Class path contains multiple SLF4J providers — приложение запустится, но логи уйдут не туда. Ищите через
dependency:tree и исключайте лишнее через <exclusions>.
6. Врапер не в git или без прав на исполнение. ./mvnw: Permission denied на CI —
следствие потерянного флага. Лечение: git update-index --chmod=+x mvnw.
7. Preview-фичи. Флаг --enable-preview нужен и при компиляции, и при запуске,
причём класс, собранный с preview на Java 21, не запустится на Java 22. В прод
preview-фичи не тащим.
8. mvn clean install как рефлекс. Обсудили выше: в CI это mvn verify.
install оставьте для случая, когда локально собираете библиотеку и тут же
подключаете её из другого проекта.
9. Забытые -parameters. Без этого флага имена параметров методов не попадают
в байткод, и Spring/Jackson начинают ругаться на невозможность определить имя
аргумента. Особенно неприятно, что проявляется в рантайме, а не при сборке.
Чеклист готового окружения
- JDK 21 или 25 из проверенного дистрибутива, версия зафиксирована в
.sdkmanrc. -
java -version,javac -versionи$JAVA_HOMEуказывают на одно и то же. - В репозитории лежит врапер (
mvnw/gradlew) и он исполняемый. - Компиляция через
--release, включены-Xlint:all -Werror -parameters. - Версии зависимостей централизованы: BOM/
dependencyManagementили version catalog. -
dependency:treeчистый, конвергенция версий проверяется в CI. - Spotless/Checkstyle и Error Prone подключены и падают на CI, а не советуют.
-
.gitignoreзакрываетtarget/,build/,.idea/,*.iml. - Понятно, что уезжает в прод: fat jar, jlink-образ или native image.
Источники
- dev.java/learn — официальные учебные материалы Oracle.
- Java SE Documentation — спецификации и man-страницы инструментов.
- Maven: The Complete Reference — бесплатная книга Sonatype.
- Introduction to the Build Lifecycle и Dependency Mechanism.
- Gradle User Manual — лучшая документация среди сборщиков.
- JEP 330: Launch Single-File Source-Code Programs и JEP 400: UTF-8 by Default.
- Joshua Bloch, Effective Java, 3rd Edition — обязательна к прочтению после освоения основ.
- Maven Central — поиск артефактов и их координат.
Что дальше
Основы Java: типы, ссылки, строки, управление потоком —
разберём, чем примитив отличается от ссылки, почему == для строк работает
непредсказуемо, что такое пул строк и как на самом деле устроено присваивание.
Всё, что мы сейчас настроили, нужно именно для того, чтобы этот код было где запустить.