JVM Tuning for Containerized Applications: Production Performance Guide
Master JVM tuning for containerized Java applications in Kubernetes. Learn garbage collection optimization, memory management strategies, container-aware JVM configurations, and production-ready performance tuning techniques for enterprise workloads.
JVM Tuning for Containerized Applications: Production Performance Guide
Executive Summary
Running Java applications in containers presents unique challenges for JVM tuning. Traditional heap sizing, garbage collection strategies, and CPU allocation methods don’t translate directly to containerized environments. This comprehensive guide covers production-proven techniques for optimizing JVM performance in Kubernetes, including container-aware JVM configurations, garbage collection tuning, memory management, and monitoring strategies that ensure optimal performance and resource utilization.
Understanding JVM Container Awareness
The Container Memory Problem
Before Java 8u131 and Java 9, the JVM didn’t recognize container memory limits, leading to OutOfMemoryErrors and container OOMKills.
Legacy JVM Behavior
# Container with 2GB memory limit
docker run -m 2g openjdk:8u121 java -XX:+PrintFlagsFinal -version | grep MaxHeapSize
# Output: MaxHeapSize = 32GB (reads host memory, not container limit)
Modern Container-Aware JVM
# Java 8u191+ and Java 11+ recognize container limits
docker run -m 2g openjdk:11 java -XX:+PrintFlagsFinal -version | grep MaxHeapSize
# Output: MaxHeapSize = ~512MB (1/4 of container memory by default)
Enabling Container Support
Java 8u131 to 8u190
# Experimental flags required
java -XX:+UnlockExperimentalVMOptions \
-XX:+UseCGroupMemoryLimitForHeap \
-XX:MaxRAMFraction=1 \
-jar application.jar
Java 8u191+ and Java 11+
# Container support enabled by default
java -XX:+UseContainerSupport \
-XX:MaxRAMPercentage=75.0 \
-XX:InitialRAMPercentage=50.0 \
-XX:MinRAMPercentage=50.0 \
-jar application.jar
Production JVM Configuration Strategies
Memory Allocation Architecture
Complete Memory Model
# Container Memory Breakdown
Total Container Memory: 4096 MB
├── JVM Heap (MaxRAMPercentage): 3072 MB (75%)
├── Metaspace: 256 MB (configured limit)
├── Code Cache: 240 MB (default)
├── Thread Stacks: 256 MB (256 threads × 1MB)
├── Direct Buffers: 128 MB (off-heap)
├── Native Memory: 128 MB
└── OS Reserved: 16 MB
Production Memory Configuration
FROM eclipse-temurin:17-jre-jammy
ENV JAVA_OPTS="-XX:+UseContainerSupport \
-XX:MaxRAMPercentage=75.0 \
-XX:InitialRAMPercentage=50.0 \
-XX:MinRAMPercentage=50.0 \
-XX:MaxMetaspaceSize=256m \
-XX:MetaspaceSize=128m \
-XX:ReservedCodeCacheSize=240m \
-XX:InitialCodeCacheSize=120m \
-Xss1m \
-XX:MaxDirectMemorySize=128m"
COPY target/application.jar /app/application.jar
ENTRYPOINT exec java $JAVA_OPTS -jar /app/application.jar
Kubernetes Resource Configuration
Deployment with Proper Resource Limits
apiVersion: apps/v1
kind: Deployment
metadata:
name: java-application
namespace: production
spec:
replicas: 3
selector:
matchLabels:
app: java-application
template:
metadata:
labels:
app: java-application
spec:
containers:
- name: application
image: company/java-application:1.0.0
env:
- name: JAVA_OPTS
value: |
-XX:+UseContainerSupport
-XX:MaxRAMPercentage=75.0
-XX:InitialRAMPercentage=50.0
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:ParallelGCThreads=4
-XX:ConcGCThreads=2
-XX:+UseStringDeduplication
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/heapdump.hprof
resources:
requests:
memory: "4Gi"
cpu: "2000m"
limits:
memory: "4Gi"
cpu: "4000m"
volumeMounts:
- name: logs
mountPath: /var/log
volumes:
- name: logs
emptyDir: {}
Garbage Collection Tuning
G1GC Configuration (Recommended for Most Workloads)
Basic G1GC Setup
# G1GC with tuned parameters
java -XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:G1HeapRegionSize=16m \
-XX:G1ReservePercent=15 \
-XX:InitiatingHeapOccupancyPercent=45 \
-XX:ParallelGCThreads=4 \
-XX:ConcGCThreads=2 \
-XX:+UseStringDeduplication \
-jar application.jar
Advanced G1GC Configuration
# jvm.properties - Production G1GC Configuration
# Basic G1GC Settings
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
# Region Size (should be power of 2, between 1-32MB)
# Calculate: heap_size / 2048 (aim for ~2048 regions)
-XX:G1HeapRegionSize=16m
# Reserve Percentage (extra heap space for GC operations)
-XX:G1ReservePercent=15
# Old Generation Collection Trigger
-XX:InitiatingHeapOccupancyPercent=45
# Mixed GC Tuning
-XX:G1MixedGCCountTarget=8
-XX:G1MixedGCLiveThresholdPercent=85
-XX:G1OldCSetRegionThresholdPercent=10
# Thread Configuration (based on CPU cores)
-XX:ParallelGCThreads=4
-XX:ConcGCThreads=2
# Memory Optimization
-XX:+UseStringDeduplication
-XX:StringDeduplicationAgeThreshold=3
# Large Objects (Humongous Objects)
# Objects > 50% of region size become humongous
# Adjust application to avoid creating large objects
ZGC Configuration (Ultra-Low Latency)
ZGC for Low-Latency Applications
# ZGC configuration (Java 15+)
java -XX:+UseZGC \
-XX:ZCollectionInterval=5 \
-XX:ZAllocationSpikeTolerance=2 \
-XX:+ZProactive \
-XX:ConcGCThreads=4 \
-Xlog:gc*:file=/var/log/zgc.log:time,uptime,level,tags \
-jar application.jar
Complete ZGC Setup
apiVersion: v1
kind: ConfigMap
metadata:
name: java-zgc-config
namespace: production
data:
JAVA_OPTS: |
-XX:+UseZGC
-XX:+ZGenerational
-XX:ZCollectionInterval=5
-XX:ZAllocationSpikeTolerance=2
-XX:+ZProactive
-XX:ConcGCThreads=4
-XX:+UseLargePages
-XX:+UseTransparentHugePages
-Xlog:gc*=info:file=/var/log/gc.log:time,uptime,level,tags:filecount=5,filesize=100m
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/heapdump.hprof
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: low-latency-java-app
namespace: production
spec:
replicas: 2
template:
spec:
containers:
- name: application
image: company/low-latency-app:1.0.0
envFrom:
- configMapRef:
name: java-zgc-config
resources:
requests:
memory: "8Gi"
cpu: "4000m"
limits:
memory: "8Gi"
cpu: "8000m"
Shenandoah GC Configuration
Shenandoah for Concurrent Collection
# Shenandoah GC (Java 12+, production-ready in Java 15+)
java -XX:+UseShenandoahGC \
-XX:ShenandoahGCHeuristics=adaptive \
-XX:+ShenandoahUncommit \
-XX:ShenandoahUncommitDelay=5000 \
-XX:ShenandoahGuaranteedGCInterval=20000 \
-XX:ConcGCThreads=4 \
-jar application.jar
Parallel GC Configuration (High Throughput)
Parallel GC for Batch Processing
# Parallel GC for throughput-oriented applications
java -XX:+UseParallelGC \
-XX:ParallelGCThreads=8 \
-XX:MaxGCPauseMillis=500 \
-XX:GCTimeRatio=19 \
-XX:+UseAdaptiveSizePolicy \
-XX:AdaptiveSizePolicyWeight=90 \
-jar batch-application.jar
Production Monitoring and Diagnostics
Comprehensive GC Logging
Modern GC Logging (Java 9+)
# Unified JVM logging
-Xlog:gc*=info:file=/var/log/gc.log:time,uptime,level,tags:filecount=10,filesize=100m
-Xlog:safepoint=info:file=/var/log/safepoint.log:time,uptime,level,tags:filecount=5,filesize=50m
Legacy GC Logging (Java 8)
# Classic GC logging flags
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps
-XX:+PrintGCApplicationStoppedTime
-XX:+PrintAdaptiveSizePolicy
-XX:+PrintTenuringDistribution
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=10
-XX:GCLogFileSize=100M
-Xloggc:/var/log/gc.log
JMX Monitoring Configuration
Enabling JMX in Kubernetes
apiVersion: apps/v1
kind: Deployment
metadata:
name: java-app-with-jmx
spec:
template:
spec:
containers:
- name: application
image: company/java-application:1.0.0
env:
- name: JAVA_OPTS
value: |
-Dcom.sun.management.jmxremote
-Dcom.sun.management.jmxremote.port=9010
-Dcom.sun.management.jmxremote.local.only=false
-Dcom.sun.management.jmxremote.authenticate=true
-Dcom.sun.management.jmxremote.ssl=false
-Dcom.sun.management.jmxremote.password.file=/etc/jmx/jmxremote.password
-Dcom.sun.management.jmxremote.access.file=/etc/jmx/jmxremote.access
-Djava.rmi.server.hostname=127.0.0.1
ports:
- containerPort: 9010
name: jmx
protocol: TCP
volumeMounts:
- name: jmx-config
mountPath: /etc/jmx
readOnly: true
volumes:
- name: jmx-config
secret:
secretName: jmx-credentials
defaultMode: 0400
Prometheus JMX Exporter
JMX Exporter Configuration
# jmx-exporter-config.yaml
lowercaseOutputName: true
lowercaseOutputLabelNames: true
whitelistObjectNames:
- "java.lang:type=Memory"
- "java.lang:type=GarbageCollector,*"
- "java.lang:type=Threading"
- "java.lang:type=Runtime"
- "java.lang:type=OperatingSystem"
- "java.lang:type=MemoryPool,*"
rules:
# Heap Memory
- pattern: 'java.lang<type=Memory><HeapMemoryUsage>(\w+)'
name: jvm_memory_heap_$1_bytes
type: GAUGE
# Non-Heap Memory
- pattern: 'java.lang<type=Memory><NonHeapMemoryUsage>(\w+)'
name: jvm_memory_nonheap_$1_bytes
type: GAUGE
# Garbage Collection
- pattern: 'java.lang<type=GarbageCollector, name=(.+)><>CollectionCount'
name: jvm_gc_collection_count
labels:
gc: "$1"
type: COUNTER
- pattern: 'java.lang<type=GarbageCollector, name=(.+)><>CollectionTime'
name: jvm_gc_collection_seconds
labels:
gc: "$1"
type: COUNTER
valueFactor: 0.001
# Memory Pools
- pattern: 'java.lang<type=MemoryPool, name=(.+)><Usage>(\w+)'
name: jvm_memory_pool_$2_bytes
labels:
pool: "$1"
type: GAUGE
# Threads
- pattern: 'java.lang<type=Threading><>(\w+)'
name: jvm_threads_$1
type: GAUGE
Deployment with JMX Exporter
apiVersion: apps/v1
kind: Deployment
metadata:
name: java-app-monitored
spec:
template:
metadata:
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "9404"
prometheus.io/path: "/metrics"
spec:
containers:
- name: application
image: company/java-application:1.0.0
env:
- name: JAVA_OPTS
value: |
-javaagent:/opt/jmx_exporter/jmx_prometheus_javaagent.jar=9404:/opt/jmx_exporter/config.yaml
ports:
- containerPort: 8080
name: http
- containerPort: 9404
name: metrics
volumeMounts:
- name: jmx-config
mountPath: /opt/jmx_exporter
initContainers:
- name: jmx-exporter-download
image: curlimages/curl:latest
command:
- sh
- -c
- |
curl -L -o /jmx-exporter/jmx_prometheus_javaagent.jar \
https://repo1.maven.org/maven2/io/prometheus/jmx/jmx_prometheus_javaagent/0.19.0/jmx_prometheus_javaagent-0.19.0.jar
volumeMounts:
- name: jmx-config
mountPath: /jmx-exporter
volumes:
- name: jmx-config
configMap:
name: jmx-exporter-config
Advanced Performance Tuning
CPU and Thread Configuration
Thread Pool Sizing
// Application Configuration
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.LinkedBlockingQueue;
public class OptimalThreadPoolFactory {
public static ThreadPoolExecutor createOptimalPool() {
int availableProcessors = Runtime.getRuntime().availableProcessors();
// For CPU-bound tasks
int cpuBoundPoolSize = availableProcessors;
// For I/O-bound tasks
int ioBoundPoolSize = availableProcessors * 2;
return new ThreadPoolExecutor(
cpuBoundPoolSize, // core pool size
ioBoundPoolSize, // maximum pool size
60L, TimeUnit.SECONDS, // keep alive time
new LinkedBlockingQueue<>(1000), // work queue
new ThreadPoolExecutor.CallerRunsPolicy() // rejection policy
);
}
}
JVM Thread Configuration
# Thread tuning
-XX:ParallelGCThreads=4 # GC parallel threads (num_cpus)
-XX:ConcGCThreads=2 # Concurrent GC threads (ParallelGCThreads/4)
-XX:ActiveProcessorCount=4 # Limit JVM view of CPUs
-Xss1m # Thread stack size
Class Data Sharing (CDS)
Creating CDS Archive
# Step 1: Create class list
java -Xshare:off -XX:DumpLoadedClassList=application.lst \
-jar application.jar --dry-run
# Step 2: Create shared archive
java -Xshare:dump -XX:SharedClassListFile=application.lst \
-XX:SharedArchiveFile=application.jsa \
--class-path application.jar
# Step 3: Use shared archive
java -Xshare:on -XX:SharedArchiveFile=application.jsa \
-jar application.jar
CDS in Docker
FROM eclipse-temurin:17-jre-jammy as cds-builder
COPY target/application.jar /app/application.jar
RUN java -Xshare:off \
-XX:DumpLoadedClassList=/app/application.lst \
-jar /app/application.jar --dry-run || true
RUN java -Xshare:dump \
-XX:SharedClassListFile=/app/application.lst \
-XX:SharedArchiveFile=/app/application.jsa \
--class-path /app/application.jar
FROM eclipse-temurin:17-jre-jammy
COPY --from=cds-builder /app/application.jar /app/
COPY --from=cds-builder /app/application.jsa /app/
ENV JAVA_OPTS="-Xshare:on -XX:SharedArchiveFile=/app/application.jsa"
ENTRYPOINT exec java $JAVA_OPTS -jar /app/application.jar
Application Class Data Sharing (AppCDS)
Spring Boot AppCDS
# Generate training run
java -XX:ArchiveClassesAtExit=application.jsa \
-jar spring-boot-application.jar
# Use AppCDS archive
java -XX:SharedArchiveFile=application.jsa \
-jar spring-boot-application.jar
Performance Benchmarking
Startup Time Optimization
Measuring Startup Performance
# Startup time measurement script
#!/bin/bash
measure_startup() {
local config=$1
local iterations=10
local total_time=0
echo "Testing configuration: $config"
for i in $(seq 1 $iterations); do
start=$(date +%s%N)
docker run --rm -e JAVA_OPTS="$config" \
company/java-app:test \
java $JAVA_OPTS -jar /app/application.jar --startup-test
end=$(date +%s%N)
runtime=$(( (end - start) / 1000000 ))
total_time=$(( total_time + runtime ))
echo " Run $i: ${runtime}ms"
done
avg_time=$(( total_time / iterations ))
echo " Average: ${avg_time}ms"
}
# Test different configurations
measure_startup "-XX:+TieredCompilation -XX:TieredStopAtLevel=1"
measure_startup "-XX:+UseAppCDS -XX:SharedArchiveFile=/app/application.jsa"
measure_startup "-Xverify:none"
Throughput Benchmarking
Load Testing Configuration
# k6 load test script
import http from 'k6/http';
import { check, sleep } from 'k6';
export let options = {
stages: [
{ duration: '2m', target: 100 }, // Ramp up
{ duration: '5m', target: 100 }, // Sustain
{ duration: '2m', target: 200 }, // Ramp up
{ duration: '5m', target: 200 }, // Sustain
{ duration: '2m', target: 0 }, // Ramp down
],
thresholds: {
http_req_duration: ['p(95)<500', 'p(99)<1000'],
http_req_failed: ['rate<0.01'],
},
};
export default function () {
let response = http.get('http://java-app.production.svc.cluster.local:8080/api/data');
check(response, {
'status is 200': (r) => r.status === 200,
'response time < 500ms': (r) => r.timings.duration < 500,
});
sleep(1);
}
Complete Production Configuration
Enterprise-Grade Deployment
apiVersion: v1
kind: ConfigMap
metadata:
name: java-app-jvm-config
namespace: production
data:
JAVA_OPTS: |
# Container Support
-XX:+UseContainerSupport
-XX:MaxRAMPercentage=75.0
-XX:InitialRAMPercentage=50.0
-XX:MinRAMPercentage=50.0
# Garbage Collection (G1GC)
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:G1HeapRegionSize=16m
-XX:G1ReservePercent=15
-XX:InitiatingHeapOccupancyPercent=45
-XX:ParallelGCThreads=4
-XX:ConcGCThreads=2
-XX:+UseStringDeduplication
# Memory Management
-XX:MaxMetaspaceSize=256m
-XX:MetaspaceSize=128m
-XX:ReservedCodeCacheSize=240m
-XX:InitialCodeCacheSize=120m
-Xss1m
-XX:MaxDirectMemorySize=128m
# Performance Optimization
-XX:+TieredCompilation
-XX:+UseCompressedOops
-XX:+UseCompressedClassPointers
-XX:+OptimizeStringConcat
# Class Data Sharing
-Xshare:on
-XX:SharedArchiveFile=/app/application.jsa
# Diagnostics
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/heapdump.hprof
-XX:ErrorFile=/var/log/hs_err_pid%p.log
-XX:+UnlockDiagnosticVMOptions
-XX:NativeMemoryTracking=summary
# Logging
-Xlog:gc*=info:file=/var/log/gc.log:time,uptime,level,tags:filecount=10,filesize=100m
-Xlog:safepoint=info:file=/var/log/safepoint.log:time,uptime,level,tags:filecount=5,filesize=50m
# JMX Monitoring
-javaagent:/opt/jmx_exporter/jmx_prometheus_javaagent.jar=9404:/opt/jmx_exporter/config.yaml
# Security
-Djava.security.egd=file:/dev/./urandom
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: java-application-optimized
namespace: production
labels:
app: java-application
version: v1.0.0
spec:
replicas: 3
selector:
matchLabels:
app: java-application
template:
metadata:
labels:
app: java-application
version: v1.0.0
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "9404"
prometheus.io/path: "/metrics"
spec:
containers:
- name: application
image: company/java-application:1.0.0
envFrom:
- configMapRef:
name: java-app-jvm-config
ports:
- containerPort: 8080
name: http
protocol: TCP
- containerPort: 9404
name: metrics
protocol: TCP
resources:
requests:
memory: "4Gi"
cpu: "2000m"
limits:
memory: "4Gi"
cpu: "4000m"
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 60
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 30
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
volumeMounts:
- name: logs
mountPath: /var/log
- name: tmp
mountPath: /tmp
volumes:
- name: logs
emptyDir: {}
- name: tmp
emptyDir: {}
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app
operator: In
values:
- java-application
topologyKey: kubernetes.io/hostname
---
apiVersion: v1
kind: Service
metadata:
name: java-application
namespace: production
spec:
selector:
app: java-application
ports:
- name: http
port: 8080
targetPort: 8080
- name: metrics
port: 9404
targetPort: 9404
type: ClusterIP
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: java-application-hpa
namespace: production
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: java-application-optimized
minReplicas: 3
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 50
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 100
periodSeconds: 30
- type: Pods
value: 2
periodSeconds: 30
selectPolicy: Max
Conclusion
JVM tuning for containerized applications requires deep understanding of both JVM internals and container orchestration. Key takeaways:
- Use Container-Aware JVM Flags: Always enable
-XX:+UseContainerSupportand useMaxRAMPercentageinstead of fixed heap sizes - Choose the Right GC: G1GC for general-purpose, ZGC for ultra-low latency, Parallel GC for batch processing
- Monitor Comprehensively: Implement JMX exporters and comprehensive logging
- Test Thoroughly: Benchmark different configurations under realistic load
- Plan for Failure: Configure heap dumps and error logging for troubleshooting
Proper JVM tuning can improve application performance by 30-50% while reducing resource consumption and costs. Regular monitoring and iterative tuning ensure optimal performance as application requirements evolve.