skip to content
everything.tooling.jarsJlinkJpackage

jar, jlink, jpackage

from a jar to a tiny custom runtime to a native installer.

chapter
chapter 12.04
reading time
8 min read

three steps from your code to someone else's machine. a jar is a zip of class files plus a manifest. jlink builds a custom runtime that contains only the JDK modules you use (a hello world needs 45 MB instead of 293). jpackage wraps that runtime and your app into a real installer: .dmg, .msi, .deb.

each step removes one thing the user has to install themselves.

the picture

think of sending someone a meal. a jar is the recipe and the ingredients: useful only if they already own a kitchen (a JDK). jlink packs a tiny kitchen with only the pans this recipe needs. jpackage puts the kitchen and the ingredients in one box with a big "open me" label on it, in the shape their country's front door expects.

hover a stage.

hover a stage. the left half is what developers ship to other developers. the right half is what you ship to people who have never heard of a JDK.

what's in a jar

a jar is literally a zip file. unzip -l works on it. the only special part is META-INF/MANIFEST.MF, a text file the launcher reads.

terminal
$ jar --create --file hello.jar --main-class Hello -C out .
$ jar --list --file hello.jar
META-INF/
META-INF/MANIFEST.MF
Hello.class
$ unzip -p hello.jar META-INF/MANIFEST.MF
Manifest-Version: 1.0
Created-By: 25.0.4.1 (Eclipse Adoptium)
Main-Class: Hello
$ java -jar hello.jar
hello, world

Main-Class is what makes java -jar work. a manifest can also carry Class-Path (other jars to load), Automatic-Module-Name (from the last chapter) and Multi-Release.

multi-release jars

a library that wants to support java 8 users and use List.getFirst() (java 21) on newer JVMs can ship both versions of a class in one jar (JEP 238, java 9). the JVM picks the highest version that's not newer than itself.

  • ├──
  • │ ├── MANIFEST.MFMulti-Release: true
  • │ └──
  • │ └──
  • │ └── com/acme/Greeter.classxs.getFirst(), used on java 21+
  • └──
  • ├── Greeter.classxs.get(0), for java 8 to 20
  • └── Main.class
the real jar, built with javac --release 8 and --release 21. click a folder to fold it.
terminal
$ jar --create --file greeter.jar --main-class com.acme.Main \
      -C base . --release 21 -C v21 .
$ java -jar greeter.jar                 # java 25
hi (the java 21 way)
$ unzip -q greeter.jar -d x && java -cp x com.acme.Main
hi (the java 8 way)

that second run is the gotcha. versioned classes only work inside a jar. unzip it onto a plain directory class path and META-INF/versions is just a folder nobody looks in.

a full JDK is 293 MB on disk: compiler, javadoc, debuggers, 69 modules. a server running your app needs none of the tools and maybe five of the modules. jlink (JEP 282, java 9) copies just those modules into a new runtime folder. the recipe is two commands:

terminal
$ jdeps --print-module-deps --ignore-missing-deps app.jar
java.base,java.net.http,java.sql
$ jlink --add-modules java.base,java.net.http,java.sql \
        --strip-debug --no-header-files --no-man-pages \
        --output runtime
$ runtime/bin/java -jar app.jar
modules =
flags =
$ jlink --add-modules java.base \
      --strip-debug --no-header-files --no-man-pages \
      --output rt
size on disk44.4 MB15% of the JDK
the whole track is the full JDK: 293.1 MB
hello world, start to exit44.5 msfull JDK: 29.7 ms

no CDS archive: the full JDK ships pre-parsed classes (lib/server/*.jsa), a jlinked runtime doesn't unless you ask. that's the extra ~15 ms.

pick modules and flags. every combination was built and timed on jdk 25 (M4 Pro, macOS): size is the sum of the files, startup is the median of 40 runs of hello world. watch what happens to startup when you compress without a CDS archive.

so the flags aren't free wins:

  • --no-header-files, --no-man-pages: pure savings, nobody needs C headers or man pages on a server.
  • --strip-debug saves another 4.5 MB, and it isn't free: it removes the line number tables from the JDK's own classes. your code keeps its line numbers, but every JDK frame in a stack trace turns into Unknown Source (see below). usually fine, sometimes annoying at 3am.
  • --compress zip-6 shrinks lib/modules from 25 MB to 11 MB but costs 10 to 15 ms of startup, because classes get inflated as they load. zip-9 saved another 22 KB. not worth thinking about.
  • --generate-cds-archive adds ~28 MB of pre-parsed classes (two archives, with and without compressed oops) and wins all the startup back. the full JDK ships with these archives already, which is why a plain jlinked runtime starts slower than the JDK it came from. kinda surprising the first time.
the same exception, two runtimes
# jlink --strip-debug
	at java.base/java.util.ImmutableCollections$List12.get(Unknown Source)
	at Boom.main(Boom.java:3)
# jlink without it
	at java.base/java.util.ImmutableCollections$List12.get(ImmutableCollections.java:612)
	at Boom.main(Boom.java:3)
quick check
you jlink a runtime for your app, but your app is a plain jar (no module-info) and depends on two plain library jars. what goes into --add-modules?

jpackage: a real installer

a runtime folder still needs someone to type runtime/bin/java -jar app.jar. jpackage (JEP 392, final in java 16) runs jlink for you, adds a native launcher, an icon and metadata, and builds the installer format each OS expects:

built on--typewhat you get
macOSapp-image, dmg, pkgHello.app, a disk image, an installer package
windowsapp-image, exe, msineeds the WiX toolset for exe/msi
linuxapp-image, deb, rpmneeds dpkg/rpmbuild on that machine
terminal
$ jpackage --type app-image --name Hello --input input --main-jar hello.jar \
           --add-modules java.base \
           --jlink-options "--strip-debug --no-header-files --no-man-pages --strip-native-commands" \
           --dest out
$ out/Hello.app/Contents/MacOS/Hello small
hello, small

that Hello.app is 44.6 MB. leave out --add-modules and jpackage plays it safe with a default set of 52 modules: 122.9 MB for the same hello world. tell it what you need.

one level deeper:fat jars, jlink, native images: which one?+

a fat jar (all dependencies unzipped into one jar, what spring boot and the shade plugin produce) is the most common way to ship a server: one file, any JDK. jlink is for when you control the machine and want a small, pinned runtime, often as a container image layer. jpackage is for desktop apps and tools that end users install. and if startup time or memory really matter, GraalVM native image compiles the whole app ahead of time into one native executable, with tradeoffs (no dynamic class loading without config, slower peak throughput for some workloads). the JDK's own answer to startup is project Leyden's AOT cache, see AOT and Leyden.

keylines
  • a jar is a zip with a manifest. Main-Class makes it runnable.
  • multi-release jars pick per-JVM class versions, but only when run from the jar.
  • jdeps finds the modules, jlink builds the runtime. 45 MB for hello world.
  • compressing a runtime costs startup. a CDS archive buys it back.
  • jpackage = jlink + launcher + installer, built on the OS you're targeting.