Java in a container
Container-aware JVM, heap as a percentage, CPU quotas and thread pools, layered jars, and startup time.
A JVM asks the machine how much memory and how many CPUs it has, and sizes its heap and its thread pools from the answers. In a container the machine lies, or rather the container runtime tells the truth through a mechanism — cgroups — that older JVMs did not read. The result was a generation of services that were killed by the kernel for using memory they thought they had. Modern Java reads cgroups correctly; the settings that follow from that are what this lesson is about.
The JVM and cgroups
A container's memory limit and CPU quota are cgroup settings the kernel enforces. Java 10 (and 8u191) taught the JVM to read them: Runtime.availableProcessors() returns the CPU quota rounded up, not the host's core count, and the default heap size is a fraction of the container's limit, not the host's RAM. java -XX:+PrintFlagsFinal -version | grep -E 'MaxHeapSize|ActiveProcessorCount' inside the container is how you check what it decided.
The kernel enforces the memory limit on the whole process, not the heap. A JVM's footprint is the heap plus Metaspace, the code cache, thread stacks, direct buffers, GC data structures and the native allocator's overhead — the memory-areas lesson measured each one. Set the heap to the container limit and the process exceeds the limit the first time Metaspace grows, and the kernel's answer is OOMKilled, exit code 137, with nothing in the Java log because there was no Java exception.
MaxRAMPercentage, not -Xmx
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-XX:InitialRAMPercentage=50.0", "-jar", "app.jar"]-XX:MaxRAMPercentage sizes the heap as a fraction of the container's memory limit, so one image serves every size of container: at a 1 GiB limit the heap is 768 MiB and the other quarter is for everything else; at 4 GiB the heap is 3 GiB. The default is 25%, which is a heap a quarter the size of the container — right for a laptop shared with an IDE, wrong for a container that exists to run this one process. 75% is the usual starting point for a service, lower if it uses direct buffers heavily (Netty, Kafka clients) or runs many threads.
-Xmx still works and pins the heap regardless of the limit, which is the right choice when you want the number to be explicit and the container is sized to it — this repository's API runs under systemd rather than in a container, with -Xmx384m and a MemoryMax=1G on the unit, the same shape with the same reasoning in its comments: the heap cannot grow past the flag, so the extra room is for non-heap growth under load. Whichever you use, -XX:+ExitOnOutOfMemoryError makes a heap exhaustion end the process so the orchestrator restarts it, rather than leaving a JVM that answers health checks and nothing else.
CPU: the quota, and what the pools see
A container with a CPU limit of 2 is not two cores; it is a quota — 200 ms of CPU time per 100 ms period, across however many cores it happens to run on. A JVM with the limit reads availableProcessors() == 2, and sizes from that: the common ForkJoinPool gets one worker, the G1 collector gets two threads, Tomcat's default of 200 request threads stays 200 because it never asked. Two consequences to check:
- A fractional limit rounds up.
cpu: 500misavailableProcessors() == 1, soparallelStream()is sequential and the JIT compiler threads share one CPU with the application, which makes the warm-up in the JIT lesson painfully slow. Give a JVM at least one whole CPU, and prefer a request without a limit where the platform allows it — throttling a JVM at its quota produces latency spikes that look like GC pauses and are not. - Pools sized from cores are sized from the quota. A
newFixedThreadPool(availableProcessors())in a500mcontainer is one thread. Size I/O pools from the downstream's capacity, as the executors lesson says, not from CPU count; and set-XX:ActiveProcessorCount=Nonly when you have measured that the JVM's guess is wrong.
Spring Boot layered jars
A fat jar changes entirely when one class changes, so every deploy pushes and pulls 40 MB. Spring Boot's layered jar splits it by rate of change, and the Dockerfile copies each part as its own layer:
FROM eclipse-temurin:21-jdk AS build
# ... build as in the last lesson, then:
RUN java -Djarmode=tools -jar target/app.jar extract --layers --destination extracted
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
COPY --from=build /src/extracted/dependencies/ ./
COPY --from=build /src/extracted/spring-boot-loader/ ./
COPY --from=build /src/extracted/snapshot-dependencies/ ./
COPY --from=build /src/extracted/application/ ./
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "org.springframework.boot.loader.launch.JarLauncher"]Dependencies change when the POM does; the application layer changes every commit and is a few hundred kilobytes. A deploy that changed only code pushes only that layer, and a node that already has the dependency layer pulls only that layer. (-Djarmode=layertools is the Boot 2 and 3.2 spelling of the same command.) Class Data Sharing, -XX:SharedArchiveFile from a training run in the build, is the next step for startup time; Boot 3.3+ can produce the archive during extract.
Health checks, signals and startup
The orchestrator needs to know two things the process must tell it. Is it alive — a HEALTHCHECK in the Dockerfile, or a liveness probe in Kubernetes, hitting Actuator's /actuator/health/liveness — and is it ready for traffic, which is a different question the Actuator lesson separated: a JVM that is up and still warming its connection pool is alive and not ready. Point the two probes at the two endpoints, and give the startup probe or initialDelaySeconds enough room for a Spring Boot start on a throttled CPU, which is longer than on your laptop.
Shutdown is the same contract in reverse. The orchestrator sends SIGTERM, waits a grace period, then ends the process outright. Java handles SIGTERM by running shutdown hooks — and Spring Boot's server.shutdown=graceful with spring.lifecycle.timeout-per-shutdown-phase=20s finishes in-flight requests first — only if Java is PID 1 or is forwarded the signal, which is why the exec-form ENTRYPOINT matters and a sh -c "java ..." wrapper loses requests on every deploy.