App bundles

Champaca never starts a JVM. It reads the JDK's class files and a few data files from $JAVA_HOME, and jolt runs everything else as native code. So an app can ship with a slice of the JDK's files beside it, and whoever runs it needs no JDK. jolt bundle puts that slice, a native binary of champaca and a warmed startup cache into one directory you can copy anywhere.

Building one

A Java program, given its class path and main class:

jolt bundle -o out/jack -cp target/classes:lib/jackson-databind.jar:lib/jackson-core.jar:lib/jackson-annotations.jar \
          -m Jack --train

A jolt program that calls Java through champaca.api, given its namespace and its project:

jolt bundle -o out/demo --jolt demo.core --project ../demo -cp lib/jackson-databind.jar:... --name demo --train x y

Relative paths are the champaca checkout's, since jolt runs its tasks there. To build, you need JAVA_HOME set to a JDK 25 (for jdeps and jlink) and jolt (the pinned build, $JOLT, or jolt on your PATH).

What lands in the directory:

PathWhat it is
bin/NAMEthe launcher, a shell script; call it from anywhere, through a symlink too
lib/champaca, champaca's own jolt build binary, and for --jolt the program's binary
runtime/a jlink image of the modules jdeps says the class path needs, plus jdk.charsets and jdk.localedata (see below) and any --add-modules, with bin/, the VM and every native library removed, since champaca loads none of them; legal/ stays
app/the class path's jars and directories, copied
cache/the startup cache, warmed at build time

The launcher sets JAVA_HOME to runtime/, CHAMPACA_CACHE_DIR to cache/ and CHAMPACA_BUNDLE to the directory, then runs the binary. For a jolt program it also sets CHAMPACA_CLASSPATH, which (ldk/init!) reads when it's given no :classpath, so the program needn't know where its jars went. It should still call (ldk/save-cache!) before it exits, as outside a bundle (performance).

The training run

Without --train, the build boots the JDK once (through champaca -version) and caches that boot. The program's own code, and any JDK code it reaches beyond the boot, then compiles on its first real run.

--train ARGS... runs the program once at build time with those arguments, through the launcher, so the cache holds what that run compiled. The JDK's own AOT cache uses the same kind of training run. Everything after --train goes to the program, so put it last, and pick arguments that exercise the paths you care about without side effects you don't want at build time.

These runs leave out the builder's own CHAMPACA_* settings, because CHAMPACA_BT or CHAMPACA_LINES would warm a cache under a key the launcher's runs never look for.

Moving it

The bundle keeps its cache wherever it goes. Two things make that work:

  • Inside a bundle, the cache key hashes the runtime image's index (a CRC-32 of every resource's name, offset and sizes) instead of the file's modification time, which a copy changes. The index doesn't cover the class bytes, and for the image a bundle ships with that's fine. Outside a bundle the key keeps the modification time, so a rewritten lib/modules still gets a fresh cache.
  • A class path entry inside CHAMPACA_BUNDLE is named relative to it in the library cache, so the entries for app/x.jar answer whether the bundle sits in /opt or in a home directory.

Measured on an M1 Pro on 2026-11-07 (load average about 5). Each bundle was moved after it was built and run with env -i, so no JAVA_HOME and no JDK on the PATH, three runs each:

ProgramBundle sizeWarm run, movedCompiled in that run
Hello (TreeMap, String.format, java.time)89 MB0.49 to 0.52 snothing
Jackson's probe (test/libs/Jack.java), three jars, java.desktop and java.sql128 MB0.87 to 0.92 snothing, output the same as HotSpot's
a jolt program serializing a map with Jackson150 MB0.76 snothing

About 28 MB of each is jdk.localedata and jdk.charsets. --locales none drops the 27 MB of locale data (Hello with java.base alone was 61 MB).

On macOS, the first launch of a freshly copied binary costs about 0.4 s more while the system checks it: 0.57 s against 0.15 s for a binary that does nothing. That's once per copy, and it isn't champaca's time.

One file

--single FILE also writes the bundle as one executable file: a short shell header followed by the directory as a tar.gz. Its first run unpacks the bundle into ~/.cache/champaca/single/NAME-<digest>/ (CHAMPACA_SINGLE_DIR changes the place, and XDG_CACHE_HOME is honoured), and every run after that starts it from there, the cache in that copy included. The digest is the payload's SHA-256, so a new build unpacks beside an old one instead of over it. Every run touches its copy, and a first run deletes the same app's other copies left untouched for a week, so rebuilds don't pile up. The header unpacks into a temporary directory and moves it into place only once the launcher is there, so two first runs at once don't trip over each other, and a damaged file stops with couldn't unpack and leaves nothing behind.

The file is the bundle compressed, about half its size: 88.0 MB for the 176 MB of examples/hn (a command-line tool). Its first run, unpacking included, took 9.2 s, and later ones 2.4 s.

Read-only bundles

An installed bundle often can't write its own directory. Then champaca keeps a cache for it in the user's home, under ~/.cache/champaca/bundles/<a hash of the bundle's path>/. The first run copies the bundle's cache there and runs on the copy, saving what it compiles as any run does, so the next run starts warm. The bundle's own files are never written.

Each version of the bundle's cache gets its own copy, named after the key and a hash of what the bundle's cache holds. So a new install starts fresh from its own trained cache, while what runs of the old one compiled stays with the old copy, which goes after a week unused.

The copying never deletes a copy, though two things can, as in any cache. A run that finds its copy damaged drops it and builds a new one, and pruning removes a copy unused for a week or beyond the 16 newest. When two runs start at once, both copy, and the one that finishes second uses the first one's. A copy that fails to load is dropped and rebuilt there by that run's cold start.

For Jackson's bundle (a 20 MB cache), that first run took 1.25 s against 1.02 to 1.04 s for the next two. That covers the copy and a fresh binary's first launch together, at a load average of 8, with nothing compiled in any of them.

CHAMPACA_USER_CACHE_DIR puts these caches elsewhere (a relative path is taken from the directory the program starts in), and off turns them off. When the user cache can't be written either, a run says so once on stderr and keeps nothing it compiles. A bundle that moves, or is removed, leaves its copy behind; deleting ~/.cache/champaca/bundles gives the space back.

Without a user cache (off, or no writable home), champaca reads the bundle's cache and skips the save. Code the training run didn't reach then compiles on every run, though the run doesn't fail for it.

If such a cache has nothing for this run, or holds something that doesn't load (a damaged state.img, a missing runtime.so, a cache built for another key), the run goes without a cache, as CHAMPACA_CACHE=off gives, and says so once on stderr: champaca: the startup cache at <dir> can't be written and has none for this run, so this run uses none. A writable cache drops a damaged copy and builds a new one. Damage found inside a class entry after the boot fails that one run in a writable cache. In one that can't be written at all, it fails each run until the bundle is rebuilt or reinstalled.

Signing

A jolt build binary is unsigned, and macOS refuses to start an unsigned binary that was downloaded (the file carries a quarantine attribute).

jolt bundle --signable builds the binaries with jolt build --signable, which needs a C compiler, so you can sign them afterwards with codesign and notarize them before handing the bundle to anyone. An ad-hoc signature (codesign -s -) on such a binary passes codesign --verify --strict, and the bundle runs as before. Notarizing needs an Apple Developer ID, which the build can't supply.

For a bundle you copied yourself, xattr -d com.apple.quarantine <file> lifts the mark. Linux has no such check.

Limits

  • A bundle runs on the OS and architecture it was built on, because its compiled code is Chez machine code. Build one on each platform you ship to.
  • Its size is mostly the runtime and the binaries. java.desktop, which Jackson needs for java.beans, takes the runtime from 24 MB to 51 MB.
  • The class path is fixed when the bundle is built. A program that loads plugins from elsewhere still can, but their classes cache under their real paths.
  • A bundle's licenses aren't settled yet. It ships jolt's binaries, under the EPL 2.0, next to champaca, under the GPL version 3 with the Classpath Exception. The EPL 2.0 allows that pairing only when jolt names the GPL as a Secondary License, which it doesn't today. Running champaca from a source checkout on your own jolt isn't affected.
  • jdeps decides the modules, and it can't see what a program reaches by name: a charset, a locale's data, a ServiceLoader provider, jdk.zipfs, jdk.crypto.ec. So jdk.charsets (3 MB) and jdk.localedata (27 MB) always go in, and a bundle formats NumberFormat.getInstance(Locale.GERMANY) and French dates as HotSpot does. --locales en,de keeps only those locales' data (jlink's --include-locales), and after --locales none numbers and dates format as English. Anything else reached by name goes in --add-modules.