Packaging and Running: Fat Jars and Graceful Shutdown
Deploying a Java web application used to mean four chores: install a JDK, install Tomcat, drop the war into webapps, then pray the server's versions match your laptop's. Spring Boot compresses all four into one line: java -jar app.jar. This article covers the three things behind that sentence — what is actually inside the jar, how it boots itself, and how to make it stop with dignity.
Six words, one line each (used throughout):
- fat jar (executable jar): the jar with every dependency packed inside, also called an uber-jar; Boot's
repackagegoal produces it - MANIFEST.MF: the instruction sheet inside the jar; its
Main-Classtells the JVM whosemainto run BOOT-INF/: the directory inside a fat jar holding your classes and dependencies — invisible to a standard JVM, reachable only through Boot's own loader- ClassLoader: the layer that "finds bytecode by name and turns it into a Class object"; Java lets you write your own
- SIGTERM / SIGKILL: two Unix stop signals — the first says "pack your bags and leave", the second says "the power just went out"
- profile / external config: the mechanism letting one jar read different files per environment, so ops never repackage to change a URL
a fat jar is a self-heating ration box. Rice, side dishes, water and the heating packet all live in one container; you need no kitchen (Tomcat) and no gas line (external dependencies) — pull the tab and it cooks itself. That is "bringing your own runtime". The cost is real too: the box is heavy (tens of MB), and once the tab is pulled the heating packet cannot be paused midway, so when you tear it open (shutdown timing) has to be planned. Changing the menu means swapping the box, not the whole kitchen — which is exactly why having a single artifact pays off.

After this article you should be able to answer three questions:
- Why must
java -cp app.jar com.example.BeeApplicationfail whilejava -jar app.jarworks? - Why should
-Xmxbecome-XX:MaxRAMPercentageinside a container? - How do you restart a production service without users noticing? (Hint: two config lines plus one signal.)
The first time you use Spring Boot you wonder: why does java -jar app.jar run an entire web service? With classic Spring MVC you built a war, dropped it into an external Tomcat's webapps, and made sure the server had the right Tomcat version. Spring Boot stuffs all of that into a single jar.
| Aspect | Executable jar (fat jar) | war + external container |
|---|---|---|
| Carrier | Embedded Tomcat/Jetty/Undertow | External Tomcat/WebLogic |
| Startup | java -jar app.jar | Dropped into webapps |
| Dependencies | Bundled, self-contained | Container supplies the servlet stack |
| Multiple instances | Copy the jar and run | Keep container versions identical |
| Cloud-native fit | Natural for containers and K8s | Fights the image model |
| Status | Recommended | Legacy systems, mandated war delivery |
war is not useless — it serves the historical case of "the company maintains a fleet of Tomcats and apps must be deployed into them". For new projects the fat jar wins decisively: the application and its runtime become one indivisible deliverable, copy-and-run, a natural fit for one-process-per-container.
Spring Boot can build wars too: change packaging to war and have the main class extend SpringBootServletInitializer. Without a hard "must deploy to an external container" requirement, there is no reason to give up the jar.
The most direct way to understand it is to unpack it. Run unzip -l (or jar tf):
$ unzip -l bee-app-1.0.0.jar Length Date Time Name--------- ---------- ----- ---- 985 2026-10-01 10:20 META-INF/MANIFEST.MF 0 2026-10-01 10:20 BOOT-INF/ 0 2026-10-01 10:20 BOOT-INF/classes/ 3421 2026-10-01 10:20 BOOT-INF/classes/com/example/BeeApplication.class 89 2026-10-01 10:20 BOOT-INF/classes/application.yml 0 2026-10-01 10:20 BOOT-INF/lib/ 1246720 2026-10-01 10:20 BOOT-INF/lib/spring-boot-3.2.0.jar 698112 2026-10-01 10:20 BOOT-INF/lib/spring-core-6.1.0.jar 0 2026-10-01 10:20 org/springframework/boot/loader/ 28456 2026-10-01 10:20 org/springframework/boot/loader/JarLauncher.classThree key areas:
BOOT-INF/classes/: your own bytecode and config (application.yml,static/)BOOT-INF/lib/: every third-party dependency, stored as nested jars — not unpacked classesorg/springframework/boot/loader/: Spring Boot's own loader and launcher code
Now the manifest:
Manifest-Version: 1.0Main-Class: org.springframework.boot.loader.launch.JarLauncherStart-Class: com.example.BeeApplicationSpring-Boot-Version: 3.2.0Spring-Boot-Classes: BOOT-INF/classes/Spring-Boot-Lib: BOOT-INF/lib/
precisely because BOOT-INF/classes is not at the archive root, java -cp app.jar com.example.BeeApplication always fails — the JVM's standard loader only sees com/example/... at the root, not the classes under BOOT-INF/classes. This is not a misconfiguration; it is inherent to the fat jar and only -jar can trigger its own loader.
Seeing Main-Class: JarLauncher explains it: java -jar starts Spring Boot's JarLauncher, not your BeeApplication. The full chain is:
- You run
java -jar app.jar - The JVM reads the MANIFEST, sees
Main-Class: JarLauncher, and reflectively calls itsmain JarLauncherreadsStart-Classfrom the same MANIFEST and learns the real entry point isBeeApplication- It creates a special
LaunchedURLClassLoaderand **wiresBOOT-INF/lib/.jarandBOOT-INF/classes/onto its search path* - It loads and reflectively invokes
BeeApplication.mainwith that loader
Extend that chain one box upstream and you have the artifact's whole life — most people only stare at java -jar, unaware that the build step repackage is what makes the jar self-running at all:

The core problem it solves is "jar in jar": Java was never designed to hold jars inside a jar. A standard JVM reads flat archives and cannot open nested dependencies. Spring Boot's answer is a custom URL protocol plus a JarFile extension: it maps each nested jar entry to a special URL that LaunchedURLClassLoader opens one by one, so the JVM's class loading finally "sees" those nested dependencies.
LaunchedURLClassLoader's parent is the application class loader (AppClassLoader). It additionally takes over resource lookup under BOOT-INF, which is also why the loader code must sit at the root of the outer jar — the standard loader has to load it before any business class.
"Two loaders, two visibilities" is easier to follow as a hand-off than as a diagram. This animation runs it in seven frames; note frame 2, when the standard loader still has no idea your classes exist:

Now spread the five-step chain out as a single-step run. The left column is what this JVM does in order; the right panel refreshes "what Main-Class says" and "who can see whom" as you go. Watch beat 3 (the road gets built) and beat 5 (the wheel gets handed over):
java -jar target/bee-app-1.0.0.jar// 1. The JVM opens the zip and reads only Main-Class from META-INF/MANIFEST.MF// 2. Main-Class = org.springframework.boot.loader.launch.JarLauncher// 3. JarLauncher builds LaunchedClassLoader and mounts BOOT-INF/classes plus lib// 4. The new loader resolves the class named by Start-ClassSpringApplication.run(BeeApplication.class, args); // everything familiar starts here| command | java -jar |
| what the JVM knows | only the zip central directory |
| search path | app.jar itself |
| thread | main |
java.exe → JLI_LaunchLauncherHelperThe same fact seen from the other side is a layering question. Click from ① to ⑤; box ② is the one most people get wrong in an interview:
Packaging is not hard; the tricky part is what to skip and which environment to activate.
| Command / flag | Effect | When to use |
|---|---|---|
mvn clean package | Clean and package | Everyday local builds |
-DskipTests | Compile tests but do not run them | Most CI pipelines |
-Dmaven.test.skip=true | Do not even compile tests | Emergency when test code will not compile |
-Pprod | Activate the prod profile | Per-environment config |
-U | Force SNAPSHOT updates | Stale dependency resolution |
-Dspring.profiles.active=prod | Choose the runtime profile | At run time after packaging |
Every one of those flags assumes a precondition: the pom contains spring-boot-maven-plugin. That single line is the switch for repackage, and the watershed between "a jar that runs itself" and "a few hundred KB of ordinary archive". Tick it through — and look at the scope on test, because that is exactly what -DskipTests is arguing about:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version> <!-- 版本由 BOM 统管,子依赖不写 version -->
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>demo-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>The flags above are only seasoning; the lifecycle is the conveyor belt itself. Before touching the pom, watch what a single mvn package actually runs, and in what order — press the four parameters in sequence:
Now revisit the table above with those four pictures in mind: -Pprod swaps the profile, -DskipTests flips a surefire switch, but the package phase itself always drags along every goal bound to it.
Multi-environment packaging, declared in pom.xml:
<profiles> <profile> <id>dev</id> <activation><activeByDefault>true</activeByDefault></activation> <properties> <profiles.active>dev</profiles.active> </properties> </profile> <profile> <id>prod</id> <properties> <profiles.active>prod</profiles.active> </properties> </profile></profiles><build> <resources> <resource> <directory>src/main/resources</directory> <filtering>true</filtering> <!-- let ${profiles.active} be substituted --> </resource> </resources></build># Build the production jar: the profile value is filtered into application.ymlmvn clean package -Pprod -DskipTests-DskipTestsstill compiles test classes (catching broken test code);-Dmaven.test.skip=trueskips compilation entirely — faster but it can hide problemsfilteringreplaces@profiles.active@(or${profiles.active}) inapplication.ymlwith the active profile value- Build-time and run-time profiles are different things: building decides "which config goes into the jar", running decides "which config is read" — do not conflate them
java -jar takes more than a path; keep JVM flags and application arguments separate:
java -Xms512m -Xmx512m -XX:+UseG1GC -XX:MaxGCPauseMillis=200 \ -Dspring.profiles.active=prod -Dserver.port=8080 \ -jar bee-app-1.0.0.jar \ --logging.level.com.example=DEBUG- JVM flags go before
-jarand are parsed by the JVM:-Xms/-Xmx(heap),-XX:+UseG1GC(collector),-XX:MaxGCPauseMillis(pause goal) - System properties use
-D:-Dspring.profiles.active=prodpasses the profile as a system property - Application arguments go after the jar with
--:--logging.level.com.example=DEBUGoverrides config, with the highest precedence
| Flag | Meaning | Starter advice |
|---|---|---|
-Xms | Initial heap | Set equal to -Xmx to avoid repeated heap growth |
-Xmx | Max heap | In containers, 50–75% of the limit, leaving room for metaspace and direct memory |
-XX:+UseG1GC | Use the G1 collector | Default on JDK 9+, friendlier for large heaps |
-XX:MaxRAMPercentage | Heap as a percentage of RAM | Preferred in containers, e.g. 75.0 |
-Xss | Thread stack size | Usually untouched; shrink only with very many threads |
"bigger -Xmx is better" is the classic myth. A larger heap lengthens Full GC pauses, and the real footprint is heap + metaspace + thread stacks + direct memory + JVM overhead. In a production container the safest form is -XX:MaxRAMPercentage=75.0 — let the JVM compute the heap from the cgroup limit instead of hard-coding an -Xmx that ignores the container.
This is the most important part of the article. Many production "data inconsistency" incidents come not from business code but from stopping the process the wrong way.
Think about what kill -9 does: the JVM receives SIGKILL and no callback runs at all. A "debit plus reward points" transaction that has debited but not yet credited is torn in half; a request read into memory but not yet returned is cut off and the client sees a connection reset; pooled connections are ripped away while the database still holds an "uncommitted transaction".
Spring Boot offers graceful shutdown in two lines:
server: shutdown: graceful # default is immediatespring: lifecycle: timeout-per-shutdown-phase: 30s # max wait per shutdown phaseserver.shutdown: gracefulswitches the web server from "immediate" to "graceful"timeout-per-shutdown-phase: 30sbounds the "wait for in-flight requests" phase; without a bound it could wait forever

Once enabled, a normal kill <pid> (no -9) follows this complete path:
- Receive SIGTERM:
kill,systemd stop,docker stopand K8s pod deletion all send this, not-9 - Stop accepting new requests: the web connector closes; the load balancer detects unhealthy and drains traffic
- Wait for in-flight requests: bounded by
timeout-per-shutdown-phase, or until they drain - Close the context and fire destroy callbacks:
@PreDestroy,DisposableBean#destroy,@Bean(destroyMethod)in order - Shut down pools and resources: thread pools, data sources, messaging clients wrap up
- JVM exits
graceful shutdown only responds to SIGTERM; kill -9 skips everything above. So the first rule of production ops is: always kill <pid> first, watch the logs for "graceful shutdown complete", and only then consider -9. Giving the stop script enough timeout (e.g. systemd's TimeoutStopSec) is far cheaper than chasing data inconsistency later.
Plain nohup java -jar ... & has two problems: no PID to stop cleanly, and no idea whether the service actually started. A practical script manages the PID and waits for a health check:
#!/usr/bin/env bashset -euo pipefailAPP_NAME="bee-app"JAR="bee-app-1.0.0.jar"PID_FILE="${APP_NAME}.pid"LOG_FILE="logs/startup.log"JVM_OPTS="-Xms512m -Xmx512m -XX:+UseG1GC -XX:MaxRAMPercentage=75.0"start() { if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then echo "$APP_NAME already running (pid $(cat "$PID_FILE"))"; exit 0 fi mkdir -p logs nohup java $JVM_OPTS -jar "$JAR" --spring.profiles.active=prod \ >> "$LOG_FILE" 2>&1 & echo $! > "$PID_FILE" printf 'waiting for health check' for _ in $(seq 1 30); do if curl -sf http://localhost:8080/actuator/health | grep -q '"status":"UP"'; then echo ' UP'; return 0 fi printf '.'; sleep 1 done echo ' FAILED'; exit 1}stop() { [ -f "$PID_FILE" ] || { echo 'not running'; exit 0; } PID="$(cat "$PID_FILE")" kill "$PID" # send SIGTERM to trigger graceful shutdown for _ in $(seq 1 30); do kill -0 "$PID" 2>/dev/null || { rm -f "$PID_FILE"; echo 'stopped'; return 0; } sleep 1 done echo 'timeout, force kill'; kill -9 "$PID"; rm -f "$PID_FILE"}case "${1:-}" in start) start ;; stop) stop ;; restart) stop; start ;; *) echo "usage: $0 {start|stop|restart}"; exit 1 ;;esacnohup ... &starts it in the background, andecho $! > PID_FILErecords the child so you can stop it precisely- After startup it polls
/actuator/healthand only treatsUPas success, avoiding a "started but not ready" illusion stopsendsSIGTERMfirst and waits up to 30 seconds — leaving graceful shutdown a way out — before falling back to-9
On production hosts the cleaner approach is systemd, which supports graceful shutdown natively (it sends SIGTERM by default) and restarts on crash:
[Unit]Description=Bee Spring Boot ApplicationAfter=network.target mysql.serviceWants=mysql.service[Service]Type=simpleUser=appWorkingDirectory=/opt/beeEnvironment="SPRING_PROFILES_ACTIVE=prod"EnvironmentFile=/opt/bee/app.env # secrets such as DB_PASSWORD live here, not in the unitExecStart=/usr/bin/java -Xms512m -Xmx512m -XX:+UseG1GC \ -XX:MaxRAMPercentage=75.0 -jar /opt/bee/bee-app.jarSuccessExitStatus=143 # JVM exits 143 on graceful stop; treat it as successTimeoutStopSec=30 # give graceful shutdown time before SIGKILLRestart=on-failureRestartSec=5[Install]WantedBy=multi-user.targetSuccessExitStatus=143: after SIGTERM the JVM's normal exit code is 143 (128+15); without this systemd would mark it failedTimeoutStopSec=30: mirrors the app'stimeout-per-shutdown-phase; systemd only sendsSIGKILLafter it expiresRestart=on-failure: auto-restart on abnormal exit, covering part of what a process supervisor doesEnvironmentFile: keeps passwords out of the unit file and out of version control
On K8s or multi-instance environments, /actuator/health is how the orchestrator decides whether an instance may take traffic. It distinguishes two probes: readiness (join load balancing) and liveness (restart the instance). Graceful shutdown plus a readiness probe is what makes "drain traffic, then stop the process" invisible to users.
management: endpoint: health: probes: enabled: true endpoints: web: exposure: include: health,infoEnabling probes exposes dedicated /actuator/health/readiness and /actuator/health/liveness endpoints — the next article, "Docker Deployment", wires them into an orchestration file.
After packaging, application.yml, templates and certificates live inside the jar, not on a filesystem path:
// Bad: a jar resource has no real file path; this throws FileNotFoundException when packagedFile file = new File("classpath:config/rules.json");// Good: always use ClassPathResource or the classpath: prefixResource resource = new ClassPathResource("config/rules.json");InputStream in = resource.getInputStream();Trap: new File("src/main/resources/xxx") works in the IDE and fails with FileNotFoundException the moment you package. src/main/resources becomes BOOT-INF/classes in the jar, and that directory no longer exists. Any resource bundled into the jar must be read via ClassPathResource or getResourceAsStream.
To change a database URL, many people unzip the jar, edit the config, and zip it back — easily corrupting the zip structure. The right approach is external config override: Spring Boot prefers application.yml next to the jar or in a config/ subdirectory.
/opt/bee/├── bee-app.jar├── application.yml # same directory, higher priority than inside the jar└── config/ └── application-prod.yml # config/ subdirectory, even higherTip: external config priority is command-line args > config/ next to the jar > the jar's directory > classpath:/config/ inside > classpath:/ inside. Ops only touch the config/ directory and never edit the jar — that is sustainable.
That chain — "edit a file" to "never repack again" — deserves its own walkthrough, because shipping a release every time a database password changes is a lesson nearly every team has paid for:

If a line like Started BeeApplication in 8.3 seconds keeps growing, something heavy happens at startup: scanning many beans, initializing pools, warming caches, fetching remote config. Use --debug or add spring-boot-starter-actuator to inspect startup metrics and find the slow segment instead of optimizing blindly.
The "it runs in the IDE but not under java -jar" story has a nastier variant: the application starts fine, and then some bean trips over a resource that never becomes a real file on disk. Here is that live scene — do not read the notes yet, click the frame you believe is the culprit:
WeChat Pay requires the merchant certificate to be passed to the SDK as a File. On the developer's machine everything works; delivered to the server, startup fails immediately. Ops follows the error, creates a certs directory under /opt/bee, and it still fails.
From the moment Spring Boot's main is called to the moment it can serve, a great many beans are created and wired. This demo runs the container's assembly — understanding bean creation order means understanding where startup time goes.
- Toggle the condition switches (
@ConditionalOn*) and watch the same code assemble different beans - More beans and deeper dependency chains mean slower startup — the root cause of "a large app takes tens of seconds to boot"
- To speed it up: trim unnecessary auto-configurations, use
lazy-initialization, or move heavy work to after startup
The second lab opens the fat jar itself, so the layout stops being a diagram you memorized and becomes something you can look inside. Start with layout, then loader.
The third lab moves one level up: what the JVM does while Docker builds an image, and why the image is far larger than the jar it contains.
The fourth lab walks the shutdown path — the one that decides whether a deploy corrupts data or not.
Enough buttons — type the same things yourself. This console talks to the same in-browser kernel, and every reply is computed there: start with whoami to see which class loader is running, probe the app with curl /actuator/health, then walk the labs one by one:
run lab fatjar loader right after boot and read the two views together — the first is the container wiring beans, the second is the loader laying the road. The readiness probe from Section 8 only turns UP once that second line finishes, which is why the Section 7 script polls the health endpoint instead of sleeping for ten seconds.
outcome: the heap gets roughly 358MiB and the rest is left to non-heap memorywhy: MaxRAMPercentage is read from the cgroup limit, so it follows the containerthis is the recommended default for containerized Boot apps
take away three things — a fat jar's Main-Class is JarLauncher, not your main class, and LaunchedURLClassLoader solves the jar-in-jar loading problem; build flags decide what goes into the jar while run flags decide what config is read, so do not mix them; graceful shutdown is a production discipline: kill <pid> with SIGTERM first, let it run "stop new requests → drain in-flight → destroy beans → release resources", and only then -9. Get these three right and delivery is largely stable.