The most surprising thing about configuring JVM heap and CPU for Docker is that the JVM often doesn’t know it’s in a container, leading to it hogging resources or refusing to start.

Let’s see this in action. Imagine a simple Java app, MyApp.java:

public class MyApp {
    public static void main(String[] args) {
        System.out.println("Starting MyApp...");
        // Simulate some work
        try {
            Thread.sleep(Long.MAX_VALUE);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

And a Dockerfile:

FROM openjdk:17-jdk-slim
COPY MyApp.java .
RUN javac MyApp.java
CMD ["java", "MyApp"]

If you build and run this with default settings, you might get an OutOfMemoryError or your container might be killed by the Docker host’s OOM killer, even if the host has plenty of memory. This happens because by default, the JVM tries to allocate heap based on the host’s total memory, not the container’s limit.

The problem is that Docker containers have resource limits (CPU, memory) imposed by the Docker daemon. The JVM, by default, is oblivious to these limits. It uses standard OS calls to determine available memory and CPU, which on a Linux host often means seeing the entire host’s resources, not just what’s allocated to its container.

The core issue lies in how the JVM’s garbage collector (GC) and thread scheduler interact with the operating system’s view of resources. Without proper configuration, the JVM might:

  1. Request too much heap: The JVM’s default heap size (-Xmx) can be set to a large percentage of available memory. If it sees the host’s memory, it can request more than the container is allowed, leading to the container being terminated by the orchestrator or Docker itself.
  2. Miscalculate CPU shares: Similarly, the JVM’s threading model and GC can be influenced by CPU availability. If it thinks it has access to all host CPUs, it might spawn too many threads or aggressively use CPU, impacting other containers or the host.

To fix this, we need to tell the JVM about its container environment.

1. Container-Aware Heap (-Xmx and -Xms)

The JVM needs to respect the container’s memory limit. For Java 10 and later, the JVM can automatically detect cgroup memory limits. However, it’s best practice to explicitly set it using flags that understand containerization.

  • Diagnosis: Run your JVM application inside a container with a strict memory limit. If it crashes with OutOfMemoryError or is killed by the OOM killer, the JVM is likely requesting too much heap.
    docker run -it --memory=256m --name myapp_test myapp_image
    
  • Fix: Use -XX:MaxRAMPercentage to set the maximum heap size as a percentage of the container’s memory limit.
    # In your Dockerfile CMD or entrypoint script
    CMD ["java", "-XX:MaxRAMPercentage=75.0", "MyApp"]
    
    Or, if you need to set initial heap size (-Xms) as well, you can use -XX:InitialRAMPercentage.
    CMD ["java", "-XX:InitialRAMPercentage=50.0", "-XX:MaxRAMPercentage=75.0", "MyApp"]
    
  • Why it works: -XX:MaxRAMPercentage instructs the JVM to calculate its maximum heap size (-Xmx) based on the container’s reported memory limit (from cgroups), not the host’s. Setting it to 75% leaves room for the OS and other processes within the container.

2. Container-Aware CPU (-XX:ActiveProcessorCount)

The JVM’s garbage collector, particularly parallel and G1 collectors, can be sensitive to the number of available CPUs. Without knowing the container’s CPU limit, it might oversubscribe.

  • Diagnosis: Monitor CPU usage of your JVM container. If it consistently pegs at 100% of its allocated CPU limit and impacts other containers, the JVM might be trying to use more CPU than it should. You can use docker stats for this.
    docker stats myapp_test
    
  • Fix: For Java 10+, use -XX:ActiveProcessorCount. This flag tells the JVM how many CPU cores are available to the container.
    # In your Dockerfile CMD or entrypoint script
    CMD ["java", "-XX:ActiveProcessorCount=2", "MyApp"]
    
    You can dynamically set this in an entrypoint script.
    # entrypoint.sh
    CPU_COUNT=$(grep -c ^processor /proc/cpuinfo)
    exec java -XX:ActiveProcessorCount=$CPU_COUNT MyApp
    
    Then COPY entrypoint.sh /entrypoint.sh and RUN chmod +x /entrypoint.sh and CMD ["/entrypoint.sh"].
  • Why it works: -XX:ActiveProcessorCount informs the JVM about the number of CPU cores it should consider available for its internal threading and GC operations, preventing it from over-allocating CPU resources within the container’s limits.

3. Older JVM Versions (Pre-Java 10)

For older JVMs, automatic detection of cgroup limits is not built-in. You’ll need to rely on environment variables or manual configuration.

  • Diagnosis: Same as above, but you’ll likely find that -XX:MaxRAMPercentage and -XX:ActiveProcessorCount have no effect.
  • Fix:
    • Heap: Set -Xmx directly based on your container’s memory limit. For example, if your container has 512MB, set -Xmx384m. This requires careful calculation and is less flexible.
    • CPU: This is harder. You might need to use JVM flags that tune GC thread counts, like -XX:ParallelGCThreads and -XX:ConcGCThreads, and set them based on the expected CPU count. This is highly empirical.
  • Why it works: You are manually overriding the JVM’s default resource discovery with values that you, the administrator, have determined are appropriate for the container’s environment.

4. Orchestration and Docker Compose

When using orchestrators like Kubernetes or tools like Docker Compose, the resource limits are set at that level.

  • Diagnosis: Check your docker-compose.yml or Kubernetes deployment manifests for resources.limits.memory and resources.limits.cpu.
    # docker-compose.yml example
    services:
      myapp:
        image: myapp_image
        deploy:
          resources:
            limits:
              memory: 512M
              cpus: '1.0' # Represents 1 CPU core
    
  • Fix: Ensure your JVM flags complement these limits. For Compose, you might use environment variables in your entrypoint script to read Docker-provided resource information, though this is less common than using flags directly. The JVM flags like -XX:MaxRAMPercentage are generally preferred as they are directly understood by the JVM.
  • Why it works: Orchestrators provide the actual resource constraints. The JVM flags ensure the JVM respects these constraints internally.

5. Garbage Collector Tuning

The choice of Garbage Collector also matters. G1 GC is generally the default and works well with container awareness.

  • Diagnosis: If you’re using older collectors (like Parallel GC) and experiencing issues, it might be related to thread management.
  • Fix: Stick with G1 GC or tune specific GC threads if necessary, but -XX:ActiveProcessorCount is usually sufficient.
    CMD ["java", "-XX:+UseG1GC", "-XX:MaxRAMPercentage=75.0", "MyApp"]
    
  • Why it works: G1 is designed to be more scalable and aware of available CPU resources, making it a better fit for containerized environments.

6. Java Agent Overheads

Some Java agents (APM, profiling) can add overhead and might also be unaware of container limits.

  • Diagnosis: If your application is consistently hitting resource limits after adding agents, the agent might be the culprit.
  • Fix: Check the agent’s documentation for container-specific configurations or resource tuning options. Sometimes, simply reducing the agent’s own resource footprint is necessary.
  • Why it works: Agents often run in their own threads or use JNI, and their behavior can be affected by the same container resource constraints.

The next thing you’ll likely encounter after getting JVM resource configuration right is dealing with application-level performance bottlenecks that become apparent once the JVM is no longer fighting for resources.

Want structured learning?

Take the full Java course →