Статьи

Почему моя JVM имеет доступ к меньшему количеству памяти, чем указано через -Xmx?

«Эй, можешь зайти и взглянуть на что-нибудь странное». Вот как я начал изучать случай поддержки, ведущий меня к этому сообщению в блоге. Конкретная проблема была связана с тем, что разные инструменты сообщали разные цифры о доступной памяти.

Короче говоря, один из инженеров исследовал чрезмерное использование памяти конкретным приложением, которому, по его сведениям, было предоставлено 2G кучи для работы. Но по какой-то причине инструмент JVM, похоже, не решил, сколько памяти на самом деле имеет процесс. Например, jconsole предположил, что общая доступная куча равна 1 963M, а jvisualvm утверждает, что она равна 2 048M. Итак, какой из инструментов был правильным и почему другой отображал другую информацию?

Это было действительно странно, особенно с учетом того, что обычные подозреваемые были устранены – JVM не использовала никаких очевидных уловок:

  • -Xmx и -Xms были равны, так что сообщенные числа не были изменены во время увеличения кучи во время выполнения
  • JVM не удалось динамически изменить размер пулов памяти, отключив политику адаптивного определения размера ( -XX: -UseAdaptiveSizePolicy )

Воспроизведение разницы

Первым шагом к пониманию проблемы было приближение к реализации инструментов. Доступ к информации о доступной памяти через стандартные API-интерфейсы так же прост:

1
System.out.println("Runtime.getRuntime().maxMemory()="+Runtime.getRuntime().maxMemory());

И действительно, это было то, что, казалось, использовал инструмент под рукой. Первый шаг к получению ответа на такой вопрос – создание воспроизводимого контрольного примера. Для этого я написал следующий фрагмент:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package eu.plumbr.test;
//imports skipped for brevity
 
public class HeapSizeDifferences {
 
  static Collection<Object> objects = new ArrayList<Object>();
  static long lastMaxMemory = 0;
 
  public static void main(String[] args) {
    try {
      List<String> inputArguments = ManagementFactory.getRuntimeMXBean().getInputArguments();
      System.out.println("Running with: " + inputArguments);
      while (true) {
        printMaxMemory();
        consumeSpace();
      }
    } catch (OutOfMemoryError e) {
      freeSpace();
      printMaxMemory();
    }
  }
 
  static void printMaxMemory() {
    long currentMaxMemory = Runtime.getRuntime().maxMemory();
    if (currentMaxMemory != lastMaxMemory) {
      lastMaxMemory = currentMaxMemory;
      System.out.format("Runtime.getRuntime().maxMemory(): %,dK.%n", currentMaxMemory / 1024);
    }
  }
 
  static void consumeSpace() {
    objects.add(new int[1_000_000]);
  }
 
  static void freeSpace() {
    objects.clear();
  }
}

Код распределяет порции памяти через новый int [1_000_000] в цикле и проверяет память, которая в настоящее время известна как доступная для среды выполнения JVM. Всякий раз, когда он обнаруживает изменение последнего известного размера памяти, он сообщает об этом, печатая выходные данные Runtime.getRuntime (). MaxMemory (), подобные следующим:

1
2
Running with: [-Xms2048M, -Xmx2048M]
Runtime.getRuntime().maxMemory(): 2,010,112K.

Действительно – хотя я и указал JVM для использования 2G кучи, среда выполнения как-то не может найти 85M . Вы можете перепроверить мою математику, конвертировав вывод Runtime.getRuntime (). MaxMemory () в МБ, разделив 2,010,112K на 1024. Результат, который вы получите, равен 1,963M, дифференцируясь от 2048M ровно на 85M.

Нахождение первопричины

После того, как я смог воспроизвести этот случай, я обратил внимание на следующее: работа с разными алгоритмами GC также, похоже, дает разные результаты:

Алгоритм GC Runtime.getRuntime (). MaxMemory ()
-XX: + UseSerialGC 2,027,264K
-XX: + UseParallelGC 2,010,112K
-XX: + UseConcMarkSweepGC 2,063,104K
-XX: + UseG1GC 2,097,152K

Помимо G1, который потребляет ровно 2G, который я дал процессу, каждый другой алгоритм GC, казалось, постоянно терял полуслучайный объем памяти.

Теперь пришло время покопаться в исходном коде JVM, где в исходном коде CollectedHeap я обнаружил следующее:

1
2
3
4
5
6
7
// Support for java.lang.Runtime.maxMemory():  return the maximum amount of
// memory that the vm could make available for storing 'normal' java objects.
// This is based on the reserved address space, but should not include space
// that the vm uses internally for bookkeeping or temporary storage
// (e.g., in the case of the young gen, one of the survivor
// spaces).
virtual size_t max_capacity() const = 0;

Ответ был довольно хорошо скрыт, я должен это признать. Но намек все еще оставался на виду для действительно любопытных умов – ссылаясь на тот факт, что в некоторых случаях одно из пространств выживших может быть исключено из вычислений размера кучи .

Java-куча-PermGen-разные-памяти бассейны

Отсюда все пути были попутными – включение регистрации GC обнаружило, что действительно, с кучей 2G алгоритмы Serial, Parallel и CMS все устанавливают размеры пространств оставшихся в живых с точностью, равной отсутствующей разнице. Например, в приведенном выше примере ParallelGC регистрация GC продемонстрировала следующее:

01
02
03
04
05
06
07
08
09
10
Running with: [-Xms2g, -Xmx2g, -XX:+UseParallelGC, -XX:+PrintGCDetails]
Runtime.getRuntime().maxMemory(): 2,010,112K.
 
... rest of the GC log skipped for brevity ...
 
 PSYoungGen      total 611840K, used 524800K [0x0000000795580000, 0x00000007c0000000, 0x00000007c0000000)
  eden space 524800K, 100% used [0x0000000795580000,0x00000007b5600000,0x00000007b5600000)
  from space 87040K, 0% used [0x00000007bab00000,0x00000007bab00000,0x00000007c0000000)
  to   space 87040K, 0% used [0x00000007b5600000,0x00000007b5600000,0x00000007bab00000)
 ParOldGen       total 1398272K, used 1394966K [0x0000000740000000, 0x0000000795580000, 0x0000000795580000)

из этого вы можете видеть, что пространство Eden установлено на 524 800 КБ, оба пространства выживших (от и до) установлены на 87 040 К, а Старое пространство имеет размер 1 398 272 КБ. Суммарное сложение Eden, Old и одного из пространств выживших составляет ровно 2 010 112 K, подтверждая, что недостающие 85M или 87 040K действительно были оставшимся пространством Survivor .

Резюме

После прочтения поста у вас появилось новое понимание деталей реализации Java API. В следующий раз, когда некоторые инструменты визуализируют, что общий доступный размер кучи будет немного меньше, чем указанный в Xmx размер кучи, вы узнаете, что разница равна размеру одного из ваших пространств Survivor.

Я должен признать, что этот факт не особенно полезен в повседневной деятельности по программированию, но это не было темой для поста. Вместо этого я написал пост, описывающий особую характеристику, которую я всегда ищу в хороших инженерах – любопытство . Хорошие инженеры всегда стремятся понять, как и почему что-то работает так, как оно работает. Иногда ответ остается скрытым, но я все же рекомендую вам попытаться найти ответы. В конце концов знания, накопленные на этом пути, начнут приносить дивиденды.