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:

  1. GC algorithm divides the heap into 2 regions, Young Generation and Old Generation
  2. Young Generation is further divided into Eden and Survivor Spaces, while Old Generation is mainly comprised of Tenured Space
  3. 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
  4. 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
  1. -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
  2. 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
  3. 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:

  1. Since we didn’t set -Xms, the JVM on my system initially allocates 512 MB of memory, which means Young Generation is actually align_512KB(512 MB / (NewRatio + 1)) = 178782208 bytes
  2. 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.