Почему повторное распределение памяти наблюдается медленнее при использовании 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 достаточно умен, чтобы знать, что ему не нужно выделять какую-либо новую память.
Ответы
Ответ 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, и простые особенности должны вызывать предупреждение для пользователей.