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.
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"]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);
}
}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-apiKey points
- Ship an executable jar in a two-stage, non-root container image.
- Size the heap with
MaxRAMPercentagein 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.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>management.endpoints.web.exposure.include=health,info
management.endpoint.health.probes.enabled=true
server.shutdown=gracefulmvn -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