Почему повторное распределение памяти наблюдается медленнее при использовании Epsilon против G1?

Мне было любопытно измерить время, потраченное на выделение памяти в JDK 13 с использованием G1 и Epsilon. Результаты, которые я наблюдал, являются неожиданными, и мне интересно понять, что происходит. В конечном счете, я хочу понять, как сделать использование Epsilon более производительным, чем G1 (или, если это невозможно, почему).

Я написал небольшой тест, который неоднократно выделяет память. В зависимости от ввода в командной строке он будет либо:

  • создать 1024 новых массива 1 МБ или
  • создайте 1024 новых массива размером 1 МБ, измерьте время, выделенное для выделения, и распечатайте прошедшее время для каждого выделения. Это измеряет не только само распределение, но включает время, потраченное на все остальное, что происходит между двумя вызовами к System.nanoTime() - тем не менее, это, кажется, полезный сигнал для прослушивания.

Вот код:

public static void main(String[] args) {
    if (args[0].equals("repeatedAllocations")) {
        repeatedAllocations();
    } else if (args[0].equals("repeatedAllocationsWithTimingAndOutput")) {
        repeatedAllocationsWithTimingAndOutput();
    }
}

private static void repeatedAllocations() {
    for (int i = 0; i < 1024; i++) {
        byte[] array = new byte[1048576]; // allocate new 1MB array
    }
}

private static void repeatedAllocationsWithTimingAndOutput() {
    for (int i = 0; i < 1024; i++) {
        long start = System.nanoTime();
        byte[] array = new byte[1048576]; // allocate new 1MB array
        long end = System.nanoTime();
        System.out.println((end - start));
    }
}

Вот информация о версии JDK, которую я использую:

$ java -version
openjdk version "13-ea" 2019-09-17
OpenJDK Runtime Environment (build 13-ea+22)
OpenJDK 64-Bit Server VM (build 13-ea+22, mixed mode, sharing)

Вот несколько способов запуска программы:

  • распределение только с использованием G1: $ time java -XX:+UseG1GC Scratch repeatedAllocations
  • только выделение, Эпсилон: $ time java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC Scratch repeatedAllocations
  • распределение + синхронизация + вывод с использованием G1: $ time java -XX:+UseG1GC Scratch repeatedAllocationsWithTimingAndOutput
  • выделение + синхронизация + выход, эпсилон: time java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC Scratch repeatedAllocationsWithTimingAndOutput

Вот некоторые моменты запуска G1 только с выделениями:

$ time java -XX:+UseG1GC Scratch repeatedAllocations
real    0m0.280s
user    0m0.404s
sys     0m0.081s

$ time java -XX:+UseG1GC Scratch repeatedAllocations
real    0m0.293s
user    0m0.415s
sys     0m0.080s

$ time java -XX:+UseG1GC Scratch repeatedAllocations
real    0m0.295s
user    0m0.422s
sys     0m0.080s

$ time java -XX:+UseG1GC Scratch repeatedAllocations
real    0m0.296s
user    0m0.422s
sys     0m0.079s

Вот некоторые моменты запуска Epsilon только с выделенными ресурсами:

$ time java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC Scratch repeatedAllocations
real    0m0.665s
user    0m0.314s
sys     0m0.373s

$ time java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC Scratch repeatedAllocations
real    0m0.652s
user    0m0.313s
sys     0m0.354s

$ time java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC Scratch repeatedAllocations
real    0m0.659s
user    0m0.314s
sys     0m0.362s

$ time java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC Scratch repeatedAllocations
real    0m0.665s
user    0m0.320s
sys     0m0.367s

С или без синхронизации + выход, G1 быстрее, чем Epsilon. В качестве дополнительного измерения, используя временные числа из repeatedAllocationsWithTimingAndOutput, среднее время выделения больше при использовании Epsilon. В частности, один из локальных прогонов показал, что G1GC в среднем составляла 227 218 нанограмм на выделение, тогда как Epsilon составляла в среднем 521 217 нанограмм (я записал выходные числа, вставил их в электронную таблицу и использовал функцию average для каждого набора чисел).

Я ожидал, что тесты Epsilon будут заметно быстрее, однако на практике я вижу примерно в 2 раза медленнее. Максимальное время выделения с G1 определенно выше, но только с перерывами - большинство распределений G1 значительно медленнее, чем у Epsilon, почти на порядок медленнее.

Вот график 1024 раза от запуска repeatedAllocationsWithTimingAndOutput() с G1 и Epsilon. Темно-зеленый - для G1; светло-зеленый для Эпсилон; Ось Y - "нанос на распределение"; Меньшие линии сетки по оси Y каждые 250000 нанос. Это показывает, что время выделения Epsilon очень стабильно, каждый раз около 300-400 тыс. Нанос. Это также показывает, что время G1 значительно быстрее в большинстве случаев, но также периодически - в 10 раз медленнее, чем у Epsilon. Я предполагаю, что это может быть связано с работой сборщика мусора, что было бы нормально и нормально, но также, похоже, сводит на нет идею, что G1 достаточно умен, чтобы знать, что ему не нужно выделять какую-либо новую память.

enter image description here

Ответы

Ответ 1

Приведенный выше комментарий @Holger объясняет ту часть, которую мне не хватало в первоначальном тесте - получение новой памяти из ОС обходится дороже, чем переработка памяти в JVM. В комментарии the8472 указывалось, что код приложения не сохраняет ссылок на какие-либо из выделенных массивов, поэтому тестирование не проверяло то, что я хотел. Изменяя тест, чтобы сохранить ссылку на каждый новый массив, результаты теперь показывают, что Epsilon превосходит G1.

Вот что я сделал в коде, чтобы сохранить ссылки. Определите это как переменную-член:

static ArrayList<byte[]> savedArrays = new ArrayList<>(1024);

затем добавьте это после каждого выделения:

savedArrays.add(array);

Эпсилон-ассигнования аналогичны ранее, что ожидается:

$ time java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC Scratch repeatedAllocations
real    0m0.587s
user    0m0.312s
sys     0m0.296s

$ time java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC Scratch repeatedAllocations
real    0m0.589s
user    0m0.313s
sys     0m0.297s

$ time java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC Scratch repeatedAllocations
real    0m0.605s
user    0m0.316s
sys     0m0.313s

G1 теперь намного медленнее, чем прежде, а также медленнее, чем Epsilon:

$ time java -XX:+UseG1GC Scratch repeatedAllocations
real    0m0.884s
user    0m1.265s
sys     0m0.538s

$ time java -XX:+UseG1GC Scratch repeatedAllocations
real    0m0.884s
user    0m1.251s
sys     0m0.533s

$ time java -XX:+UseG1GC Scratch repeatedAllocations
real    0m0.864s
user    0m1.214s
sys     0m0.528s

Повторно запуская время на выделение ресурсов с помощью repeatedAllocationsWithTimingAndOutput(), средние значения теперь совпадают с тем, что Epsilon быстрее.

average time (in nanos) for 1,024 consecutive 1MB array allocations
Epsilon 491,665
G1      883,981

Ответ 2

Я полагаю, что вы видите затраты на подключение памяти при первом доступе.

В случае с Epsilon выделения всегда достигают новой памяти, что означает, что сама ОС должна подключать физические страницы к процессу JVM. В случае G1 происходит то же самое, но после первого цикла GC он размещает объекты в уже подключенной памяти. G1 будет испытывать случайные скачки задержки, связанные с паузами GC.

Но есть особенности ОС. По крайней мере, в Linux, когда JVM (или вообще любой другой процесс) "резервирует" и "фиксирует" память, память фактически не подключена: физические страницы ей еще не назначены. Как оптимизация, Linux делает это при первом доступе записи на страницу. Кстати, эта операционная система проявляется как sys%, поэтому вы видите это во времени.

И это, пожалуй, правильная вещь для ОС, когда вы оптимизируете занимаемую площадь, например, много процессов, запущенных на машине, (pre-) выделяют много памяти, но вряд ли ее используют. Это произойдет, скажем, с -Xms4g -Xmx4g: ОС с радостью сообщит, что все 4G "зафиксированы", но пока ничего не произойдет, пока JVM не начнет писать там.

Все это приводит к этой странной уловке: pre- касание всей памяти кучи в JVM начинается с -XX:+AlwaysPreTouch (обратите внимание head, это самые первые образцы):

$ java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -Xms4g -Xmx4g \
       Scratch repeatedAllocationsWithTimingAndOutput | head
491988
507983
495899
492679
485147

$ java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -XX:+AlwaysPreTouch -Xms4g -Xmx4g \
       Scratch repeatedAllocationsWithTimingAndOutput | head
45186
42242
42966
49323
42093

И здесь, из-за стандартного запуска Epsilon выглядит хуже, чем G1 (обратите внимание tail, это самые последние образцы):

$ java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -Xms4g -Xmx4g \
       Scratch repeatedAllocationsWithTimingAndOutput | tail
389255
386474
392593
387604
391383

$ java -XX:+UseG1GC -Xms4g -Xmx4g \
  Scratch repeatedAllocationsWithTimingAndOutput | tail
72150
74065
73582
73371
71889

... но это изменится, как только подключение памяти закончится (обратите внимание tail, это самые последние образцы):

$ java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -XX:+AlwaysPreTouch -Xms4g -Xmx4g \
       Scratch repeatedAllocationsWithTimingAndOutput | tail
42636
44798
42065
44948
42297

$ java -XX:+UseG1GC -XX:+AlwaysPreTouch -Xms4g -Xmx4g \
        Scratch repeatedAllocationsWithTimingAndOutput | tail
52158
51490
45602
46724
43752

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

В целом, именно поэтому -XX:+AlwaysPreTouch является рекомендуемым вариантом для рабочих нагрузок с малой задержкой и высокой пропускной способностью, которые могут принимать предварительную стоимость запуска и предварительную оплату RSS.

UPD: Если подумать, это ошибка Epsilon UX, и простые особенности должны вызывать предупреждение для пользователей.