1

На работе появилась задача раскопать завалы, лежащие в 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 &lt; 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 &lt; 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), эффект тот же.

aepot
  • 49,560
  • Комментарии были перемещены в чат; пожалуйста, не продолжайте дискуссию здесь. Прежде чем разместить комментарий ниже этого, пожалуйста, ознакомьтесь с назначением комментариев. Комментарии, которые не запрашивают уточнения или не предлагают улучшения, скорее всего должны быть ответами, размещены на [meta] или написаны в [chat]. Комментарии, продолжающие дискуссию, могут быть удалены. – αλεχολυτ Nov 07 '23 at 18:21

2 Answers2

4

Вся очистка завалов должна происходить по инициативе GC, и только по инициативе GC.

Триггерить ручную сборку не следует. Исключение может составить только особый редкий случай, когда:

  • Вы попробовали ручную очистку и это позитивно отразилось на требуемых параметрах расхода памяти или производительности в рабочем окружении, а не на тестовых примерах
  • Никакие другие способы оптимизации работы GC с помощью общей конфигурации не помогают
  • Вы на 146% понимаете, что делаете

Во всех остальных случаях ваша задача сводится к двум основным стремлениям

  • Уменьшение количества/частоты аллокаций новых объектов: пулы объектов, аллокации в стеке, значимые типы данных и т.п.
  • Не хранить ссылки на ненужные объекты, чтобы дать возможность сборщику их вычистить

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

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

Что касается самого вопроса, то изучать надо проблему накопления мусора, а не сборки. На абстрактных циклах ничего понять невозможно. Эвристика поведения GC на таких примерах максимум сводится к такому выводу: "вот здесь и сейчас наверняка он сработает, но это не точно".

aepot
  • 49,560
  • Спасибо за ответ! Так как я работаю с инженерным ПО и прям необходимо после некоторых дорогих операций производить очистку (иначе у человека закончиться ОЗУ) - я и затеял весь это сыр-бор. Так вот вопрос остается открытым - почему в каких-то случаях из примеров очистка производилась, а в каких-то нет? – Maxim Gerasimov Nov 07 '23 at 19:36
  • 2
    @MaximGerasimov если оператива заканчивается, GC сам перейдёт в режим агрессивной сборки. Практически невероятно, что по причине лени сборщика вы упадёте в Out of Memory. Он очень хорошо оптимизирован, чтобы недопускать таких случаев. – aepot Nov 07 '23 at 19:42
  • 2
    По причине лени GC не будет Out of Memory, зато вполне может быть подвисание UI из-за блокирующих GC, которые запустились в "неудачный" момент. Вполне нормально вызывать сборку мусора вручную в определенных ситуациях, например, при закрытии большой формы, когда много объектов становятся ненужными. – MSDN.WhiteKnight Nov 08 '23 at 06:25
  • 1
    @MSDN.WhiteKnight этот как раз про особые случаи. – aepot Nov 08 '23 at 06:27
3

Сборщик мусора не дает гарантий, что объект будет собран после того, как переменной присвоено значение 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?.