Java Установка и инструментарий: JDK, Maven, Gradle, структура проекта
0%

Установка и инструментарий: JDK, Maven, Gradle, структура проекта

Установка и инструментарий: JDK, Maven, Gradle, структура проекта

В большинстве языков «установка» — это скучный первый шаг, который можно пролистать. В Java пролистывать нельзя: половина проблем, с которыми новичок сидит по три часа (UnsupportedClassVersionError, ClassNotFoundException, «у меня собирается, на CI нет»), растут ровно отсюда — из непонимания, что именно установлено, кто и во что компилирует код и откуда JVM берёт классы в момент запуска.

Поэтому статья построена не как список команд, а как модель. Сначала разберёмся, что физически лежит в каталоге JDK и почему инструментов там так много. Потом соберём и запустим программу голыми руками, без сборщика, чтобы увидеть механику. И только после этого перейдём к Maven и Gradle — станет понятно, какую именно работу они за нас делают и почему их конфигурация выглядит именно так.

Модель, из которой всё следует

Java — язык с промежуточным представлением. Компилятор javac переводит текст не в машинный код, а в байткод — компактную инструкцию для стековой виртуальной машины. Байткод кладётся в .class-файлы, .class-файлы упаковываются в .jar (обычный ZIP с манифестом), а .jar уже исполняет JVM, которая в рантайме дожимает горячие участки в машинный код через JIT-компилятор.

Что входит в JDK и как код проходит путь от исходника до машинного кода

Из этой схемы следуют три практических вывода, которые стоит принять сразу:

  1. Переносимость есть, но она версионная. .class не зависит от ОС и процессора, зато жёстко помечен версией формата. JVM 17 не запустит класс, собранный под 21. Обратное работает: JVM 21 запустит класс от Java 8.
  2. Компиляция и запуск — два разных мира с двумя разными наборами классов. То, что видел компилятор, и то, что найдёт JVM, — не одно и то же. Отсюда весь класс ошибок «компилируется, но падает в рантайме».
  3. Производительность появляется не сразу. Первые тысячи вызовов метода интерпретируются, дальше подключается 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 ищет класс по имени, обходя загрузчики по цепочке, и первый нашедший побеждает:

Разница между двумя исключениями — важный диагностический сигнал. 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 installinstall захламляет локальный репозиторий артефактами и, что хуже, маскирует ошибки «забыли объявить зависимость», потому что нужный 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 не попадает

Три жёстких правила, которые ловят почти все ошибки компоновки:

  1. Пакет обязан совпадать с путём каталога. 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» или невидимые классы.
  2. Имя пакета — обратный доменный адрес вашей организации: com.example, ru.company.project. Это не эстетика — это защита от коллизий в общем пространстве имён экосистемы.
  3. Тесты зеркалят основной код по пакетам. Тогда тест видит 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.

Формы поставки артефакта

Что именно вы отдадите в прод — тоже часть тулчейна, и решение принимается рано.

Четыре формы поставки Java-приложения: thin jar, fat jar, jlink-образ и native image

# 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.

Источники

Что дальше

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

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

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

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

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