Почему GC.Collect не собирает List помеченный как null?
На работе появилась задача раскопать завалы, лежащие в LOH. После некоторых попыток я обнаружил непонятное для себя поведение GC:
public class Program
{
public static void Test()
{
List<int> list = new List<int>(1000000000);
list = null;
GC.Collect();
}
public static void Main(string[] args)
{
Test();
for (long i = 0; i < 100000000000; i++)
{
string temp = new string(new char[] { 'a', 'b', 'c' });
}
}
}
В таком виде сборщик мусора срабатывает, но не очищает выделенную память под List даже при условии, что на него ничего не ссылается. Даже больше - она не очистится даже после периодической сборке мусора в цикле.
Если же удалить строчку с форсированным вызовом GC, то память очистится практически сразу, когда в цикле потребуется выделить новую. В чем дело?
Дополняю вопрос:
Если переписать код вот так:
public class Program
{
public static void Test()
{
List<int> list = new List<int>(1000000000);
list = null;
}
public static void Main(string[] args)
{
Test();
GC.Collect();
for (long i = 0; i < 100000000000; i++)
{
string temp = new string(new char[] { 'a', 'b', 'c' });
}
}
}
То очистка list не произойдет при первом вызове GC.Collect, она произойдет при втором (автоматическом) вызове сборщика внутри цикла for.
А если написать вот так:
public class Program
{
public static void Test()
{
List<int> list = new List<int>(1000000000);
list = null;
}
public static void Main(string[] args)
{
Test();
GC.Collect();
while (true) ;
}
}
То list не очистится вовсе. Честно говоря, это также вгоняет меня в ступор.
GCSettings.LargeObjectHeapCompactionMode пробовал менять на CompactOnce, также пробовал GC.Collect(2), эффект тот же.
Ответы (2 шт):
Вся очистка завалов должна происходить по инициативе GC, и только по инициативе GC.
Триггерить ручную сборку не следует. Исключение может составить только особый редкий случай, когда:
- Вы попробовали ручную очистку и это позитивно отразилось на требуемых параметрах расхода памяти или производительности в рабочем окружении, а не на тестовых примерах
- Никакие другие способы оптимизации работы GC с помощью общей конфигурации не помогают
- Вы на 146% понимаете, что делаете
Во всех остальных случаях ваша задача сводится к двум основным стремлениям
- Уменьшение количества/частоты аллокаций новых объектов: пулы объектов, аллокации в стеке, значимые типы данных и т.п.
- Не хранить ссылки на ненужные объекты, чтобы дать возможность сборщику их вычистить
При всём при этом стоит помнить, что сборщик даже после очистки значительных объемов оперативы необязательно сразу вернёт эту память операционной системе. То есть в рамках внешней телеметрии расход памяти приложением не изменится, даже если сборка прошла успешно.
Доверяйте сборщику и изучайте его вместо того, чтобы тратить ресурсы на ручную сборку просто потому что кажется, что так лучше. Не лучше, не факт что лучше.
Что касается самого вопроса, то изучать надо проблему накопления мусора, а не сборки. На абстрактных циклах ничего понять невозможно. Эвристика поведения GC на таких примерах максимум сводится к такому выводу: "вот здесь и сейчас наверняка он сработает, но это не точно".
Сборщик мусора не дает гарантий, что объект будет собран после того, как переменной присвоено значение null. GC решает, что объект не нужен, на основе информации о времени жизни переменных, которую получает от кода, сгенерированного JIT-компилятором. Точно не документировано, когда оканчивается время жизни локальной переменной (это может зависеть от версии .NET, от конфигурации Debug/Release и включен ли Tiered JIT), но обычно оно заканчивается только после возврата метода, к которому она прикреплена. Ваш первый пример не вызовет сборку объекта, так как вы вызываете GC.Collect в том же методе, где объявлена переменная.
Вот здесь есть интересное обсуждение на тему времени жизни переменных, с ответами от разработчиков GC и JIT: Why do I see a different GC behavior in .NET5 and .NET Framework(4.7.2) on Windows 10?.