Наверх ▲

Секреты сборки мусора в Java

Алексей Рагозин Алексей Рагозин Специализируется на разработке высоконагруженных распределённых систем на платформе Java. Более чем за 10 лет работы в индустрии он получил опыт разработки информационных систем в таких отраслях, как финансы, телеком, E-commerce и здравоохранение.

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

Кто из тех, кто находится в этом зале, использует Java? Поднимите, пожалуйста, руки. А теперь пусть поднимут руки те, кого серьезно беспокоят проблемы типа пауз «сборщика мусора» (англ. garbage collector) и «сюрпризов» JVM. Ну, и пусть поднимут руки те, кто использует в продакшне JVM с более чем 4 ГБ памяти на процесс. Что ж, я думаю, мой доклад окажется весьма интересным для вас.

О чем доклад?

О чем я хочу вам рассказать? Поскольку здесь собрались не только разработчики Java, сначала я расскажу о том, что такое «сборка мусора» вообще и какие алгоритмы там используются. Потом мы плавно перейдем к тому, зачем нужны нелюбимые нами паузы “stop-the-world”. Закончу я несколькими интересными фактами, касающимися оптимизации JVM, которая ранее разрабатывалась компанией Sun, при работе с большими хипами. Также поговорим о минимизации задержек.

«Сборка мусора»

Какие языки используют автоматическое управление памятью? На самом деле, проще указать, какие его не используют. Я пытался составить такой список и понял, что все вновь появляющиеся языки используют автоматическую «сборку мусора». Несмотря на все недостатки, это очень удобно. Все хотят этим пользоваться.

Способы «сборки мусора»

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

Более сложный способ – поиск транзитивного замыкания всех ссылок в графе объектов. Так мы гарантированно находим все досягаемые объекты. Это довольно трудоемкий процесс: чтобы обойти весь граф, нам надо, по сути, просканировать всю память.

Существует еще и третий – самый радикальный – способ: вообще не собирать «мусор». Когда память закончится, можно «убить» процесс. Звучит страшновато, но на практике этот способ тоже часто оказывается полезным.

Подсчет ссылок

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

Например, нет очищения циклических графов. Это очень большая нагрузка на производительность – каждый раз, когда мы что-то читаем, записываем ссылку на объект в локальную переменную, нам нужно увеличивать счетчик. Если это происходит в многопоточной среде, у нас возникает жуткий конфликт (англ. contention), связанный со счетчиками ссылок. Работает все это очень плохо.

Транзитивное замыкание ссылок

Транзитивное замыкание ссылок используется во всех достаточно серьезных виртуальных машинах, которые не «умирают» после каждого запроса или нескольких запросов. Чтобы осуществлять транзитивное замыкание, нужно сформировать набор корневых ссылок из статических переменных и локальных переменных.

То есть нам нужно просканировать кадры стека и собрать ссылки оттуда. Современные Java-машины используют обычный стек. Кадры могут перемежаться нативным кодом. Некоторые виртуальные машины используют свой стек, но это неважно. Его все равно нужно сканировать.

Затем, начиная с этих корневых ссылок, мы обходим весь граф объектов памяти. Те объекты, до которых мы дошли, являются «живыми». Те, до которых мы не дошли, представляют собой «мусор» - эту память можно использовать повторно.

Есть один неприятный момент: пока мы обходим граф объектов, он не должен меняться. Если он будет меняться, окажется, что мы не обошли какие-то из достижимых объектов. Это одна из основных причин, по которым для «сборки мусора» нужны паузы.

Алгоритмы «сборки мусора»

Какие алгоритмы используют транзитивное замыкание? Очевидно, что это Mark-Sweep. Первая – это обход графа и установка особого флажка на каждом из достижимых объектов. Второе – сканирование всей памяти и помещение объектов без флажка в список свободного пространства.

Второй популярный алгоритм – сборка копированием. Он позволяет осуществить все вышеперечисленное за один проход. Когда мы находим в графе новый объект, мы сразу копируем его в другое пространство памяти.

Таким образом, когда граф пройден, все «живые» объекты находятся в другом пространстве памяти, и всё предыдущее пространство памяти можно считать свободным. Естественно, при этом мы перемещаем указатели объектов с одного на другой. Этот подход широко используется на практике. Единственный его недостаток в том, что нужно иметь достаточно много свободного пространства, чтобы скопировать туда все «живые» объекты.

Третий алгоритм – Mark-Sweep-Compact. Это тоже очень популярный. Он выполняет Mark-Sweep, затем, после выполнения действия “sweep” он перемещает объект в памяти, чтобы выполнить дефрагментацию и обеспечить непрерывность свободного места, которое станет удобно выделять.

Java может создавать объекты очень быстро – это помимо уплотнения памяти. Используются отдельные пулы памяти для потоков. Выделение объектов в памяти в языке Java – практически бесплатная операция.

Трехцветная маркировка

Поговорим о маркировке. Так называемая трехцветная маркировка предполагает наличие трех типов объектов. Черные объекты – те, которые достижимы. Белые – те, которые недостижимы. Серые – еще не обойденные объекты, на которые есть указатели из черных объектов.

Маркировка начинается с некоторого списка корней. Дальше мы обходим граф в длину или в ширину и маркируем все объекты. Когда объектов больше не остается, маркировка считается завершенной. Обычно список серых объектов хранится в стеке. Если стек переполнен, создается некоторая очередь, которая заполняется при обходе графа. Белые объекты – это свободное пространство. Его теперь надо очистить.

Сборка копированием

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

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

Эффективность процесса «сборки мусора»

Какова эффективность этого процесса? Здесь вы видите формулу, которая оценивает «стоимость» освобождения – количество циклов процессора на 1 МБ освобождаемой памяти. Эти формулы интересны тем, что в числителе каждой из них стоит количество «мусора в куче». То есть эффективность «сборки мусора» прямо пропорциональна количеству «мусора», которое мы храним в «куче».

Если мы хотим использовать 100 % памяти, занимать ее всю полезными данными, то «сборка мусора» у нас не будет работать совсем. Дальше начинается “trade open” – насколько у нас мало памяти и много вычислительных ресурсов, или наоборот. Стоит сказать, что “trade open” – это достаточно плохой путь. Современные программы производят много «мусора», ресурсы процессора и память они тоже расходуют.

Если у нас такая виртуальная машина, которая задействует JavaScript и не хранит в своем хипе много данных, мы можем позволить себе иметь там много «мусора». Java, как правило, хранит много полезных данных в памяти, поэтому мы такого допустить не можем.

Слабая гипотеза о поколениях

Имеем неразрешенный парадокс: как бы нам сделать такой «сборщик мусора», чтобы он и память зря не тратил, и ресурсы процессора не расходовал? На помощь нам приходит так называемая «слабая гипотеза о поколениях».

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

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

Демография объектов в «куче»

На этом слайде показана кривая «смертности» объектов. Если мы проведем черту где-то на временной оси, то до этой черты суммарная «смертность» объектов будет намного выше. Время жизни объектов в молодом поколении нужно выбирать так, чтобы зеленый столбик на гистограмме был значительно выше синего.

Если на этой кривой будут какие-то еще «горбы», это будет смертельно для современных «сборщиков мусора». Это одна из основных проблем на практике: приложение не удовлетворяет условиям гипотезы о поколениях.

Очень часто такое происходит за счет кэширования. Казалось бы, мы что-то кэшируем, чтобы увеличить производительность, но на практике это приводит к тому, что у нас объекты «умирают» в старом поколении вместо молодого, и ресурсы уходят на сборку мусора вместо того, чтобы тратиться на что-то полезное.

«Сборка мусора», основанная на поколениях

Чтобы реализовать «сборку мусора», основанную на поколениях (англ. generational collection), нам нужно молодое поколение со своим «сборщиком мусора» и старое поколение со своим «сборщиком мусора». Также необходима некоторая процедура продвижения, которая будет перемещать объекты из молодого поколения в старое после того, как они «проживут» определенное время.

Время практически всегда считается в количестве сборок молодого поколения, которые объект «пережил». Обычно оно динамически выставляется JVM на основе статистики.

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

Барьер на запись

Это решается с помощью такого механизма, как «барьер на запись» (англ. write barrier). Каждый раз, когда программный код пишет указатель в память, он проверяет дополнительные условия и выполняет какой-то дополнительный код, который определяет тик барьера на запись.

Наиболее популярный барьер для разделения старого и молодого поколения – это так называемый Card Marking Write Barrier. Старое поколение разбивается на страницы – в HotSpot JVM это 512 байт, и на каждую страницу приходится 1 байт флага. Если мы пишем в страницу указатель, мы устанавливаем для этого флага определенное значение. Мы помечаем его каким-то образом.

Молодая сборка в HotSpot JVM

В итоге у нас получается следующая картина: когда у нас заполнено молодое поколение, в HotSpot JVM молодое поколение состоит из 3-х частей. Это Eden – пространство, в которое выделяются новые объекты и 2 пространства survival space, которые нужны для сборки копированием – это те пространства, куда сборщик копированием помещает «живые» объекты.

Поскольку в молодом поколении количество «выживающих» объектов мало, пространства survival space поменьше. Их 2, поскольку нам всегда нужно, чтобы одно из них было свободно.

Итак, когда начинается сборка молодого поколения, мы сначала собираем все корневые указатели из кадров стека, с каких-то статических структур JVM (не статических полей в классах, классы – это такие же объекты в JVM, они могут выгружаться из памяти).

Чтобы собрать ссылки со старого пространства, мы сканируем таблицы карт и только те страницы, которые помечены как «грязные». Дальше я покажу, что этого достаточно. После того, как сбор корневых указателей заканчивается, мы обновляем таблицу «грязных» страниц – на самом деле, она обновляется во время сканирования, отдельного прохода нет.

Затем мы начинаем сборку копированием. Сборка копированием производится из одного пространства survival space во 2-е, которое изначально было свободным. При перемещении объекта мы меняем все указатели, которые указывали на него из других объектов, чтобы они указывали в правильное место.

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

При молодой сборке большинство объектов копируется в новое пространство survival space. Те объекты, которые провели достаточно времени в молодом поколении, копируются в старое пространство памяти. Если пространство survival space заканчивается, объект сразу копируется в старые. Это плохо, потому что туда попадают молодые объекты. Зато JVM продолжает работать.

Когда «сборка мусора» в молодом поколении закончена, мы получаем свободные пространства в Eden и предыдущем пространстве survival space. Какие-то страницы оказываются помеченными как «грязные».

Так выполняется «сборка мусора» в молодом поколении практически во всех JVM. Есть только одна JVM, которая использует «сборщик мусора», основанный на поколениях, и не использует сборку копированием для молодого поколения.

Паузы stop-the-world

Возвращаемся к паузам stop-the-world. Зачем они нужны? Во-первых, обход графа объектов требует, чтобы этот граф не изменялся. Во-вторых, при перемещении объекта нам нужно обновлять все указатели, которые на него ссылаются. Большинство JVM «не умеют» это делать, не останавливая процесс. Есть одна JVM, которая это «умеет». Для этого нам и нужны паузы.

Пауза stop-the-world – это ситуация, когда все потоки приложения останавливаются. То есть процесс останавливается полностью, и работают только служебные потоки JVM. Процесс в это время не отвечает (англ. unresponsive process). Это очень плохо. Если это затянется надолго, это создаст проблемы.

Как можно бороться с такими паузами? Во-первых, если мы сделаем все наши алгоритмы «сборки мусора» многопоточными, то паузы будут короче – ядер на современных серверах много, а мы все это запустим на всех ядрах. Работа будет выполнена быстрее. Это один из популярных способов, который широко используется во всех индустриальных JVM.

Во-вторых, можно попытаться заставить алгоритмы работать без пауз. Это работает для параллелизма и широко используется параллельное маркирование (англ. concurrent marking), но это не используется для перемещения объектов. Есть только одна JVM, которая «умеет» перемещать объекты без пауз.

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

Параллельное маркирование

Как работает параллельное маркирование (англ. concurrent marking)? Граф объектов меняется по мере обхода. Понятно, почему он меняется в Java. Как ни странно, такая же проблема существует в функциональных языках, потому что там очень часто используются отложенные вычисления.

Когда у нас срабатывает замыкание, которое должно выполниться, меняется наш граф объектов. Например, Haskell, который имеет одну из самых «продвинутых» систем управления памятью в системах, отличных от Java, вынужден бороться с этой проблемой.

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

Подходы

Существует 2 подхода к параллельному маркированию. Первый – это использование того же самого алгоритма Card Marking Write Barrier, который применяется для молодой «сборки мусора».

Второй подход подразумевает использование другого типа барьера – так называемого “Snapshot-at-the-beginning”. Card Marking Write Barrier – наиболее популярный. Барьер Snapshot-at-the-beginning (SATB) используется в новом «сборщике мусора» от Hotspot JVM.

Существует еще одна новая JVM – Azul Zing, которая использует вместо барьера на запись барьер на чтение (англ. read barrier). Если прикладной процесс прочитал указатель на объект из памяти, значит, объект является «живым». Можно его прямо в этот момент пометить как «живой». При барьере на запись мы эту операцию откладываем, а при барьере на чтение мы сразу фильтруем объекты как «живые» по мере использования.

Барьер на запись SATB

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

У нас происходит маркирование. Есть некоторый поток marking, у него есть 2 корневых ссылки. Он начинает выполнять маркирование. Пока он его выполняет, приложение берет и меняет ссылку. Поток продолжает маркировать объекты.

Маркирование закончилось, и объект В остался немаркированным, потому что эта ссылка появилась после того, как маркер закончил с объектом А. Но за счет того, что предыдущая ссылка была помещена в очередь, поток-маркировщик «достает» указатели из очереди и продолжает маркирование этих объектов.

В итоге остается «проблема стрелы и черепахи»: маркировщик никогда не догонит потоки приложения. Поэтому в конце данного процесса мы все равно делаем паузу – останавливаем приложение, и маркировщик очищает все очереди указателей.

Барьер на запись Card Marking

Как работает барьер на запись Card Marking? Нам нужна пауза, чтобы собрать корневые ссылки (для SATB ситуация та же самая). Дальше мы просто начинаем обходить граф объектов в фоновом режиме. После его обхода мы смотрим, какие страницы памяти изменились за время обхода, и заново перемаркируем объекты, которые находятся на этих страницах.

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

Перемещение объектов

Следующее действие, которое требует пауз stop-the-world, это перемещение объектов. Перемещать объекты тяжело – нужно обновить все указатели, которые ссылаются на этот объект, которые можно маркировать где угодно. Без пауз это сделать очень сложно. Без пауз это идет только в Azul Zing, все остальные JVM вынуждены делать паузы для перемещения объектов.

Популярный подход – инкрементальное уплотнение. После маркировки и выполнения действия “sweep”, когда мы знаем, какая память у нас свободна, мы можем решить, что память не настолько фрагментирована и не нужно ее переупаковывать. Мы можем или пропустить процедуру упаковки, или переупаковать только часть пространства.

Во втором случае у нас будет инкрементальное уплотнение. Оно реализовано в «сборщике мусора» HotSpot G1. JRockit тоже «умеет» делать инкрементальное уплотнение. Но на практике это обходится довольно дорого. Нельзя сказать, что это панацея от всех бед, потому что перемещать объекты – это дорогое удовольствие, даже если делать это инкрементально. Количество циклов ЦПУ, которые мы должны затратить на этот процесс, довольно велико.

Еще один подход – не уплотнять память совсем. Это приводит к проблемам фрагментации. В принципе, проблемы фрагментации существовали в языках программирования задолго до появления Java. Есть множество эффективных подходов типа субаллокаторов, которые помогают нивелировать проблемы с фрагментацией.

Один из сборщиков мусора, который используется в HotSpot JVM – Concurrent Mark-Sweep HotSpot как раз применяет этот подход. Он не перемещает объекты в старом поколении, он использует отдельные списки свободного доступа для объектов разного размера, чтобы избежать фрагментации. Это «спасает» не всегда, но для многих приложений это работает хорошо.

Oracle HotSpot JVM

Какие алгоритмы используются в индустриальных JVM? В HotSpot JVM (ранее – от Sun, теперь – от Oracle) есть 6 способов «сборки мусора». Есть волшебные «ключи», которые можно использовать в этих 6 комбинациях – в других комбинациях они не работают.

Среди этих алгоритмов простая однопоточная маркировка, 2 варианта Concurrent Mark-Sweep. Сборки идут параллельно или молодой сборке или и старой, и молодой сборке. Есть Concurrent Mark-Sweep, который не упаковывает и имеет наименьший размер пауз среди этих алгоритмов. Он тоже существует в 2-х вариантах. В 1-м варианте «сборка мусора» однопоточная, во 2-м – многопоточная.

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

Oracle JRockit JVM

JRockit JVM тоже использует алгоритм Concurrent Mark-Sweep. У нее множество опций, и мы можем выбрать фазу маркировки – параллельную (англ. concurrent) или stop-the-world. Мы можем выбрать фазу упаковки – инкрементальную или stop-the-world-упаковку всей «кучи».

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

Azul Zing

Это очень интересная JVM. Она использует Concurrent Mark-Sweep Compact и для молодого, и для старого поколений. Все фазы «сборки мусора» реально выполняются без пауз за счет использования барьера на чтение. Он более сложен в реализации, и при работе на процессорах x86 Azul Zing требует некоторых ухищрений с ядром Linux, но это очень интересный вариант.

Масштабируемость JVM

Насколько хорошо масштабируются алгоритмы «сборки мусора»? Как ни странно, когда начинаешь анализировать, от чего зависит время пауз, оказывается, что при росте размера хипа проблема не сильно усугубляется.

Очень часто бывает такое: люди получают проблемы на 4 ГБ и думают, что на 32 ГБ вообще ничего работать не будет. На самом деле, на 32 ГБ проблемы будут практически того же порядка, что и  на 4 ГБ.

Длительность пауз CMS сборщика

Я пропущу часть слайдов. Перейдем к тому, что касается моделей и времени пауз. Здесь представлена модель для алгоритма Concurrent Mark-Sweep, который делает самые короткие паузы для HotSpot JVM. JRockit тоже за ним не успевает, потому что там нет алгоритмов перемещения объектов – все алгоритмы перемещают объекты, а Concurrent Mark-Sweep их не перемещает.

В Concurrent Mark-Sweep у нас есть 3 паузы. Это пауза молодой «сборки мусора», пауза в начале маркировки и пауза при финальной перемаркировке. Если проанализировать все временные компоненты, которые добавляют время в эту паузу, то получится интересная картина: единственный компонент, который растет пропорционально размеру «кучи» в Java – это время, которое требуется на сканирование таблиц «грязных» страниц. Остальные компоненты зависят от приложения и очень мало зависят от размера используемой «кучи».

После того, как я получил таблицу и померил в реальных паузах, выяснилось, что JVM тратит довольно много времени на сканирование таблиц и страниц. При 32 ГБ хипа размер таблиц и страниц составляет 64 МБ, и на сканирование уходит около 50 миллисекунд. Это очень много, потому что операция очень проста – мы просто читаем таблицу из памяти.

Патч OpenJDK

Я посмотрел на OpenJDK и нашел там интересный цикл, который читает эту таблицу байт за байтом. После изменения 20 строк кода, которые делают этот цикл более эффективным для современных процессоров. В итоге получилась очень интересная картина. Время сканирования таблиц и «грязных» страниц уменьшилось практически в 8 раз.

Это графики синтетического теста. Синим показано время паузы обычной HotSpot JVM, красным – время паузы пропатченной JVM. По оси Х отложен размер «кучи» в гигабайтах, максимум там – 18 ГБ. Это синтетический тест. Реальное приложение так масштабировать по размеру «кучи» не получается, там набор данных (англ. data set) не настолько гибкий.

Для алгоритма Concurrent Mark-Sweep графики выглядят несколько более странно, но выигрыш там тоже вполне очевиден. На реальных приложениях результаты такие, что паузы уменьшаются в 2-3 раза. У меня они со 150 миллисекунд уменьшились до 70 миллисекунд на максимуме. Сейчас этот патч ждет своей очереди на включение в JDK.

Резюме

Далее последует некоторое резюме этого доклада. «Сборка мусора» не понятна интуитивно, как и время пауз. Я построил эту модель в качестве эксперимента, но она очень хороша на практике.

Очень многое зависит от приложения, оно запросто может сломать принцип работы «сборщика мусора». Если у нас начинают интенсивно «умирать» объекты в старом поколении, то все станет очень плохо. JVM не «падает», если она не успевает вычищать «мусор». Это приводит к длительным паузам, во время которых очищается и дефрагментируется вся «куча». Этого хочется избежать.

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

JVM может работать практически без пауз. Использовать алгоритм Concurrent Mark-Sweep на приложении, которое не делало ничего странного с точки зрения JVM, довольно просто. Мне удалось получить гарантированные паузы не более 150 миллисекунд на непропатченной версии JDK.

К сожалению, механизм автоматического управления памятью не универсален. Есть приложения, которые совсем не укладываются в гипотезу о поколениях. Если говорить о примерах, то тут стоит вспомнить про распределенные хранилища типа HBase, Cassandra. Они используют память для хранения больших объектов, которые «живут» довольно долго, переходят в старое поколение и потом создают нагрузку на «сборщик мусора» в старом поколении.

Альтернативы

Что делать при работе с такими приложениями? Какие есть альтернативы? В Java есть достаточно простой способ управления памятью, минуя «сборщик мусора». Есть такая вещь, как байт-буфер (ByteBuffer). Мы можем запросить у JVM блок памяти в обычном хипе операционной системы, а не в хипе, который управляется JVM. Мы не сможем создавать там объекты, мы должны будем оперировать с ними как с массивом байтов. В принципе, приложения, которые работают с большими объемами данных, все равно байтами оперируют и довольно редко – объектами.

Это достаточно эффективный способ, он работает на всех платформах. Проблемы здесь те же самые, что и в других системах управления памятью. Мы можем фрагментировать внешнюю память, мы можем фрагментировать системный хип, хотя с этим есть свои проблемы в 64-битных системах.

Мы не можем освободить эту память явным образом. То есть для того, чтобы память, выделенная для хипа, была освобождена, нужно, чтобы ByteBuffer-объект был собран «сборщиком мусора». В некоторых случаях это тоже приводит к проблемам.

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

Также существует Real Time System Java, которая использует принцип арены аллокаторов. То есть мы можем выделить пул памяти, который не затрагивается «сборщиком мусора». Объекты вне этой арены не могут иметь ссылки и указатели на объекты внутри арены. Мы всегда можем «выкинуть» эту арену и не боятся того, что где-то останутся указатели на нее. Как и все системы Real Time, Real Time System Java сделана не для быстродействия, а для обеспечения гарантированного времени отклика.

Также есть недокументированный класс Unsafe. Он позволяет вызывать malloc, realloc и free напрямую. Можно работать непосредственно с этими указателями.

На этом я заканчиваю. Если у вас есть вопросы, смело их задавайте.

Вопросы и ответы

Реплика из зала: Алексей, вы можете открыть слайд про молодую «сборку мусора»? Вы говорите, что перемещение объектов – дорогая операция. У нас есть 2 пространства survival space – S1 и S2. Для чего мы тогда перемещаем объекты из S1 в S2 на следующем шаге?

Алексей Рагозин: Сборка копированием требует, чтобы одно пространство у нас было полностью свободным. Сначала мы из Eden и S1 перемещаем объекты в S2, то есть на следующей сборке мусора у нас будут заполнены Eden и S2. Пространство S1 останется свободным, и мы будем копировать объекты туда.

Реплика из зала: А S1 и S2 не могут меняться по аналогии с репликацией по схеме «ведущий-ведомый» (англ. master-slave)?

Алексей Рагозин: Они постоянно меняются – то одно из них является пространством назначения (англ. to-space), то другое. На предыдущей сборке мы копировали объекты в S2, на следующей мы будем копировать их в S1. Еще через сборку мы снова будем копировать их в S2. То есть одно пространство survival space всегда должно оставаться свободным.

Реплика из зала: Знаете, я не Java-специалист, я просто хотел кое-что уточнить. Вы говорите, что JVM может работать без пауз, точнее, с паузами в 100-200 миллисекунд – так 100-200 миллисекунд это без пауз?

Алексей Рагозин: Нет, это не без пауз. Но эти паузы таковы, что для многих в этом зале они окажутся не критичными. Для меня они критичны, у меня обычное соглашение SLA, там указаны десятки миллисекунд, не больше.

Реплика из зала: А как оно вообще бывает в жизни? Без пауз, 200 миллисекунд, а в реальной жизни что там – секундные паузы?

Алексей Рагозин: Бывают секундные паузы, бывают 10-секундные паузы. Где-то 5 лет назад у нас на JVM от компании Sun были проблемы с минутными паузами.

Реплика из зала: Спасибо, очень интересно.

Реплика из зала: Скажите, а вот при параллельной сборке что параллельно собирается?

Алексей Рагозин: Параллельно можно выполнять маркировку графа и копирование объектов.

Реплика из зала: Нет, я имею в виду вот что. Допустим, у нас есть несколько потоков, и каждый из них имеет свой пул, правильно?

Алексей Рагозин: Нет. Они имеют свой пул только тогда, когда у нас не происходит сборка мусора, то есть каждый поток резервирует себе какое-то пространство в свободной памяти и создает там объекты, чтобы не конкурировать за part pointer. Когда начинается сборка, там уже нет разделения на пространство потоков.

Реплика из зала: Если у нас параллельная сборка, если мы собираем «мусор» несколькими потоками, как они делят между собой это адресное пространство?

Алексей Рагозин: У них там очередь задач и таск-стили.

Реплика из зала: А конкурентная сборка тоже выполняется несколькими потоками, или там тоже только один какой-то поток?

Алексей Рагозин: Да, конкурентная сборка тоже выполняется несколькими потоками, если у нас Concurrent Marking – по умолчанию количество потоков для Concurrent Marking равно половине ядер в системе, если JVM запущена с ключом «минус сервер».

Реплика из зала: А какие проблемы там? Есть ли конфликты блокировок (англ. lock contentions) при доступе к объектам? То есть нужен атомарный доступ к каждому объекту, если мы хотим его маркировать?

Алексей Рагозин: Нет, если объект уже маркирован, это патентная операция. Если мы 1 раз пометили объект как достижимый, мы можем еще раз пометить его как достижимый – хуже от этого не станет.

Реплика из зала: Спасибо.

Реплика из зала: Здравствуйте. Мне бы хотелось сказать кое-что по поводу предыдущего вопроса про паузы. Там на довольно серьезном проекте удавалось сократить паузы максимум до 50 миллисекунд на хипе в 8 ГБ. Но там был «жесткий тюнинг» алгоритма Concurrent Mark-Sweep – в нем очень много чего можно сделать. Это «живой» проект.

Алексей Рагозин: Я знаю. Есть статья по поводу того, что можно сделать с Concurrent Mark-Sweep.

Реплика из зала: Поэтому 100-200 миллисекунд – это далеко не предел.

Алексей Рагозин: Это хип в 32 ГБ.

Реплика из зала: А, тогда – может быть. Я хотел бы спросить, есть ли у вас опыт использования G1?

Алексей Рагозин: Нет, потому что все попытки использовать его в тестовом окружении приводили к “full gc”.

Реплика из зала: Это когда?

Алексей Рагозин: На Update 26. Разработчики G1 думали, что они смогут реализовать его по принципу single space, а не на основе поколений. Когда они начали его тестировать, то поняли, что без гипотезы о поколениях им тоже не обойтись. Сейчас они активно «тюнят» основанную на поколениях механику, у нее недостаточно «умная» эвристика, чтобы работать хорошо.

Реплика из зала: Он же, в принципе, гарантирует…

Алексей Рагозин: Он ничего не гарантирует – на последнем запуске он даже не пытался делать “partial collection” – он сразу «сваливался» в “full gc”, как только у него кончалась память.

Реплика из зала: Вы это про HotSpot JVM?

Алексей Рагозин: Да, про HotSpot.

Реплика из зала: Здравствуйте, я бы хотел вас спросить про Concurrent Mark-Sweep в HotSpot JVM. Периодически он пишет блокировки в CMS-режиме. Было что-то такое? При этом он встает секунд на… много.

Алексей Рагозин: Если Concurrent Mark-Sweep не успевает очищать память… Грубо говоря, если нам нужно выделить память в старом пространстве, а у нас ее нет, то JVM прекращает конкурентную сборку и включает однопоточную cборку (full gc) всей «кучи». Это проблема. Бороться с этим надо либо более агрессивными Concurrent-фазами, либо увеличением размера хипа, чтобы было больше времени на «сборку мусора». Также проблема может заключаться во фрагментации хипа. Это «лечится» или тюнингом приложения, или увеличением хипа.

Реплика из зала: А как посмотреть на эту фрагментацию хипа, как предсказать такую ситуацию?

Алексей Рагозин: Есть специальные ключи, я их показывал. У меня на aragozin.blogspot.com есть целая серия статей, посвященных недокументированным ключам в JVM. Там очень много диагностических приемов, в том числе тех, которые показывают статистику по свободному месту в CMS garbage collector.

Реплика из зала: Я еще хотел спросить про PermGen, есть масса проектов с большими PermGen. Как там происходит «сборка мусора»?

Алексей Рагозин: Точно так же, как на старом пространстве. Просто по умолчанию сборка там не происходит. Есть ключ, который включает Concurrent Mark-Sweep и для PermGen тоже.

Спасибо за внимание! Вижу, что тема для многих актуальна. Я время от времени выступаю с докладами и о «жестком тюнинге» JVM – если интересно, обращайтесь.

Комментарии

Нет ни одного комментария

Только пользователи могут оставлять комментарии

Возможно, вам будет интересно:

Артем Вольфтруб

Артем Вольфтруб

Директор по разработке компании «Грамант». Более 15 занимаюсь разработкой программного обеспечения. Эксперт в области веб-технологий и электронноий рекламы.
Неоднократно выступал с докладами на профессиональных конференциях (HighLoad, РИТ, CodeFest и других)

Артем Вольфтруб (Gramant) рассказывает о тонкостях создания баннерной системы и работы с ней.

Ярослав Сергеев

Ярослав Сергеев

Исполнительный директор компании Wamba, ранее - php-разработчик, архитектор, тимлид, IT-директор и, наконец, CEO. Знает Wamba, как никто другой (речь идёт как о технической стороне проекта, так и о команде сотрудников).

Рассказ о проекте Mamba и "граблях", на которые наступала команда по мере его роста.

Джош Беркус (Josh Berkus)

Джош Беркус (Josh Berkus)

Член команды PostgreSQL, специалист по БД.

Джош Беркус (Mozilla) рассказывает о крупномасштабном приложении для сбора данных Firehose (англ. «пожарный шланг»).