JVM Flags for Containers

Size the heap from the container limit, understand how the JVM counts CPUs, and fail fast on OutOfMemoryError instead of limping on.

SyntaxJAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError" java -XshowSettings:system -version # what the JVM sees in this container

The JVM reads the container's cgroup limits when it starts. It has done so by default since JDK 10, so in Java 25 Runtime.availableProcessors() and the default heap size follow the container's limits, not the host machine's. The defaults are cautious, though, and a few flags make a large difference.

Memory

For anything but a very small container, the default maximum heap is a quarter of the memory limit. On a 1 GiB container that is 256 MiB, which usually leaves most of the memory you pay for unused. Set it as a percentage so the same image works at any size:

-XX:MaxRAMPercentage=75.0

Do not go to 100%. The heap is only part of the process: metaspace, thread stacks, the JIT code cache, GC structures and direct buffers used by networking libraries all live outside it. If the total passes the limit, the kernel kills the process with no Java error at all; Kubernetes shows OOMKilled and exit code 137. Something between 60% and 80% is a typical range; services with many threads or heavy off-heap use need the lower end. -Xmx still works and takes priority, but it fixes one number into the image.

CPU

The JVM sizes GC threads, the JIT compiler threads and the common ForkJoinPool from the CPU count it sees. It takes that from the CPU quota, which Kubernetes sets from limits.cpu. Since JDK 19 it ignores CPU shares, which come from requests.cpu, so a pod with only a request sees every CPU on the node. -XX:ActiveProcessorCount=2 overrides the count when you need to.

The GC choice also depends on size. On a machine the JVM does not consider server class (fewer than 2 CPUs or less than about 1792 MB of memory) it picks Serial GC instead of G1. A service in a one-CPU container, or in one with a 1 GiB memory limit, gets single-threaded Serial collection unless you choose a collector with -XX:+UseG1GC.

Failing well

  • -XX:+ExitOnOutOfMemoryError: exit on the first OutOfMemoryError so the orchestrator restarts a clean process, instead of running on with threads that died halfway through their work.
  • -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps: write a dump to a mounted volume for later analysis. A dump is about as large as the heap, so the volume needs the space.

Where to put the flags

JAVA_TOOL_OPTIONS is read by every JVM in the container, including tools, and is echoed to stderr as Picked up JAVA_TOOL_OPTIONS: ... at start. JDK_JAVA_OPTIONS is read only by the java launcher. Either one lets operations change flags in a deployment file without rebuilding the image. Log the effective values at startup so you can see what you got.

Example

Example · java

import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.util.stream.Collectors;

// Log what the JVM decided, once, at startup. Values depend on the container.
void main() {
    Runtime rt = Runtime.getRuntime();
    String collectors = ManagementFactory.getGarbageCollectorMXBeans().stream()
            .map(GarbageCollectorMXBean::getName)
            .collect(Collectors.joining(", "));
    String flags = String.join(" ", ManagementFactory.getRuntimeMXBean().getInputArguments());

    IO.println("java " + Runtime.version().feature());
    IO.println("cpus " + rt.availableProcessors());
    IO.println("max heap MiB " + rt.maxMemory() / (1024 * 1024));
    IO.println("gc " + collectors);
    IO.println("flags " + flags);
}

// Prints, in a 1-CPU, 512 MiB container with MaxRAMPercentage=75, something like:
// java 25
// cpus 1
// max heap MiB 371
// gc Copy, MarkSweepCompact
// flags -XX:MaxRAMPercentage=75.0

When to use it

  • Raise the heap of an API pod from the default quarter of its 2 GiB limit to 75% without baking a fixed -Xmx into the image.
  • Make a batch service exit on OutOfMemoryError so Kubernetes restarts it, with a heap dump saved to a volume for the post-mortem.
  • Explain why a service in a pod with only a CPU request created dozens of GC threads, and fix it with a limit or ActiveProcessorCount.

More examples

Flags in the image as an environment variable

Keeping flags in JAVA_TOOL_OPTIONS rather than in the ENTRYPOINT lets a Kubernetes manifest replace them for one environment. Expect the Picked up JAVA_TOOL_OPTIONS line on stderr; it is informational, not an error.

Example · dockerfile
FROM eclipse-temurin:25-jre
WORKDIR /app
COPY target/orders-service.jar app.jar
# Defaults that operations can override per deployment without a rebuild
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError"
USER 10001
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

Kubernetes resources and JVM flags together

Setting the memory request equal to the limit avoids the pod being scheduled on a node that cannot actually give it the memory. The dumps volume must be large enough for a heap-sized file, or the dump fails at the worst moment.

Example · yaml
containers:
  - name: orders
    image: registry.example.com/orders-service:1.4.0
    resources:
      requests:
        cpu: "1"
        memory: 1Gi
      limits:
        cpu: "2"        # the JVM sizes its threads from this quota
        memory: 1Gi     # MaxRAMPercentage is a share of this
    env:
      - name: JAVA_TOOL_OPTIONS
        value: >-
          -XX:MaxRAMPercentage=70.0
          -XX:+ExitOnOutOfMemoryError
          -XX:+HeapDumpOnOutOfMemoryError
          -XX:HeapDumpPath=/dumps
    volumeMounts:
      - name: dumps
        mountPath: /dumps

Checking what the JVM sees

These two commands answer most container sizing questions without deploying anything. Try --cpus=2 --memory=2g to see the collector change from Serial to G1; at 512m the JVM stays on Serial even with two CPUs, because it also needs about 1792 MB to count as a server-class machine.

Example · bash
# Container metrics as the JVM reads them (Linux only)
docker run --rm --memory=512m --cpus=1 eclipse-temurin:25-jre \
  java -XshowSettings:system -version

# The heap size and collector the JVM chose
docker run --rm --memory=512m --cpus=1 eclipse-temurin:25-jre \
  java -XX:MaxRAMPercentage=75.0 -XX:+PrintFlagsFinal -version \
  | grep -E ' MaxHeapSize | UseSerialGC | UseG1GC '

Discussion

  • Be the first to comment on this lesson.