AdvancedJava · Lesson 8 of 9

Packaging, the JVM & Production

Executable jars, container images, memory and garbage-collection settings, logging and diagnostics.

Spring Boot's Maven plugin builds a single executable jar (java -jar app.jar). Ship it in a small container image built in two stages, running as a non-root user. Configure the app with environment variables, never hard-coded secrets.

In containers, size the heap relative to the container's memory with -XX:MaxRAMPercentage rather than fixed -Xmx values. The default G1 garbage collector suits most services; ZGC (-XX:+UseZGC) keeps pauses tiny for very large heaps.

The JDK ships powerful diagnostics: jcmd lists JVMs and dumps threads, Java Flight Recorder (JFR) records low-overhead production profiles, and System.Logger (or SLF4J with Logback, Spring's default) produces structured logs. Expose health checks — Spring Boot Actuator adds /actuator/health — and handle shutdown gracefully.

DockerfileDockerfile
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn -q dependency:go-offline
COPY src ./src
RUN mvn -q package -DskipTests

FROM eclipse-temurin:21-jre
WORKDIR /app
RUN useradd --system --uid 1001 app
COPY --from=build /app/target/*.jar app.jar
USER app
EXPOSE 8080
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError"
ENTRYPOINT ["java", "-jar", "app.jar"]
Diagnostics.javaJava
import java.lang.management.ManagementFactory;

public class Diagnostics {
    private static final System.Logger LOG = System.getLogger("results-api");

    public static void main(String[] args) throws Exception {
        Runtime rt = Runtime.getRuntime();
        LOG.log(System.Logger.Level.INFO, "JVM {0}, {1} CPUs, max heap {2} MB",
            Runtime.version(), rt.availableProcessors(), rt.maxMemory() / 1024 / 1024);
        LOG.log(System.Logger.Level.INFO, "GC: {0}",
            ManagementFactory.getGarbageCollectorMXBeans().stream().map(b -> b.getName()).toList());

        Runtime.getRuntime().addShutdownHook(new Thread(() ->
            LOG.log(System.Logger.Level.INFO, "Shutting down: closing pools, finishing requests")));

        long before = rt.totalMemory() - rt.freeMemory();
        var garbage = new java.util.ArrayList<byte[]>();
        for (int i = 0; i < 200; i++) garbage.add(new byte[1024 * 1024]);
        long during = rt.totalMemory() - rt.freeMemory();
        garbage = null;
        System.gc();
        long after = rt.totalMemory() - rt.freeMemory();
        LOG.log(System.Logger.Level.INFO, "Used heap MB: before {0}, during {1}, after GC {2}",
            before >> 20, during >> 20, after >> 20);
    }
}
TerminalShell
java -XX:MaxRAMPercentage=75 -XX:+UseG1GC Diagnostics.java

# Inspect a running JVM
jcmd                                   # list Java processes
jcmd <pid> Thread.print                # thread dump (find deadlocks / stuck requests)
jcmd <pid> GC.heap_info

# Record a 60-second production profile with Java Flight Recorder
jcmd <pid> JFR.start duration=60s filename=recording.jfr
jfr print --events jdk.GCPhasePause recording.jfr | head

docker build -t results-api . && docker run -p 8080:8080 -m 512m results-api

Key points

  • Ship an executable jar in a two-stage, non-root container image.
  • Size the heap with MaxRAMPercentage in containers; G1 by default, ZGC for low pauses.
  • Use jcmd, JFR and structured logs to diagnose problems in production.

Exercise

Package the Spring Boot API from the previous lesson as a jar and a Docker image, add Spring Boot Actuator, run the container with a 512 MB memory limit, and capture a 30-second JFR recording while sending it requests.

Show solution

Try the exercise yourself first — then compare your approach with this one.

Add the Actuator starter and expose the health endpoint. Package with mvn package and run the image with a 512 MB memory limit; the JVM sizes its heap from MaxRAMPercentage. The runtime-only image has no jcmd, so start Flight Recorder with a JVM flag instead: it records the first 30 seconds while you send requests. Copy the recording out and inspect it with the jfr tool from your JDK.

pom.xml (dependency)XML
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
src/main/resources/application.propertiesText
management.endpoints.web.exposure.include=health,info
management.endpoint.health.probes.enabled=true
server.shutdown=graceful
TerminalShell
mvn -q package -DskipTests
docker build -t results-api .
docker run -d --name results-api -p 8080:8080 -m 512m \
  -e JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:StartFlightRecording=duration=30s,filename=/tmp/rec.jfr" \
  results-api

curl localhost:8080/actuator/health          # {"status":"UP",...}
for i in $(seq 1 200); do curl -s -o /dev/null localhost:8080/api/students/1; done

sleep 35                                     # let the 30-second recording finish
docker cp results-api:/tmp/rec.jfr .
jfr summary rec.jfr | head -20
docker stop results-api                      # graceful shutdown in the logs

Check your understanding

  1. Which JVM option sizes the heap relative to a container's memory limit?

  2. What is Java Flight Recorder (JFR)?

  3. What does jcmd <pid> Thread.print produce?

  4. Why run the container as a non-root user?

Ask AI