JVM in Containers
JVM in Containers Heap Native Memory GC CPU OOMKilled and Warmup
Production behavior of Java 17 JVMs in containers: container awareness, heap sizing, native memory, metaspace, thread stacks, GC choices, CPU throttling, memory limits, OOMKilled diagnosis, startup time, warmup, and JIT behavior
Part 100 — JVM in Containers: Heap, Native Memory, GC, CPU, OOMKilled, Warmup
Fokus part ini: memahami JVM di container sebagai runtime yang hidup di bawah cgroup limits, bukan sebagai proses bebas di server besar. Heap hanyalah satu bagian dari memory. CPU limit memengaruhi GC, JIT, thread scheduling, latency, dan startup. OOMKilled sering bukan Java
OutOfMemoryError, tetapi keputusan kernel/platform.
Catatan penting:
This part does not assume CSG uses a specific JVM distribution, GC algorithm,
heap percentage, Kubernetes resource policy, Java base image, container runtime,
or performance tuning standard.
Treat JVM flags, container limits, GC policy, memory sizing, CPU limits,
heap dump policy, and production diagnostics as internal verification items.
Container membuat JVM lebih predictable jika dikonfigurasi benar.
Container juga membuat JVM lebih mudah gagal jika memory/CPU dipahami secara dangkal.
Kesalahan umum:
memory limit = heap size
CPU limit = harmless quota
OOMKilled = Java heap leak
more threads = more throughput
liveness failure = app bug
fast startup locally = fast startup in Kubernetes
Senior engineer harus melihat JVM containerized service sebagai sistem dengan beberapa budget:
heap budget
native memory budget
thread stack budget
metaspace budget
code cache budget
direct buffer budget
GC CPU budget
JIT warmup budget
startup deadline budget
1. Mental Model: Container Limit Outside, JVM Budgets Inside
The container memory limit applies to the process as a whole.
It includes:
Java heap
metaspace
thread stacks
direct byte buffers
mapped files
JIT code cache
GC structures
JNI/native libraries
class metadata
JVM internal allocations
application native allocations
Therefore:
-Xmx must be smaller than container memory limit.
But “smaller” is not enough.
You need headroom for everything outside heap.
2. JVM Container Awareness
Modern JVMs are container-aware: they can read cgroup CPU and memory limits and adjust defaults.
But do not rely blindly on defaults.
Why:
platform resource policies differ
container base image may use different JVM build
memory limit may be absent in local Docker
CPU limit can distort available processor count
JVM defaults may not match latency goals
native memory may be high for your workload
Verification commands conceptually:
java -XX:+PrintFlagsFinal -version | grep -E "MaxHeapSize|MaxRAM|UseContainerSupport|ActiveProcessorCount"
java -XshowSettings:system -version
java -XshowSettings:vm -version
Inside a running pod, check:
what memory limit Kubernetes set
what CPU request/limit Kubernetes set
what JVM thinks the max heap is
what JVM thinks available processors are
3. Heap Sizing: Xmx vs MaxRAMPercentage
Two common approaches:
fixed heap size: -Xmx1024m
percentage-based: -XX:MaxRAMPercentage=70
Fixed heap is explicit:
env:
- name: JAVA_TOOL_OPTIONS
value: "-Xms512m -Xmx1024m"
Pros:
predictable
simple to reason about
stable across environments
Cons:
must align manually with container limit
bad if same manifest runs under different limits
can waste memory in small environments
Percentage-based heap adapts to container memory:
env:
- name: JAVA_TOOL_OPTIONS
value: "-XX:InitialRAMPercentage=25 -XX:MaxRAMPercentage=70"
Pros:
adapts to memory limit
useful across environments
less duplication between deployment and JVM flags
Cons:
native memory headroom must still be modeled
percentage may be too aggressive for high-thread/direct-buffer apps
less obvious during review unless documented
Review rule:
Heap sizing must be derived from container memory limit and non-heap headroom,
not copied from another service.
4. Native Memory Is Real Memory
Non-heap memory can be significant.
Sources:
thread stacks
metaspace
code cache
direct buffers
NIO/network buffers
TLS/native crypto
compression libraries
JDBC driver/native allocations
Netty/Grizzly/Tomcat buffers if applicable
mapped files
JVM internal structures
Approximate model:
container_memory_limit
= heap
+ metaspace
+ thread_stack_total
+ direct_buffers
+ code_cache
+ JVM native overhead
+ OS/process overhead
+ safety margin
If memory limit is 1 GiB and -Xmx is 950 MiB, the container is likely fragile.
A safer starting shape could be:
memory limit: 1024 MiB
heap max: 600-750 MiB
headroom: 274-424 MiB
But the correct value depends on workload.
Measure, do not guess.
5. Metaspace
Metaspace stores class metadata.
It grows with:
number of loaded classes
frameworks and reflection
JAX-RS providers/resources
JSON/XML libraries
CDI/HK2 proxies
generated classes
classloader leaks
application server deployment model
Failure mode:
java.lang.OutOfMemoryError: Metaspace
Container failure mode:
process memory grows due to metaspace
kernel kills container before Java throws clear OOME
You can cap metaspace:
-XX:MaxMetaspaceSize=256m
But capping too low causes startup/runtime failure.
Review questions:
Is classloading stable after startup?
Does redeploy/reload happen in-process?
Are dynamic proxies/generated classes used heavily?
Is metaspace monitored?
6. Thread Stack Memory
Each Java thread consumes stack memory.
Default stack size depends on platform/JVM.
If a service creates many threads, memory rises outside heap.
Sources of threads:
HTTP request workers
Jersey/Servlet container pools
Kafka consumers
JDBC pool housekeeper
scheduler threads
async executors
resilience library schedulers
OpenTelemetry exporters
GC threads
JIT compiler threads
Thread stack estimate:
thread_stack_total = number_of_threads * stack_size
If 500 threads use 1 MiB stack each:
~500 MiB native memory potential
You can configure stack size:
-Xss512k
But too-small stack can cause:
StackOverflowError
framework recursion failure
deep serialization/deserialization failure
Better first step:
control thread pool sizes
avoid unbounded executors
measure thread count
7. Direct Buffers and Network I/O
Direct buffers are allocated outside Java heap.
They may be used by:
NIO
HTTP clients
server connectors
Kafka clients
PostgreSQL/JDBC internals
TLS stacks
Netty-based libraries
file streaming
compression
Failure symptoms:
java.lang.OutOfMemoryError: Direct buffer memory
container OOMKilled
latency spikes during streaming
memory not visible in heap histogram
Possible cap:
-XX:MaxDirectMemorySize=128m
But capping too low can break high-throughput network or streaming workloads.
Senior review:
If service streams files, handles multipart, uses Kafka, or heavy HTTP clients,
non-heap buffer budget must be considered.
8. Code Cache and JIT Memory
JIT compiles hot methods into native code stored in code cache.
Code cache matters for:
long-running service throughput
warmup behavior
CPU usage after startup
latency stabilization
Failure symptoms:
CodeCache is full warning
performance degradation
higher CPU
less optimized hot path
Usually you do not tune code cache first.
But for container memory budgeting, remember:
JIT is not free.
9. Java 17 GC Options: Choosing for Workload
Java 17 commonly defaults to G1 GC for server workloads.
Common GC choices:
| GC | Use Case | Trade-off |
|---|---|---|
| G1 | balanced general-purpose service | good default, tunable pauses |
| ZGC | low-latency larger heaps | more advanced, verify support/perf |
| Shenandoah | low-latency workloads on supported builds | JVM distribution dependent |
| Serial | small/simple containers | low overhead, poor for larger services |
For enterprise JAX-RS services, first principles matter more than fashion:
What is latency SLO?
What heap size?
What allocation rate?
What CPU limit?
What pause tolerance?
What throughput requirement?
Example G1 flag:
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
But MaxGCPauseMillis is a goal, not a guarantee.
Do not tune GC without GC logs and latency measurements.
10. GC Logging in Containers
GC logs are essential for diagnosing memory and pause behavior.
Java 17 unified logging example:
-Xlog:gc*,safepoint:file=/app/logs/gc.log:time,level,tags:filecount=5,filesize=20M
But file logging inside container needs explicit storage/rotation.
Alternative:
-Xlog:gc*:stdout:time,level,tags
Trade-off:
stdout integrates with platform logging
file logs can be easier to download/analyze but require volume/rotation
Review questions:
Are GC logs enabled in performance environments?
Can they be enabled in production during incident?
Where do they go?
Can operators correlate GC pauses with request latency?
11. CPU Requests, Limits, and JVM Behavior
CPU request and limit are different.
request = scheduling reservation signal
limit = maximum CPU quota before throttling
CPU throttling affects:
request latency
GC duration
JIT compilation
thread scheduling
startup time
Kafka polling
timeout behavior
Failure mode:
Service has enough memory but high latency.
CPU throttling delays GC and request handling.
Timeouts trigger retries.
Retries increase CPU.
System enters cascading failure.
Review checklist:
Is CPU limit set?
Is throttling monitored?
Does JVM see correct processor count?
Are GC/JIT threads too many for CPU quota?
Are timeouts calibrated for throttled environment?
Sometimes removing CPU limits while keeping requests can improve latency predictability, depending on platform policy.
Do not change this casually; verify internal Kubernetes standard.
12. ActiveProcessorCount
The JVM uses available processor count for internal sizing.
This can affect:
GC threads
JIT compiler threads
ForkJoinPool parallelism
common pool behavior
framework defaults
You can override:
-XX:ActiveProcessorCount=2
This is useful when:
container CPU quota is interpreted unexpectedly
framework defaults create too many threads
performance tests need deterministic CPU model
But override is dangerous if copied blindly.
Review question:
Is ActiveProcessorCount set because of measured behavior or cargo-cult tuning?
13. OOMKilled vs Java OutOfMemoryError
These are not the same.
Java OutOfMemoryError happens inside JVM:
java.lang.OutOfMemoryError: Java heap space
java.lang.OutOfMemoryError: Metaspace
java.lang.OutOfMemoryError: Direct buffer memory
unable to create native thread
OOMKilled happens outside JVM:
kernel/container runtime kills process because cgroup memory limit is exceeded
In Kubernetes:
Last State: Terminated
Reason: OOMKilled
Exit Code: 137
If kernel kills the process, Java may not produce heap dump or clean logs.
Therefore:
No heap dump does not mean no memory problem.
No Java OOME does not mean JVM was not memory-related.
14. OOM Diagnosis Workflow
When pod is OOMKilled:
1. Check pod status and exit code
2. Check memory limit and actual memory usage
3. Check JVM heap max
4. Check GC logs if available
5. Check thread count
6. Check direct buffer/native memory indicators
7. Check recent traffic/file streaming/batch workload
8. Check deployment change: flags, image, dependency, config
9. Check whether heap dump exists
10. Check node pressure events
Kubernetes evidence:
kubectl describe pod <pod>
kubectl top pod <pod>
kubectl logs <pod> --previous
JVM evidence if process is still alive:
jcmd <pid> VM.native_memory summary
jcmd <pid> GC.heap_info
jcmd <pid> Thread.print
Native Memory Tracking requires enabling:
-XX:NativeMemoryTracking=summary
It has overhead. Verify production policy.
15. Heap Dumps in Containers
Heap dumps are useful but dangerous.
They can contain:
PII
access tokens
secrets
customer data
pricing data
request payloads
Common flags:
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/app/dumps
Container concern:
heap dump can exceed ephemeral storage
heap dump path may not be writable
OOMKilled may prevent dump from being written
heap dump must be protected as sensitive artifact
Review checklist:
Is heap dump enabled?
Where does it write?
Is there enough storage?
Is access controlled?
Is retention defined?
Is PII/security handling documented?
16. ExitOnOutOfMemoryError
For production services, continuing after OOME is often unsafe.
Flag:
-XX:+ExitOnOutOfMemoryError
Benefit:
process exits clearly
Kubernetes can restart
service avoids corrupted/unstable state
Risk:
restart loop if root cause persists
heap dump may need time/storage before exit
can hide slow memory leak if observability is weak
Prefer fail-fast with observability:
record reason
emit metrics/logs if possible
capture dump if policy allows
restart cleanly
page if repeated
17. Startup Time
Startup time in Kubernetes includes more than Java main().
image pull
container creation
JVM startup
classpath scan
DI/bootstrap
JAX-RS resource/provider registration
configuration fetch
secret access
database pool initialization
Kafka client initialization
cache warmup
readiness probe success
Startup failure can come from:
slow image pull
CPU throttling
classloading/JIT
misconfigured probes
remote dependency during startup
schema migration at startup
secret/config service latency
18. Startup Probe vs Liveness Probe
Startup probe protects slow-starting apps from premature liveness kill.
Bad setup:
liveness starts too early
JVM still warming/bootstrap
Kubernetes kills container
CrashLoopBackOff begins
Better:
startupProbe:
httpGet:
path: /health/startup
port: 8080
failureThreshold: 30
periodSeconds: 10
livenessProbe:
httpGet:
path: /health/live
port: 8080
periodSeconds: 10
readinessProbe:
httpGet:
path: /health/ready
port: 8080
periodSeconds: 5
Readiness should reflect ability to serve traffic.
Liveness should reflect whether restart helps.
Startup should reflect bootstrap completion.
19. Warmup and JIT Behavior
Java performance changes after startup.
Early requests may be slower due to:
classloading
JIT compilation
connection pool establishment
TLS handshake/cache
DNS cache
JAX-RS provider initialization
JSON serializer cache
database prepared statement cache
branch/profile collection
Warmup strategies:
startup warmup endpoint calls
synthetic internal requests
preload serializers/mappers
initialize connection pools lazily or eagerly by policy
use readiness only after minimum warmup
canary before full traffic
But warmup can backfire:
startup becomes too slow
warmup hammers dependencies
warmup masks lazy initialization bugs
readiness delayed unnecessarily
Senior principle:
Warm only what is needed to make first real traffic safe and predictable.
20. CPU Throttling and Timeout Cascades
CPU throttling can trigger false dependency failures.
Example chain:
CPU limit too low
JVM throttled during GC
request latency rises
HTTP client timeouts fire
callers retry
traffic increases
CPU pressure worsens
circuit breakers open
service appears unhealthy
This is not just performance tuning.
It is resilience engineering.
When investigating timeout spikes, check:
CPU throttling metrics
GC pause metrics
thread pool queue depth
request rate
retry rate
pod restarts
node pressure
21. Thread Pools in Containerized JVM
Thread pools must respect CPU and memory budgets.
Problem patterns:
unbounded executor
large servlet thread pool
large Kafka concurrency
large DB pool
common ForkJoinPool overuse
blocking calls inside async executor
If CPU limit is 2 cores, a 500-thread request pool does not create 500-core capacity.
It creates:
context switching
memory pressure
queueing
timeout amplification
harder debugging
Review alignment:
HTTP worker threads
DB pool size
Kafka consumer concurrency
outbound HTTP pool
executor pools
CPU limit/request
latency SLO
22. Database Pool vs Container Resources
Connection pool size affects JVM and database.
Too small:
request queueing
slow throughput
false timeout
Too large:
database overload
more memory per connection
more threads blocked
transaction contention
connection storms during rollout
Container perspective:
More request threads than DB connections can create waiting.
More DB connections than CPU can create overload.
More pods can multiply total DB connections.
Formula to review:
total_db_connections = pods * max_pool_size
Always compare with database capacity.
23. Kafka and Container Resource Interaction
Kafka clients are sensitive to resource starvation.
CPU/memory issues can cause:
poll loop delay
rebalance
consumer lag
duplicate processing
commit delay
producer timeout
batch memory pressure
Review:
consumer concurrency vs CPU
max.poll.interval.ms vs worst-case processing time
producer buffer.memory vs container memory
compression CPU cost
retry settings vs throttled CPU
A pod that is CPU-throttled may look like a bad Kafka consumer even if logic is correct.
24. File Streaming and Container Memory
Streaming endpoints can still consume memory.
Hidden buffering can happen in:
JAX-RS provider
servlet container
reverse proxy
gateway
HTTP client
cloud SDK
compression layer
logging filter
Container risks:
direct buffer growth
temp file growth
ephemeral storage exhaustion
heap pressure from accidental byte[] buffering
slow client tying up worker thread
Review:
max file size
streaming implementation
buffer size
proxy buffering
read/write timeout
client disconnect handling
ephemeral storage limit
25. Memory Requests vs Limits
Kubernetes memory request and limit are different.
request = scheduling hint/reservation
limit = hard-ish cgroup cap that can trigger OOMKill
If request is much lower than actual usage:
pod may be scheduled on crowded node
node pressure may increase eviction risk
performance may vary
If limit is too close to normal usage:
small spikes kill the pod
heap dump may fail
GC cannot recover native memory spikes
Review:
normal memory usage
peak memory usage
heap max
native headroom
ephemeral spike behavior
node pressure policy
26. Memory Leak vs Memory Growth
Not all memory growth is leak.
Possible healthy growth:
JIT warmup
class loading
cache warmup
connection pool initialization
serializer cache
thread pool startup
Leak indicators:
memory grows without bound
heap after full GC trends upward
thread count keeps increasing
direct memory grows after traffic stops
metaspace grows after redeploy/reload
cache ignores size/TTL
Evidence:
heap usage after GC
native memory summary
thread dump over time
cache metrics
GC logs
allocation profiling
27. Observability Signals for JVM Containers
Minimum useful signals:
container memory working set
container memory limit
container CPU usage
container CPU throttling
pod restart count
OOMKilled events
JVM heap used/max
JVM non-heap used
GC pause duration/count
thread count
class count/metaspace
HTTP latency/error rate
DB pool usage
Kafka lag
For alerting, avoid single-signal panic.
Better:
memory near limit + rising trend + GC pressure
CPU throttling + latency increase + retry increase
restarts + OOMKilled reason
DB pool saturation + request timeout
28. Practical Starting Point for Java 17 Service
A reasonable starting JVM option set might include:
-XX:MaxRAMPercentage=70
-XX:InitialRAMPercentage=25
-XX:+ExitOnOutOfMemoryError
-Xlog:gc*:stdout:time,level,tags
-Djava.security.egd=file:/dev/urandom
-Duser.timezone=UTC
But this is not universal.
You must verify:
container memory limit
native memory needs
thread counts
GC behavior
startup time
latency target
file streaming behavior
framework/runtime overhead
Do not copy JVM flags from another service without understanding its workload.
29. Anti-Patterns
[ ] Set -Xmx equal to container memory limit
[ ] Ignore native memory
[ ] Use huge thread pools because requests are slow
[ ] Tune GC without GC logs
[ ] Disable CPU limits or change memory limits without platform review
[ ] Treat OOMKilled as normal restart behavior
[ ] Store heap dumps in ephemeral filesystem without size/security policy
[ ] Use liveness probe as dependency health check
[ ] Mark pod ready before app can serve real traffic
[ ] Run performance tests only outside container
[ ] Assume local Docker behavior equals Kubernetes behavior
[ ] Ignore CPU throttling during timeout incidents
30. PR Review Checklist
For JVM/container changes:
[ ] Are memory request/limit changed?
[ ] Is heap sizing aligned with memory limit?
[ ] Is non-heap headroom considered?
[ ] Are JVM flags visible and documented?
[ ] Is Java version/distribution changed?
[ ] Is GC changed? Is evidence provided?
[ ] Are GC logs enabled in performance/diagnostic environments?
[ ] Is Native Memory Tracking needed or enabled?
[ ] Are CPU request/limit changed?
[ ] Is CPU throttling monitored?
[ ] Are thread pool sizes aligned with CPU/memory?
[ ] Is DB pool size aligned with pod count and DB capacity?
[ ] Is startup/readiness/liveness behavior affected?
[ ] Is warmup behavior explicit?
[ ] Is OOM handling configured?
[ ] Is heap dump path writable, sized, and secured?
[ ] Are file streaming/temp storage implications considered?
[ ] Are canary/performance results attached for tuning changes?
31. Internal Verification Checklist
For CSG Quote & Order or any internal enterprise service, verify:
[ ] Which JVM distribution is used?
[ ] Which Java version is used in runtime image?
[ ] Are JVM flags set in Dockerfile, Kubernetes manifest, Helm values, GitOps config, or platform default?
[ ] Is `JAVA_TOOL_OPTIONS` used?
[ ] Is `JDK_JAVA_OPTIONS` used?
[ ] What memory request/limit is configured per environment?
[ ] What CPU request/limit is configured per environment?
[ ] What is actual max heap at runtime?
[ ] Is heap fixed with -Xmx or percentage-based?
[ ] What non-heap headroom is expected?
[ ] Are GC logs enabled anywhere?
[ ] Which GC is used?
[ ] Are CPU throttling metrics available?
[ ] Are OOMKilled events alerted?
[ ] Are heap dumps enabled? Where do they go?
[ ] Is heap dump access controlled due to PII/secrets?
[ ] Is Native Memory Tracking allowed in production?
[ ] What is typical thread count under load?
[ ] What are HTTP worker, DB pool, Kafka consumer, and executor sizes?
[ ] Is startup probe configured?
[ ] Does readiness wait for true service readiness?
[ ] Are performance tests run in container/Kubernetes-like limits?
[ ] Are warmup or canary strategies used?
[ ] Are JVM/container dashboards available?
32. Debugging Playbooks
32.1 Pod OOMKilled
1. Confirm exit code 137/OOMKilled.
2. Check memory limit and usage trend.
3. Check max heap.
4. Check GC logs for heap pressure.
5. Check thread count and direct/native memory.
6. Check recent rollout/config change.
7. Check traffic spike, file upload, batch job, Kafka replay.
8. Check heap dump availability.
9. Decide: rollback, increase limit, reduce heap, reduce concurrency, fix leak.
32.2 High Latency with No Errors
1. Check CPU throttling.
2. Check GC pauses.
3. Check request thread saturation.
4. Check DB pool saturation.
5. Check outbound dependency latency.
6. Check retries.
7. Check canary vs baseline pods.
8. Check node pressure.
32.3 Slow Startup / CrashLoopBackOff
1. Check previous logs.
2. Check startup probe timing.
3. Check config/secret fetch.
4. Check CPU throttling during startup.
5. Check classpath scan/DI bootstrap.
6. Check remote dependency at startup.
7. Check liveness killing app before startup completes.
33. Senior-Level Heuristics
Heap is not container memory.
CPU limit is not free.
Thread count is memory and scheduling pressure.
GC tuning without evidence is superstition.
OOMKilled is platform evidence, not an application exception.
Readiness protects users; liveness protects the platform.
Warmup is part of rollout design, not only performance tuning.
Every JVM flag in production should have an owner and a reason.
A senior engineer should be able to answer:
Why is memory limit this value?
Why is heap this percentage?
How much native headroom do we expect?
What happens on OOM?
How do we get evidence?
What signal tells us CPU throttling is hurting latency?
Can this service survive canary traffic before full rollout?
34. Key Takeaways
1. Container memory limit includes heap and non-heap memory.
2. Heap sizing must leave headroom for native memory, thread stacks, metaspace, direct buffers, and JVM internals.
3. OOMKilled is external to JVM and may not produce heap dumps or Java exceptions.
4. CPU throttling can cause latency, GC delay, timeout, retry storm, and Kafka instability.
5. Java 17 GC defaults are good starting points, not a substitute for measurement.
6. Startup, readiness, liveness, warmup, and canary strategy must be aligned.
7. JVM/container tuning must be reviewed with observability evidence and rollback strategy.
The next part moves from JVM/container internals into Kubernetes workload lifecycle: Pods, Deployments, StatefulSets, Jobs, CronJobs, rollout, and termination behavior.
You just completed lesson 100 in final stretch. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.