Что такое сборщик мусора в программировании
В этой статье — важное понятие из компьютерной теории. Читайте, если хотите разбираться в устройстве компьютеров и памяти. Особенно полезно для бэкенда и разработки высоконагруженных систем.
Ситуация
Когда мы пишем программы, мы обычно действуем по такому принципу:
- если надо — объявляем переменную и храним в ней данные;
- если переменных нужно много — делаем много переменных;
- переменные можно объявлять внутри циклов, а циклы вкладывать в другие циклы;
В итоге наши программы могут обрабатывать сотни, тысячи и миллионы переменных с числами и текстом, и для нас это в порядке вещей. Компьютер, собственно, и создан для того, чтобы обрабатывать за нас эти массивы данных.
Проблема
Когда мы объявляем новую переменную, под неё выделяется кусок памяти, где она будет храниться. Часто бывает такое, что даже если эта переменная потом нигде не используется, то она всё равно занимает место в памяти.
Если таких переменных будет много, то программа может занять много памяти. Когда память забивается, компьютер начнёт тормозить. Вся занятая память освободится только тогда, когда программу закроют, а до того времени всё будет тормозить.
Особенно это опасно на контроллерах или носимых устройствах: там обычно мало памяти и её легко заполнить. А контроллеры управляют не только умным домом, но и, например, самолётами или промышленными станками. Если не подумать о памяти в контроллере двигателя самолёта, то у тебя на третьем часу полёта откажет двигатель.
Решение — очистка мусора и управление памятью
Кто-то в программе должен озаботиться тем, чтобы ненужные переменные умирали и высвобождали занятую ранее память. В этом деле есть два подхода: ручной и автоматический.
В ручном режиме программист сам следит за каждой переменной, объектом и сущностью. Когда объект или переменная больше не нужны, он прямо в коде пишет: «Этот готов, уносите». Для этого он использует специальные команды-деструкторы, которые удаляют переменную и освобождают память. Теперь эту область памяти может взять себе другая программа или переменная, тогда ресурсы расходуются максимально экономно.
Автоматический режим называется сборкой мусора. Это такая отдельная мини-программа внутри основной программы, которая периодически пробегает по объектам и переменным в коде и смотрит, нужны они или нет. Если нет — сборщик мусора сам удаляет переменную и освобождает память.
Особенности ручного управления
При ручной работе с памятью программист получает полный контроль над ресурсами и может в любой момент освободить уже ненужную память. Это значит, что можно написать такую программу, когда в сумме переменным нужно 500 мегабайт памяти, но за счёт своевременного удаления всегда заняты только 100 мегабайт.
Ручное управление идеально для систем со слабыми ресурсами и систем реального времени — там, где программа не имеет права тормозить.
Вместе с тем такой подход требует высокой квалификации программиста. Нужно точно знать, какая переменная тебе нужна и когда; как устроены циклы твоей программы; как работает процессор и куда он может посмотреть в тот или иной момент.
Особенности автоматического сборщика
Автоматический сборщик сам ходит по программе во время исполнения и аккуратно подчищает память, как только находит мусор.
Вроде хорошо, но нет. Сборщик мусора тоже работает неидеально:
❌ Сборщик удаляет только те переменные, в которых он уверен стопроцентно. Если есть один шанс, что переменная может когда-нибудь понадобиться, — её оставляют в памяти. То есть эффективность не стопроцентная.
❌ Сборщик — это отдельная программа, которая работает вместе с основной. И ей тоже нужны ресурсы и процессорное время. Это замедляет работу основной программы. Как если бы уборщик приходил в отделение Сбербанка посреди рабочего дня и заставлял всех на два часа покинуть рабочие места, чтобы он тут провёл влажную уборку.
❌ Если рабочей памяти очень мало, то сборщик будет работать постоянно. Но ему тоже нужна своя память для работы. И может получиться, что сборщик, например, занимает 100 МБ, а освобождает 10 МБ. Может оказаться так, что без сборщика программа будет работать эффективнее.
Автоматические сборщики — добро или зло?
Тут у разработчиков мнения разделяются.
С точки зрения быстродействия сборщики — однозначно зло, потому что они всегда замедляют работу. Есть ситуации, когда замедление незаметно, а бывает, когда оно критично.
- Например, если офисное приложение иногда подвисает на полсекунды, это почти никто не замечает. Возможно, оно у вас подвисает прямо сейчас.
- А если на полсекунды подвиснет контроллер ракетного двигателя, то эта ракета полетит не в космос, а в Вашингтон. Хьюстон, у нас проблема.
С точки зрения удобства сборщики — однозначно добро. Пишешь полезный код, а умная машина сама за тобой прибирается. Программы выходят быстрее, для их поддержки нужно меньше людей, которые могут быть менее компетентными.
Что выбрать?
С выбором интересная ситуация.
Если вы пишете приложения для iOS или OSX, вам нельзя использовать сборщики мусора из соображений быстродействия.
Языки типа JavaScript и Ruby собирают мусор сами, вы об этом можете даже никогда не узнать.
В некоторые языки можно подключить сбор мусора, пожертвовав производительностью — например, в Java, C или C++.
Путь самурая — вручную управлять памятью и делать суперпроизводительные приложения, которые летают даже на процессоре от карманного калькулятора. Но жизнь сложна и разнообразна, и однажды за всеми нами тоже придут наши собственные… сборщики.
Веб-разработка — это новый чёрный
На базе веб-технологий делают всё — от сложного софта до высокобюджетных игр. Изучите технологии и начните карьеру в ИТ. Старт бесплатно. Попробуйте, вдруг вам понравится.
Получите ИТ-профессию
В «Яндекс Практикуме» можно стать разработчиком, тестировщиком, аналитиком и менеджером цифровых продуктов. Первая часть обучения всегда бесплатная, чтобы попробовать и найти то, что вам по душе. Дальше — программы трудоустройства.
Что такое сборщик мусора c
В программировании сборка мусора (устоявшийся термин, с точки зрения русского языка правильнее «сбор мусора», англ. garbage collection, GC) — одна из форм автоматического управления памятью. Специальный код, называемый сборщиком мусора (garbage collector), периодически освобождает память, удаляя объекты, которые уже не будут востребованы приложением — то есть производит сборку мусора.
Цитата из Википедии
Посмотрите на следующий код:
Много раз мы анализировали работу подобного кода по шагам:
- Создаётся переменная с именем x (ячейка фиксированной длины в памяти)
- Где-то в памяти создаётся объект
- Ссылка на созданный объект помещается в переменную x
Все ясно, правда?
А теперь, следом за первой строкой кода запишем вторую:
Здесь тоже все понятно: число 1 записывается в переменную x (в ту ячейку памяти фиксированной длины, которая отведена под x ).
А что случилось со ссылкой на объект? Понятно, что. Она пропала, затерлась единицей. А что случилось с самим объектом?
Предположим, что объект остался в памяти. Но этот объект — явный мусор: нет переменных, которые на него ссылаются, значит, к нему никак нельзя обратиться из программы.
Хорошо бы такие объекты удалять из памяти, освобождая пространство для новых нужд. Иначе во время работы программы может наступить момент, когда мусором будет забита вся память.
Конечно, мусор надо убирать. За чистотой в доме следит сборщик мусора — составная часть интерпретатора JavaScript.
Область памяти | Ссылки |
---|
У сборщика мусора есть «журнал» учета памяти.
Область памяти | Ссылки |
---|---|
x |
Вид журнала (условно) после выполнения первой строки:
Область памяти | Ссылки |
---|---|
нет ссылок |
Вид журнала (условно) после выполнения второй строки:
Сборщик мусора, просматривая журнал, обнаружит, что на область памяти, отведенную под объект , нет ни одной ссылки. Объект будет уничтожен, свободная память пополнится. Сборщик мусора отложит метлу и выпьет стакан чаю.
А теперь посмотрите на следующий код:
; var y = x; x = 1;
Есть работа для сборщика мусора? Нет, он отдыхает. В самом деле, посмотрим, как заполняется журнал учета памяти.
После первой команды:
Область памяти | Ссылки |
---|---|
x |
Сборщик мусора на С++
Привет, Хабр! Эту статью я задумал довольно давно. Речь в ней пойдет о простейшем копирующем сборщике мусора на С++. У него довольно много ограничений (часть не мешает, часть можно обойти, если задаться целью написать какую-то серьезную библиотеку, а для кое-чего неплохо было бы заиметь зачаточную поддержку от языка), зато и кода в нем чуть больше 100 строк. Заинтересовавшихся прошу под кат. Там минимум ООП, простейшие шаблоны и жуткие магические ритуалы с указателями.
Начнем с начала. Что такое сборщик мусора и для чего он нужен?
Сборщик мусора (Gargabe Collector, GC) — это такой способ управления ресурсами, обычно — оперативной памятью, выделяемой в куче. Суть проста — программист просит сборщик мусора выделить ему кусок памяти, а тот уже сам определяет, когда он станет не нужен и может быть освобожден. Это решает большинство проблем с утечками памяти, хотя и лишает возможности героически превознемогать ошибки сегментации. Какая жалость.
Сборщики мусора бывают двух видов — консервативные и копирующие.
Нечто вроде первого типа реализовано в последнем стандарте. Механизм shared_ptr позволяет отказаться от явного использования операторов new и delete. Он считает ссылки, которые указывают на объект и избавляется от него, когда их число становится нулевым. Проблемы с этим подходом возникают, когда создается множество мелких объектов с коротким, но не одинаковым сроком жизни. В результате, куча превращается в кровавое месиво из валидных объектов и лоскутков свободной памяти по несколько десятков байт. Из-за этого создание новых объектов начинает занимать целую вечность и нативный код начинает завидовать Питону.
Для решения этой проблемы — фрагментации кучи – придуман второй тип сборщиков — копирующий. До поры до времени, его стратегия борьбы с мусором — созерцание. Когда же его становится слишком много, он делает следующее:
1. Копирует все нужные данные в другую область памяти.
2. Меняет все указатели на актуальные.
3. Освобождает всю память, которая уже не используется.
Сразу уточню, что я не смотрел вплотную ни одного алгоритма сборки мусора, ни как работают «взрослые» библиотеки GC для С++. Вероятно, у алгоритма, который я сейчас опишу, есть название, возможно, именное, но в тут я обойдусь без ссылок на источники.
Чтобы определить объекты, которые еще нужны программе, предлагаю рассматривать память как обычный граф. Тогда «живыми» объектами после сборки мусора будут считаться те, которые достижимы через цепочку указателей. Здесь возникают вопросы. Во-первых — как для любого возможного объекта, который программист может попросить создать, определить, где у него находятся указатели. Первый способ — с помощью шаблонной магии для каждого класса объектов создавать свой собственный аллокатор. Ужасная идея по многим причинам. Второй — в заголовке каждого объекта писать всю информацию, которая нужна для работы GC (ах да, для классов с виртуальными функциями эта реализация не годится. Пара идей у меня есть, при необходимости).
Заголовок тоже можно использовать многими способами. Мой — самый простой, который вообще может быть (для реализации, но не использования). Во-первых, каждый объект, который планирует создаваться через сборщик мусора, должен иметь в начале своего определения эту структурку:
enum < STRUCT_SZ = 0, REF_COUNT = 1 >; struct gcHeader < union < int gcData[2]; gcHeader* post_gcAddress; >; >;
Во-вторых, сразу после заголовка, и нигде более, должны идти все указатели, которые также относятся к сборщику мусора. Соответственно, в gcData[REF_COUNT] будет хранится их количество. Это одно из ограничений, которое накладывает моя реализация. В gcData[STRUCT_SZ] будет храниться размер структуры в байтах. Назначение указателя я раскрою позднее. Что удобно, размер структуры оказался равен размеру указателя (сейчас 2014, народ!).
Отлично, теперь мы сможем обойти всю нашу память. Вопрос в том, откуда начать обход. Единственная область памяти, которая нам стопроцентно доступна в любой момент — это стек. Проблема та же, что и с указателями в структурах — мы понятия не имеем, какая кучка байт указывает на объект GC. Поэтому, нужно расположение каждого такого указателя записывать явно.
vector referencesStack; template class stackRef < public: T* ref; stackRef()< ref = nullptr; referencesStack.push_back(reinterpret_cast(&ref)); > ~stackRef() < referencesStack.pop_back(); >>;
Класс stackRef действует просто. При инициализации, он всего-лишь добавляет свой адрес в стек ссылок. Деструктор, соответственно, удаляет неактуальный адрес из того же стека. Работа стека вызовов и конструкторов с деструкторами синхронизирована, так что аномалий возникать не будет.
В классе нужно переопределить кучу операторов — разыменования, присваивания, etc, но этим появится смысл заниматься не раньше, чем со мной свяжутся парни из Boost Foundation.
Вспомогательные структуры готовы. Можно перейти к выделению памяти.
Классной фичей такого способа управления ресурсами является именно способ их выделения. Стандартному аллокатору С++ приходится после каждого delete обновлять списки свободных блоков, а после new находить среди блоков подходящий, потом дробить его на два мелких блока, а потом что-то еще, что там делают современные аллокаторы. В случае сборщика мусора, операция delete просто не нужна, так что вся занятая память будет идти одним сплошным блоком. Чтобы создать новый объект, нужно всего лишь увеличить размер этого блока, т. е. сдвинуть один указатель. Простая операция, которая выполняется за O(1). Реально перестало это казаться такой уж замечательной идеей, из-за того, что провоцирует кучу сборок тогда, когда можно было бы просто переиспользовать уже не нужную память, но пока можно остановиться на этом.
Разделим память, подконтрольную сборщику мусора на куски по 4 килобайта и свяжем их в список. На самом деле, чуть больше 4 килобайт, но это вопрос моей лени.
const int CHUNK_SIZE = 4096; const int OVERDRAFT = 128; const int ACTUAL_SIZE = CHUNK_SIZE + OVERDRAFT; //Я только что сэкономил себе минут 15 на отладку, если где-то ошибусь в арифметике. struct gcChunk < gcChunk* next; char data[ACTUAL_SIZE];//Эта память и будет выдаваться программе. >; gcChunk* firstChunk; gcChunk* currentChunk; int chunkCount; int currentOffset;
firstChunk — начало списка, currentChunk — последний созданный блок памяти. сurrentOffset — начало свободного сегмента памяти относительно currentChunk.
gcHeader* gcRawAlloc(int size, int refCount) < if (size >CHUNK_SIZE)// Ради proof of concept, уметь создавать большие объекты необязательно return nullptr; if (currentOffset + size > CHUNK_SIZE) < // Было бы правильнее использовать listиз STL, но мне показалось, что лучше будет явно показать весь процесс. ++chunkCount; currentChunk->next = new gcChunk(); currentChunk = currentChunk->next; currentChunk->next = nullptr; currentOffset = 0; > gcHeader* new_obj = reinterpret_cast(&(currentChunk->data[currentOffset])); new_obj->gcData[STRUCT_SZ] = size; new_obj->gcData[REF_COUNT] = (refCount
Здесь неочевидных моментов уже больше. Разберем 12-ую строчку.
На этом этапе нам удобнее не думать о том, какой именно тип у нового объекта. Мы точно знаем, что у него есть наш gcHeader, и этого пока достаточно.
После того, как мы выделили память для нового объекта, нужно инициализировать его заголовок. Что может означать загадочное присваивание
temp->gcData[REF_COUNT] = (refCount
Для этого снова посмотрим на определение заголовка
struct gcHeader < union < int gcData[2]; gcHeader* post_gcAddress; >; >;
Ключевое слово union означает, что и массив gcData и указатель post_gcAddress располагаются по одному адресу. Это полезно для экономии памяти, но проблема в том, что С++ не запоминает, как данные в union использовались в последний раз — как массив, или как ссылка. Помогает такая особенность архитектур процессоров, как необходимость выравнивания данных.
Если вкратце, то любые переменные длиннее одного байта, должны располагаться на адресах, которые кратны машинному слову в байтах. Нарушение выравнивания на современных процессорах сильно замедляет работу программы, а старые ARM вообще отказываются в таких условиях работать. В результате, 2 или 3 младших бита указателя могут быть использованы как угодно по усмотрению программиста. Например, при реализации красно-черных деревьев, последний бит часто задействуют вместо булевой переменной.
Тут — то же самое. Если младший бит равен единице, то эти восемь байт — гарантированно массив из двух int. Можно, например, использовать еще один бит, чтобы указывать сборщику мусора, что-то типа «это ссылка на полиморфный объект, у него есть указатель vtable, не затри его».
Ну и небольшая обертка над функцией, чтобы использование аллокатора не вызывало особой боли.
template T* gcAlloc()< return reinterpret_cast(gcRawAlloc(sizeof(T), T::refCount)); >
Тут следует поставить emplace new, чтобы корректно инициализировались объекты с конструкторами. Как видно, класс объекта, который мы хотим создать, должен иметь статическую константу refCount. Её можно вычислять автоматически с помощью внешнего препроцессора. В противном случае, я вижу тут как минимум три способа отстрелить себе ногу.
Перед тем, как пользоваться этой функцией, нужно инициализировать кучу.
void gcInit()< firstChunk = currentChunk = new gcChunk; firstChunk->next = nullptr; currentOffset = 0; chunkCount = 1; >
Пора посмотреть на реализацию самой сборки мусора.
Первая функция, gcCollect, должна начать кучу с чистого листа, не забыв сохранить указатели на старый список. Эти строки почти повторяют инициализацию.
void gcCollect()< //execvp("rm", "cppgc.cpp");//Без этой строки алгоритм просто нельзя назвать корректным. gcChunk* newFirstChunk = currentChunk = new gcChunk; currentChunk->next = nullptr; currentOffset = 0; chunkCount = 1;
Далее, мы с каждого указателя, который хранится на стеке, начинаем процесс сборки.
for (auto i = referencesStack.begin();i != referencesStack.end(); ++i ) gcMove(*i);
А теперь просто освобождаем ненужную память.
//Сборка завершена, достижимые данные перемещены в другую область памяти, старые можно безболезненно удалить gcChunk iter = firstChunk; firstChunk = newFirstChunk; while (iter != nullptr)< gcChunk* t = iter->next; delete[] iter; iter = t; > >
Обратите внимание, delete вызывается только для больших блоков памяти. Таким образом, деструкторы объектов в сборщике мусора никогда не будут вызваны. Это не проблема для классов, у которых деструкторы только освобождают память, но нет возможности, например, автоматически закрывать соединения и файловые дескрипторы. Mark & Sweep-алгоритмы могут помочь с этим, но и писать их сильно сложнее.
Последний штрих — функция gcMove.
bool isPointer(gcHeader a) < return (a.gcData[REF_COUNT] & 1) == 0; >void gcMove(gcHeader** current)< if (*current == nullptr) return; if (isPointer(**current))/Ссылка>post_gcAddress; return; > gcHeader* new_obj = gcRawAlloc((*current)->gcData[STRUCT_SZ], (*current)->gcData[REF_COUNT]); memcpy(new_obj, (*current), sizeof(char) * (*current)->gcData[STRUCT_SZ]); gcHeader** iterator = reinterpret_cast(new_obj) + 1; (*current)->post_gcAddress = new_obj; (*current) = new_obj; int refCount = new_obj->gcData[REF_COUNT] >> 1; for (int i = 0; i
Разберем функцию с середины. Нам нужна возможность перенаправлять ссылки, поэтому в функцию передается указатель на указатель на данные.
GC выделяет объекту нужное количество памяти в новой куче (он знает, сколько, из заголовка) и копирует все данные из старой инкарнации в новую. Потом он пишет новый адрес объекта в старый заголовок. Теперь, если на объект указывает несколько ссылок, алгоритм сможет определить, что объект уже один раз перемещался (младший бит, гарантированно, 0) и не станет копировать его лишний раз впоследствии. Осталось перенаправить старый указатель на новую копию объекта.
Теперь, нужно разобраться с указателями, самого объекта. С ними нужно сделать то же самое. Строчка
gcHeader** iterator = reinterpret_cast(temp) + 1;
получает указатель на первую ссылку в структуре (если она есть, конечно). Помним, что sizeof(gcHeader) == sizeof(void*). В противном случае, это будет занимать на пару строк больше.
Что делать дальше, вопрос уже спорный. Я просто для каждого указателя рекурсивно вызываю функцию gcMove. Такой алгоритм соответствует обходу графа в глубину. Однако, это не лучший выбор.
Киллер-фича копирующих сборщиков мусора для меня — это возможность поддерживать локальность по ссылкам. Если коротко, объекты, которые ссылаются друг на друга, и в памяти тоже должны находиться как можно ближе. Так процессор сможет эффективнее использовать свою кэш-память.
Скрытый текст
Можно провести эксперимент. Создать связный список и в произвольные места начать вставлять элементы. А затем — просто обойти и распечатать весь список. Скорее всего, программа на С++ будет выполнять последний этап дольше, чем Java или C# после принудительной сборки мусора. Всё из-за того, что в случае С++, процессор будет постоянно стопориться на кеш-промахи и ждать, пока прибудут данные из медленной оперативной памяти. В случае Java, это будет практически обход цельного массива.
Мой GC такого не умеет. Я выбрал обход в глубину из-за простоты. Желательно перемещать объекты в порядке обхода в ширину. Будет совсем здорово заморочиться и выстраивать объекты в памяти в соответствии со статистикой обращений к ним, как в этом алгоритме, чтобы добиться оптимального количества промахов.
На этом всё. Осталось продемонстрировать работу со сборщиком.
В качестве примера возьму простейшее дерево поиска.
struct searchTree < gcHeader gc; searchTree* left; searchTree* right; int key; static const int refCount = 2; >;
Как уже упоминалось, в начале должен стоять заголовок, а после заголовка все указатели на объекты нашей кучи.
void stAdd(searchTree* &target, int key)< if (target == nullptr)< target = gcAlloc(); target->left = target->right = nullptr; target->key = key; return; > if (target->key == key) return; if (target->key < key) stAdd(target->left, key); else stAdd(target->right, key); >
Обычное добавление в дерево. Обратите внимание, как используется gcAlloc.
searchTree* stFind(searchTree* target, int key)< if (target == nullptr || target->key == key) return target; if (target->key < key) return stFind(target->left, key); else return stFind(target->right, key); > void stPrint(searchTree* t, int indent = 0)< if (t == nullptr) return; for (int i = 0; i < indent; ++i) cout key left, indent + 1); stPrint(t->right, indent + 1); > void stCut(searchTree* &target, int key)< if (target == nullptr || target->key == key) < target = nullptr; return; >if (target->key < key) stCut(target->left, key); else stCut(target->right, key); >
stFind возвращает ссылку на поддерево с нужным ключом, stPrint распечатывает ключи и адреса поддеревьев, stCut обрезает поддерево, в котором хранится искомый ключ.
int main() < gcInit(); stackRefroot; stAdd(root.ref, 2); stAdd(root.ref, 1); stAdd(root.ref, 3); stAdd(root.ref, 6); stAdd(root.ref, 5); stAdd(root.ref, 4); stAdd(root.ref, 8); stackRef additionalRef; additionalRef.ref = stFind(root.ref, 3); cout
Before GC 0xd92058 224 0xd92018 2 0xd92058 3 0xd92078 6 0xd920d8 8 0xd92098 5 0xd920b8 4 0xd92038 1 After GC 0xd93108 224 0xd930e8 2 0xd93108 3 0xd93128 6 0xd93148 8 0xd93168 5 0xd93188 4 0xd931a8 1 Deleted some elements and GC'd. 0xd92038 160 0xd92018 2 0xd92038 3 0xd92058 6 0xd92078 8 0xd92098 1
Как видно работа со структурами для программиста особо не меняется. Что тут происходит:
1. Мы произвольно заполняем дерево поиска.
2. Создаем еще одну стековую ссылку на один из элементов, чтобы проверить, как GC реагирует на множественные ссылки на один объект.
3. Распечатываем дерево, additionalReference и currentOffset.
4. Вхолостую вызываем сборку мусора.
5. Снова распечатываем дерево. Все указатели, которые нужны — поменялись.
6. Обрезаем одно поддерево и снова вызываем сборку мусора. Всё снова работает как надо. Обратите внимание currentOffset уменьшился, а корень дерева вернулся на тот же адрес, по которому находился в первый раз.
Выводы
Итак, в С++ можно использовать Garbage Collector. Причем, вполне себе симпатичный, на мой замыленный взгляд, да еще и с минимальным оверхедом. Попробую перечислить всё, что нужно сделать, чтобы он был действительно удобным и полезным.
Сначала — организационные моменты.
1. Глобальные переменные — это, конечно, совсем не клёво. Нужно всё оформить как человеческий класс и\или аллокатор С++.
2. Заставлять в каждый класс вставлять хедер — почти садизм. Нужно просто давать отнаследоваться от абстрактного класса, у которого должны быть два метода — getSize и getLayout. Последний должен возвращать ссылку на массив, в котором хранятся относительные координаты всех указателей (затея с «все ссылки стоят начале» ну совсем не годится для чего-то серьезного). Этот массив однозначно должен заполняться автоматически, но я пока что не представляю, как это можно реализовать.
Следующий вопрос — автоматическая сборка. Когда выдвинули саму идею GC, никто не подразумевал, что кто-то реально будет постоянно вызывать что-то вроде функции gcCollect, всё должно происходить само по себе. Но С++ — особый случай. Он славится тем, что весь поток выполнения под носом, или, по крайней мере, предсказуем. Своенравный Garbage Collector любого другого языка здесь — это практически идеологическое преступление. Так что, у него должно быть как минимум два режима:
1. Прозрачный.
2. Бросок исключения после исчерпания некой квоты памяти. Тут уже программисту решать — убраться или выделить память форсированно.
И еще один вопрос. Многопоточность. Тут всё плохо. Чтобы начать сборку мусора, нужно приостановить все потоки, чтобы ничего не сломать. В итоге придется написать половину JVM. Самым лучшим решением мне кажется его отсутствие. Для каждого потока можно просто создавать свой выделенный GC, а если понадобится передать что-то другому подпроцессу, то обычные shared_ptr никто не отменял. Без разделяемой памяти жить, как правило, гораздо веселее.
Закончим на печальной ноте. Такой сборщик мусора тотально несовместим ни с одной готовой библиотекой. Их объекты не смогут предоставлять необходимые данные. Несмотря на то, что std::list, std::map и std::set только выиграют, если переписать их специально под GC, переделывать N гигабайт исходников Boost, например, совершенно бесперспективно. Однако, для борьбы с фрагментацией в случае маленьких объектов, мне такая штука кажется очень даже полезной.
Скачать и поиграться можно отсюда.
- с++
- сборка мусора
- писать статьи дико тяжело
- C++
- Системное программирование
Сборка мусора в Java: что это такое и как работает в JVM
Перед началом статьи хочу сказать, что еще больше полезной и нужной информации вы найдете в моём Telegram-канале. Подпишитесь, мне будет очень приятно.
37 показов
18K открытий
Что такое сборка мусора в Java?
Сборка мусора — это процесс восстановления заполненной памяти среды выполнения путем уничтожения неиспользуемых объектов.
В таких языках, как C и C++, программист отвечает как за создание, так и за уничтожение объектов. Иногда программист может забыть уничтожить бесполезные объекты, и выделенная им память не освобождается. Расходуется все больше и больше системной памяти, и в конечном итоге она больше не выделяется. Такие приложения страдают от “утечек памяти”.
После определенного момента памяти уже не хватает для создания новых объектов, и программа нештатно завершается из-за OutOfMemoryErrors.
В C++ для сборки мусора можно воспользоваться методом delete(), а в C — методом free(). В Java сборка мусора происходит автоматически в течение всего времени работы программы. Это устраняет необходимость выделения памяти и, следовательно, позволяет избежать утечек.
Сборка мусора в Java — это процесс, с помощью которого программы Java автоматически управляют памятью. Java-программы компилируются в байт-код, который запускается на виртуальной машине Java (JVM).
Когда Java-программы выполняются на JVM, объекты создаются в куче, которая представляет собой часть памяти, выделенную для них.
Пока Java-приложение работает, в нем создаются и запускаются новые объекты. В конце концов некоторые объекты перестают быть нужны. Можно сказать, что в любой момент времени память кучи состоит из двух типов объектов.
- Живые — эти объекты используются, на них ссылаются откуда-то еще.
- Мертвые — эти объекты больше нигде не используются, ссылок на них нет.
Сборщик мусора находит эти неиспользуемые объекты и удаляет их, чтобы освободить память.
Как разыменовать объект в Java
Основная цель сборки мусора — освободить память кучи, уничтожив объекты, которые не содержат ссылку. Когда на объект нет ссылки, предполагается, что он мертв и больше не нужен. Таким образом, память, занятая объектом, может быть восстановлена.
Есть несколько способов убрать ссылки на объект и сделать его кандидатом на сборку мусора. Вот некоторые из них.
Сделать ссылку нулевой
Student student = new Student(); student = null;
Назначить ссылку другому объекту
Student studentOne = new Student(); Student studentTwo = new Student(); studentOne = studentTwo; // Т
Использовать анонимный объект
register(new Student());
Как работает сборка мусора в Java?
Сборка мусора в Java — автоматический процесс. Программисту не нужно явно отмечать объекты, подлежащие удалению.
Сборка мусора производится в JVM. Каждая JVM может реализовать собственную версию сборки мусора. Однако сборщик должен соответствовать стандартной спецификации JVM для работы с объектами, присутствующими в памяти кучи, для маркировки или идентификации недостижимых объектов и их уничтожения через уплотнение.
Каковы источники для сборки мусора в Java?
Сборщики мусора работают с концепцией корней сбора мусора (GC Roots) для идентификации живых и мертвых объектов.
Примеры таких корней.
- Классы, загружаемые системным загрузчиком классов (не пользовательские загрузчики классов).
- Живые потоки.
- Локальные переменные и параметры выполняемых в данный момент методов.
- Локальные переменные и параметры методов JNI.
- Глобальная ссылка на JNI.
- Объекты, применяемые в качестве монитора для синхронизации.
- Объекты, удерживаемые из сборки мусора JVM для своих целей.
Сборщик мусора просматривает весь граф объектов в памяти, начиная с этих корней и следуя ссылкам на другие объекты.
Этапы сборки мусора в Java
Стандартная реализация сборки мусора включает в себя три этапа.
Пометка объектов как живых
На этом этапе GC (сборщик мусора) идентифицирует все живые объекты в памяти путем обхода графа объектов.
Когда GC посещает объект, то помечает его как доступный и, следовательно, живой. Все объекты, недоступные из корней GC, рассматриваются как кандидаты на сбор мусора.
Зачистка мертвых объектов
После фазы разметки пространство памяти занято либо живыми (посещенными), либо мертвыми (не посещенными) объектами. Фаза зачистки освобождает фрагменты памяти, которые содержат эти мертвые объекты.
Компактное расположение оставшихся объектов в памяти
Мертвые объекты, которые были удалены во время предыдущей фазы, не обязательно находились рядом друг с другом. Поэтому вы рискуете получить фрагментированное пространство памяти.
Память можно уплотнить, когда сборщик мусора удалит мертвые объекты. Оставшиеся будут располагаться в непрерывном блоке в начале кучи.
Процесс уплотнения облегчает последовательное выделение памяти для новых объектов.
Что такое сбор мусора по поколениям?
Сборщики мусора в Java реализуют стратегию сбора мусора поколений, которая классифицирует объекты по возрасту.
Необходимость отмечать и уплотнять все объекты в JVM неэффективна. По мере выделения все большего количества объектов их список растет, что приводит к увеличению времени сбора мусора. Эмпирический анализ приложений показал, что большинство объектов в Java недолговечны.
В приведенном выше примере ось Y показывает количество выделенных байтов, а ось X — количество выделенных байтов с течением времени. Как видно, со временем все меньше и меньше объектов сохраняют выделенную память.
Большинство объектов живут очень мало, что соответствует более высоким значениям в левой части графика. Вот почему Java классифицирует объекты по поколениям и выполняет сборку мусора в соответствии с ними.
Область памяти кучи в JVM разделена на три секции:
Молодое поколение
Вновь созданные объекты начинаются в молодом поколении. Молодое поколение далее подразделяется на две категории.
- Пространство Эдема — все новые объекты начинают здесь, и им выделяется начальная память.
- Пространства выживших (FromSpace и ToSpace) — объекты перемещаются сюда из Эдема после того, как пережили один цикл сборки мусора.
Процесс, когда объекты собираются в мусор из молодого поколения, называется малым событием сборки мусора.
Когда пространство Эдема заполнено объектами, выполняется малая сборка мусора. Все мертвые объекты удаляются, а все живые — перемещаются в одно из оставшихся двух пространств. Малая GC также проверяет объекты в пространстве выживших и перемещает их в другое (следующее) пространство выживших.
Возьмем в качестве примера следующую последовательность.
- В Эдеме есть объекты обоих типов (живые и мертвые).
- Происходит малая GC — все мертвые объекты удаляются из Эдема. Все живые объекты перемещаются в пространство-1 (FromSpace). Эдем и пространство-2 теперь пусты.
- Новые объекты создаются и добавляются в Эдем. Некоторые объекты в Эдеме и пространстве-1 становятся мертвыми.
- Происходит малая GC — все мертвые объекты удаляются из Эдема и пространства-1. Все живые объекты перемещаются в пространство-2 (ToSpace). Эдем и пространство-2 снова пусты.
Таким образом, в любое время одно из пространств для выживших всегда пусто. Когда выжившие объекты достигают определенного порога перемещения по пространствам выживших, они переходят в старшее поколение.
Для установки размера молодого поколения можно воспользоваться флагом -Xmn.
Старшее поколение
Объекты-долгожители в конечном итоге переходят из молодого поколения в старшее. Оно также известно как штатное поколение и содержит объекты, которые долгое время оставались в пространствах выживших.
Пороговое значение срока службы объекта определяет, сколько циклов сборки мусора он может пережить, прежде чем будет перемещен в старшее поколение.
Процесс, когда объекты отправляются в мусор из старшего поколения, называется основным событием сборки мусора.
Для установки начального и максимального размера памяти кучи вы можете воспользоваться флагами -Xms и -Xmx.
Поскольку Java задействует сборку мусора по поколениям, то чем больше событий сборки мусора переживает объект, тем дальше он продвигается в куче. Он начинает в молодом поколении и в конечном итоге заканчивает в штатном поколении, если проживет достаточно долго.
Чтобы понять продвижение объектов между пространствами и поколениями, рассмотрим следующий пример.
Когда объект создается, он сначала помещается в пространство Эдема молодого поколения. Как только происходит малая сборка мусора, живые объекты из Эдема перемещаются в пространство FromSpace. Когда происходит следующая малая сборка мусора, живые объекты как из Эдема, так и из пространства перемещаются в пространство ToSpace.
Этот цикл продолжается определенное количество раз. Если объект все еще “в строю” после этого момента, следующий цикл сборки мусора переместит его в пространство старшего поколения.
Постоянное поколение
Метаданные, такие как классы и методы, хранятся в постоянном поколении. JVM заполняет его во время выполнения на основе классов, используемых приложением. Классы, которые больше не используются, могут переходить из постоянного поколения в мусор.
Для установки начального и максимального размера постоянного поколения вы можете воспользоваться флагами -XX:PermGen и -XX:MaxPermGen.
Мета-пространство
Начиная с Java 8, на смену пространству постоянного поколения (PermGen) приходит пространство памяти MetaSpace. Реализация отличается от PermGen — это пространство кучи теперь изменяется автоматически.
Это позволяет избежать проблемы нехватки памяти у приложений, которая возникает из-за ограниченного размера пространства PermGen в куче. Память мета-пространства может быть собрана как мусор, и классы, которые больше не используются, будут автоматически очищены, когда мета-пространство достигнет максимального размера.
Типы сборщиков мусора в виртуальной машине Java
Сборка мусора повышает эффективности памяти в Java, поскольку объекты без ссылок удаляются из памяти кучи и освобождается место для новых объектов.
У виртуальной машины Java есть восемь типов сборщиков мусора. Рассмотрим каждый из них в деталях.
Серийный GC
Это самая простая реализация GC. Она предназначена для небольших приложений, работающих в однопоточных средах. Все события сборки мусора выполняются последовательно в одном потоке. Уплотнение выполняется после каждой сборки мусора.
Запуск сборщика приводит к событию “остановки мира”, когда все приложение приостанавливает работу. Поскольку на время сборки мусора все приложение замораживается, не следует прибегать к такому в реальной жизни, если требуется, чтобы задержки были минимальными.
Аргумент JVM для использования последовательного сборщика мусора -XX:+UseSerialGC.
Параллельный GC
Параллельный сборщик мусора предназначен для приложений со средними и большими наборами данных, которые выполняются на многопроцессорном или многопоточном оборудовании. Это реализация GC по умолчанию, и она также известна как сборщик пропускной способности.
Несколько потоков предназначаются для малой сборки мусора в молодом поколении. Единственный поток занят основной сборкой мусора в старшем поколении.
Запуск параллельного GC также вызывает “остановку мира”, и приложение зависает. Такое больше подходит для многопоточной среды, когда требуется завершить много задач и допустимы длительные паузы, например при выполнении пакетного задания.
Аргумент JVM для использования параллельного сборщика мусора: -XX:+UseParallelGC.
Старый параллельный GC
Это версия Parallel GC по умолчанию, начиная с Java 7u4. Это то же самое, что и параллельный GC, за исключением того, что в нем применяются несколько потоков как для молодого поколения, так и для старшего поколения.
Аргумент JVM для использования старого параллельного сборщика мусора: -XX:+UseParallelOldGC.
CMS (Параллельная пометка и зачистка) GC
Также известен как параллельный сборщик низких пауз. Для малой сборки мусора задействуются несколько потоков, и происходит это через такой же алгоритм, как в параллельном сборщике. Основная сборка мусора многопоточна, как и в старом параллельном GC, но CMS работает одновременно с процессами приложений, чтобы свести к минимуму события “остановки мира”.
Из-за этого сборщик CMS потребляет больше ресурсов процессора, чем другие сборщики. Если у вас есть возможность выделить больше ЦП для повышения производительности, то CMS предпочтительнее, чем простой параллельный сборщик. В CMS GC не выполняется уплотнение.
Аргумент JVM для использования параллельного сборщика мусора с разверткой меток: -XX:+UseConcMarkSweepGC.
G1 (Мусор — первым) GC
G1GC был задуман как замена CMS и разрабатывался для многопоточных приложений, которые характеризуются крупным размером кучи (более 4 ГБ). Он параллелен и конкурентен, как CMS, но “под капотом” работает совершенно иначе, чем старые сборщики мусора.
Хотя G1 также действует по принципу поколений, в нем нет отдельных пространств для молодого и старшего поколений. Вместо этого каждое поколение представляет собой набор областей, что позволяет гибко изменять размер молодого поколения.
G1 разбивает кучу на набор областей одинакового размера (от 1 МБ до 32 МБ — в зависимости от размера кучи) и сканирует их в несколько потоков. Область во время выполнения программы может неоднократно становиться как старой, так и молодой.
После завершения этапа разметки G1 знает, в каких областях содержится больше всего мусора. Если пользователь заинтересован в минимизации пауз, G1 может выбрать только несколько областей. Если время паузы неважно для пользователя или предел этого времени установлен высокий, G1 пройдет по большему числу областей.
Поскольку G1 GC идентифицирует регионы с наибольшим количеством мусора и сначала выполняет сбор мусора в них, он и называется: “Мусор — первым”.
Помимо областей Эдема, Выживших и Старой памяти, в G1GC присутствуют еще два типа.
- Humongous (Огромная) — для объектов большого размера (более 50% размера кучи).
- Available (Доступная) — неиспользуемое или не выделенное пространство.
Аргумент JVM для использования сборщика мусора G1: -XX:+UseG1GC.
Сборщик мусора Эпсилон
Epsilon — сборщик мусора, который был выпущен как часть JDK 11. Он обрабатывает выделение памяти, но не реализует никакого реального механизма восстановления памяти. Как только доступная куча исчерпана, JVM завершает работу.
Его можно задействовать для приложений, чувствительных к сверхвысокой задержке, где разработчики точно знают объем памяти приложения или даже добиваются ситуации (почти) полной свободы от мусора. В противном случае пользоваться Epsilon GC не рекомендуется.
Аргумент JVM для использования сборщика мусора Epsilon: -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC.
Shenandoah — новый GC, выпущенный как часть JDK 12. Ключевое преимущество Shenandoah перед G1 состоит в том, что большая часть цикла сборки мусора выполняется одновременно с потоками приложений. G1 может эвакуировать области кучи только тогда, когда приложение приостановлено, а Shenandoah перемещает объекты одновременно с приложением.
Shenandoah может компактировать живые объекты, очищать мусор и освобождать оперативную память почти сразу после обнаружения свободной памяти. Поскольку все это происходит одновременно, без приостановки работы приложения, то Shenandoah более интенсивно нагружает процессор.
Аргумент JVM для сборщика мусора Шенандоа: -XX:+UnlockExperimentalVMOptions -XX:+UseShenandoahGC.
ZGC — еще один GC, выпущенный как часть JDK 11 и улучшенный в JDK 12. Он предназначен для приложений, которые требуют низкой задержки (паузы в менее чем 10 мс) и/или задействуют очень большую кучу (несколько терабайт).
Основные цели ZGC — низкая задержка, масштабируемость и простота в применении. Для этого ZGC позволяет Java-приложению продолжать работу, пока выполняются все операции по сбору мусора. По умолчанию ZGC освобождает неиспользуемую память и возвращает ее в операционную систему.
Таким образом, ZGC привносит значительное улучшение по сравнению с другими традиционными GCS, обеспечивая чрезвычайно низкое время паузы (обычно в пределах 2 мс).
Аргумент JVM для использования сборщика мусора ZGC: —XX:+UnlockExperimentalVMOptions -XX:+UseZGC.
Примечание: как Shenandoah, так и ZGC планируется вывести из экспериментальной стадии в продакшен при выпуске JDK 15.
Как правильно выбрать сборщик мусора
Если у вашего приложения нет строгих требований ко времени задержки, вам стоит просто запустить приложение и предоставить выбор правильного сборщика самой JVM.
В большинстве случаев настройки по умолчанию отлично работают. При необходимости можно настроить размер кучи для повышения производительности. Если производительность по-прежнему не соответствует ожиданиям, попробуйте изменить сборщик в соответствии с требованиями вашего приложения.
- Последовательный. Если в приложении небольшой набор данных (примерно до 100 МБ), и/или оно будет работать на одном процессоре без каких-либо требований к времени задержки.
- Параллельный. Если приоритет — пиковая производительность приложения, и требования к времени задержки отсутствуют (или допустимы паузы в одну секунду и более).
- CMS/G1. Если время отклика важнее, чем общая пропускная способность, и паузы при сборке мусора должны быть короче одной секунды.
- ZGC. Если у времени отклика высокий приоритет и/или задействована очень большая куча.
Преимущества сборки мусора
У сборки мусора в Java множество преимуществ.
Прежде всего, это упрощает код. Не нужно беспокоиться о правильном назначении памяти и циклах высвобождения. Вы просто прекращаете использовать объект в коде, и память, которую он занимал, в какой-то момент автоматически восстановится.
Программистам, работающим на языках без сборки мусора (таких как C и C++), приходится реализовывать ручное управление памятью у себя в коде.
Также повышается эффективность памяти Java, поскольку сборщик мусора удаляет из памяти кучи объекты без ссылок. Это освобождает память кучи для размещения новых объектов.
Некоторые программисты выступают за ручное управление памятью вместо сборки мусора, но сборка мусора — уже стандартный компонент многих популярных языков программирования.
Для сценариев, когда сборщик мусора негативно влияет на производительность, Java предлагает множество вариантов настройки, повышающих эффективность GC.
Рекомендации по сбору мусора
Избегайте ручных триггеров
Помимо основных механизмов сборки мусора, один из важнейших моментов относительно этого процесса в Java — недетерминированность. То есть невозможно предсказать, когда именно во время выполнения она произойдет.
С помощью методов System.gc() или Runtime.gc() можно включить в код подсказку для запуска сборщика мусора, но это не гарантирует, что он действительно запустится.
Пользуйтесь инструментами для анализа
Если у вас недостаточно памяти для запуска приложения, вы столкнетесь с замедлениями, длительным временем сбора мусора, событиями “остановки мира” и, в конечном итоге, ошибками из-за нехватки памяти. Возможно, это указывает, что куча слишком мала, но также может и значить, что в приложении произошла утечка памяти.
Вы можете прибегнуть к помощи инструмента мониторинга, например jstat или Java Flight Recorder, и увидеть, растет ли использование кучи бесконечно, что может указывать на ошибку в коде.
Отдавайте предпочтение настройкам по умолчанию
Если у вас небольшое автономное Java-приложение, вам, скорее всего, не понадобится настраивать сборку мусора. Настройки по умолчанию отлично вам послужат.
Пользуйтесь флагами JVM для настройки
Лучший подход к настройке сборки мусора в Java — установка JVM-флагов. С помощью флагов можно задать сборщик мусора (например, Serial, G1 и т.д.), начальный и максимальный размер кучи, размер разделов кучи (например, Молодого поколения, Старшего поколения) и многое другое.
Выбирайте сборщик правильно
Хороший ориентир в плане начальных настроек — характер настраиваемого приложения. К примеру, параллельный сборщик мусора эффективен, но часто вызывает события “остановки мира”, что делает его более подходящим для внутренней обработки, где допустимы длительные паузы.
С другой стороны, сборщик мусора CMS предназначен для минимизации задержек, а значит идеально подходит для веб-приложений, где важна скорость реагирования.
В этой статье мы обсудили сборку мусора Java, механизм ее работы и ее типы.
Для многих простых Java-приложений программисту нет необходимости сознательно отслеживать сборку мусора. Однако тем, кто хочет развить навыки работы с Java, важно понимать, как происходит данный процесс.
Это также очень популярный вопрос на собеседованиях: как на миддл-, так и на сеньор-позиции в бэкенд-разработке.