Heap


В предыдущей статье мы познакомились с терминологией и основными идеями, лежащими в основе сборщиков мусора Java HotSpot VM и многих других виртуальных машин. Теперь мы, наконец, можем взять в руки лопату и приступить к разгребанию нашей кучи. Сегодня у нас на обзоре две лопаты два сборщика мусора, используемые огромным количеством Java-программ, зачастую даже не подозревающих об этом. Это Serial GC и Parallel GC. Их популярность объясняется просто — данные сборщики выбираются виртуальной машиной по умолчанию для большинства аппаратных конфигураций.

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

Serial GC


Serial GC (он же последовательный сборщик) — младший с точки зрения заложенной в него функциональности, но старший с точки зрения продолжительности присутствия в JVM сборщик мусора. Он медленно, но верно собирал мусор еще тогда, когда многие из нас даже не подозревали о существовании языка Java. И до сих пор продолжает собирать. Так же медленно, но так же верно.

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

Использование Serial GC включается опцией -XX:+UseSerialGC.

Принципы работы


При использовании данного сборщика куча разбивается на четыре региона, три из которых относятся к младшему поколению (Eden, Survivor 0 и Survivor 1), а один (Tenured) — к старшему:

Регионы кучи


Среднестатистический объект начинает свою жизнь в регионе Eden (переводится как Эдем, что вполне логично). Именно сюда его помещает JVM в момент создания. Но со временем может оказаться так, что места для вновь создаваемого объекта в Eden нет, в таких случаях запускается малая сборка мусора.

Первым делом такая сборка находит и удаляет мертвые объекты из Eden. Оставшиеся живые объекты переносятся в пустой регион Survivor. Один из двух регионов Survivor всегда пустой, именно он выбирается для переноса объектов из Eden:

Малая сборка 1


Мы видим, что после малой сборки регион Eden полностью опорожнен и может быть использован для размещения новых объектов. Но рано или поздно наше приложение опять займет всю область Eden и JVM снова попытается провести малую сборку, на этот раз очищая Eden и частично занятый Survivor 0, после чего перенося все выжившие объекты в пустой регион Survivor 1:

Малая сборка 2


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

Малая сборка 3


JVM постоянно следит за тем, как долго объекты перемещаются между Survivor 0 и Survivor 1, и выбирает подходящий порог для количества таких перемещений, после которого объекты перемещаются в Tenured, то есть переходят в старшее поколение. Если регион Survivor оказывается заполненным, то объекты из него также отправляются в Tenured:

Малая сборка 4


Описанный процесс малой сборки мусора достаточно прост, но причины использования регионов Survivor, причем именно двух, не всегда понятны. Я думаю, детальное объяснение причин мы оставим за рамками данной статьи (разобрали в комментариях), а здесь лишь отметим, что из двух основных способов работы с выжившими объектами — уплотнение и копирование — в Sun при разработке малого сборщика мусора пошли по второму пути, так как он проще в реализации и зачастую оказывается производительнее.

В случае, когда места для новых объектов не хватает уже в Tenured, в дело вступает полная сборка мусора, работающая с объектами из обоих поколений. При этом старшее поколение не делится на подрегионы по аналогии с младшим, а представляет собой один большой кусок памяти, поэтому после удаления мертвых объектов из Tenured производится не перенос данных (переносить уже некуда), а их уплотнение, то есть размещение последовательно, без фрагментации. Такой механизм очистки называется Mark-Sweep-Compact по названию его шагов (пометить выжившие объекты, очистить память от мертвых объектов, уплотнить выжившие объекты).

Полная сборка 1


Акселераты


Самые наблюдательные читатели наверняка заметили, что в начале описания принципов работы говорится о том, что в разделе Eden создается среднестатистический объект, а не любой. Такая оговорка сделана неспроста. Дело в том, что бывают еще объекты-акселераты, размер которых настолько велик, что создавать их в Eden, а потом таскать за собой по Survivor’ам слишком накладно. В этом случае они размещаются сразу в Tenured.

Куча мала?


Важными факторами в описанных процессах являются абсолютный размер кучи и относительные размеры регионов внутри нее.

По мере заполнения кучи данными JVM может не только проводить чистку памяти, но и запрашивать у ОС выделение дополнительной памяти для расширения регионов. Причем в случае, если реально используемый объем памяти падает ниже определенного порога, JVM может вернуть часть памяти операционной системе. Для регулирования аппетита виртуальной машины существуют известные всем опции Xms и Xmx.

И хотя установки граничных значений кучи иногда достаточно, чтобы программа работала и слишком уж явно не тормозила, более тонкая настройка сборщика для достижения требуемой производительности выполняется регулированием размеров различных регионов. Мы рассмотрим примеры такого регулирования и его влияния на работу программы в отдельной статье, а здесь пока просто перечислим параметры, с помощью которых это делается (см. ниже).

Тут также стоит отметить, что по умолчанию младшее поколение занимает одну треть всей кучи, а старшее, соответственно, две трети. При этом каждый регион Survivor занимает одну десятую младшего поколения, то есть Eden занимает восемь десятых. В итоге реальные пропорции регионов по умолчанию выглядят так:

Пропорции регионов


А что же происходит, если даже после выделения максимального объема памяти и ее полной чистки, места для новых объектов так и не находится? В этом случае мы ожидаемо получаем java.lang.OutOfMemoryError: Java heap space и приложение прекращает работу, оставляя нам на память свою кучу в виде файла для анализа. Технически, это происходит в случае, если работа сборщика начинает занимать не менее 98% времени и при этом сборки мусора освобождают не более 2% памяти.

Ситуации STW


С этим сборщиком все достаточно просто, так как вся его работа — это один сплошной STW. В начале каждой сборки мусора работа основных потоков приложения останавливается и возобновляется только после окончания сборки. Причем всю работу по очистке Serial GC выполняет не торопясь, в одном потоке, последовательно, за что и удостоился своего имени.

Настройка


Мы уже коснулись того, что с помощью опций Xms и Xmx можно настроить начальный и максимально допустимый размер кучи соответственно. Наверняка большинство из вас это уже делали. Теперь давайте попробуем копнуть по-глубже.

Существуют опции -XX:MinHeapFreeRatio=? и -XX:MaxHeapFreeRatio=?, которые задают минимальную и максимальную долю свободного места в каждом поколении, при достижении которой размер поколения будет автоматически увеличен или уменьшен соответственно. Например, если MinHeapFreeRatio=35, то при падении доли свободного места в каком-либо поколении ниже 35%, этому поколению будет предоставлено дополнительное место, чтобы не менее 35% стало свободным. Аналогично, если MaxHeapFreeRatio=65, то при увеличении доли свободного места в поколении до 65% и более, часть выделенной этому поколению памяти будет освобождена для возвращения к желаемому порогу. Значения данных параметров по умолчанию зависят от аппаратных характеристик компьютера.

Установить желаемое отношение размера старшего поколения к суммарному размеру регионов младшего поколения можно с помощью опции -XX:NewRatio=?. Например, NewRatio=3 означает, что для младшего поколения (Eden + S0 + S1) будет отведена четверть кучи, а для старшего — три четверти. Контринтуитивное название этой опции вносит некоторую путаницу даже в документацию Oracle, но работает она именно так. Тут проще запомнить, что там, где названия опций заканчиваются на Ratio, реальная величина будет обратной той, что вы указали.

При желании можно ограничить размер младшего поколения абсолютными величинами снизу и сверху с помощью опций -XX:NewSize=? и -XX:MaxNewSize=?. Если вы хотите установить для NewSize и MaxNewSize одинаковые значения, то можно просто использовать опцию -Xmn. Например, -Xmn256m эквивалентно -XX:NewSize=256m -XX:MaxNewSize=256m.

Можно еще залезть внутрь младшего поколения и настроить отношение размера Eden к размерам Survivor. Это делается с помощью опции -XX:SurvivorRatio=?. Например, при SurvivorRatio=6 каждый регион Survivor будет занимать одну восьмую размера всего младшего поколения, а Eden — шесть восьмых (помним про правило опций *Ratio).

C помощью опции -XX:-UseGCOverheadLimit можно отключить порог активности сборщика в 98%, при достижении которого возникает OutOfMemoryError.

Если вам интересно последить за тем, как стареют ваши объекты в регионе Survivor и какие целевые значения для его размера установлены в данный момент, можно использовать опцию -XX:+PrintTenuringDistribution, которая добавляет статистику по Survivor к выводу информации о некоторых сборках мусора.

Достоинства и недостатки


Основное достоинство данного сборщика очевидно — это непритязательность по части ресурсов компьютера. Так как всю работу он выполняет последовательно в одном потоке, никаких заметных оверхедов и негативных побочных эффектов у него нет.

ЧерепахаГлавный недостаток тоже понятен — это долгие паузы на сборку мусора при заметных объемах данных. Кроме того, видно, что все настройки Serial GC крутятся вокруг размеров различных регионов кучи. То есть для тонкой настройки требуется самому что-то изучать, настраивать, экспериментировать и прочее. Кому-то это может прийтись не по душе.

Если вашему приложению не требуется большой размер кучи для работы (Oracle указывает условную границу 100 МБ), оно не очень чувствительно к коротким остановкам и ему для работы доступно только одно ядро процессора, то можно приглядеться к этому варианту. В противном случае можно поискать вариант по-лучше.



Parallel GC


Parallel GC (параллельный сборщик) развивает идеи, заложенные последовательным сборщиком, добавляя в них параллелизм и немного интеллекта. Если на вашем компьютере больше одного процессорного ядра и вы явно не указали, какой сборщик хотите использовать в своей программе, то почти наверняка JVM остановит свой выбор на Parallel GC. Он достаточно простой, но в то же время достаточно функциональный, чтобы удовлетворить потребности большинства приложений.

Параллельный сборщик включается опцией -XX:+UseParallelGC.

Принципы работы


При подключении параллельного сборщика используются те же самые подходы к организации кучи, что и в случае с Serial GC — она делится на такие же регионы Eden, Survivor 0, Survivor 1 и Old Gen (знакомый нам под именем Tenured), функционирующие по тому же принципу. Но есть два принципиальных отличия в работе с этими регионами: во-первых, сборкой мусора занимаются несколько потоков параллельно; во-вторых, данный сборщик может самостоятельно подстраиваться под требуемые параметры производительности. Давайте разберемся, как это устроено.

Для определения количества потоков, которые будут использоваться при сборке мусора, на компьютере с N ядрами процессора, JVM по умолчанию применяет следующую формулу: если N ? 8, то количество потоков равно N, иначе для получения количества потоков N домножается на коэффициент, зависящий от других параметров, обычно это 5/8, но на некоторых платформах коэффициент может быть меньше.

По умолчанию и малая и полная сборка задействуют многопоточность. Малая пользуется ею при переносе объектов в старшее поколение, а полная — при уплотнении данных в старшем поколении.

Каждый поток сборщика получает свой участок памяти в регионе Old Gen, так называемый буфер повышения (promotion buffer), куда только он может переносить данные, чтобы не мешать другим потокам. Такой подход ускоряет сборку мусора, но имеет и небольшое негативное последствие в виде возможной фрагментации памяти:

Параллельная сборка


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

Например, если при малой сборке JVM не удается укладываться в отведенное вами время, размер младшего поколения может быть уменьшен. Если не удается достигнуть заданной пропускной способности, а с задержкой проблем нет, то размер поколения будет увеличен. И так далее.

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

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

В случае, если вы задали слишком жесткие требования, которые сборщик не может выполнить, он будет ориентироваться на следующие приоритеты (в порядке убывания важности):

  1. Снижение максимальной паузы.
  2. Повышение пропускной способности.
  3. Минимизация используемой памяти.

При этом Parallel GC оставляет нам возможность самостоятельно корректировать размеры регионов, как и в последовательном сборщике. Но не рекомендуется делать и то и другое одновременно, чтобы не дезориентировать алгоритмы автоматической подстройки. Либо мы выделяем приложению достаточно памяти, указываем желаемые параметры производительности и наблюдаем со стороны, либо сами залезаем в настройки регионов, но тогда лишаемся права требовать от сборщика автоматической подстройки под нужные нам критерии производительности. Ругаться он на нас в случае нарушения данного правила не будет, но и эффективно выполнять свою работу тоже не сможет.

Ситуации STW


Как и в случае с последовательным сборщиком, на время операций по очистке памяти все основные потоки приложения останавливаются. Разница только в том, что пауза, как правило, короче за счет выполнения части работ в параллельном режиме.

Настройка


Для параллельного сборщика применимы все те же опции, что и для последовательного. Вы можете вручную устанавливать размеры регионов памяти или пропорции между ними. Ниже перечислены те опции, которые добавляются параллельным сборщиком к тому, что мы уже рассматривали выше.

Вы можете вручную указать количество потоков, которое хотели бы выделить для сборки мусора. Это делается с помощью опции -XX:ParallelGCThreads=?. Например, -XX:ParallelGCThreads=9 ограничит количество потоков девятью. Имейте в виду, что увеличение количества потоков не только сильнее распараллеливает сборку, но и увеличивает фрагментацию региона Tenured, а также добавляет накладные расходы на синхронизацию этих потоков.

При желании вы можете полностью отключить параллельные работы по уплотнению объектов в старшем поколении опцией -XX:-UseParallelOldGC.

Установка желаемых параметров производительности сборщика выполняется с помощью опций -XX:MaxGCPauseMillis=? и -XX:GCTimeRatio=?.

MaxGCPauseMillis устанавливает ограничение на максимальное время приостановки программы для сборки мусора. Например, -XX:MaxGCPauseMillis=400, укажет JVM, что паузы на сборку мусора желательно не затягивать дольше, чем на 400 миллисекунд. По умолчанию такого ограничения нет. При установке данного параметра следует помнить, что ограничение на время сборки может приводить к необходимости выполнять ее чаще, в результате чего будет страдать общая пропускная способность.

С помощью опции GCTimeRatio вы можете указать желаемый порог пропускной способности (отношения времени работы программы ко времени сборки мусора). Например, при -XX:GCTimeRatio=49 JVM будет пытаться выполнять сборки таким образом, чтобы они суммарно занимали не больше 2% времени работы программы (отношение времени сборки ко времени работы программы будет 1 / (1 + 49)).

Опции -XX:YoungGenerationSizeIncrement=? и -XX:TenuredGenerationSizeIncrement=? устанавливают, на сколько процентов следует при необходимости увеличивать младшее и старшее поколение соотвественно. По умолчанию оба этих параметра равны 20.

А вот скорость уменьшения размеров поколений регулируется не процентами, а специальным фактором через опцию -XX:AdaptiveSizeDecrementScaleFactor. Она указывает, во сколько раз уменьшение должно быть меньше увеличения. Эта опция распространяется на оба поколения. Например, при -XX:AdaptiveSizeDecrementScaleFactor=2 каждое уменьшение поколения будет в два раза меньше, чем его увеличение (то есть оба поколения будут уменьшаться на 10% при -XX:GenerationSizeIncrement=20 и -XX:TenuredGenerationSizeIncrement=20).

Достоинства и недостатки


Бесспорным плюсом данного сборщика на фоне Serial GC является возможность автоматической подстройки под требуемые параметры производительности и меньшие паузы на время cборок. При наличии нескольких процессорных ядер выигрыш в скорости будет практически во всех приложениях.

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

В целом, Parallel GC — это простой, понятный и эффективный сборщик, подходящий для большинства приложений. У него нет скрытых накладных расходов, мы всегда можем поменять его настройки и ясно увидеть результат этих изменений.

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

Часть 3 — Сборщики CMS GC и G1 GC >

Ранее
< Часть 1 — Введение

Комментарии (12)


  1. Envy
    29.10.2015 15:52
    +2

    Спасибо за статью, интересно почитать будет про сборщики в коммерческих JVM еще.


    1. alygin
      29.10.2015 18:18

      Тема, действительно, интересная. Как позволит время, поразбираемся с проприетарными/коммерческими jvm.


  1. lukdiman
    29.10.2015 18:28
    +1

    Так всё-таки расскажите почему именно два региона survival и зачем перекладывать объекты между ними?


    1. alygin
      29.10.2015 20:12
      +3

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

      Вы просто заводите два указателя на начало новой области. Первый указатель (назовем его T) смещается вправо каждый раз, когда в новую область копируется объект, то есть он всегда указывает на первый свободный блок новой области. При этом на том месте старой области, где находился перемещаемый объект, мы делаем пометку о том, что он был перемещен, и там же оставляем его новый адрес. Первым делом перемещаем таким образом все руты из старой области в новую. И вот тут вступает в действие второй указатель (назовем его R). Он тоже начинает смещаться вправо по уже размещенным в новой области объектам. В каждом объекте он ищет ссылки на другие объекты и смотрит на то место в старом регионе, куда они указывают. Если там стоит метка о перемещении и новый адрес, то этот адрес используется для подмены. Если же там лежит объект, то он перемещается в новый регион, на его месте ставится метка и новый адрес, на который так же заменяется ссылка, по которой его нашли, при этом T опять смещается вправо. Как только R догонит T, окажется, что мы собрали все живые объекты в новой области, размещенные компактно, да еще и с корректно обновленными ссылками, а старый регион можем объявить пустым. Все быстро и просто.

      Недостаток такого метода в том, что требуется всегда держать в наличии пустой регион, в который мы будем перемещать живые объекты. Но тут нас выручает слабая гипотеза о поколениях, которую мы рассматривали в прошлой статье. Оказывается, что для вмещения всех живых объектов новой области не нужно быть очень большой, так как подавляющее число объектов в младшем поколении окажутся мусором. В нашем случае новый регион — это пустой Survivor, а старый регион — это Eden + заполненный Survivor. То есть логичнее смотреть на Eden как на дополнение к текущему активному Survivor'у, а не наоборот, тогда все становится на свои места. Мы просто переподключаем Eden каждый раз к новому Survivor'у после очистки младшего поколения.

      В итоге мы получаем небольшой оверхед по памяти (пустой Survivor), но зато и высокую скорость работы. Реализация варианта с уплотнением оказывается медленнее. Уплотнение используется при сборках мусора в старшем поколении, так как там уже заведомо старые объекты и мы не можем опираться на гипотезу поколений.


      1. lukdiman
        03.11.2015 11:48

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


        1. alygin
          03.11.2015 14:38
          +2

          Уплотнять объекты в рамках очищаемой области памяти можно двумя способами:

          1. Выискиваем подходящие «дырки» в областях и рассовываем по ним объекты. Это крайне накладной способ, так как вам придется решать задачу, похожую на задачу раскроя, но на самом деле даже более сложную, раз вы планируете оптимизировать ее, пытаясь оставить как можно больше объектов на своих местах. По затратам процессора это в разы больше того алгоритма, что используется при копировании. Для обновления адресов вам также придется заводить отдельную структуру данных, чтобы хранить соответствие старого адреса и нового, и использовать как минимум один дополнительный проход по всем объектам в конце алгоритма. Так как объекты по большей части достаточно маленькие, размер дополнительной структуры данных под хранение соответствий адресов, может не сильно отличаться от размера самих объектов.

          2. Вы можете просто перебирать все объекты подряд и копировать их «влево», впритык к предыдущему живому объекту. При этом остается та же самая проблема с необходимостью ведения отдельной структуры под хранение соответствий адресов, что и в первом варианте. И проблема с доп. проходом для обновления адресов тоже никуда не девается.

          При этом работа описанных случаев возможна только тогда, когда мы уже знаем, какие объекты живые, а какие мертвые. А это значит, что предварительно нужно выполнить еще как минимум один проход по побъектам для получения этого знания. Если все это сложить, то общий алгоритм упаковки получается намного накладнее по времени и ресурсам процессора, при этом без явного выигрыша по использованию дополнительной памяти. Можете попробовать написать псевдокод для упаковки, он гораздо сложнее и требовательнее к ресурсам, чем может показаться на первый взгляд.

          В случае же копирования у нас используется всего один проход по объектам для решения всех трех задач: поиск живых объектов, уплотнение, обновление ссылок.


  1. grossws
    29.10.2015 20:29
    +1

    Хорошо пишете, спасибо. Про G1GC писать планируете?


    1. alygin
      29.10.2015 20:32

      Обязательно, в следующей статье будут CMS и G1.


      1. grossws
        29.10.2015 20:39

        Особенно интересно поведение G1GC при использовании больших короткоживущих объектов (например, больших битсетов в Lucene).

        Сам пока не тестировал, но Shawn Heisey из Lucene/Solr пишет, что у него всё неплохо, хотя Lucene guys предостерегают насчёт G1. См. wiki.apache.org/solr/ShawnHeisey#GC_Tuning_for_Solr.


        1. alygin
          29.10.2015 20:53

          Да, в G1 своя специфика работы с т. н. громадными объектами, о ней в статье обязательно расскажу. И если их много и они короткоживущие, то можно получить неприятности.


  1. lany
    30.10.2015 11:04
    -1

    Общее замечание про подкручивание опций JVM. По моему опыту, если вы испытываете проблемы со сборкой мусора, это свидетельствует о реальных багах в исполняемом коде (утечки памяти, переполнение очереди финализации из-за большого количества finalize() и так далее). Перед тем как ругать джаву за тормознутость и крутить опции JVM, снимите дамп памяти и поанализируйте его хорошенько.


    1. alygin
      30.10.2015 12:54

      Согласен, но только отчасти. Я бы перефразировал на "… это часто свидетельствует..." Конечно, в расточительной и протекающей программе нужно прежде всего сосредоточиться на поиске проблем в коде. Но все-таки бывают ситуации, когда можно испытывать проблемы со сборкой мусора и в добротной, абсолютно герметичной программе, не использующей finalizer'ы. А если речь идет о транзакционном сервисе с большой кучей и жестким SLA, то без подкручивания опций JVM редко обходится. Вот мне хабр как раз подсовывает Историю одного garbage collection'а в качестве похожей публикации с примером такой программы. Или вот пример Solr из комментариев выше. Да и не всегда есть возможность код поправить, мы ведь и сторонними инструментами пользуемся.