Основные принципы настройки Garbage Collection с нуля

В данной статье я бы не хотел заострять внимание на принципе работы сборщика мусора — об этом прекрасно и наглядно описано здесь: habrahabr.ru/post/112676. Хочется больше перейти к практическим основам и количественным характеристикам по настройке Garbage Collection в JVM — и попытаться понять насколько это может быть эффективным.

Количественные характеристики оценки эффективности GC


Рассмотрим следующие показатели:

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


Как правило, перечисленные характеристики являются компромиссными и улучшение одной из них ведёт к затратам по остальным. Для большинства приложений важны все три характеристики, но зачастую одна или две имеют большее значение для приложения — это и будет отправной точкой в настройке.

Основные принципы настройки GC



Рассматривают три основных фундаментальных правила по пониманию настройки GC:
  • Необходимо стремиться к тому, чтобы максимальное количество объектов очищалось при работе малого GC(minor grabage collection). Этот принцип позволяет уменьшить число и частоту работы полного сборщика мусора(full garbage collection) — чья работа является основной причиной больших задержек в приложении
  • Чем больше памяти выделено приложению, тем лучше работает сборка мусора и тем лучше достигаются количественные характеристики по пропускной способности и времени отклика
  • Эффективно настроить можно только 2 из 3 количественных характеристик — пропускная способность, время отклика, размер выделенной памяти — под эффективным значением размера необходимой памяти понимается её минимизация


Рассмотрим пример простого приложения(которое, к примеру, может эмулировать работу вэб-приложения, в ходе которого идёт обращение к БД и накопление возвращаемого результат), в котором в несколько потоков идёт обращение к методу makeObjects(), в ходе которого в цикле непрерывно формируется объект, занимающий определённый объём в куче, затем с ним происходят какие-либо вычисления — делается задержка, ссылка на объект при этом не утекает из метода и по его завершению GC может понять, что данный объект подлежит очистке.
package ru.skuptsov;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class MemoryConsumer implements Runnable {

	private static final int OBJECT_SIZE = 1024 * 1024;
	private static final int OBJECTS_NUMBER = 8;
	private static final int ADD_PROCESS_TIME = 1000;
	private static final int NUMBER_OF_REQUEST_THREADS = 50;
	private static final long EXPERIMENT_TIME = 30000;
	private static volatile boolean stop = false;

	public static void main(String[] args) throws InterruptedException {

		start();
		Thread.sleep(EXPERIMENT_TIME);
		stop();
	}

	private static void start() {
		ExecutorService execService = Executors.newCachedThreadPool();
		for (int i = 0; i < NUMBER_OF_REQUEST_THREADS; i++)
			execService.execute(new MemoryConsumer());
	}

	private static void stop() {
		stop = true;

	}

	@Override
	public void run() {
		while (true && !stop) {
			makeObjects();
		}

	}

	private void makeObjects() {
		List<byte[]> objectList = new ArrayList<byte[]>();
		for (int i = 0; i < OBJECTS_NUMBER; i++) {
			objectList.add(new byte[OBJECT_SIZE]);
		}

		try {
			Thread.sleep(ADD_PROCESS_TIME);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}

	}
}


Эксперимент длится некоторое время, далее для оценки эффективности будем использовать общее время задержки, вызванное сборщиком мусора. Задержка необходима для того, чтобы после финальной маркировки объектов на удаление не появилась ссылка на очищаемый объект. О том, что существует jvm, которая может помечать и очищать объекты не вызывая «stop-the-world» паузу и как функционируют различные типы GC — подробно описано здесь habrahabr.ru/post/148322 — мы не рассматриваем такой вариант.

Запускать эксперимент мы будем на:
C:\>java -XX:+PrintCommandLineFlags -version
-XX:MaxHeapSize=4290607104 -XX:ParallelGCThreads=8 -XX:+PrintCommandLineFlags -XX:-UseLargePagesIndividualAllocation -XX:+UseParallelGC
java version "1.6.0_16"
Java(TM) SE Runtime Environment (build 1.6.0_16-b01)
Java HotSpot(TM) 64-Bit Server VM (build 14.2-b01, mixed mode)

Для которого по умолчанию включен режим — server и UseParallelGC(многопоточная работа фазы малой сборки мусора)

Для оценки общей величины паузы сборщика мусора можно запускать в режиме:
java -XX:+PrintGCTimeStamps -XX:+PrintGCDetails -verbose:gc -Xloggc:gc.log ru.skuptsov.MemoryConsumer

И суммировать задержку по логу gc.log:
0.167: [Full GC [PSYoungGen: 21792K->13324K(152896K)] [PSOldGen: 341095K->349363K(349568K)] 362888K->362687K(502464K) [PSPermGen: 2581K->2581K(21248K)], 0.0079385 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]

Где real=0.01 secs — реальное время, затраченное на сборку.

А можно воспользоваться утилитой VisualVm, с установленным плагином VisualGC, в котором наглядно можно наблюдать распределение памяти по различным областям GC(Eden, Survivor1, Survivor2, Old) и видеть статистику по запуску и длительности сборки мусора.

Определение размера необходимой памяти


Для начала мы должны запустить приложение с возможно большим размером памяти, чем это это реально необходимо приложению. Если мы не знаем изначально, сколько будет занимать наше приложение в памяти — можно запустить приложение без указания -Xmx и -Xms и HotSpot VM сама выберет размер памяти. Если при старте приложения мы получим OutOfMemory(Java heap space или PermGen space), то мы можем итеративно увеличивать размер доступной памяти(-Xmx или -XX:PermSize) до тех пор пока ошибки не уйдут.
Следующим шагом будет вычисление размера долго-живущих живых данных — это размер old и permanent областей кучи после фазы полной сборки мусора. Этот размер — примерный объём памяти, необходимый для функционирования приложения, для его получения можно посмотреть на размер областей после серии полной сборки. Как правило размер необходимой памяти для приложения -Xms и -Xmx в 3-4 раза больше, чем объём живых данных. Так, для лога, указанного выше — величина old области после фазы полной сборки мусора — 349363K. Тогда предлагаемое значение -Xmx и -Xms ~ 1400 Мб. -XX:PermSize and -XX:MaxPermSize — в 1.5 раз больше, чем PermGenSize после фазы полной сборки мусора — 13324K ~ 20 Мб. Размер young generation принимаю равным 1-1.5 размера объёма живых данных ~ 525 Мб. Тогда получаем строку запуска jvm с такими параметрами:

java -Xms1400m -Xmx1400m -Xmn525m -XX:PermSize=20m ru.skuptsov.MemoryConsumer


В VisualVm получаем такую картину:



Всего за 30 сек эксперимента было произведено 54 сборки — 31 малых и 23 полных — с общим временем остановки 3,227c. Данная величина задержки может не удовлетворять необходимым требованиям — посмотрим, сможем ли мы улучшить ситуацию без изменения кода приложения.

Настройка допустимого времени отклика


Следующие параметры необходимо замерять и учитывать при настройке времени отклика:
  • Измерение длительности малой сборки мусора
  • Измерение частоты малой сборки мусора
  • Измерение длительности худшего случая полной сборки мусора
  • Измерение частоты худшего случая полной сборки мусора


Корректировка размера young и old generation

Время, необходимое для осуществления фазы малой сборки мусора, напрямую зависит от числа объектов в young generation, чем меньше его размер — тем меньше длительность, но при этом возрастает частота, т.к. область начинает чаще заполняться. Попробуем уменьшить время каждой малой сборки, уменьшив размер young generation, сохранив при этом размер old generation. Примерно можно оценить, что каждую секунду мы должны очищать в young generation 50потоков*8объектов*1Мб~ 400Мб. Запустим с параметрами:

java -Xms1275m -Xmx1275m -Xmn400m -XX:PermSize=20m ru.skuptsov.MemoryConsumer


В VisualVm получаем такую картину:



На общее время работы малой сборки мусора мы повлиять не смогли — 1,533с — увеличилась частота малых сборок, но общее время ухудшилось — 3,661 из-за того, что увеличилась скорость заполнения old generation и увеличилась частота вызова полной сборки мусора. Чтобы побороть это — попробуем увеличить размер old generation — запустим jvm с параметрами:

java -Xms1400m -Xmx1400m -Xmn400m -XX:PermSize=20m ru.skuptsov.MemoryConsumer




Общая пауза теперь улучшилась и составляет 2,637 с а общее значение необходимой для приложения памяти при этом уменьшилось — таким образом итеративно можно найти правильный баланс между old и young generation для распределения времени жизни объектов в конкретном приложении.

Если время задержки по-прежнему нас не устраивает — можно перейти к concurrent garbage collector, включив опцию -XX:+UseConcMarkSweepGC — алгоритм, который будет пытаться выполнять основную работу по маркировке объектов на удаление в отдельном потоке параллельно потокам приложения.

Настройка Concurrent garbage collector

ConcMarkSweep GC требует более внимательной настройки, — одной из основных целей является уменьшение количества stop-the-world пауз при отсутствии достаточного места в old generation для расположения объектов — т.к. эта фаза занимает в среднем больше времени, чем фаза полной сборки мусора при throughput GC. Как результат — может увеличиться длительность худшего случая сборки мусора, необходимо избегать частых переполнений old generation. Как правило, — при переходе на ConcMarkSweep GC рекомендуют увеличить размер old generation на 20-30% — запустим jvm с параметрами:

java -Xms1680m -Xmx1680m -Xmn400m -XX:+UseConcMarkSweepGC -XX:PermSize=20m ru.skuptsov.MemoryConsumer




Общая пауза сократилась до 1,923 с.

Корректировка размера survivor

Снизу под графиком вы видите распределение объёма памяти приложения по числу переходов между стадиями Eden, Survivor1 и Survivor2 перед тем как они попадут в Old Generation. Дело в том, что один из способов уменьшения числа переполнений old generation в ConcMarkSweep GC — предотвратить прямое перетекание объектов из young generation напрямую в old — минуя survivor области.

Для слежения за распределением объектов по этапам можно запустить jvm с параметром -XX:+PrintTenuringDistribution.
В gc.log можем наблюдать:
Desired survivor size 20971520 bytes, new threshold 1 (max 4)
- age   1:   40900584 bytes,   40900584 total

Общее размер survivor объектов — 40900584, CMS по умолчанию использует 50% барьер заполненности области survivor. Таким образом получаем размер области ~ 80 Мб. При запуске jvm он задаётся параметром -XX:SurvivorRatio, который определяется из формулы:
survivor space size = -Xmn<value>/(-XX:SurvivorRatio=<ratio> + 2)



Получаем
java -Xms1680m -Xmx1680m -Xmn400m -XX:SurvivorRatio=3 -XX:+UseConcMarkSweepGC -XX:PermSize=20m ru.skuptsov.MemoryConsumer

Желая оставить размер eden space тем же — получаем:
java -Xms1760m -Xmx1760m -Xmn480m -XX:SurvivorRatio=5 -XX:+UseConcMarkSweepGC -XX:PermSize=20m ru.skuptsov.MemoryConsumer




Распределение стало лучше, но общее время сильно не изменилось в силу специфики приложения, дело в том, что после частых малых сборок мусора размер выживших объектов всегда больше, чем доступный размер областей survivor, поэтому в нашем случае мы можем пожертвовать правильным распределением в угоду размера eden space:
java -Xms1760m -Xmx1760m -Xmn480m -XX:SurvivorRatio=100 -XX:+UseConcMarkSweepGC -XX:PermSize=20m ru.skuptsov.MemoryConsumer




Итог


В результате мы сумели сократить размер общей паузы с 3,227 с до 1,481 с на 30 с эксперимента, немного увеличив при этом общее потребление памяти. Много это или мало — зависит от конкретной специфики, в частности, учитывая тенденцию к уменьшению стоимости физической памяти и принцип максимизации используемой памяти — всё равно важно найти баланс между различными областями GC и процесс этот, скорее, творческий, чем научный.
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 11

    0
    Спасибо за статью, не знал многих тонкостей. Интересно, как в Xamarin удалось сделать сразу два garbage collector-а, при этом уживающихся: docs.xamarin.com/guides/android/advanced_topics/garbage_collection/
      0
      Ну, там ничего военного нет. С точки зрения Dalvik VM Замариновский GC — это просто аллокатор. Когда освобождается хендл из C++-кода к Java-объекту, dalvik знает, что теперь объект можно удалить. В то же время внутри Замарина решение о том, что пора освобождать хендл, принимает тамошний GC.

      Похожая ситуация бывает в других подобных проектах. Например, Edge.js объединяет .NET и Node.js. Запускается Node-процесс, внутри которого, напомню, крутиться виртуальная машина v8 со своим сборщиком мусора. Edge-модуль внутри этого процесса подгружает DLLи CLR (виртуальной машины .NET), которая в свою очередь также имеет свой сборщик. Поскольку процесс один, то и адресное пространство тоже общее. Но на практике большая часть объектов видна либо для v8, либо для CLR. Два сборщика мусора могут проверять свои участки памяти и чистить ее. А задача Edge как раз и состоит в управлении общими ресурсами и обмена данными между ними.
      0
      Интересные примеры. Было бы полезно тоже самое проделать для java7 и java8.
        +1
        Я бы еще добавил, что можно дешево снимать информацию с приложения, с помощью Java Mission Control. Правда, «дешево» исключительно в терминах производительности, а не стоимости решения для production. В тестовом же окружении фича бесплатная. Можно записать данные об аллокациях (даже по таймеру) и потом долго «втыкать» за чашечкой кофе.

        учитывая тенденцию к уменьшению стоимости физической памяти

        Больше хип — больше пауза во время Full GC. При выходе же за 32 гигабайта и отказа от сжатых указателей можно, вообще, оказаться в ситуации менее выигрышной, чем с 30 гигабайтами хипа. Иногда выгодней разбить приложение на несколько JVM, чтобы уменьшить Latency.

        Еще можно добавить, что Xms и Xmx выставляют одинаковыми не просто так, а чтобы не словить паузу во время увеличения хипа.
          0
          Кроме того, gc.log довольно удобно анализировать с помощью IBM Support Assistant, в продакшене так искали вялотекущие утечки памяти, да и точку, когда что-то начинает пожирать память легко вычислить, а дальше по логам разбираться.
          +2
          Чем больше памяти выделено приложению, тем лучше работает сборка мусора и тем лучше достигаются количественные характеристики по пропускной способности и времени отклика
          WUT? При увеличении памяти можно получить значительное увеличение дисперсии (упрощенно — разброса) latency. При большой куче время полной сборки (full gc) с s-t-w (wtop the world) паузой больше, что означает увеличение latency, если доходит до full gc.

          Некоторые товарищи запускают jvm с огромным хипом и периодически просто рестартуют целиком, не дожидаясь full gc.

          Из классики. В антипаттернах использования Apache Cassandra есть прекрасная табличка, показывающая к чему может приводить бездумное увеличение -Xms/Xmx.

          У нас на одной задаче при -Xmx от 12G до 16G на ноде Кассандры были жуткие тормоза, высокое latency, таймауты и потери insert'ов, при 8G всё стало работать стабильно с нормальным потоком (6 нод по 500-600 MByte/s per node, на 2x SSD в RAID1).

          В какой-то теме ранее упоминал этот кейс, но там была неточность. Указанный там поток (50 Mbit/s) относился к входному потоку на нодах, до join'а со словарями в памяти и некоторой денормализации данных из исходного потока.
            0
            В Cassandra причина еще и в том, что там активно используется off-heap cache. И чем больше памяти забирает Java, тем меньше памяти остается на дисковый кэш и на этот off heap cache.
              0
              У нас была довольно старая cassandra, до появления off-heap row cache (он появился в 1.0, если правильно помню). Кроме того, даже при нем 8G или 16G на машине с 256G с точки зрения кэша — без разницы.
              0
              что то у меня приведенная табличка вызывает подозрения. Особенно сравнение 8гигов именно с 40гигами а не с 31 гигом например. Ведь известно ж что джава включает compressedoops если выставлен хип до 32гигов что и нагрузку на цпу уменьшает и размеры утилизации хипа потому и перформанс на 40гигах хуже изза того что указатели становятся 64битными. Так что в табличке не договаривают кое чего. Не из за большого хипа производительность падает а из-за чрезмерно большого
                0

                Сразу под табличкой пример из жизни с хипом 12-16G против 8G. И там, и там comperessedoops, ессно.

                  0
                  Под табличкой ваш пример я конечно видел. Может бытб именно вашему примеру есть какое то другое обьяснение. Но я обратил внимание именно на табличку. Что очень умело выбраны размеры хипа для сравнения. Хитро выбраны я бы сказал

            Only users with full accounts can post comments. Log in, please.