Общие сведения о сборке мусора и советы по повышению производительности
Рико Мариани
Microsoft Corporation
Апрель 2003 г.
Сводка: Сборщик мусора .NET предоставляет высокоскоростную службу выделения с хорошим использованием памяти и отсутствием долгосрочных проблем фрагментации. В этой статье объясняется, как работают сборщики мусора, а затем рассматриваются некоторые проблемы с производительностью, которые могут возникнуть в среде сбора мусора. (10 печатных страниц)
Область применения:
Microsoft® платформа .NET Framework
Содержимое
Введение
Упрощенная модель
Сбор мусора
Производительность
Завершения
Заключение
Введение
Чтобы понять, как правильно использовать сборщик мусора и с какими проблемами производительности могут возникнуть при работе в среде сборки мусора, важно понимать основы работы сборщиков мусора и то, как эти внутренние действия влияют на выполнение программ.
Эта статья разбита на две части: сначала я обсудим характер сборщика мусора среды CLR в общих чертах с использованием упрощенной модели, а затем обсудим некоторые последствия этой структуры.
Упрощенная модель
В пояснительных целях рассмотрим следующую упрощенную модель управляемой кучи. Обратите внимание, что это не то, что на самом деле реализовано.
Рис. 1. Упрощенная модель управляемой кучи
Ниже приведены правила для этой упрощенной модели.
- Все объекты, которые можно собирать с помощью мусора, выделяются из одного непрерывного диапазона адресного пространства.
- Куча делится на поколения (подробнее об этом позже), чтобы можно было устранить большую часть мусора, просмотрев лишь небольшую часть кучи.
- Все объекты в поколении примерно одного возраста.
- Более многочисленные поколения указывают на области кучи с более старыми объектами— эти объекты, скорее всего, будут стабильными.
- Самые старые объекты находятся на самых низких адресах, а новые объекты создаются по возрастающим адресам. (Адреса растут на рисунке 1 выше.)
- Указатель выделения для новых объектов отмечает границу между используемой (выделенной) и неиспользуемой (свободной) областями памяти.
- Периодически куча сжимается путем удаления мертвых объектов и скольжения живых объектов вверх к нижнему концу кучи. Это расширяет неиспользуемую область в нижней части схемы, в которой создаются новые объекты.
- Порядок объектов в памяти остается в том порядке, в котором они были созданы, для хорошей локальности.
- Между объектами в куче никогда не возникает пробелов.
- Фиксируется только часть свободного пространства . При необходимости из операционной системы в диапазоне зарезервированных адресов извлекается ** больше памяти.
Сбор мусора
Самый простой вид сбора, который нужно понять, — это полная сжатие сборки мусора, поэтому я начну с обсуждения этого.
Полные коллекции
В полной коллекции необходимо остановить выполнение программы и найти все корни в куче сборки мусора. Эти корни бывают различных форм, но в первую очередь это стек и глобальные переменные, которые указывают в кучу. Начиная с корней, мы посещаем каждый объект и следуем за каждым указателем объекта, содержащимся в каждом посещенном объекте, помечающем объекты по мере прохождения. Таким образом, сборщик найдет все доступные или живые объекты. Другие объекты, недостижимые , в настоящее время осуждаются.
Рис. 2. Корнями в кучу сборки мусора
После определения недостижимых объектов мы хотим освободить это пространство для последующего использования; цель сборщика на этом этапе заключается в том, чтобы сдвинуть живые объекты вверх и устранить впустую пространство. После остановки выполнения сборщик может безопасно переместить все эти объекты и исправить все указатели, чтобы все было правильно связано в новом расположении. Выжившие объекты повышаются до следующего поколения (т. е. границы для поколений обновляются), и выполнение может возобновиться.
Частичные коллекции
К сожалению, полная сборка мусора просто слишком дорогая, чтобы делать каждый раз, поэтому теперь уместно обсудить, как наличие поколений в коллекции помогает нам.
Сначала рассмотрим мнимый случай, когда нам чрезвычайно повезло. Предположим, что недавно был выполнен полный сбор и куча хорошо сжата. Выполнение программы возобновляется, и происходит выделение некоторых ресурсов. На самом деле происходит много и много выделений, и после достаточного объема выделений система управления памятью решает, что пришло время для сбора.
Вот где нам повезло. Предположим, что за все время, когда мы запускались с момента последней коллекции, мы вообще не записывали ни один из старых объектов, в которые были записаны только новые выделенные объекты нулевого поколения (поколение 0). Если бы это произошло, мы были бы в отличной ситуации, потому что мы можем значительно упростить процесс сборки мусора.
Вместо обычного полного сбора мы можем просто предположить, что все старые объекты (поколение1,поколение 2) по-прежнему живы, или, по крайней мере, достаточно их живых, что не стоит смотреть на эти объекты. Кроме того, так как ни одна из них не была написана (помните, как нам повезло?), нет указателей от старых объектов к новым объектам. То, что мы можем сделать, это посмотреть на все корни, как обычно, и если какие-либо корни указывают на старые объекты, просто игнорировать эти. Для других корней (тех, которые указывают на поколение0) мы продолжаем как обычно, следуя всем указателям. Всякий раз, когда мы находим внутренний указатель, который возвращается в старые объекты, мы игнорируем его.
Когда этот процесс будет завершен, мы будем посещать все живые объекты в поколении0 , не посещая никакие объекты из более старших поколений. Затем объекты поколения0 можно осудить как обычно, и мы скользим вверх по этой области памяти, оставляя старые объекты нетронутыми.
Теперь это действительно отличная ситуация для нас, потому что мы знаем, что большая часть мертвого пространства, скорее всего, будет в более молодых объектах, где есть много оттока. Многие классы создают временные объекты для возвращаемых значений, временных строк и различных других служебных классов, таких как перечислители и whatnot. Глядя только на0-е поколение дает нам простой способ вернуть большую часть мертвого пространства, глядя только на очень немногие объекты.
К сожалению, нам никогда не посчастливилось использовать этот подход, так как по крайней мере некоторые старые объекты должны измениться, чтобы они указывали на новые объекты. Если это произойдет, недостаточно просто игнорировать их.
Обеспечение работы поколений с барьерами записи
Чтобы алгоритм, описанный выше, работал, необходимо знать, какие старые объекты были изменены. Чтобы запомнить расположение объектов грязное, мы используем структуру данных, называемую карта таблицей, и для поддержания этой структуры данных компилятор управляемого кода создает так называемые барьеры для записи. Эти два понятия являются ключевыми для успеха сборки мусора на основе поколения.
Карта таблицу можно реализовать различными способами, но проще всего представить ее как массив битов. Каждый бит в таблице карта представляет диапазон памяти в куче, скажем, 128 байт. Каждый раз, когда программа записывает объект в какой-то адрес, код барьера записи должен вычислить, какой 128-байтовый фрагмент был записан, а затем задать соответствующий бит в таблице карта.
С помощью этого механизма можно вернуться к алгоритму сбора данных. Если выполняется сборка мусора поколения0, мы можем использовать алгоритм, описанный выше, игнорируя любые указатели на более старые поколения, но после этого необходимо также найти каждый указатель объекта в каждом объекте, лежащем на блоке, который был помечен как измененный в таблице карта. Мы должны относиться к ним так же, как к корням. Если мы также рассмотрим эти указатели, то мы правильно соберем только объекты поколения0 .
Этот подход не помог бы вообще, если карта таблица всегда была полной, но на практике сравнительно немногие указатели из более старых поколений фактически изменяются, поэтому есть существенная экономия от этого подхода.
Производительность
Теперь, когда у нас есть базовая модель работы, давайте рассмотрим некоторые вещи, которые могут пойти не так, что это приведет к замедлению. Это даст нам хорошее представление о том, каких вещей мы должны стараться избегать, чтобы получить лучшую производительность из сборщика.
Слишком много выделений
Это действительно самое главное, что может пойти не так. Выделение новой памяти с помощью сборщика мусора выполняется довольно быстро. Как видно на рисунке 2 выше, все, что обычно должно произойти, — это перемещение указателя выделения для создания пространства для нового объекта на стороне "выделенного" — это не происходит гораздо быстрее. Тем не менее, рано или поздно сбор мусора должен произойти, и, при всех равных условиях, лучше, чтобы это произошло позже, чем раньше. Поэтому вы хотите убедиться, что при создании новых объектов это действительно необходимо и уместно, хотя создание одного объекта выполняется быстро.
Это может показаться очевидным советом, но на самом деле очень легко забыть, что одна маленькая строка кода, которую вы пишете, может вызвать большое количество выделений. Например, предположим, что вы пишете функцию сравнения какого-либо вида и предполагаете, что объекты имеют поле ключевых слов и что вы хотите, чтобы сравнение не учитывалось регистр ключевых слов в указанном порядке. В этом случае нельзя просто сравнить строку ключевых слов целиком, так как первая ключевое слово может быть очень короткой. Было бы заманчиво использовать String.Split, чтобы разбить ключевое слово строку на части, а затем сравнить каждую часть по порядку с помощью обычного сравнения без учета регистра. Звучит здорово, верно?
Ну, как оказалось, делать это так не так хорошо. Вы видите, что String.Split создаст массив строк, то есть один новый строковый объект для каждого ключевое слово изначально в строке ключевых слов и еще один объект для массива. Yikes! Если мы делаем это в контексте рода, это много сравнений, и ваша двухстрочный функция сравнения теперь создает очень большое количество временных объектов. Внезапно сборщик мусора будет работать очень трудно от вашего имени, и даже с самой умной схемой сбора есть просто много мусора, чтобы очистить. Лучше написать функцию сравнения, которая вообще не требует выделения.
Выделение Too-Large
При работе с традиционным распределителем, таким как malloc(), программисты часто пишут код, который делает как можно меньше вызовов malloc(), так как они знают, что затраты на выделение сравнительно высоки. Это приводит к практике выделения блоков, часто спекулятивно выделяя объекты, которые нам могут понадобиться, чтобы мы могли сделать меньше общего выделения. Предварительно выделенные объекты затем вручную управляются из своего рода пула, что фактически создает своего рода высокоскоростной пользовательский распределитель.
В управляемом мире эта практика является гораздо менее привлекательной по нескольким причинам:
Во-первых, затраты на выделение ресурсов очень низкие— нет поиска свободных блоков, как в случае с традиционными распределителями; Все, что должно произойти, это граница между свободными и выделенными областями, которые должны двигаться. Низкая стоимость выделения означает, что самой веской причины для создания пула просто нет.
Во-вторых, если вы решите заранее выделить, вы, конечно, будете делать больше выделений, чем требуется для ваших непосредственных потребностей, что, в свою очередь, может привести к дополнительным сборкам мусора, которые в противном случае могли бы быть ненужными.
Наконец, сборщик мусора не сможет освободить место для объектов, которые вы перезапускаете вручную, так как с глобальной точки зрения все эти объекты, включая те, которые в настоящее время не используются, по-прежнему живут. Вы можете обнаружить, что много памяти тратится впустую, сохраняя готовые к использованию, но не используемые объекты под рукой.
Это не значит, что предварительное выделение всегда плохая идея. Вы можете сделать это для принудительного первоначального распределения определенных объектов вместе, например, но вы, скорее всего, обнаружите, что она менее привлекательна в качестве общей стратегии, чем в неуправляемом коде.
Слишком много указателей
При создании структуры данных, которая представляет собой большую сетку указателей, у вас возникнут две проблемы. Во-первых, будет много операций записи объектов (см. рис. 3 ниже), а во-вторых, когда придет время для сбора этой структуры данных, сборщик мусора будет следовать всем этим указателям и при необходимости изменять их все по мере перемещения. Если структура данных является долгосрочной и не сильно изменится, сборщику потребуется только посетить все эти указатели при выполнении полных сборок (на уровне2-го поколения). Но если вы создаете такую структуру на временной основе, скажем, в рамках обработки транзакций, то вы будете платить за это гораздо чаще.
Рис. 3. Структура данных с большим количеством указателей
Структуры данных с большим количеством указателей могут также иметь другие проблемы, не связанные со временем сборки мусора. Опять же, как мы уже говорили ранее, при создании объектов они выделяются непрерывно в порядке выделения. Это удобно, если вы создаете большую, возможно, сложную структуру данных путем, например, восстановления информации из файла. Несмотря на то, что у вас есть разнородные типы данных, все объекты будут находиться близко друг к другу в памяти, что, в свою очередь, поможет процессору получить быстрый доступ к этим объектам. Однако со временем и изменением структуры данных новые объекты, скорее всего, потребуется присоединить к старым объектам. Эти новые объекты будут созданы гораздо позже и поэтому не будут находиться рядом с исходными объектами в памяти. Даже когда сборщик мусора сжимает память, объекты не будут перетасовываны в памяти, они просто "скользят" вместе, чтобы удалить впустую пространство. В результате беспорядок может стать настолько плохо с течением времени, что вы можете быть склонны сделать свежую копию всей структуры данных, все красиво упакованы, и пусть старый беспорядочный один будет осужден сборщиком в свое время.
Слишком много корней
Сборщик мусора, конечно, должен предоставлять корням специальную обработку во время сбора— они всегда должны быть перечислены и должным образом рассмотрены в свою очередь. Коллекция gen0 может быть быстрой только в той степени, что вы не даете ему поток корней, чтобы рассмотреть. Если создать глубоко рекурсивную функцию, которая содержит много указателей на объекты среди локальных переменных, результат на самом деле может оказаться весьма дорогостоящим. Эта стоимость связана не только с необходимостью учитывать все эти корни, но и в чрезвычайно большом количестве объектов поколения0 , которые эти корни могут оставаться в живых не очень долго (обсуждается ниже).
Слишком много операций записи объектов
Еще раз ссылаясь на наше предыдущее обсуждение, помните, что каждый раз, когда управляемая программа изменяла указатель объекта, также активируется код барьера для записи. Это может быть плохо по двум причинам:
Во-первых, стоимость барьера записи может быть сравнима со стоимостью того, что вы пытались сделать в первую очередь. Например, если вы выполняете простые операции в каком-либо классе перечислителя, вам может потребоваться переместить некоторые ключевые указатели из коллекции main в перечислитель на каждом шаге. На самом деле этого можно избежать, так как вы фактически удвоите стоимость копирования этих указателей из-за барьера записи, и вам, возможно, придется делать это один или несколько раз на цикл перечислителя.
Во-вторых, запуск барьеров записи вдвойне плохо, если вы на самом деле пишете на старых объектах. При изменении старых объектов вы эффективно создаете дополнительные корневые элементы для проверка (описано выше) при следующей сборке мусора. Если вы изменили достаточно ваших старых объектов вы бы эффективно свести на нет обычные улучшения скорости, связанные со сбором только молодого поколения.
Эти две причины, конечно, дополняются обычными причинами для того, чтобы не делать слишком много пишет в какой-либо программе. При всех равных условиях лучше прикоснуться к меньшему объему памяти (на самом деле для чтения или записи), чтобы обеспечить более экономичное использование кэша процессора.
Слишком много объектов с почти длительным сроком существования
Наконец, возможно, самая большая ловушка поколения сборщика мусора является создание многих объектов, которые не являются ни точно временными, ни они точно долгоживущие. Эти объекты могут вызвать много проблем, потому что они не будут очищены коллекцией gen0 (самый дешевый), так как они по-прежнему будут необходимы, и они могут даже выжить в коллекции1-го поколения, потому что они все еще используются, но они скоро умирают после этого.
Беда в том, что как только объект прибыл на уровень2-го поколения, только полная коллекция избавится от него, и полные коллекции являются достаточно дорогостоящими, что сборщик мусора задерживает их до тех пор, пока это разумно возможно. Таким образом, в результате наличия многих "почти долгоживущие" объекты является то, что ваше поколение2 будет, как правило, расти, потенциально с тревожной скоростью; он не может получить очистки почти так быстро, как вы хотели бы, и когда он получает получить очищенный он, безусловно, будет гораздо дороже сделать это, чем вы могли бы пожелать.
Чтобы избежать таких объектов, лучшие линии защиты идут следующим образом:
- Выделите как можно меньше объектов, учитывая объем временного пространства, который вы используете.
- Сведите к минимуму размеры объектов с более длительным сроком существования.
- Храните в стеке как можно меньше указателей на объекты (это корневые указатели).
Если вы сделаете это, ваши коллекции поколения0 , скорее всего, будут весьма эффективными, и поколение1 будет расти не очень быстро. В результате коллекции1-го поколения можно выполнять реже, и, когда становится разумным выполнить сбор данныхпоколения 1 , объекты среднего времени существования уже будут мертвы и могут быть восстановлены в то время, дешево.
Если все идет отлично, то во время устойчивых операций ваш размер2-го поколения не будет увеличиваться вообще!
Завершения
Теперь, когда мы рассмотрели несколько тем с упрощенной моделью распределения, я хотел бы немного усложнить ситуацию, чтобы мы могли обсудить еще одно важное явление, а именно стоимость методов завершения и завершения. Короче говоря, метод завершения может присутствовать в любом классе — это необязательный элемент, который сборщик мусора обещает вызвать для в противном случае мертвых объектов, прежде чем освободить память для этого объекта. В C# для указания метода завершения используется синтаксис ~Class.
Влияние завершения на коллекцию
Когда сборщик мусора впервые сталкивается с объектом, который в противном случае является мертвым, но по-прежнему нуждается в завершении, он должен отказаться от попытки освободить пространство для этого объекта в это время. Вместо этого объект добавляется в список объектов, требующих завершения. Кроме того, сборщик должен убедиться, что все указатели в объекте остаются действительными до завершения завершения. В основном это то же самое, что сказать, что каждый объект, нуждающийся в завершении, похож на временный корневой объект с точки зрения сборщика.
После завершения сбора поток завершения со метким именем пройдет через список объектов, требующих завершения, и вызовет методы завершения. Когда это будет сделано, объекты снова становятся мертвыми и будут естественным образом собраны обычным образом.
Завершение и производительность
С этим базовым пониманием завершения мы уже можем вывести некоторые очень важные вещи:
Во-первых, объекты, требующие завершения, живут дольше, чем объекты, которые этого не делают. На самом деле, они могут жить гораздо дольше. Например, предположим, что объект, который входит в поколение2 , должен быть завершен. Завершение будет запланировано, но объект все еще находится в2-м м поколения, поэтому он не будет повторно собран до следующего поколения2-го поколения. Это может быть очень долгое время действительно, и, на самом деле, если дела идут хорошо, это будет долгое время, потому что2-го поколения коллекции являются дорогостоящими, и поэтому мы хотим , чтобы они происходили очень редко. Старые объекты, требующие завершения, могут ждать десятки, если не сотни коллекций поколения0 , прежде чем их пространство будет освобождено.
Во-вторых, объекты, требующие завершения, вызывают сопутствующий ущерб. Так как внутренние указатели объектов должны оставаться действительными, в памяти останутся не только объекты, которые непосредственно нуждаются в завершении, но и все, на что ссылается объект, прямо или косвенно, также останется в памяти. Если огромное дерево объектов было привязано одним объектом, требующим завершения, то все дерево задержится, возможно, надолго, как мы только что обсуждали. Поэтому очень важно использовать методы завершения и размещать их на объектах, имеющих как можно меньше внутренних указателей на объекты. В приведенном выше примере дерева можно легко избежать проблемы, переместив ресурсы, требующие завершения, в отдельный объект и сохранив ссылку на этот объект в корне дерева. С этим скромным изменением только один объект (надеюсь, хороший небольшой объект) будет задерживаться и стоимость завершения сведена к минимуму.
Наконец, объекты, требующие завершения, создают работу для потока метода завершения. Если процесс завершения является сложным, один и единственный поток метода завершения будет тратить много времени на выполнение этих шагов, что может привести к невыполненной работе и, следовательно, привести к тому, что больше объектов задержится в ожидании завершения. Поэтому крайне важно, чтобы методы завершения делали как можно меньше работы. Помните также, что хотя все указатели объектов остаются действительными во время завершения, эти указатели могут привести к объектам, которые уже были завершены и, следовательно, могут быть менее полезными. Как правило, безопаснее избегать следующих указателей объектов в коде завершения, даже если указатели являются допустимыми. Безопасный, короткий путь к коду завершения является лучшим.
IDisposable и Dispose
Во многих случаях возможно, что объекты, которые в противном случае всегда должны быть завершены, чтобы избежать этих затрат путем реализации интерфейса IDisposable . Этот интерфейс предоставляет альтернативный метод для освобождения ресурсов, время существования которых хорошо известно программисту, и это на самом деле происходит довольно много. Конечно, лучше, если ваши объекты просто используют только память и, следовательно, не требуют завершения или удаления вообще; но если требуется завершение и есть много случаев, когда явное управление объектами является простым и практичным, то реализация интерфейса IDisposable является отличным способом избежать или, по крайней мере, сократить затраты на завершение.
В языке C# этот шаблон может быть весьма полезным:
class X: IDisposable
{
public X(…)
{
… initialize resources …
}
~X()
{
… release resources …
}
public void Dispose()
{
// this is the same as calling ~X()
Finalize();
// no need to finalize later
System.GC.SuppressFinalize(this);
}
};
При ручном вызове Dispose устраняется необходимость в том, чтобы сборщик сохранял объект в активном состоянии и вызывал метод завершения.
Заключение
Сборщик мусора .NET предоставляет высокоскоростную службу выделения с хорошим использованием памяти и отсутствием долгосрочных проблем фрагментации, однако можно выполнять действия, которые приведут к гораздо меньшей, чем оптимальная производительность.
Чтобы лучше использовать распределитель, следует рассмотреть следующие методики:
- Выделите всю память (или как можно больше), которая будет использоваться с заданной структурой данных одновременно.
- Удалите временные выделения, которые можно избежать с небольшими затратами по сложности.
- Сведите к минимуму количество записей указателей объектов, особенно записей, выполненных в старые объекты.
- Уменьшите плотность указателей в структурах данных.
- Используйте методы завершения в ограниченном количестве, а затем только для "конечных" объектов, насколько это возможно. При необходимости разорвать объекты, чтобы помочь в этом.
Регулярная практика проверки ключевых структур данных и проведения профилей использования памяти с помощью таких средств, как Профилировщик распределения, будет иметь большое значение для поддержания эффективного использования памяти и обеспечения оптимальной работы сборщика мусора.