Подтвердить что ты не робот

Любой способ обхода WPF вызова GC.Collect(2), кроме отражения?

Недавно мне пришлось проверить этот монстра в производственный код, чтобы манипулировать частными полями в классе WPF: (tl; dr как мне избежать этого?)

private static class MemoryPressurePatcher
{
    private static Timer gcResetTimer;
    private static Stopwatch collectionTimer;
    private static Stopwatch allocationTimer;
    private static object lockObject;

    public static void Patch()
    {
        Type memoryPressureType = typeof(Duration).Assembly.GetType("MS.Internal.MemoryPressure");
        if (memoryPressureType != null)
        {
            collectionTimer = memoryPressureType.GetField("_collectionTimer", BindingFlags.Static | BindingFlags.NonPublic)?.GetValue(null) as Stopwatch;
            allocationTimer = memoryPressureType.GetField("_allocationTimer", BindingFlags.Static | BindingFlags.NonPublic)?.GetValue(null) as Stopwatch;
            lockObject = memoryPressureType.GetField("lockObj", BindingFlags.Static | BindingFlags.NonPublic)?.GetValue(null);

            if (collectionTimer != null && allocationTimer != null && lockObject != null)
            {
                gcResetTimer = new Timer(ResetTimer);
                gcResetTimer.Change(TimeSpan.Zero, TimeSpan.FromMilliseconds(500));
            }
        }                
    }       

    private static void ResetTimer(object o)
    {
        lock (lockObject)
        {
            collectionTimer.Reset();
            allocationTimer.Reset();
        }
    }
}

Чтобы понять, почему я буду делать что-то настолько безумное, вам нужно посмотреть MS.Internal.MemoryPressure.ProcessAdd():

/// <summary>
/// Check the timers and decide if enough time has elapsed to
/// force a collection
/// </summary>
private static void ProcessAdd()
{
    bool shouldCollect = false;

    if (_totalMemory >= INITIAL_THRESHOLD)
    {
        // need to synchronize access to the timers, both for the integrity
        // of the elapsed time and to ensure they are reset and started
        // properly
        lock (lockObj)
        {
            // if it been long enough since the last allocation
            // or too long since the last forced collection, collect
            if (_allocationTimer.ElapsedMilliseconds >= INTER_ALLOCATION_THRESHOLD
                || (_collectionTimer.ElapsedMilliseconds > MAX_TIME_BETWEEN_COLLECTIONS))
            {
                _collectionTimer.Reset();
                _collectionTimer.Start();

                shouldCollect = true;
            }
            _allocationTimer.Reset();
            _allocationTimer.Start();
        }

        // now that we're out of the lock do the collection
        if (shouldCollect)
        {
            Collect();
        }
    }

    return;
}

Важный бит близок к концу, где он вызывает метод Collect():

private static void Collect()
{
    // for now only force Gen 2 GCs to ensure we clean up memory
    // These will be forced infrequently and the memory we're tracking
    // is very long lived so it ok
    GC.Collect(2);
}

Да, этот WPF фактически заставляет сборку мусора gen 2, которая заставляет полностью блокировать GC. Естественно встречающийся GC происходит без блокировки кучи gen 2. На практике это означает, что всякий раз, когда вызывается этот метод, наше приложение блокируется. Чем больше памяти используется вашим приложением, и чем фрагментирована ваша куча gen 2, тем дольше это займет. Наше приложение в настоящее время кэширует довольно много данных и может легко захватить концертную память, а принудительный GC может заблокировать наше приложение на медленном устройстве в течение нескольких секунд - каждые 850 MS.

Ибо, несмотря на возражения автора об обратном, легко прийти к сценарию, где этот метод вызывается с большой частотой. Этот код памяти WPF возникает при загрузке BitmapSource из файла. Мы виртуализируем список просмотров с тысячами элементов, где каждый элемент представлен миниатюрами, хранящимися на диске. Когда мы прокручиваем вниз, мы динамически загружаем эти миниатюры и что GC происходит на максимальной частоте. Таким образом, прокрутка становится невероятно медленной и изменчивой, при этом приложение постоянно блокируется.

С этим ужасным взломом отражения, о котором я говорил выше, мы вынуждаем таймеров никогда не встречаться, и поэтому WPF никогда не заставляет GC. Кроме того, по-видимому, никаких неблагоприятных последствий не наблюдается - память растет, когда один свиток и, в конечном счете, GC запускается естественным образом, не блокируя основной поток.

Есть ли другой способ предотвратить эти вызовы на GC.Collect(2), которые не настолько грубо отвратительны, как мое решение? Хотелось бы получить объяснение того, какие конкретные проблемы могут возникнуть в результате этого взлома. Под этим я подразумеваю проблемы с тем, чтобы избежать вызова GC.Collect(2). (мне кажется, что GC, естественно, должен быть достаточным)

4b9b3361

Ответ 1

Примечание: Выполняйте это только в том случае, если это вызывает узкое место в вашем приложении, и убедитесь, что вы понимаете последствия - см. Hans answer для хорошее объяснение того, почему они помещают это в WPF в первую очередь.

У вас есть какой-то неприятный код, пытающийся исправить неприятный взлом в рамках... Поскольку все это статично и называется из нескольких мест в WPF, вы не можете действительно лучше, чем использовать отражение, чтобы его разбить (другие решения гораздо хуже).

Так что не ожидайте там чистого решения. Нет такой вещи, если они не изменят код WPF.

Но я думаю, что ваш хак может быть проще и избежать использования таймера: просто взломайте значение _totalMemory, и все готово. Это a long, что означает, что он может перейти к отрицательным значениям. И очень большие отрицательные значения при этом.

private static class MemoryPressurePatcher
{
    public static void Patch()
    {
        var memoryPressureType = typeof(Duration).Assembly.GetType("MS.Internal.MemoryPressure");
        var totalMemoryField = memoryPressureType?.GetField("_totalMemory", BindingFlags.Static | BindingFlags.NonPublic);

        if (totalMemoryField?.FieldType != typeof(long))
            return;

        var currentValue = (long) totalMemoryField.GetValue(null);

        if (currentValue >= 0)
            totalMemoryField.SetValue(null, currentValue + long.MinValue);
    }
}

Теперь ваше приложение должно будет выделить около 8 экзабайт перед вызовом GC.Collect. Излишне говорить, что если это произойдет, вам придется решать большие проблемы.:)

Если вы беспокоитесь о возможности переполнения, просто используйте long.MinValue / 2 в качестве смещения. Это все еще оставляет вас с 4 экзабайтами.

Обратите внимание, что AddToTotal выполняет проверку границ _totalMemory, но делает это с помощью Debug.Assert здесь:

Debug.Assert(newValue >= 0);

Поскольку вы будете использовать версию выпуска .NET Framework, эти утверждения будут отключены (с ConditionalAttribute), поэтому нет необходимости беспокоиться об этом.


Вы спросили, какие проблемы могут возникнуть при таком подходе. Давайте посмотрим.

  • Самый очевидный: MS изменяет код WPF, который вы пытаетесь взломать.

    Ну, в этом случае это в значительной степени зависит от характера изменения.

    • Они изменяют тип имени/поля имя/тип поля: в этом случае взлом не будет выполняться, и вы вернетесь к поведению запаса. Код отражения довольно защищен, он не будет генерировать исключение, он просто ничего не сделает.

    • Они меняют вызов Debug.Assert на проверку времени выполнения, которая включена в версии выпуска. В этом случае ваше приложение обречено. Любая попытка загрузить изображение с диска будет выбрасываться. К сожалению.

      Этот риск смягчается тем фактом, что их собственный код в значительной степени взломан. Они не собираются бросать, это должно остаться незамеченным. Они хотят, чтобы он сидел тихо и терпел неудачу. Загрузка изображений с изображениями является гораздо более важной функцией, которая не должна нарушаться каким-либо кодом управления памятью, единственной целью которого является минимизация использования памяти.

    • В случае вашего исходного патча в OP, если они изменяют постоянные значения, ваш хак может перестать работать.

    • Они меняют алгоритм, сохраняя класс и поле целыми. Ну... все может произойти, в зависимости от изменения.

  • Теперь предположим, что хак работает и успешно отключает вызов GC.Collect.

    Очевидным риском в этом случае является увеличение использования памяти. Поскольку коллекции будут менее частыми, больше памяти будет выделено в данный момент времени. Это не должно быть большой проблемой, поскольку коллекции все равно будут возникать естественным образом, когда gen 0 заполняется.

    У вас также будет больше фрагментации памяти, что является прямым следствием меньшего количества коллекций. Это может быть или не быть проблемой для вас - так профиль вашего приложения.

    Меньшие коллекции также означают, что меньшее количество объектов продвигается в более высокое поколение. Это хорошая вещь. В идеале, у вас должны быть краткосрочные объекты в gen 0 и долгоживущие объекты в gen 2. Частые коллекции фактически приведут к тому, что краткосрочные объекты будут продвигаться в gen 1, а затем в gen 2, и вы получите многие недостижимые объекты в гене 2. Они будут очищены только коллекцией gen 2, вызовет фрагментацию кучи и фактически увеличат время GC, так как придется тратить больше времени на сжатие кучи. Это на самом деле главная причина, по которой вызов GC.Collect сам считается плохой практикой - вы активно побеждаете в стратегии GC, и это влияет на все приложение.

В любом случае правильным подходом было бы загрузить изображения, масштабировать их и отображать эти миниатюры в пользовательском интерфейсе. Вся эта обработка должна выполняться в фоновом потоке. В случае изображений JPEG загрузите встроенные миниатюры - они могут быть достаточно хорошими. И используйте пул объектов, поэтому вам не нужно создавать новые растровые изображения каждый раз, это полностью обходит проблему класса MemoryPressure. И да, именно то, что предлагают другие ответы;)

Ответ 2

Я думаю, что у вас все хорошо. Молодцы, хороший хак, Reflection - отличный инструмент для исправления кода фреймворка. Я использовал его много раз. Просто ограничьте его использование представлением, которое отображает ListView, слишком опасно постоянно активировать его.

Немного о нулевой проблеме, ужасный взлом ProcessAdd(), конечно, очень груб. Это следствие того, что BitmapSource не реализует IDisposable. Решение сомнительного дизайна, SO наполнены вопросами об этом. Тем не менее, обо всех них идет о противоположной проблеме, этот таймер не достаточно быстро, чтобы идти в ногу. Это просто не очень хорошо.

Нет ничего, что можно было бы сделать, чтобы изменить способ работы этого кода. Значения, которые он отключает, являются объявлениями const. Основываясь на значениях, которые могли быть подходящими 15 лет назад, вероятный возраст этого кода. Он начинается с одного мегабайта и называет "10 с МБ" проблемой, тогда жизнь была проще: они забыли написать его так, чтобы он масштабировался должным образом, GC.AddMemoryPressure(), вероятно, будет хорошо сегодня. Слишком немного слишком поздно, они не могут это исправить, без существенного изменения поведения программы.

Вы можете наверняка победить таймер и избежать своего взлома. Несомненно, проблема, с которой вы сейчас сталкиваетесь, заключается в том, что ее интервал примерно такой же, как скорость, с которой пользователь прокручивает ListView, когда он ничего не читает, а просто пытается найти интересующую запись. Проблема с дизайном пользовательского интерфейса, которая так часто встречается с просмотрами списков с тысячами строк, проблема, которую вы, вероятно, не хотите решать. Что вам нужно сделать, это кешировать миниатюры, собирая те, которые, как вы знаете, вам понадобятся в будущем. Лучший способ сделать это - сделать это в потоке threadpool. Измерьте время, пока вы это сделаете, вы можете потратить до 850 мс. Однако этот код не будет меньше, чем у вас сейчас, но не намного красивее.

Ответ 3

.NET 4.6.2 исправит это, убив класс MemoryPressure alltogether. Я просто проверил предварительный просмотр, и мои зависания пользовательского интерфейса полностью исчезли.

.NET 4.6 реализует его

internal SafeMILHandleMemoryPressure(long gcPressure)
{
    this._gcPressure = gcPressure;
    this._refCount = 0;
    GC.AddMemoryPressure(this._gcPressure);
}

тогда как pre. 4.6.2 у вас был этот грубый класс MemoryPressure, который заставил бы GC.Collect каждые 850 мс (если между ними не было выделено растровых изображений WPF) или каждые 30 секунд, независимо от того, сколько растровых изображений WPF вы выделили.

Для справки старый дескриптор реализован как

internal SafeMILHandleMemoryPressure(long gcPressure)
{
    this._gcPressure = gcPressure;
    this._refCount = 0;
    if (this._gcPressure > 8192L)
    {
        MemoryPressure.Add(this._gcPressure);   // Kills UI interactivity !!!!!
        return;
    }
    GC.AddMemoryPressure(this._gcPressure);
}

Это имеет огромное значение, так как вы можете видеть, что время подвески GC резко падает в простом тестовом приложении, которое я написал, чтобы воспроизвести проблему. введите описание изображения здесь

Здесь вы видите, что время подвески GC снижалось с 2,71 до 0,86 с. Это остается практически неизменным даже для нескольких грабёров, управляемых несколькими ГБ. Это также увеличивает общую производительность приложений, поскольку теперь фоновый GC может выполнять свою работу там, где он должен: в фоновом режиме. Это предотвращает внезапные остановки всех управляемых потоков, которые могут продолжать работать, хотя GC очищает все. Не многие люди знают, что дает GC, но это делает реальную разницу в мире ок. 10-15% для общих нагрузок приложений. Если у вас есть управляемое приложение с несколькими GB, где полный GC может занять несколько секунд, вы заметите резкое улучшение. В некоторых тестах у приложения была утечка памяти (управляемая куча 5 ГБ, полное время ожидания 7 ГБ). Я наблюдал задержки 35 секунд UI из-за этих принудительных GC!

Ответ 4

К обновленному вопросу о том, каковы конкретные проблемы, с которыми вы можете столкнуться, используя подход к отражению, я думаю, что @HansPassant тщательно оценил ваш конкретный подход. Но, в более общем плане, риск, который вы используете в своем текущем подходе, - это тот же риск, с которым вы сталкиваетесь, используя любое отражение против кода, который у вас нет; он может измениться под вами в следующем обновлении. До тех пор, пока вам будет удобно, код, который у вас есть, должен иметь незначительный риск.

Чтобы надеяться на исходный вопрос, может возникнуть способ обхода проблемы GC.Collect(2) путем сведения к минимуму количества операций BitmapSource. Ниже приведен пример приложения, иллюстрирующий мою мысль. Подобно тому, что вы описали, он использует виртуализированный ItemsControl для отображения эскизов с диска.

В то время как могут быть и другие, основная точка интереса заключается в том, как создаются миниатюрные изображения. Приложение создает кеш объектов WriteableBitmap. Поскольку элементы списка запрашиваются пользовательским интерфейсом, он считывает изображение с диска, используя BitmapFrame для получения информации об изображении, в первую очередь данных пикселя. Объект WriteableBitmap вытаскивается из кеша, его пиксельные данные перезаписываются, а затем присваивается модели представления. Поскольку существующие элементы списка выходят из поля зрения и перерабатываются, объект WriteableBitmap возвращается в кеш для последующего повторного использования. Единственное BitmapSource -связанное действие, понесенное во время всего этого процесса, - это фактическая загрузка изображения с диска.

Стоит отметить, что образ, возвращаемый методом GetBitmapImageBytes(), должен быть точно такого же размера, как и в кеше WriteableBitmap для этого подхода к перезаписи пикселей для работы; в настоящее время 256 х 256. Для простоты растровые изображения, которые я использовал в своем тестировании, уже были в таком размере, но при необходимости нужно было бы реализовать масштабирование.

MainWindow.xaml:

<Window x:Class="VirtualizedListView.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="500" Width="500">
    <Grid>
        <ItemsControl VirtualizingStackPanel.IsVirtualizing="True"
                      VirtualizingStackPanel.VirtualizationMode="Recycling"
                      VirtualizingStackPanel.CleanUpVirtualizedItem="VirtualizingStackPanel_CleanUpVirtualizedItem"
                      ScrollViewer.CanContentScroll="True"
                      ItemsSource="{Binding Path=Thumbnails}">
            <ItemsControl.ItemTemplate>
                <DataTemplate>
                    <Border BorderBrush="White" BorderThickness="1">
                        <Image Source="{Binding Image, Mode=OneTime}" Height="128" Width="128" />
                    </Border>
                </DataTemplate>
            </ItemsControl.ItemTemplate>
            <ItemsControl.ItemsPanel>
                <ItemsPanelTemplate>
                    <VirtualizingStackPanel />
                </ItemsPanelTemplate>
            </ItemsControl.ItemsPanel>
            <ItemsControl.Template>
                <ControlTemplate>
                    <Border BorderThickness="{TemplateBinding Border.BorderThickness}"
                            Padding="{TemplateBinding Control.Padding}"
                            BorderBrush="{TemplateBinding Border.BorderBrush}"
                            Background="{TemplateBinding Panel.Background}"
                            SnapsToDevicePixels="True">
                        <ScrollViewer Padding="{TemplateBinding Control.Padding}" Focusable="False">
                            <ItemsPresenter SnapsToDevicePixels="{TemplateBinding UIElement.SnapsToDevicePixels}" />
                        </ScrollViewer>
                    </Border>
                </ControlTemplate>
            </ItemsControl.Template>
        </ItemsControl>
    </Grid>
</Window>

MainWindow.xaml.cs:

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;

namespace VirtualizedListView
{
    public partial class MainWindow : Window
    {
        private const string ThumbnailDirectory = @"D:\temp\thumbnails";

        private ConcurrentQueue<WriteableBitmap> _writeableBitmapCache = new ConcurrentQueue<WriteableBitmap>();

        public MainWindow()
        {
            InitializeComponent();
            DataContext = this;

            // Load thumbnail file names
            List<string> fileList = new List<string>(System.IO.Directory.GetFiles(ThumbnailDirectory));

            // Load view-model
            Thumbnails = new ObservableCollection<Thumbnail>();
            foreach (string file in fileList)
                Thumbnails.Add(new Thumbnail(GetImageForThumbnail) { FilePath = file });

            // Create cache of pre-built WriteableBitmap objects; note that this assumes that all thumbnails
            // will be the exact same size.  This will need to be tuned for your needs
            for (int i = 0; i <= 99; ++i)
                _writeableBitmapCache.Enqueue(new WriteableBitmap(256, 256, 96d, 96d, PixelFormats.Bgr32, null));
        }

        public ObservableCollection<Thumbnail> Thumbnails
        {
            get { return (ObservableCollection<Thumbnail>)GetValue(ThumbnailsProperty); }
            set { SetValue(ThumbnailsProperty, value); }
        }
        public static readonly DependencyProperty ThumbnailsProperty =
            DependencyProperty.Register("Thumbnails", typeof(ObservableCollection<Thumbnail>), typeof(MainWindow));

        private BitmapSource GetImageForThumbnail(Thumbnail thumbnail)
        {
            // Get the thumbnail data via the proxy in the other app domain
            ImageLoaderProxyPixelData pixelData = GetBitmapImageBytes(thumbnail.FilePath);
            WriteableBitmap writeableBitmap;

            // Get a pre-built WriteableBitmap out of the cache then overwrite its pixels with the current thumbnail information.
            // This avoids the memory pressure being set in this app domain, keeping that in the app domain of the proxy.
            while (!_writeableBitmapCache.TryDequeue(out writeableBitmap)) { Thread.Sleep(1); }
            writeableBitmap.WritePixels(pixelData.Rect, pixelData.Pixels, pixelData.Stride, 0);

            return writeableBitmap;
        }

        private ImageLoaderProxyPixelData GetBitmapImageBytes(string fileName)
        {
            // All of the BitmapSource creation occurs in this method, keeping the calls to 
            // MemoryPressure.ProcessAdd() localized to this app domain

            // Load the image from file
            BitmapFrame bmpFrame = BitmapFrame.Create(new Uri(fileName));
            int stride = bmpFrame.PixelWidth * bmpFrame.Format.BitsPerPixel;
            byte[] pixels = new byte[bmpFrame.PixelHeight * stride];

            // Construct and return the image information
            bmpFrame.CopyPixels(pixels, stride, 0);
            return new ImageLoaderProxyPixelData()
            {
                Pixels = pixels,
                Stride = stride,
                Rect = new Int32Rect(0, 0, bmpFrame.PixelWidth, bmpFrame.PixelHeight)
            };
        }

        public void VirtualizingStackPanel_CleanUpVirtualizedItem(object sender, CleanUpVirtualizedItemEventArgs e)
        {
            // Get a reference to the WriteableBitmap before nullifying the property to release the reference
            Thumbnail thumbnail = (Thumbnail)e.Value;
            WriteableBitmap thumbnailImage = (WriteableBitmap)thumbnail.Image;
            thumbnail.Image = null;

            // Asynchronously add the WriteableBitmap back to the cache
            Dispatcher.BeginInvoke((Action)(() =>
            {
                _writeableBitmapCache.Enqueue(thumbnailImage);
            }), System.Windows.Threading.DispatcherPriority.Loaded);
        }
    }

    // View-Model
    public class Thumbnail : DependencyObject
    {
        private Func<Thumbnail, BitmapSource> _imageGetter;
        private BitmapSource _image;

        public Thumbnail(Func<Thumbnail, BitmapSource> imageGetter)
        {
            _imageGetter = imageGetter;
        }

        public string FilePath
        {
            get { return (string)GetValue(FilePathProperty); }
            set { SetValue(FilePathProperty, value); }
        }
        public static readonly DependencyProperty FilePathProperty =
            DependencyProperty.Register("FilePath", typeof(string), typeof(Thumbnail));

        public BitmapSource Image
        {
            get
            {
                if (_image== null)
                    _image = _imageGetter(this);
                return _image;
            }
            set { _image = value; }
        }
    }

    public class ImageLoaderProxyPixelData
    {
        public byte[] Pixels { get; set; }
        public Int32Rect Rect { get; set; }
        public int Stride { get; set; }
    }
}

В качестве эталона (для себя, если не более того, я полагаю), я протестировал этот подход на 10-летнем ноутбуке с процессором Centrino и почти не испытывал проблем с текучестью в пользовательском интерфейсе.

Ответ 5

Хотелось бы, чтобы я мог похвастаться этим, но я считаю, что лучший ответ уже есть: Как я могу предотвратить сбор мусора при вызове ShowDialog в окне xaml?

Даже из кода метода ProcessAdd видно, что ничего не выполняется, если _totalMemory достаточно мала. Поэтому я считаю, что этот код намного проще в использовании и с меньшими побочными эффектами:

typeof(BitmapImage).Assembly
  .GetType("MS.Internal.MemoryPressure")
  .GetField("_totalMemory", BindingFlags.NonPublic | BindingFlags.Static)
  .SetValue(null, Int64.MinValue / 2); 

Нам нужно понять, что должен делать этот метод, и комментарий из источника .NET довольно ясен:

/// Avalon currently only tracks unmanaged memory pressure related to Images.  
/// The implementation of this class exploits this by using a timer-based
/// tracking scheme. It assumes that the unmanaged memory it is tracking
/// is allocated in batches, held onto for a long time, and released all at once
/// We have profiled a variety of scenarios and found images do work this way

Итак, мой вывод состоит в том, что, отключив свой код, вы рискуете заполнить свою память из-за того, как управляются изображения. Однако, поскольку вы знаете, что приложение, которое вы используете, велико и что для его вызова может потребоваться GC.Collect, очень простое и безопасное исправление было бы для вас называть его самим, когда вы считаете, что можете.

Этот код пытается выполнить его каждый раз, когда общая используемая память переходит через порог с таймером, поэтому это происходит нечасто. Для них это будет 30 секунд. Итак, почему бы вам не вызвать GC.Collect(2), когда вы закрываете формы или делаете другие вещи, которые освобождают использование многих изображений? Или когда компьютер находится в режиме ожидания или приложение не находится в фокусе и т.д.?

Я потратил время, чтобы проверить, откуда приходит значение _totalMemory, и кажется, что каждый раз, когда они создают WritableBitmap, они добавляют к нему память для _totalMemory, которая вычисляется здесь: http://referencesource.microsoft.com/PresentationCore/R/dca5f18570fed771.html как pixelWidth * pixelHeight * pixelFormat.InternalBitsPerPixel / 8 * 2; и далее в методах, которые работают с Freezables. Это внутренний механизм для отслеживания памяти, выделенной графическим представлением почти любого элемента управления WPF.

Мне кажется, что вы можете не только установить _totalMemory на очень низкое значение, но и захватить механизм. Иногда вы можете прочитать значение, добавить к нему большое значение, вычитаемое изначально, и получить фактическое значение памяти, используемое рисованными элементами управления, и решить, хотите ли вы GC.Collect или нет.