Building an Executable JAR

Choose between a thin JAR with a lib folder, a shaded JAR and a Spring Boot JAR, and avoid the merging bugs that break start-up.

Syntaxjava -jar target/orders-service-1.0.0.jar # needs Main-Class in META-INF/MANIFEST.MF unzip -p target/orders-service-1.0.0.jar META-INF/MANIFEST.MF

The JAR that mvn package or gradle build produces by default holds only your own classes and resources. java -jar needs two more things: a Main-Class entry in META-INF/MANIFEST.MF naming the class with main, and every dependency on the class path. Since java -jar ignores -cp and CLASSPATH, the dependencies have to be inside the JAR or listed in the manifest.

Three shapes

  • Thin JAR plus lib/: the manifest's Class-Path lists dependency JARs by relative path, and they are copied next to the JAR. Simple and transparent, but you ship a folder, not a file. Gradle's application plugin builds a close relative through installDist and distZip: a lib/ folder plus start scripts that set the class path, so the manifest needs no Class-Path.
  • Shaded (uber or fat) JAR: the Maven Shade plugin unpacks every dependency into one archive. One file to copy, but files with the same path in different JARs collide. In Gradle the Shadow plugin does the same job.
  • Spring Boot JAR: spring-boot-maven-plugin (goal repackage) or the Gradle bootJar task keeps dependency JARs intact under BOOT-INF/lib and sets Main-Class to Boot's own launcher, with your class in Start-Class. Nothing is merged, so nothing collides.

What breaks in a shaded JAR

Many libraries register implementations in META-INF/services files for ServiceLoader; JDBC drivers do this for java.sql.Driver. If two JARs have the same services file and the shade step keeps only one, the other implementation silently disappears and you get "No suitable driver" or a missing JSON module at run time. Add the ServicesResourceTransformer. Signed dependencies carry signature files that no longer match the merged archive, which fails with SecurityException: Invalid signature file digest; exclude them. The Shade plugin also warns about overlapping classes: read those warnings, because the class that wins is arbitrary.

Containers

For Docker images, one big JAR is a poor fit: any code change rebuilds a layer that contains all the dependencies too. A thin JAR with lib/ lets dependencies sit in their own layer, cached between builds. Spring Boot can extract its JAR into layers with java -Djarmode=tools -jar app.jar extract --layers for the same reason.

Check what you built

Before blaming the code, look inside: jar --list --file target/app.jar shows the entries, and unzip -p target/app.jar META-INF/MANIFEST.MF shows the manifest. A missing Main-Class fails with "no main manifest attribute, in app.jar"; a missing dependency fails later with NoClassDefFoundError, often only on the first request that touches it.

Example

Example · xml
<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-shade-plugin</artifactId>
      <version>3.6.0</version> <!-- or newer -->
      <executions>
        <execution>
          <phase>package</phase>
          <goals><goal>shade</goal></goals>
          <configuration>
            <transformers>
              <!-- Writes Main-Class into the merged manifest -->
              <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                <mainClass>com.example.orders.App</mainClass>
              </transformer>
              <!-- Concatenates META-INF/services files instead of keeping one -->
              <transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
            </transformers>
            <filters>
              <!-- Signatures from signed JARs are invalid once merged -->
              <filter>
                <artifact>*:*</artifact>
                <excludes>
                  <exclude>META-INF/*.SF</exclude>
                  <exclude>META-INF/*.DSA</exclude>
                  <exclude>META-INF/*.RSA</exclude>
                </excludes>
              </filter>
            </filters>
          </configuration>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>

When to use it

  • Package a small worker service as one file that operations can start with java -jar on any server with a JDK.
  • Fix a shaded service that loses its PostgreSQL driver because META-INF/services files were overwritten.
  • Split a Spring Boot JAR into layers so Docker rebuilds only the application layer on code changes.

More examples

What java -jar does with the manifest

The manifest is the first entry in the archive, and its Main-Class attribute is the only thing that tells java -jar where to start. The class loader here sees only the JAR, just as java -jar ignores the outside class path.

Example · java
import java.util.jar.Attributes;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.jar.JarOutputStream;
import java.util.jar.Manifest;
import javax.tools.ToolProvider;

void main() throws Exception {
    Path dir = Files.createTempDirectory("jar");
    Path src = dir.resolve("src/com/acme/orders/App.java");
    Files.createDirectories(src.getParent());
    Files.writeString(src, """
        package com.acme.orders;

        public class App {
            public static void main(String[] args) {
                System.out.println("orders-service started with " + String.join(" ", args));
            }
        }
        """);

    // javac -d classes ...
    Path classes = dir.resolve("classes");
    int rc = ToolProvider.getSystemJavaCompiler()
            .run(null, null, null, "-d", classes.toString(), src.toString());
    IO.println("javac exit: " + rc);

    // What maven-jar-plugin or jar --main-class writes
    Manifest manifest = new Manifest();
    manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0");
    manifest.getMainAttributes().put(Attributes.Name.MAIN_CLASS, "com.acme.orders.App");
    Path jar = dir.resolve("orders.jar");
    try (var out = new JarOutputStream(Files.newOutputStream(jar), manifest)) {
        out.putNextEntry(new JarEntry("com/acme/orders/App.class"));
        out.write(Files.readAllBytes(classes.resolve("com/acme/orders/App.class")));
        out.closeEntry();
    }

    // What java -jar does: read Main-Class, load it from the JAR, call main
    String mainClass;
    try (var jf = new JarFile(jar.toFile())) {
        jf.stream().map(JarEntry::getName).forEach(n -> IO.println("entry: " + n));
        mainClass = jf.getManifest().getMainAttributes().getValue("Main-Class");
    }
    IO.println("Main-Class: " + mainClass);
    try (var loader = new URLClassLoader(new URL[] { jar.toUri().toURL() }, null)) {
        Class<?> app = Class.forName(mainClass, true, loader);
        app.getMethod("main", String[].class)
           .invoke(null, (Object) new String[] { "--port=8080" });
    }

    try (var walk = Files.walk(dir)) {
        walk.sorted(Comparator.reverseOrder()).forEach(p -> p.toFile().delete());
    }
}

// Output:
// javac exit: 0
// entry: META-INF/MANIFEST.MF
// entry: com/acme/orders/App.class
// Main-Class: com.acme.orders.App
// orders-service started with --port=8080

A runnable distribution with Gradle

installDist copies the application JAR and every runtime dependency into lib/ and writes start scripts that set the class path for you. The plain JAR still holds no dependencies, so start the service through the bin script rather than with java -jar.

Example · kotlin
plugins {
    application
}

application {
    mainClass = "com.example.orders.App"
}

// Optional: records the entry point in the manifest too; java -jar on this
// JAR alone still fails, because the dependencies are not on its class path
tasks.jar {
    manifest {
        attributes("Main-Class" to "com.example.orders.App")
    }
}

// ./gradlew installDist  -> build/install/orders-service/bin and lib
// ./gradlew distZip      -> build/distributions/orders-service-1.0.0-SNAPSHOT.zip
// build/install/orders-service/bin/orders-service --port=8080

Building, inspecting and running

Reading the manifest settles most start-up failures in seconds. The layer extraction separates dependencies from application classes so a Docker build caches them independently.

Example · bash
./mvnw -B -ntp package

# What is inside, and what does the manifest say?
jar --list --file target/orders-service-1.0.0.jar | head
unzip -p target/orders-service-1.0.0.jar META-INF/MANIFEST.MF

# Run it; extra -cp entries would be ignored here
java -jar target/orders-service-1.0.0.jar --port=8080

# Spring Boot: split into layers for a Docker image
java -Djarmode=tools -jar target/orders-service-1.0.0.jar extract --layers --destination extracted

Discussion

  • Be the first to comment on this lesson.