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.

Estimated effort: ~4 hours per microservice. Most of the time is measuring, not changing. For a team with 10 services, budget two days.

Step 1: Fix Container Memory (30 minutes)

This is the single most impactful change. If you do nothing else, do this.

In your Containerfile

Containerfile ENTRYPOINT
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

In your Kubernetes deployment

deployment.yaml
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

Check heap sizing
# 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

pom.xml
<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

application.properties
# 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

Check metrics endpoint
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:

Match GC threads to CPU limit
# 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)

application.properties
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.

Caveat: Avoid 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:

Containerfile (3-stage AppCDS pattern)
# 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)

  1. Run your app under realistic load for 15-30 minutes
  2. Collect P50 and P99 RSS from Prometheus: container_memory_rss
  3. Set requests.memory to P50 RSS + 10%
  4. Set limits.memory to P99 RSS + 25-30%
  5. Set limits.cpu to 2-4x requests.cpu for GC headroom
  6. Run jcmd pid VM.native_memory summary to verify heap + off-heap fits within limits

Step 7: Fix HPA (30 minutes)

hpa.yaml
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)

application.properties
# Graceful shutdown — drain in-flight requests before stopping
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s
deployment.yaml — pod spec
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

bash
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.

Pro Tip — Active JVM Warmup: A pod that passes its startupProbe isn't necessarily ready to handle traffic at full speed. The JIT hasn't compiled hot paths, connection pools aren't established, and caches are cold. Create a custom HealthIndicator that performs active warmup (invoking key endpoints, loading caches) before reporting UP. Include it in the readiness health group:
application.properties — warmup readiness gate
# 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

ChangeEffortImpactCode Change?
MaxRAMPercentage=755 minPrevents OOMKillNo
ParallelGCThreads = CPU limit5 min30-50% shorter GC pausesNo
spring.threads.virtual.enabled1 min50% thread stack savingsNo
Actuator + Micrometer30 minFull JVM observability2 deps + 3 lines
AppCDS 3-stage Containerfile2 hrs35-55% faster startupNo
Right-size resources1 hr40-60% memory savingsNo
HPA on RPS30 minEliminates GC thrashNo
Graceful shutdown + health probes15 minZero-downtime deploys2 props + YAML
Order matters: Fix memory first (Step 1), add observability second (Step 2), then tune based on data (Steps 3-7). Step 8 (lifecycle) can be done in parallel with any other step. Never tune without a baseline.
JVM Cheat Sheet → Spring Boot Reference →