Привет, Хабр!

Когда 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? Пройдите короткое вступительное тестирование и узнайте, насколько уверенно вы разбираетесь в теме.

Комментарии (1)


  1. aleksandy
    29.05.2025 17:47

    Почему java -jar игнорирует твой -cp

    Потому что так решили разработчики JVM.

    как это обойти

    Никак. Надо менять способ запуска и, соответственно, дистрибуции.