Привет, Хабр!
Когда java -jar
цинично игнорирует ваш -cp
, хочется грустить, но спокойствие, сегодня рассмотрим, почему так происходит и как это обойти.
Откуда ноги растут: приоритет Class-Path’а в -jar
JVM при запуске с -jar
делает две нетривиальные вещи:
Создаёт Application ClassLoader и кладёт в него: сам запущенный JAR, всё, что перечислено в Class-Path
манифеста. Игнорирует всё, что вы пытались подсунуть через -cp
или CLASSPATH
.
Логика: гарантировать повторяемый запуск одной капсулы кода.
Пример:
# структура проекта
src/
com/example/App.java
libs/
commons-lang3-3.14.0.jar
# компиляция
javac -d out src/com/example/App.java
# манифест без Class-Path
echo "Main-Class: com.example.App" > MANIFEST.MF
# сборка
jar cfm app.jar MANIFEST.MF -C out .
# «Ложная надежда»: пробуем передать -cp
java -cp libs/commons-lang3-3.14.0.jar -jar app.jar
# получаем NoClassDefFoundError – зависимость не видна
Что именно должно быть в MANIFEST.MF
Минимальный набор для самостоятельного JAR»а:
Manifest-Version: 1.0
Main-Class: com.example.App
Class-Path: libs/commons-lang3-3.14.0.jar libs/guava-33.0.0.jar
Пути относительные к расположению JAR»а. Разделитель — пробел, а не запятая. Переносы — только CRLF или LF + пробел в начале продолжения строки. Максимум 72 байта в строке — придётся разбивать. Если библиотек много — лучше переключаться на uber‑JAR.
Автоматическая генерация в Gradle
jar {
manifest {
attributes(
'Main-Class': 'com.example.App',
'Class-Path': configurations.runtimeClasspath
.collect { "libs/${it.name}" }
.join(' ')
)
}
from {
configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) }
}
}
Не только пишем Class-Path
, но и кладём зависимости в libs/
рядом с app.jar
.
Собираем JAR с встроенным classpath
Gradle Shadow Plugin
plugins {
id 'com.github.johnrengelman.shadow' version '8.1.1'
}
shadowJar {
archiveClassifier.set('')
minimize() // срежет неиспользуемые классы
}
Запускаем:
./gradlew shadowJar
java -jar build/libs/app.jar # работает без внешних lib’ов
Shadow перезаписывает MANIFEST.MF
— Class-Path
исчезает. Все классы зависимостей пакуются внутрь. Возможность shade»ить пакеты, чтобы избежать конфликтов версий.
Maven Shade
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.1</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.example.App</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
Альтернатива № 1: Main-Class + shell-обёртка
Когда хотите держать JAR чистым, а зависимости — в libs/
:
Bash-launcher (run.sh)
#!/usr/bin/env bash
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
java \
-cp "$SCRIPT_DIR/libs/*:$SCRIPT_DIR/app.jar" \
com.example.App "$@"
"$@"
прокидывает аргументы дальше. На Windows аналогичный .cmd
с set CLASSPATH=
и ;
вместо :
.
Но минус: два артефакта вместо одного.
Альтернатива № 2: Jigsaw + jlink
С Java 9+ можно пойти дальше и вообще отказаться от класса‑паса:
# module-info.java
module com.example.app {
requires org.apache.commons.lang3;
}
# сборка
jlink \
--module-path mods:$(jdeps --print-module-path libs/*.jar | tr ':' '\n') \
--add-modules com.example.app \
--output dist
./dist/bin/com.example.app
Получаем самодостаточный runtime, лишний код отрезан, класс‑паса нет. Весит столько, сколько нужно приложению, а не целый JDK.
Профилактика NoClassDefFoundError
Мгновенный рентген запущенной JVM
# Показать, какие JAR'ы реально проглотил AppClassLoader
jcmd $(pgrep -f app.jar) VM.classloaders | less
Команда (jcmd ... VM.classloaders
) доступна с JDK 11+. Видно иерархию лоудеров, от Bootstrap до пользовательских, плюс список JAR»ов.
Летучий аудит: -verbose:class
Временно перезапускаем сервис с дополнительным флагом:
java -jar -verbose:class app.jar | grep "com.google.common.base.Preconditions"
JVM печатает каждую загрузку класса и JAR‑источник. Ловим момент, когда нужный класс ищется, но не находится. Сохраняйте вывод в файл и фильтруйте grep
.
jdeps против слепых зон
Когда не уверены, в каком JAR‑файле должен лежать класс:
# покажет транзитивные зависимости
jdeps --recursive --multi-release 17 app.jar | less
Флаг --multi-release
важен для multi‑release JAR»ов. Если в выводе нет нужного модуля/пакета — значит, его правда забыли уложить в Class-Path
или uber‑JAR.
Правильный Docker-слой
Контейнеры часто прячут проблему:
# Анализируем лёгкий runtime, собранный jlink'ом
FROM eclipse-temurin:17-jre
COPY dist/ /opt/app/
ENTRYPOINT ["/opt/app/bin/com.example.app"]
Если JAR‑ы кладутся в ${APP_HOME}/libs
, проверьте, что COPY действительно подтягивает libs/*
. Для multi‑stage‑build удобно держать артефакты в /build
и копировать ровно то, что надо:
COPY --from=builder /build/app.jar /opt/app/app.jar
COPY --from=builder /build/libs /opt/app/li
Когда uber-JAR или jlink — не ваш выбор
Частые обновления и микро‑патчи. Если вы выкатываете сервис десятками мелких версий в день, собирать и тащить 50-мегабайтный uber‑JAR или целый jlink‑runtime ради фикса из пары классов экономически бессмысленно. Инкрементные деплой‑стратегии (JRebel, Spring DevTools, hot‑swap в Kubernetes) теряют прелесть, потому что каждый пуш превращается в полный ребилд > перекладка жирного артефакта.
Динамические плагины и runtime‑скрипты. Приложения, которые по ходу жизни подтягивают сторонние JAR‑ы (DSL‑engine, плагинная архитектура, Apache Beam JobServer), требуют гибкого classpath»а. Uber‑JAR запечатывает вселенную, а jlink строит кастомный JRE без возможности --add-modules
на лету. В таких случаях shell‑лаунчер с аккуратным -cp "$LIBS/*:$EXT/*"
остаётся единственным адекватным вариантом.
Если у вас полтора JAR»а зависимостей — прописывайте их в манифесте. Если десятки — соберите uber‑JAR и спите спокойно. Нужен fine‑grained контроль и дистрибутив ≤ 40 MB? Пора познакомиться с jlink
. А когда инфраструктура требует — пишите shell‑лаунчер, добавьте health‑check и резвитесь с JVM флагами.
Если вы сталкивались с неожиданными тормозами Hibernate из-за неправильных JPQL-запросов, рекомендую посетить открытый урок 19 июня — на нём подробно разберете, как избежать типичных ошибок и повысить производительность в несколько раз. Это практический урок с конкретными приёмами оптимизации — от выявления антипаттернов до эффективного использования JOIN FETCH и кэширования.
Если тема интересна — записывайтесь на странице курса "Java Developer. Professional".
Готовы проверить свои знания Java? Пройдите короткое вступительное тестирование и узнайте, насколько уверенно вы разбираетесь в теме.
aleksandy
Потому что так решили разработчики JVM.
Никак. Надо менять способ запуска и, соответственно, дистрибуции.