Why is my JVM's max heap size less than -Xmx?
TL;DR
Because Max heap size = -Xmx - max possible Survivor Space size
(with Parallel Garbage Collector, one of the two Survivor Spaces always needs to be empty, so objects can be copied into it during GC). The same question is also answered more concisely in this Stack Overflow post.
How we got here
Some time ago at work, I was looking into the memory usage of our JVM-based application. As I scrolled through the GC logs, I noticed that its max heap size was lower than what we specified with -Xmx
. “Strange,” I thought, blissfully unaware of the rabbit hole that I was about to enter. Fast forward through numerous Google searches, blogs, documentations, and even the JDK source code, I now finally know why, and wanted to share what I found.
Below is the code we will use to understand JVM’s memory division:
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
public class Memory {
public static void main(String[] args) {
MemoryMXBean heapMB = ManagementFactory.getMemoryMXBean();
System.out.println("Heap: "
+ "max " + heapMB.getHeapMemoryUsage().getMax());
ManagementFactory.getMemoryPoolMXBeans().forEach(mb -> {
System.out.println(mb.getName() + ": "
+ "max " + mb.getUsage().getMax());
});
}
}
We run it on Java 8 using 1 GB of memory and otherwise default settings:
$ javac Memory.java
$ java -Xmx1g Memory
Heap: max 954728448
Code Cache: max 251658240
Metaspace: max -1
Compressed Class Space: max 1073741824
PS Eden Space: max 313524224
PS Survivor Space: max 22020096
PS Old Gen: max 716177408
As we can see from the first line in the output, the max heap size is 954728448
bytes, which is smaller than 1 GB (1073741824
bytes). Why is that?
Reason 0: GC Algorithm
Before we dive into the calculations, it’s important to note that because we are running Java 8, Parallel Garbage Collector is used by default. And since the GC algorithm decides how to divide up memory, different collectors could show different results. For example, G1 Garbage Collector, the default algorithm starting from Java 9, does not have our aforementioned issue:
$ java -Xmx1g -XX:+UseG1GC Memory
Heap: max 1073741824
Code Cache: max 251658240
Metaspace: max -1
Compressed Class Space: max 1073741824
G1 Eden Space: max -1
G1 Survivor Space: max -1
G1 Old Gen: max 1073741824
The first line actually shows max 1073741824
, equaling -Xmx1g
.
Reason 1: Survivor Space
To understand why the math works out the way it does for Parallel GC, let’s first look at a simpler example, by setting the initial JVM memory also to 1 GB:
$ java -Xmx1g -Xms1g Memory
Heap: max 1029177344
Code Cache: max 251658240
Metaspace: max -1
Compressed Class Space: max 1073741824
PS Eden Space: max 268435456
PS Survivor Space: max 44564480
PS Old Gen: max 716177408
Here, the max heap size is 1029177344
bytes, which is 44564480
bytes fewer than 1 GB. 44564480
also happens to be the max Survivor Space size from the second last line.
In fact, this is not a coincidence. Here are some good links explaining the terminology, and how Java’s memory model works, but in short:
- GC algorithm divides the heap into 2 regions, Young Generation and Old Generation
- Young Generation is further divided into Eden and Survivor Spaces, while Old Generation is mainly comprised of Tenured Space
- Objects are initially allocated in Eden Space; once it becomes full, a Minor GC is triggered, during which the GC algorithm copies live objects from Eden to Survivor Space
- Also during Minor GC, live objects from Survivor Space are either copied to another Survivor Space, or to the Tenured Space, depending on how many Minor GCs they have “survived”
To do step 4, Parallel GC allocates two equal-sized Survivor Spaces, and always keeps one of them empty. The empty Survivor Space cannot be used in the heap, thus reducing the max heap size by that amount. (On the other hand, G1 GC manages its Survivor Spaces differently, and therefore doesn’t have this requirement.)
Reason 2: Adaptive Sizing
The previous section on Survivor Space makes sense, but doesn’t explain why the numbers are different when we don’t set -Xms
. As it turns out, with Parallel GC, another default flag comes to play: -XX:+UseAdaptiveSizePolicy
. As the name implies, it enables Adaptive Size Policy, which essentially changes the heap size, as well as space boundaries to meet the JVM’s goals for latency, throughput, and memory footprint (more details here and here).
To verify, let’s try turning off this feature:
$ java -Xmx1g -XX:-UseAdaptiveSizePolicy Memory
Heap: max 1051721728
Code Cache: max 251658240
Metaspace: max -1
Compressed Class Space: max 1073741824
PS Eden Space: max 313524224
PS Survivor Space: max 22020096
PS Old Gen: max 716177408
1051721728 + 22020096 = 1073741824
. Math is beautiful.
When not set, my JVM picks a -Xms
smaller than 1 GB, and calculates the Survivor Space size accordingly. But since the Survivor Space size can be changed by Adaptive Size Policy, the max heap size calculation needs to take into account the max possible size of Survivor Space.
Reason 3: Alignment
We are almost ready to go through the full breakdown of the numbers. The final piece of the puzzle is a detail that I was only able to figure out by reading the JDK source code: alignment. Throughout the calculations, GC algorithms try to divide memory into increments of an alignment size. Empirically on my system, the alignment size is 512 KB (524288
bytes).
The math
Finally, what happens when we run our original command:
$ java -Xmx1g Memory
Heap: max 954728448
Code Cache: max 251658240
Metaspace: max -1
Compressed Class Space: max 1073741824
PS Eden Space: max 313524224
PS Survivor Space: max 22020096
PS Old Gen: max 716177408
-Xmx
is divided according to the default-XX:NewRatio=2
, the ratio between Old Generation and Young Generation:1073741824 / (2 + 1) = 357913941.33
- The result is rounded down to 512 KB alignment, and becomes the size of Young Generation:
357564416
- This means the size of Old Generation =
1073741824 - 357564416 = 716177408
, corresponding to the last line of the output
- The result is rounded down to 512 KB alignment, and becomes the size of Young Generation:
- Adaptive Size Policy is enabled by default, so to account for the max possible Survivor Space, we divide Young Generation by the default
-XX:MinSurvivorRatio=3
(confusingly, this is not a ratio like NewRatio, but rather a proportion):357564416 / 3 = 119188138.67
- The result is again rounded down to 512 KB alignment, and becomes the max possible Survivor Space:
119013376
- The result is again rounded down to 512 KB alignment, and becomes the max possible Survivor Space:
- The max heap is calculated by subtracting max possible Survivor Space from
-Xmx
:1073741824 - 119013376 = 954728448
954728448
is exactly the max heap printed, hooray.
Loose ends
We see that the printed max Survivor Space, 22020096
, is a lot less than the max possible Survivor Space, 119013376
. This is because we arrive at that number rather differently:
- Since we didn’t set
-Xms
, the JVM on my system initially allocates512 MB
of memory, which means Young Generation is actuallyalign_512KB(512 MB / (NewRatio + 1)) = 178782208
bytes - The initial max Survivor Space used by Adaptive Size Policy is calculated by dividing Young Generation according to the default
-XX:InitialSurvivorRatio=8
(again, more like a proportion):align_512KB(178782208 / 8) = 22020096
And 22020096
bytes is what the JVM has as the initial Survivor Space size.
Also, why are some “ratios” actually ratios, while some others are not? The best clue I found is from this article documenting a behavior change in JDK6: users are expected to configure -XX:SurvivorRatio
, which is a ratio. Then for Parallel GC, if the flag is set, the algorithm adds 2
(recall that there are 2 Survivor Spaces) to -XX:SurvivorRatio
to configure -XX:InitialSurvivorRatio
and -XX:MinSurvivorRatio
. My guess is that since the latter flags are not often set directly by users, the name “ratio” is used throughout for consistency.
Discussion
Does this information have any practical use? Probably not, considering how Java 8 was released in 2014, and that newer versions of JVMs default to the G1 Garbage Collector, which in most cases works better. Still, sometimes when you are nerd sniped, and find yourself pounding the table at 3 am wondering why nothing makes sense, these reasonings often cease to matter.
Discuss this post on HN.