Прежде чем писать код, полезно понять, чем этот код собирается и запускается. В Java вокруг самого языка есть небольшой набор инструментов, без которых ничего не заработает. Разберём их по порядку — от того, что вы ставите на машину, до сборщиков, которые тянут зависимости за вас.
Компилятор javac превращает исходник в байт-код, jar складывает классы в архив. При запуске JVM ищет каждый класс в classpath, а java -jar кладёт туда только сам архив: классы библиотек остаются в кеше сборщика и не находятся — отсюда NoClassDefFoundError на первом же обращении.
JDK, JRE и JVM — кто есть кто
Три буквы, которые путают чаще всего. Разберёмся, что внутри.
JVM (Java Virtual Machine) — виртуальная машина. Это программа, которая исполняет скомпилированный Java-код. Вы пишете .java, компилятор превращает его в байт-код (.class), а JVM этот байт-код выполняет. Именно JVM даёт знаменитое «написал один раз — запускается везде»: байт-код одинаковый, а под каждую операционную систему есть своя JVM.
JRE (Java Runtime Environment) — среда выполнения. Это JVM плюс стандартные библиотеки (коллекции, работа с файлами, сетью и т. д.). Этого набора достаточно, чтобы запускать готовые Java-программы, но не хватает, чтобы их компилировать.
JDK (Java Development Kit) — комплект разработчика. Это JRE плюс инструменты разработки: компилятор javac, отладчик и прочее. Для разработки вам нужен именно JDK.
Короткая формула: JDK = JRE + инструменты разработки, JRE = JVM + библиотеки.
Коробки вложены не для красоты: каждая следующая целиком содержит предыдущую.
На практике сегодня отдельный JRE почти не ставят — скачивают JDK (например, сборку Temurin, Liberica или Amazon Corretto) и работают с ним.
Компиляция и запуск вручную
Чтобы прочувствовать, что происходит под капотом сборщиков, один раз стоит собрать программу руками.
Пусть есть файл Hello.java:
живой пример
public class Hello {
public static void main(String[] args) {
System.out.println("Привет, Java!");
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Компилируем компилятором javac — он создаёт Hello.class с байт-кодом:
javac Hello.java
Запускаем командой java (имя класса, без расширения .class):
java Hello
Простой случай можно запустить вообще без отдельной компиляции — java Hello.java скомпилирует и запустит в один шаг. Так умеет любая Java начиная с 11-й, а с 22-й — и программы из нескольких файлов. Это удобно для экспериментов, но реальные проекты всё равно собирают полноценно.
Для экспериментов есть и третий способ, без файлов вовсе. jshell (в JDK с 9-й версии) это интерактивная оболочка: вводите выражение, сразу видите результат, без класса, без main и без компиляции руками.
$ jshell
jshell> var items = java.util.List.of("a", "b", "c")
items ==> [a, b, c]
jshell> items.size() * 2
$2 ==> 6
jshell> /exit
Это самый быстрый способ проверить, что делает незнакомый метод, как ведёт себя 0.1 + 0.2 или что вернёт "привет".substring(2), и он же удобен для проверки фактов из статей: половина сомнений «а точно так?» снимается за десять секунд. Внутри доступны все классы JDK, а сторонний jar подключается ключом --class-path.
classpath — где искать классы
Как только классов становится больше одного и появляются чужие библиотеки, JVM нужно объяснить, где их искать. За это отвечает classpath — список папок и архивов, в которых лежат .class-файлы.
# запустить класс, указав, что искать классы надо в папке out и в библиотеке lib/some.jar
java -cp out:lib/some.jar Hello
Разделитель в classpath зависит от системы: двоеточие : в Linux и macOS, точка с запятой ; в Windows. Руками classpath собирают редко — обычно за вас это делает сборщик, и понимать это полезно в основном для диагностики ошибок вида ClassNotFoundException (класс не нашёлся в classpath).
Classpath — не абстракция: JVM держит его строкой и по ней ищет каждый класс. Эта программа печатает свой classpath и показывает, что бывает с классом, которого в нём нет:
живой пример
public class ClasspathDemo {
public static void main(String[] args) {
System.out.println("версия JVM: " + System.getProperty("java.version"));
System.out.println("classpath: " + System.getProperty("java.class.path"));
System.out.println("свой класс: " + ClasspathDemo.class.getName());
try {
Class.forName("com.google.common.collect.ImmutableList");
System.out.println("guava нашлась");
} catch (ClassNotFoundException e) {
System.out.println("нет в classpath: " + e.getMessage());
}
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Guava в classpath нет — и вместо загруженного класса приходит ClassNotFoundException с его именем. Ровно это видно в логе, когда сборщик не положил библиотеку рядом.
Что такое jar
Раскидывать сотни .class-файлов неудобно. jar (Java ARchive) — это обычный zip-архив с .class-файлами и сопроводительными данными внутри. Один файл вместо россыпи классов: его удобно передавать, подключать как зависимость и запускать.
«Сопроводительные данные» — это прежде всего файл META-INF/MANIFEST.MF, несколько строк метаданных архива. Там же лежит Main-Class — та самая точка входа, без которой java -jar не знает, какой класс запускать, и Class-Path со списком соседних jar, которые тоже надо добавить к запуску.
Если в jar прописана точка входа (главный класс), его можно запустить напрямую:
java -jar app.jar
Библиотеки, которые вы подключаете к проекту, тоже приезжают в виде jar-файлов. Ваш собственный проект на выходе сборки чаще всего тоже превращается в jar.
java -jar и зависимости
java -jar app.jar кладёт в classpath только этот архив (и то, что перечислено в его манифесте в Class-Path). Обычный jar из Gradle или Maven содержит лишь ваши классы: библиотеки-зависимости лежат в кеше сборщика и в архив не попадают. Отсюда классическая картина: gradle run работает, а java -jar на сервере падает с NoClassDefFoundError: com/fasterxml/jackson/... — класс был при компиляции, но не нашёлся при запуске.
Варианта три. «Толстый» jar со всеми зависимостями внутри — bootJar в Spring Boot или плагин shadow; один файл, самый простой деплой. Явный classpath: скопировать зависимости в lib/ и запускать java -cp "app.jar:lib/*" com.example.Main. Или образ Docker, куда сборка кладёт и приложение, и библиотеки слоями, — стандарт для облака.
Maven и Gradle — зачем нужны сборщики
Пока проект — это один файл, javac и java хватает. Но реальное приложение состоит из десятков своих классов и зависит от чужих библиотек, у которых есть свои зависимости. Скачивать всё это руками, прописывать classpath и следить за версиями — мучительно. Эту работу берут на себя сборщики: они скачивают зависимости, компилируют код, прогоняют тесты и собирают итоговый jar.
В мире Java два основных сборщика — Maven и Gradle. Оба умеют одно и то же; различаются языком описания и тем, где их встретишь. Maven это XML и жёсткий стандартный жизненный цикл, поэтому он стоит в большинстве корпоративных и давно живущих проектов. Gradle это скрипт на Kotlin или Groovy с кэшем и инкрементальной сборкой, поэтому его чаще берут в новые проекты и там, где сборка долгая. В чужом проекте берут то, что уже стоит; в новом — Gradle, если нет корпоративного стандарта.
Координаты зависимости. Любая библиотека в Java однозначно описывается тремя частями: groupId (кто издал, обычно в стиле обратного домена), artifactId (что это за библиотека) и version (версия). Эта тройка — её координаты. По координатам сборщик находит и скачивает нужный jar из репозитория (по умолчанию — Maven Central).
Путь одной строки координат до classpath: смотрите, на каком шаге появляется сам файл jar.
Maven описывает проект в файле pom.xml (XML). Зависимость выглядит так:
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.2.1-jre</version>
</dependency>
Gradle описывает проект в build.gradle (Groovy) или build.gradle.kts (Kotlin). Та же зависимость — одной строкой по координатам:
dependencies {
implementation("com.google.guava:guava:33.2.1-jre")
}
Слово перед координатами — не украшение, а область видимости: где именно зависимость нужна.
implementation— и при компиляции, и при запуске. Наружу, в модули, которые зависят от вашего, она при этом не протекает.compileOnly— только компилятору, в поставку не попадёт. Так подключают то, что на месте даст кто-то другой.runtimeOnly— наоборот: компилятору не нужна, а при запуске обязана быть. Классический пример — драйвер базы данных.testImplementation— видна только тестам. Отсюда и знакомая авария: библиотеку подключили так, код случайно на неё сослался, тесты зелёные, а в продеNoClassDefFoundError.
У Maven то же самое называется scope и пишется отдельным тегом: compile, provided, runtime, test.
Команда сборки тоже короткая: mvn package у Maven, gradle build у Gradle. Но запускать её стоит не установленным на машине сборщиком, а обёрткой из репозитория: ./gradlew build и ./mvnw package. Обёртка это маленький скрипт и файл с номером версии, они лежат в проекте, и при первом запуске обёртка сама скачивает ровно ту версию сборщика, которая записана. Смысл в одном: у всех разработчиков и у CI одна и та же версия, и «у меня собирается, а у тебя нет» из-за разных Gradle исчезает как класс проблем. Правило простое: если в проекте есть gradlew или mvnw, глобальный gradle и mvn не трогают вовсе.
Транзитивные зависимости и конфликт версий
Вы подключили три библиотеки, а в собранном приложении их сорок. Это не ошибка: у каждой библиотеки есть свои зависимости, у тех свои, и сборщик тянет всё дерево целиком. Такие зависимости называют транзитивными, и работа с ними и есть главная причина, по которой сборщик нужен, а не только «скачать jar». Посмотреть дерево можно командой ./gradlew dependencies или mvn dependency:tree; по нему видно, кто именно притащил библиотеку, о которой вы не просили.
Проблема начинается, когда две ветки дерева требуют одну библиотеку в разных версиях: одна просит Jackson 2.15, другая 2.17. В classpath может лежать только одна, и сборщики решают конфликт по-разному. Gradle по умолчанию берёт самую новую из запрошенных версий. Maven берёт ближайшую к корню дерева: ту, что объявлена на меньшей глубине, а при равной глубине первую по порядку в pom.xml, и это может оказаться старая версия. Отсюда ошибки вида NoSuchMethodError при запуске: код одной библиотеки собран против метода, которого в выбранной версии другой ещё нет. Найти виновника помогает ./gradlew dependencyInsight --dependency jackson-databind или тот же dependency:tree, а чинят это тремя способами: явно объявить нужную версию у себя, исключить транзитивную зависимость из конкретной библиотеки (exclude) или, лучше всего, взять готовый согласованный набор версий, BOM: Spring Boot публикует такой, и implementation(platform(...)) в Gradle или dependencyManagement в Maven заставляют всё дерево использовать версии из него.
Базовая структура проекта
Maven и Gradle по умолчанию ожидают одинаковую раскладку папок — её придерживается почти весь экосистемный код:
my-app/
├── pom.xml # или build.gradle(.kts)
└── src/
├── main/
│ ├── java/ # исходный код приложения
│ └── resources/ # файлы-настройки, шаблоны и т. п.
└── test/
├── java/ # код тестов
└── resources/ # данные для тестов
Главное правило: рабочий код — в src/main, тесты — в src/test. Сборщик знает эти пути и не требует их настраивать. Результат сборки складывается в отдельную папку (target/ у Maven, build/ у Gradle), которую в систему контроля версий не коммитят.
IDE делает ровно то же самое. Когда IntelliJ IDEA или другая среда «сама собирает по кнопке», она читает тот же build.gradle или pom.xml, скачивает те же зависимости по тем же координатам и собирает тот же classpath, только показывает это не в терминале, а подсветкой и списком библиотек в дереве проекта. Поэтому после правки файла сборки среда просит перечитать проект: пока она этого не сделала, новая зависимость есть в файле, но в classpath IDE её нет, и импорт подчёркивается красным. И поэтому же, когда «в IDE работает, а из терминала нет», сравнивают именно эти два classpath: обычно среда собрала проект под другой JDK или подхватила зависимость, которую в файл сборки забыли записать.
Управление версией JDK
JDK выходит регулярно, и на одной машине часто нужно несколько версий: один проект на Java 17, другой на Java 21. Держать их и переключаться помогают менеджеры версий.
- SDKMAN! — популярный инструмент для Linux и macOS. Ставит и переключает JDK (и сам Gradle/Maven) одной командой:
sdk install java 21.0.3-tem, затемsdk use java 21.0.3-tem. - Какая версия активна, определяют переменная
JAVA_HOME(путь к нужному JDK) иPATH. Командыjava -versionиjavac -versionпоказывают, какая версия активна сейчас.
Отдельно стоит знать про LTS-версии (Long-Term Support) — это выпуски с долгой поддержкой: 17, 21 и вышедшая в сентябре 2025-го 25. Для рабочих проектов обычно выбирают именно их: под них дольше выходят обновления безопасности.
Собрано новее, чем запускают
У версий есть вторая сторона, о которую спотыкаются уже на сервере. Собрали проект на Java 21, а запускают там, где стоит 17, — и приложение падает ещё до первой строки вашего кода: UnsupportedClassVersionError: ... has been compiled by a more recent version of the Java Runtime. У байт-кода в заголовке записана версия, и старая JVM просто отказывается такой файл читать. Ни код, ни зависимости тут ни при чём — разъехались версии сборки и запуска.
Лечится это не уговором «поставьте везде одинаковый JDK», а тем, что целевую версию задают явно: javac --release 17, у Gradle — java { toolchain { languageVersion = JavaLanguageVersion.of(17) } } или пара sourceCompatibility/targetCompatibility, у Maven — maven.compiler.release. Тогда компилятор и байт-код выпустит подходящий, и заодно не даст воспользоваться тем, чего в 17-й ещё не было.
Глубже: модули JPMS: module-info и путь модулейрасширенное
Слова «путь модулей» и «platform-загрузчик» встречаются в статье про загрузку классов, а определения модулей в фазе нет. С Java 9 у classpath появился сосед, и знать о нём нужно, даже если свои модули вы не объявляете.
Classpath это плоский список, где любой публичный класс виден любому другому, и две библиотеки с одинаковым пакетом молча перекрывают друг друга. Модульная система (JPMS) добавляет над пакетами ещё один уровень: модуль объявляет, какие пакеты он экспортирует наружу и от каких модулей зависит, в файле module-info.java в корне исходников:
module shop.orders {
requires java.sql;
requires com.fasterxml.jackson.databind;
exports shop.orders.api;
opens shop.orders.model to com.fasterxml.jackson.databind;
}
requires перечисляет зависимости, и их отсутствие видно при запуске, а не при первом обращении; exports открывает пакет для использования, всё остальное недоступно снаружи даже при public; opens разрешает отражение в пакет для конкретного модуля, без этого Jackson не прочитает приватные поля. Сама платформа разбита на модули (java.base, java.sql, java.net.http), и именно поэтому загрузчик платформы из статьи про загрузку классов называется так: он грузит модули платформы, а не всё подряд.
Путь модулей (--module-path) это место, где JVM ищет модули, по аналогии с classpath. Jar с module-info на пути модулей становится настоящим модулем; jar без него на пути модулей становится автоматическим модулем с именем из файла; всё, что на classpath, попадает в безымянный модуль, который видит все экспортированные пакеты и ведёт себя по-старому.
Что это значит на практике. Большинство приложений на Spring Boot живут на classpath и модулей не объявляют, и это нормально: выигрыш JPMS в сильной инкапсуляции и в сборке урезанного образа среды выполнения (jlink собирает JRE только из нужных модулей, что уменьшает контейнер). Столкнуться с модулями всё равно придётся: ошибка module java.base does not "opens java.lang" to unnamed module при отражении в JDK лечится флагом --add-opens и означает, что библиотека лезет во внутренности платформы; библиотеки с module-info требуют аккуратности с одинаковыми пакетами в разных jar (split packages), которые JPMS запрещает. Сборщики (Maven, Gradle) понимают оба пути и сами решают, куда положить зависимость, если у проекта есть module-info.java.
Коротко
- JVM исполняет байт-код, JRE = JVM + библиотеки (только запуск), JDK = JRE + инструменты (для разработки нужен JDK).
javac Hello.javaкомпилирует исходник в байт-код.class,java Helloего запускает.- classpath — список путей, где JVM ищет классы; ошибка
ClassNotFoundExceptionозначает, что класс там не нашёлся. - jar — zip-архив с классами и манифестом
META-INF/MANIFEST.MF, гдеMain-Classзадаёт точку входа; готовое приложение запускают какjava -jar app.jar. - Maven (
pom.xml) и Gradle (build.gradle) скачивают зависимости по координатамgroupId:artifactId:versionи собирают проект. - Стандартная раскладка одинакова: код в
src/main/java, тесты вsrc/test/java. - У зависимости есть область видимости:
implementation,compileOnly,runtimeOnly,testImplementation(у Maven —scope). Тестовая зависимость, на которую сослался рабочий код, даётNoClassDefFoundErrorв проде. - Несколько версий JDK на машине удобно держать через SDKMAN!; для работы выбирают LTS-версии (17, 21, 25). Байт-код, собранный новее, чем JVM на сервере, падает с
UnsupportedClassVersionError— целевую версию задают через--release. - Модули JPMS добавляют над пакетами
module-infoсrequires,exportsиopens; путь модулей это сосед classpath, jar без описателя становится автоматическим модулем; приложения на Spring Boot обычно живут на classpath, а модули встречают через--add-opensиjlink. jshellпроверяет выражение без файла; сборку запускают обёрткой./gradlew/./mvnw(версия одна у всех), IDE читает тот же файл сборки; транзитивные зависимости дают конфликт версий (Gradle берёт новую, Maven ближайшую),NoSuchMethodErrorлечат явной версией,excludeили BOM.
Что почитать дальше
- Синтаксис и типы данных — из чего состоит сам код, который вы компилируете.
- Исключения — как Java сообщает об ошибках во время выполнения.
- ООП в Java — классы, объекты и то, как из них строят программу.
- Сборка мусора в Java — что делает JVM с памятью после запуска и зачем флаги
-Xms/-Xmx.