Intro
It’s not a secret that JVM is an extremly complex execution environment. It has many options that can be set to tune the application execution. What happens if we don’t set any of them or we pick only few? In this article I will explain what is Java ergonomics and how it works to supply default JVM options. I will also tell why it’s never safe to rely on the defaults.
Problem
Most of the time when we setup JAVA_OPTS we are doing it to tune the application performance. What happens when we don’t set any of them? Java ergonomics comes to the rescue.
Java ergonomics
By Oracle:
Ergonomics is the process by which the Java Virtual Machine (JVM) and garbage collection heuristics, such as behavior-based heuristics, improve application performance.
Java ergonomics checks existing system resources and sets the JVM options accordingly. It can set the heap size, garbage collector, and other options based on the available memory, CPU, and other resources.
How is Ergonomics deciding on which Garbage Collector to pick?
Our JVM (jdk17) has six garbage collectors:
- SerialGC
- ParallelGC
- G1GC
- ZGC
- ShenandoahGC
- EpsilonGC.
It’s worth to know that from above list only two GC’s are picked ergonomics:
- SerialGC
- G1GC
How is Ergonomics deciding on which Garbage Collector to pick? Let’s find out!
We will use PrintFinalFlags to see what options are set.
docker run --memory=1GB --cpus="1.0" azul/zulu-openjdk-alpine:17-latest java -XX:+UnlockDiagnosticVMOptions -XX:+PrintFlagsFinal -version
Of course that produces a lot of output, most of them are static defaults, so we will narrow the results to ones that are java ergonomics changes:
docker run --memory=1GB --cpus="1.0" azul/zulu-openjdk-alpine:17-latest java -XX:+UnlockDiagnosticVMOptions -XX:+PrintFlagsFinal -version | grep ergonomic
Here are JVM options adjustments done by java ergonomics for 1GB of RAM and 1 CPU:
intx CICompilerCount = 2 {product} {ergonomic}
size_t InitialHeapSize = 16777216 {product} {ergonomic}
size_t MaxHeapSize = 268435456 {product} {ergonomic}
size_t MaxNewSize = 89456640 {product} {ergonomic}
size_t MinHeapDeltaBytes = 196608 {product} {ergonomic}
size_t MinHeapSize = 8388608 {product} {ergonomic}
size_t NewSize = 5570560 {product} {ergonomic}
uintx NonNMethodCodeHeapSize = 5826188 {pd product} {ergonomic}
uintx NonProfiledCodeHeapSize = 122916026 {pd product} {ergonomic}
size_t OldSize = 11206656 {product} {ergonomic}
uintx ProfiledCodeHeapSize = 122916026 {pd product} {ergonomic}
uintx ReservedCodeCacheSize = 251658240 {pd product} {ergonomic}
bool SegmentedCodeCache = true {product} {ergonomic}
size_t SoftMaxHeapSize = 268435456 {manageable} {ergonomic}
bool THPStackMitigation = false {diagnostic} {ergonomic}
bool UseCompressedClassPointers = true {product lp64_product} {ergonomic}
bool UseCompressedOops = true {product lp64_product} {ergonomic}
bool UseSerialGC = true {product} {ergonomic}
Among those changes we can spot SerialGC being picked by java ergonomics.
In short - G1GC is used when:
- available operational memory is 1792 MB or more
- there is more then one CPU cores available
otherwise SerialGC is picked.
Using below specs will make JVM to pick G1GC:
docker run --memory=1792MB --cpus="2.0" azul/zulu-openjdk-alpine:17-latest java -XX:+UnlockDiagnosticVMOptions -XX:+PrintFlagsFinal -version | grep ergonomic
intx CICompilerCount = 2 {product} {ergonomic}
uint ConcGCThreads = 1 {product} {ergonomic}
uint G1ConcRefinementThreads = 2 {product} {ergonomic}
size_t G1HeapRegionSize = 1048576 {product} {ergonomic}
uintx GCDrainStackTargetSize = 64 {product} {ergonomic}
size_t InitialHeapSize = 29360128 {product} {ergonomic}
size_t MarkStackSize = 4194304 {product} {ergonomic}
size_t MaxHeapSize = 469762048 {product} {ergonomic}
size_t MaxNewSize = 281018368 {product} {ergonomic}
size_t MinHeapDeltaBytes = 1048576 {product} {ergonomic}
size_t MinHeapSize = 8388608 {product} {ergonomic}
uintx NonNMethodCodeHeapSize = 5826188 {pd product} {ergonomic}
uintx NonProfiledCodeHeapSize = 122916026 {pd product} {ergonomic}
uintx ProfiledCodeHeapSize = 122916026 {pd product} {ergonomic}
uintx ReservedCodeCacheSize = 251658240 {pd product} {ergonomic}
bool SegmentedCodeCache = true {product} {ergonomic}
size_t SoftMaxHeapSize = 469762048 {manageable} {ergonomic}
bool THPStackMitigation = false {diagnostic} {ergonomic}
bool UseCompressedClassPointers = true {product lp64_product} {ergonomic}
bool UseCompressedOops = true {product lp64_product} {ergonomic}
bool UseG1GC = true {product} {ergonomic}
Why this magic number?
Java picks G1GC when your machine is considered a server-class machine. The threshold of 1792MB is based on the assumption that machines with more than 2GB of RAM qualify as server-class. As often some RAM can be reserved by OS for integrated GPU - that’s how this magic number was born.
In case you would like to see the implementation - it’s here.
How is my default heap size calculated?
Again we could use brute-force method and execute above docker command few times with different params, but I try to shorten that process and present how it looks below.
Ergonomics is calculating the heap size based on the available memory. The process is however nonlinear. There are three different formulas used to calculate the heap size:
- for machines with RAM less then 256 MB, max heap size is 1/2 of RAM
- for machines with RAM more then 256 MB but less then 512 MB, max heap size is 127 MB
- for machines with RAM more then 512 MB, max heap size is 1/4 of RAM
Why it’s not safe to use defaults?
Let’s say you have 2GB RAM and 2 CPU VPS instance.
docker run --memory=2G --cpus="2.0" azul/zulu-openjdk-alpine:17-latest java -XX:+UnlockDiagnosticVMOptions -XX:+PrintFlagsFinal -version | grep ergonomic
intx CICompilerCount = 2 {product} {ergonomic}
uint ConcGCThreads = 1 {product} {ergonomic}
uint G1ConcRefinementThreads = 2 {product} {ergonomic}
size_t G1HeapRegionSize = 1048576 {product} {ergonomic}
uintx GCDrainStackTargetSize = 64 {product} {ergonomic}
size_t InitialHeapSize = 33554432 {product} {ergonomic}
size_t MarkStackSize = 4194304 {product} {ergonomic}
size_t MaxHeapSize = 536870912 {product} {ergonomic}
size_t MaxNewSize = 321912832 {product} {ergonomic}
size_t MinHeapDeltaBytes = 1048576 {product} {ergonomic}
size_t MinHeapSize = 8388608 {product} {ergonomic}
uintx NonNMethodCodeHeapSize = 5826188 {pd product} {ergonomic}
uintx NonProfiledCodeHeapSize = 122916026 {pd product} {ergonomic}
uintx ProfiledCodeHeapSize = 122916026 {pd product} {ergonomic}
uintx ReservedCodeCacheSize = 251658240 {pd product} {ergonomic}
bool SegmentedCodeCache = true {product} {ergonomic}
size_t SoftMaxHeapSize = 536870912 {manageable} {ergonomic}
bool THPStackMitigation = false {diagnostic} {ergonomic}
bool UseCompressedClassPointers = true {product lp64_product} {ergonomic}
bool UseCompressedOops = true {product lp64_product} {ergonomic}
bool UseG1GC = true {product} {ergonomic}
Ergonomics will suggest using G1GC, but max heap size will be setup to only 512MB. Does it sound good for you? That might be correct if we run JVM on our local machine as we still want to have some room for other applications. However, if we run JVM on a dedicated server / kubernates pod, we might want to use all available resources, as we don’t plan to share them with other applications.
In this case you might want to set:
- -XX:MaxRAMPercentage=80.0
- -XX:InitialRAMPercentage=80.0
This will assign 80% of available OS memory to the heap. From my experience it’s good factor to start with, as one needs to remember that JVM is not only heap memory, but also metaspace, code cache, thread stacks, etc.
Let’s see that in action:
docker run --memory=2G --cpus="2.0" azul/zulu-openjdk-alpine:17-latest java -XX:+UnlockDiagnosticVMOptions -XX:+PrintFlagsFinal -XX:InitialRAMPercentage=80.0 -XX:MaxRAMPercentage=80.0 -version | grep -E "HeapSize"
size_t ErgoHeapSizeLimit = 0 {product} {default}
size_t HeapSizePerGCThread = 43620760 {product} {default}
size_t InitialHeapSize = 1719664640 {product} {ergonomic}
size_t LargePageHeapSizeThreshold = 134217728 {product} {default}
size_t MaxHeapSize = 1719664640 {product} {ergonomic}
size_t MinHeapSize = 8388608 {product} {ergonomic}
uintx NonNMethodCodeHeapSize = 5826188 {pd product} {ergonomic}
uintx NonProfiledCodeHeapSize = 122916026 {pd product} {ergonomic}
uintx ProfiledCodeHeapSize = 122916026 {pd product} {ergonomic}
size_t SoftMaxHeapSize = 1719664640 {manageable} {ergonomic}
In our case the resulting Xms and Xmx are ~1.7GB. Operating on percentages is a good practice as it allows to scale the application without the need to change the JVM options.
Note
I had a case in my previous job where, for some reason, JAVA_OPTS were accidentally not set up on one specific Kubernetes cluster, and applications started to behave strangely. I quickly found out that due to this regression, our monolithic backend was using only 1GB of heap size.
Summary
JVM has a lot of options that need some default values. While Java ergonomics strives to set reasonable defaults, they may not always align with your application’s needs. Always customize JVM options based on your unique circumstances, and never hesitate to override the defaults when necessary.
comments powered by Disqus