Java. Сборщики мусора. Часть первая.
Честно признаюсь — я жутко ленив. Я не люблю копаться в чем-то, добиваясь наилучшего быстродействия. Я могу, к примеру, полгода перешагивать через провод удлинителя, протянутый через всю комнату, пока однажды, споткнувшись, не оторву его нахуй. Вот после этого я наконец-то озадачусь протягиванием нормальной проводки до нужного угла комнаты или сделаю перестановку. Но не раньше.
Примерно так же я относился до недавнего времени к регулярно вылетающим PermGen: OutOfMemory exception от JBOSS Application Server на моем ноуте. Ну, бывает иногда, и хуй с ним.
Пока однажды я не заметил, что перезапускаю сервер по три-четыре раза в час, теряя на этом минут пять. Пять минут в час, если кто не понял — это 730 часов в год или 30 дней, или месяц. Месяц тупого пяленья в мелькающие в консоли строки загрузки сервера и initial deploy’я проекта! Блядь!
И я решил раскопать, что именно вызывает этот самый PermGen: OutOfMemory exception. Чтобы, разумеется, “протянуть нормальную проводку”.
Прежде чем начинать разговор о том, что такое PermGen, и почему в нем заканчивается место, а также как это поправить, я расскажу на пальцах, как JavaTM работает с памятью, что такое сборщики мусора, какие виды сборщиков мусора бывают и как они работают в современных JVM.
I. Общие слова
Так уж исторически сложилось, что в отношении работы с памятью Java сравнивают с c++. Корни такого подхода лежат еще в тех временах, когда Java нигде толком не использовалась, но уже активно рекламировалась и им нужно было как-то перетягивать разработчиков с c++.
В c++ в общем случае выделение памяти происходит явно с помощью явно написанного конструктора, в котором разработчик часто вручную управляет выделением памяти с помощью операторов malloc/calloc, причем разработчику надо явно проинициализировать переменные, с которыми он работает. Удаление объекта производится вызовом деструктора, который также пишется вручную, и лучше бы ему не забыть очистить всю память, которую он выделил под объект, иначе он замудохается искать, куда утекает память приложения.
В JavaTM возлагает на виртуальную машину управление памятью — выделение памяти под объекты, удаление объектов, освобождение памяти. В том же самом общем случае объекты хранятся в куче, а не в стеке (за исключением примитивных типов), что увеличивает время выделение памяти и доступа к объекту. Зато JVM их 100% проинициализирует, так что вы не получите
Во-вторых, удалением объекта и освобождением памяти занимается не вручную написанный деструктор, а сборщик мусора (garbage collector), работающий в параллельном потоке. Что, с одной стороны, очень хорошо — теперь программисту не нужно думать о том, прибрался ли он за собой, а также о том, где взять память, но, с другой стороны, неизвестно, когда именно сборщик мусора утилизует объект и отдаст свободную память. Не говоря уж о том, что сборщик мусора тоже требует для своей работы системных ресурсов.
II. Выделение памяти
Прежде чем переходить к сборщикам мусора, не лишним будет напомнить, как происходит выделение памяти под объекты:
Рассмотрим простейший пример:
1 public class FuckingExample {
2 int x;
3 }
4 FuckingExample boolShit = new FuckingExample();
С помощью оператора new мы можем создать новый объект. При этом JVM самостоятельно выделит память под объект и проинициализирует его поля (в данном примере — x). В отличии от C++ в Java не нужно писать конструктор по умолчанию — JVM в случае его отсутствия самостоятельно справится с инициализацией объекта.
В отличии от все того же многострадального C++ не нужно явно инициализировать поля с примитивными типами — JVM дает гарантию, что присвоит переменной примитивного типа значение этого типа по умолчанию. Другими словами, вы можете быть уверенными, что в случае, если вы явно не проинициалируете переменную, как в примере, то значение x будет равно 0, а не мусору, как было бы в C++.
Впрочем, не все объекты располагаются в куче. Для примитивных типов сделано исключение, они располагаются в стеке. Если же нужен не примитивный тип int, а объект типа Integer (ну, к примеру, тебе понадобились специфические методы этого объекта), то надо объявить его так:
Integer x = new Integer(2);
В этом случае x является экземляром класса Integer и располагаться в куче.
На этом месте я закончу с выделением памяти, интересующиеся могут обратиться к книге Брюса Экеля ”Философия JAVA” или к спецификации языка
III. Освобождение памяти. Алгоритмы сборщиков мусора
Надеюсь, все заметили, что в примере, приведенном выше, у класса FuckingExample нет деструктора. Это потому, что он не нужен. После того, как объект станет (на него не будет ссылки из статической переменной или активного фрейма стека), он будет передан сборщику мусора, который очистит от него память и вернет эту свободную память приложению.
Как же именно работают эти самые сборщики мусора? Ответ на это дает нам статья Брайана Гетца ”Краткая история развития технологии утилизации памяти”, которую я бессовестно спиздел, слегка переработав.
Выделяют два типа алгоритмов сборщика мусора: подсчет ссылок и трассирующие сборщики мусора.
Подсчет ссылок
Идея очень простая: Каждый объект имеет ассоциированное с ним количество ссылок — число активных ссылок на данный объект. Если количество ссылок равно нулю — это мусор (недоступный для пользовательской программы), и он может быть переработан. Но эта простая идея требует значительной поддержки от компилятора и даёт дополнительную избыточность для мутатора (термин для пользовательской программы с точки зрения сборщика мусора). Каждый раз, когда модифицируется ссылка на указатель, например, с помощью оператора присваивания, или когда ссылка выходит за границу, компилятор должен генерировать код для того, чтобы обновить количество ссылок на указываемый объект. Если количество ссылок на объект доходит до нуля, среда выполнения может вернуть блок информации немедленно (и уменьшить количество ссылок для всех блоков, на которые ссылается возвращаемый блок) или поместить его в очередь для отсроченной утилизации.
Собственно, это все, что нужно знать про этот алгоритм сборки мусора. Все равно он на практике сейчас не используется.
Трассирующие сборщики мусора
Трассирующий сборщик останавливает приложение (хоть это и не нужно на весь период сборки мусора) и начинают трассировать объекты, начиная с корневого набора, и проходят по всем ссылкам, пока все доступные объекты не будут проверены. Корни могут быть найдены в регистрах программ, в локальных (стековых) переменных, в стеке каждого из потоков и в статических переменных.
Маркирующе-зачищающие сборщики мусора
Наиболее простая форма трассирующего сборщика мусора, впервые предложенная создателем языка Lisp Джоном Маккарти в 1960г., — это маркирующе-зачищающий сборщик, в котором среда выполнения останавливается, и сборщик посещает каждый живой узел, начиная с корней, и маркирует каждый посещённый узел. Когда не существует больше не отслеженных ссылок, сборка мусора завершена, и затем динамическая память зачищается (то есть каждый объект динамической памяти проверяется), а любой непомеченный объект регенерируется как мусор и возвращается в список свободной памяти.
Маркировка-зачистка проста для реализации, может легко восстанавливать циклические структуры и не даёт дополнительной нагрузки на компилятор или мутатор как подсчёт ссылок. Но у неё есть недостатки — паузы на сбор мусора могут быть долгими, и вся динамическая память посещается в фазе зачистки, что может иметь весьма негативные последствия на работу систем с виртуальной памятью, где динамическая память может быть разбита на страницы.
Большая проблема с маркировкой-зачисткой состоит в том, что каждый активный (то есть, распределённый) объект, доступен он или нет, посещается в фазе зачистки. Потому что значительный процент объектов, вероятно, окажется мусором, что означает, что сборщик тратит значительные усилия на проверку и обработку мусора. Маркирующе-зачищающие сборщики мусора также имеют тенденцию фрагментировать динамическую память, что может вызвать проблемы с локализацией и может также стать причиной ошибок выделения памяти, даже когда доступно достаточное количество свободной памяти.
Копирующие сборщики мусора
В копирующем сборщике, другом виде маркирующего сборщика, динамическая память делится на две равные по размеру части, одна из которых содержит активные данные, а другая не используется. Когда активная часть заполнена, среда выполнения останавливается, и действующие объекты копируются из активной части в неактивную. При этом части меняются своими ролями: старая неактивная часть становится активной и наоборот.
Преимущество копирующих сборщиков мусора состоит в том, что они посещают только живые объекты, что означает, что объекты мусора не будут исследоваться, им не потребуется подкачка в память и помещение в кэш. Продолжительность циклов сборки мусора копирующего сборщика зависит от количества живых объектов. Однако, копирующие сборщики создают дополнительные затраты на копирование данных из одной части в другую с исправлением всех ссылок на местонахождение новой копии. В частности, долгоживущие объекты будут копироваться туда-сюда при каждой сборке мусора.
Копирующие сборщики приносят пользу ещё и тем, что живые объекты уплотняются в нижнюю часть динамической памяти. Это не только улучшает локальность ссылок пользовательской программы и позволяет избежать фрагментации динамической памяти, но и значительно уменьшает затраты на размещение объекта. Эта процедура становится просто добавлением ссылки к указателю вершины динамической памяти. Нет необходимости поддерживать списки свободной памяти или сохраняющиеся списки или выполнять алгоритмы наилучшей подгонки или первого подходящего — выделение места для N байт сводится к добавлению N к указателю вершины динамической памяти и возвращению его предыдущего значения.
Разработчики, которые использовали сложные схемы управления памятью для языков, не использующих сборщики мусора, могли бы сильно удивиться тому, насколько незатратным может быть распределение памяти — простое добавление ссылки — в копирующем сборщике мусора. Это может быть одной из причин распространённого мнения, что распределение объектов связанно с затратами, в более ранних версиях виртуальной машины Java не использовались копирующие сборщики мусора, и разработчики все еще подспудно считают, что затраты на распределение такие же, как в других языках, типа C, тогда как на самом деле они могут быть значительно меньше в исполняемой среде Java. Но не только издержки на распределение памяти меньше, но также для объектов, которые превратились в мусор до начала следующего цикла сборки, затраты на освобождение памяти равны нулю, так как утилизируемый объект не будет ни проверяться, ни копироваться.
Маркирующе-сжимающие сборщики мусора
Копирующий алгоритм имеет прекрасные рабочие характеристики, но его основным недостатком является потребность в объёме памяти большем в два раза, чем объём, необходимый для маркирующе-зачищающего сборщика мусора. Алгоритм маркировки-сжатия сочетает маркировку-зачистку и копирование таким образом, что эта проблема исчезает за счет того, что процесс сборки мусора усложняется. Как и маркировка-зачистка, маркировка-сжатие состоит из двух фаз. В фазе маркировки каждый действующий объект рассматривается и маркируется. Затем маркированные объекты копируются таким образом, что все действующие объекты уплотняются в самом низу динамической памяти. Если полное сжатие совершается при каждой сборке мусора, полученная в результате динамическая память такая же как и результат работы копирующего сборщика — между активной частью динамической памяти и свободной существует чёткая граница, так что затраты на размещение сравнимы с затратами при использовании копирующего сборщика мусора. Долгоживущие объекты имеют тенденцию накапливаться в самом низу динамической памяти, поэтому они не копируются циклически, как при использовании копирующего сборщика мусора.