Part 14 of 14

Java 17 Production Checklist and Performance Best Practices

Production Baseline JVM Flags

Start every Java 17 production deployment with this baseline:

java \
  # GC — choose one (see GC section)
  -XX:+UseG1GC \
  -XX:MaxGCPauseMillis=200 \
  \
  # Heap sizing
  -Xms4g -Xmx4g \
  \
  # GC logging — essential for diagnosis
  -Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=20m \
  \
  # OOM diagnostics
  -XX:+HeapDumpOnOutOfMemoryError \
  -XX:HeapDumpPath=/var/log/app/heap-dump.hprof \
  -XX:+ExitOnOutOfMemoryError \
  \
  # Metaspace
  -XX:MaxMetaspaceSize=512m \
  \
  # Code cache
  -XX:ReservedCodeCacheSize=512m \
  \
  # JFR — always-on profiling
  -XX:StartFlightRecording=duration=0,filename=/var/log/app/profile.jfr,maxsize=256m,maxage=24h \
  \
  # Container awareness
  -XX:+UseContainerSupport \
  -XX:MaxRAMPercentage=75.0 \
  \
  -jar app.jar

GC Selection for Java 17

G1GC (Default — General Purpose)

G1GC is the default since Java 9 and the right choice for most Java 17 applications:

-XX:+UseG1GC
-XX:MaxGCPauseMillis=200     # target max pause (200ms default)
-XX:G1HeapRegionSize=16m     # only override if default is too small
-XX:+UseStringDeduplication  # saves heap for string-heavy apps

G1GC works well for:

  • Applications with 4GB+ heaps
  • Mixed workloads (some latency sensitivity, some throughput)
  • Applications with significant live data (large old generation)

ZGC (Low-Latency)

Java 17 ships with production-ready ZGC (non-generational):

-XX:+UseZGC
-XX:ConcGCThreads=4         # concurrent GC threads (default: CPU count / 8)
-XX:+ZUncommit              # return unused heap to OS (default: enabled)
-XX:ZUncommitDelay=300      # seconds before uncommitting (default: 300)

ZGC is the right choice for:

  • Services with strict latency requirements (P99 < 10ms)
  • Large heaps (64GB+) where G1GC pauses become significant
  • Applications with unpredictable allocation patterns

Note: Generational ZGC (the major performance improvement) requires Java 21. Java 17’s ZGC is non-generational — it works well but is not as efficient for young-object-heavy workloads as Java 21’s Generational ZGC.

Choosing GC

Application type              → Recommended GC
─────────────────────────────────────────────
Web service (typical)         → G1GC (default)
Batch processing              → G1GC or ParallelGC
Low-latency service (<10ms)   → ZGC
Large heap (>16GB)            → ZGC
Microservice, small container → G1GC or Serial

Heap Sizing

The Equal Xms/Xmx Rule

Set min and max heap equal in production:

-Xms4g -Xmx4g

Equal sizes prevent heap resizing events (which cause GC pauses and delay startup) and make capacity predictable.

Container-Aware Sizing

In containers (Docker, Kubernetes), use percentage-based sizing:

-XX:+UseContainerSupport     # reads cgroup limits (default on Java 11+)
-XX:MaxRAMPercentage=75.0    # 75% of container memory for heap
-XX:InitialRAMPercentage=50.0

Reserve 25% for:

  • JVM non-heap (Metaspace, thread stacks, JIT code cache)
  • OS page cache
  • Native memory (NIO buffers, off-heap allocations)

Metaspace

Metaspace stores class metadata. It grows unboundedly by default. Cap it:

-XX:MetaspaceSize=128m       # initial size before first GC trigger
-XX:MaxMetaspaceSize=512m    # hard cap

A growing Metaspace (checked with jcmd <pid> VM.native_memory) suggests:

  • Dynamic class generation (cglib, ByteBuddy in non-steady state)
  • Class leaks from custom classloaders

Observability

Java Flight Recorder (JFR)

JFR is built-in, has <1% overhead, and is always-on safe:

-XX:StartFlightRecording=duration=0,filename=/var/log/app/profile.jfr,\
  maxsize=256m,maxage=24h,settings=profile

On-demand dump:

jcmd <pid> JFR.dump filename=/tmp/incident.jfr

GC Log Parsing

Enable structured GC logs (Java 9+ unified logging):

-Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=5,filesize=20m

Analyze with:

  • GCEasy (gcease.io) — web-based GC log analyzer
  • GCViewer — open-source desktop tool
  • JDK Mission Control (JMC) — analyzes JFR files with GC viewer

Key Metrics to Monitor

MetricHealthyWarning
GC pause P99 (G1)<200ms>500ms
GC pause P99 (ZGC)<10ms>50ms
Time in GC<5%>10%
Heap used / Heap max<80%>90%
Metaspace usedStableGrowing
Code cache used<80% of ReservedCodeCacheSize>90%
Thread countStableSpikes

jcmd Reference

jcmd -l                            # list JVMs
jcmd <pid> Thread.print            # thread dump
jcmd <pid> GC.heap_info            # heap summary
jcmd <pid> GC.class_histogram      # objects by type
jcmd <pid> VM.flags                # active JVM flags
jcmd <pid> VM.native_memory summary # native memory breakdown
jcmd <pid> JFR.start ...           # start JFR
jcmd <pid> JFR.dump ...            # dump JFR
jcmd <pid> GC.heap_dump /tmp/h.hprof # heap dump

JIT Compilation

Code Cache

The JIT stores compiled native code in the code cache. Default size (240m) is often too small for large apps:

-XX:ReservedCodeCacheSize=512m
-XX:+UseCodeCacheFlushing         # allow old code to be evicted

Monitor:

jcmd <pid> VM.native_memory summary | grep CodeCache

If code cache fills, the JVM falls back to interpretation — causing sudden latency spikes.

Tiered Compilation

Tiered compilation (default) uses C1 for quick compilation and C2 for optimal compilation of hot methods. Do not disable it.

For faster startup at reduced peak throughput (batch jobs, lambdas):

-XX:TieredStopAtLevel=1    # only compile to C1 (fast start)

Container Deployment

Kubernetes Resource Configuration

resources:
  requests:
    cpu: "1"
    memory: "2Gi"
  limits:
    cpu: "2"
    memory: "2Gi"

With -XX:MaxRAMPercentage=75, Java automatically uses ~1.5Gi for heap with a 2Gi container limit.

CPU Throttling

Kubernetes CPU throttling (CFS scheduler) affects JIT compilation and GC threads. Set requests below limits to allow burst:

resources:
  requests:
    cpu: "500m"   # guaranteed minimum
  limits:
    cpu: "2000m"  # burst allowed (for JIT warmup, GC)

Liveness and Readiness Probes

For Spring Boot applications, configure probes to avoid premature traffic during JIT warmup:

livenessProbe:
  httpGet:
    path: /actuator/health/liveness
    port: 8080
  initialDelaySeconds: 30    # wait for JIT warmup
  periodSeconds: 10

readinessProbe:
  httpGet:
    path: /actuator/health/readiness
    port: 8080
  initialDelaySeconds: 20
  periodSeconds: 5

Startup Optimization

Class Data Sharing (CDS)

CDS pre-parses JDK classes and stores them in a shared archive:

# Built-in CDS (already enabled by default in Java 17)
# Verify it's active:
java -Xshare:on -version

Application Class Data Sharing (AppCDS)

AppCDS extends CDS to include your application’s classes:

# Step 1: Record classes loaded at startup
java -XX:DumpLoadedClassList=classes.lst -jar app.jar
# Run a few warmup requests, then Ctrl+C

# Step 2: Create shared archive
java -Xshare:dump \
     -XX:SharedArchiveFile=app-cds.jsa \
     -XX:SharedClassListFile=classes.lst \
     -jar app.jar

# Step 3: Run with shared archive
java -Xshare:on \
     -XX:SharedArchiveFile=app-cds.jsa \
     -jar app.jar

AppCDS reduces startup time by 20–40% and reduces JVM memory footprint.

Build AppCDS into Docker Image

FROM eclipse-temurin:17-jre as builder
COPY target/app.jar /app/app.jar
WORKDIR /app
RUN java -XX:DumpLoadedClassList=classes.lst -jar app.jar &
RUN sleep 15 && kill %1 || true
RUN java -Xshare:dump -XX:SharedArchiveFile=app-cds.jsa -XX:SharedClassListFile=classes.lst -jar app.jar || true

FROM eclipse-temurin:17-jre
COPY --from=builder /app/app.jar /app/app.jar
COPY --from=builder /app/app-cds.jsa /app/app-cds.jsa
ENTRYPOINT ["java", "-Xshare:on", "-XX:SharedArchiveFile=/app/app-cds.jsa", "-jar", "/app/app.jar"]

Security Hardening

# Disable remote debugging in production
# (remove any -agentlib:jdwp=... flags)

# TLS configuration
-Dhttps.protocols=TLSv1.2,TLSv1.3
-Djdk.tls.client.protocols=TLSv1.2,TLSv1.3
-Djdk.tls.disabledAlgorithms=SSLv3,TLSv1,TLSv1.1,RC4,DES,MD5withRSA

# Deserialization protection (Article 10)
-Djdk.serialFilter="com.example.**;java.lang.*;java.util.*;maxrefs=500;maxdepth=8;!*"

# JNDI hardening (Log4Shell defense)
-Dcom.sun.jndi.ldap.object.trustURLCodebase=false
-Dcom.sun.jndi.rmi.object.trustURLCodebase=false

Memory Profiling

Heap Dump Analysis

# Trigger without crashing
jcmd <pid> GC.heap_dump /tmp/heap.hprof

# Analyze with MAT (Eclipse Memory Analyzer Tool)
# or VisualVM, or JDK Mission Control

Native Memory Tracking

# Enable at JVM start
-XX:NativeMemoryTracking=summary

# Check at runtime
jcmd <pid> VM.native_memory summary

# Baseline + diff
jcmd <pid> VM.native_memory baseline
# ... time passes ...
jcmd <pid> VM.native_memory summary.diff

Unexpected native memory growth (beyond stable heap) indicates:

  • Too many threads (stack space)
  • JNI or FFM API memory leaks
  • Code cache growth

Production Checklist

GC and Heap
[ ] Select GC: G1GC (general) or ZGC (low-latency)
[ ] Set -Xms = -Xmx (or use MaxRAMPercentage in containers)
[ ] Set -XX:MaxMetaspaceSize
[ ] Set -XX:ReservedCodeCacheSize=512m
[ ] Configure GC logging to rotating files
[ ] Set -XX:+HeapDumpOnOutOfMemoryError and -XX:+ExitOnOutOfMemoryError

Observability
[ ] Enable always-on JFR with circular buffer (maxsize, maxage)
[ ] Export JMX metrics to monitoring stack
[ ] Alert on: heap >80%, GC pause P99 >threshold, code cache >80%
[ ] Set up GC log parsing and dashboards

Container
[ ] Use -XX:+UseContainerSupport (default in Java 11+)
[ ] Use -XX:MaxRAMPercentage=75 instead of -Xmx
[ ] Set CPU requests and limits in Kubernetes
[ ] Configure liveness/readiness probes with adequate initialDelaySeconds

Startup
[ ] Build AppCDS archive into Docker image
[ ] Verify CDS is active with -Xshare:on

Security
[ ] Remove remote debug agent from production flags
[ ] Set TLS minimum to 1.2
[ ] Set deserialization filter (-Djdk.serialFilter)
[ ] Disable JNDI URL codebase loading

Migration-Specific
[ ] Remove all --illegal-access flags (invalid in Java 17)
[ ] Audit remaining --add-opens; track as debt to eliminate via library upgrades
[ ] Remove -XX:MaxPermSize
[ ] Remove -XX:+UseConcMarkSweepGC

Series Complete

You have finished the Java 17 Tutorial series. Here is what was covered:

  1. Java 17 Overview
  2. Setting Up Java 17
  3. Text Blocks (JEP 378)
  4. Records (JEP 395)
  5. Pattern Matching for instanceof (JEP 394)
  6. Switch Expressions (JEP 361)
  7. Sealed Classes (JEP 409)
  8. Pattern Matching for switch (JEP 406, Preview)
  9. Enhanced PRNGs (JEP 356)
  10. Deserialization Filters (JEP 415)
  11. JDK Encapsulation and Removed APIs
  12. Foreign Function & Memory API (JEP 412)
  13. Migration Guide
  14. Production Best Practices

Next Steps

Ready for the next LTS? The Java 21 Tutorial Series covers Virtual Threads, Pattern Matching (Final), Record Patterns, Sequenced Collections, Generational ZGC, and everything else that arrived between Java 17 and Java 21.