Apply to Your App
A checklist for bringing these optimizations to your existing Spring Boot applications. Every item is a configuration or build change — no application code rewrites.
Step 1: Fix Container Memory (30 minutes)
This is the single most impactful change. If you do nothing else, do this.
In your Containerfile
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
In your Kubernetes deployment
env:
- name: JAVA_OPTS
value: >-
-XX:MaxRAMPercentage=75.0
-XX:InitialRAMPercentage=50.0
-XX:MaxMetaspaceSize=256m
-XX:NativeMemoryTracking=summary
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "768Mi"
cpu: "2000m"
Verify
# After deploying, exec into the pod:
kubectl exec -it <pod> -- jcmd 1 VM.flags | grep -i heap
# Verify MaxHeapSize is ~75% of container limit
Step 2: Add Observability (30 minutes)
Maven dependencies
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
Configuration
# Expose the Prometheus endpoint
management.endpoints.web.exposure.include=health,info,prometheus,metrics
# GC pause histogram — REQUIRED for Grafana GC panels
management.metrics.distribution.percentiles-histogram.jvm.gc.pause=true
# Tag metrics with app name for Grafana filtering
management.metrics.tags.application=${spring.application.name}
Verify
curl http://localhost:8080/actuator/prometheus | grep jvm_gc_pause
# Should show histogram buckets, not just a counter
Step 3: Set GC Threads (5 minutes)
Add to your JAVA_OPTS:
# If your CPU request is 2 cores:
-XX:ParallelGCThreads=2
-XX:ConcGCThreads=1
Without this, the JVM defaults to the host node's CPU count. A 64-core node with a 2-CPU limit = 64 GC threads fighting for 2 CPU slots.
Step 4: Enable Virtual Threads (1 minute)
spring.threads.virtual.enabled=true
One property. Switches Tomcat's executor, @Async methods, and scheduled tasks to virtual threads. Reduces thread stack memory by ~50% for I/O-bound workloads.
synchronized blocks with I/O inside — they pin the carrier thread. Use ReentrantLock instead.
Step 5: Add AppCDS (2 hours)
Convert your Containerfile from a 1-stage or 2-stage build to a 3-stage build:
# Stage 1: Build
FROM registry.access.redhat.com/ubi9/openjdk-21 AS builder
WORKDIR /build
COPY pom.xml .
COPY src ./src
RUN mvn package -DskipTests -q
# Stage 2: CDS Training
FROM registry.access.redhat.com/ubi9/openjdk-21 AS cds-trainer
COPY --from=builder /build/target/*.jar /app/app.jar
WORKDIR /app
RUN java -Dspring.context.exit=onRefresh \
-XX:ArchiveClassesAtExit=app-cds.jsa \
-jar app.jar || true
RUN java -Xshare:dump \
-XX:SharedClassListFile=/dev/null \
-XX:SharedArchiveFile=app-cds.jsa \
-jar app.jar || true
# Stage 3: Runtime
FROM registry.access.redhat.com/ubi9/openjdk-21-runtime
COPY --from=cds-trainer /app/app.jar /app/app-cds.jsa /deployments/
WORKDIR /deployments
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -Xshare:on \
-XX:SharedArchiveFile=app-cds.jsa -jar app.jar"]
The training stage runs Spring Boot just long enough to load all classes, then dumps the CDS archive. At runtime, the JVM memory-maps the archive for near-instant class loading.
Step 6: Right-Size Resources (1 hour)
- Run your app under realistic load for 15-30 minutes
- Collect P50 and P99 RSS from Prometheus:
container_memory_rss - Set
requests.memoryto P50 RSS + 10% - Set
limits.memoryto P99 RSS + 25-30% - Set
limits.cputo 2-4xrequests.cpufor GC headroom - Run
jcmd pid VM.native_memory summaryto verify heap + off-heap fits within limits
Step 7: Fix HPA (30 minutes)
spec:
minReplicas: 2 # Never 1
behavior:
scaleUp:
stabilizationWindowSeconds: 120
scaleDown:
stabilizationWindowSeconds: 300
metrics:
- type: External
external:
metric:
name: http_requests_per_second
target:
type: AverageValue
averageValue: "50"
Scale on RPS, not CPU. GC pauses create CPU spikes that trigger false scale-out events. The 120-second stabilization window absorbs GC transients.
Step 8: Configure Lifecycle & Health Probes (15 minutes)
# Graceful shutdown — drain in-flight requests before stopping
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s
spec:
terminationGracePeriodSeconds: 40 # Must exceed shutdown timeout (30s)
containers:
- name: app
lifecycle:
preStop:
exec:
command: ["sleep", "5"] # Let LB drain before SIGTERM
startupProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
failureThreshold: 30 # 30 × 2s = 60s for JVM startup
periodSeconds: 2
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
periodSeconds: 10
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
periodSeconds: 5
Verify
kubectl describe pod <pod-name> | grep -A 5 "Liveness\|Readiness\|Startup"
Why startupProbe? Without it, livenessProbe runs from second zero — the JVM is still loading 12,000 classes and liveness kills it at second 3. CrashLoopBackOff from a health check, not from a bug.
Why preStop sleep(5)? Kubernetes sends SIGTERM and removes the pod from endpoints simultaneously, but endpoint removal takes 1-5s to propagate. The sleep delays SIGTERM so the load balancer stops sending traffic before Spring Boot starts shutting down.
HealthIndicator that performs
active warmup (invoking key endpoints, loading caches) before reporting UP. Include it in the
readiness health group:
# Include custom warmup indicator in readiness group
management.endpoint.health.group.readiness.include=applicationWarmup
management.endpoint.health.group.readiness.show-details=always
The ApplicationWarmup component implements HealthIndicator and
ApplicationListener<ApplicationReadyEvent>. It reports DOWN
until performWarmup() completes (invokes internal endpoints via RestClient),
then reports UP. The readinessProbe won't pass until warmup is done —
Kubernetes won't route traffic until the JVM is actually warm.
Startup resource spike: A fresh JVM needs 2-4× more CPU during class loading
and JIT compilation than at steady state. Consider Google's open-source
kube-startup-cpu-boost to grant temporary extra CPU during startup.
Quick Checklist
| Change | Effort | Impact | Code Change? |
|---|---|---|---|
| MaxRAMPercentage=75 | 5 min | Prevents OOMKill | No |
| ParallelGCThreads = CPU limit | 5 min | 30-50% shorter GC pauses | No |
| spring.threads.virtual.enabled | 1 min | 50% thread stack savings | No |
| Actuator + Micrometer | 30 min | Full JVM observability | 2 deps + 3 lines |
| AppCDS 3-stage Containerfile | 2 hrs | 35-55% faster startup | No |
| Right-size resources | 1 hr | 40-60% memory savings | No |
| HPA on RPS | 30 min | Eliminates GC thrash | No |
| Graceful shutdown + health probes | 15 min | Zero-downtime deploys | 2 props + YAML |