clean - c# garbage collection
Cualquier forma de evitar que WPF llame a GC. ¿Recoge(2) aparte de la reflexión? (5)
Recientemente tuve que controlar esta monstruosidad en el código de producción para manipular campos privados en una clase de WPF: (¿cómo puedo evitar tener que hacer esto?)
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();
}
}
}
Para entender por qué haría algo tan loco, debes mirar 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''s 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;
}
El bit importante está cerca del final, donde llama al método 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''s ok
GC.Collect(2);
}
Sí, eso es WPF realmente forzando una recolección de basura gen 2, lo que obliga a un GC de bloqueo completo. Un GC natural ocurre sin bloqueo en el gen 2 montón. Lo que esto significa en la práctica es que cada vez que se llama a este método, toda nuestra aplicación se bloquea. Cuanta más memoria esté usando su aplicación, y cuanto más fragmentado esté el montón de gen 2, más tardará. Nuestra aplicación actualmente almacena en caché bastante información y puede ocupar fácilmente un poco de memoria y el GC forzado puede bloquear nuestra aplicación en un dispositivo lento durante varios segundos, cada 850 MS.
Porque a pesar de las protestas del autor por lo contrario, es fácil llegar a un escenario donde este método se llama con gran frecuencia. Este código de memoria de WPF se produce al cargar un BitmapSource
desde un archivo. Virtualizamos una vista de lista con miles de elementos donde cada elemento se representa mediante una miniatura almacenada en el disco. A medida que nos desplazamos hacia abajo, estamos cargando dinámicamente en esas miniaturas, y ese GC está sucediendo a la frecuencia máxima. Así que el desplazamiento se vuelve increíblemente lento y entrecortado con la aplicación bloqueándose constantemente.
Con ese horroroso truco de reflexión que mencioné en la parte superior, forzamos a los temporizadores a que nunca se cumplan, y por lo tanto WPF nunca fuerza al GC. Además, parece que no hay consecuencias adversas: la memoria crece a medida que uno se desplaza y, finalmente, un GC se dispara de forma natural sin bloquear el hilo principal.
¿Hay alguna otra opción para evitar esas llamadas a GC.Collect(2)
que no es tan flagrantemente horrible como mi solución? Me encantaría obtener una explicación de cuáles son los problemas concretos que pueden surgir de seguir este truco. Con eso quiero decir problemas para evitar la llamada a GC.Collect(2)
. (me parece que el GC que ocurre naturalmente debería ser suficiente)
Aviso: Haga esto solo si causa un cuello de botella en su aplicación, y asegúrese de comprender las consecuencias. Consulte la respuesta de Hans para obtener una buena explicación de por qué pusieron esto en WPF en primer lugar.
Tienes un código desagradable allí tratando de arreglar un hack desagradable en el marco ... Como todo está estático y llamado desde múltiples lugares en WPF, no puedes hacer nada mejor que usar el reflejo para romperlo (otras soluciones serían mucho peores )
Así que no esperes una solución limpia allí. No existe tal cosa a menos que cambien el código WPF.
Pero creo que tu hack podría ser más simple y evitar usar un temporizador: simplemente _totalMemory
valor de _totalMemory
y listo. Es long
, lo que significa que puede ir a valores negativos. Y muy grandes valores negativos en eso.
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);
}
}
Aquí, ahora su aplicación debería asignar aproximadamente 8 exabytes antes de llamar a GC.Collect
. No hace falta decir que si esto sucede, tendrás problemas más grandes que resolver. :)
Si le preocupa la posibilidad de un long.MinValue / 2
inferior, simplemente use long.MinValue / 2
como compensación. Esto todavía te deja con 4 exabytes.
Tenga en cuenta que AddToTotal
realmente realiza la comprobación de _totalMemory
de _totalMemory
, pero lo hace con una _totalMemory
. _totalMemory
here :
Debug.Assert(newValue >= 0);
Como usará una versión de lanzamiento de .NET Framework, estas afirmaciones se desactivarán (con un ConditionalAttribute
), por lo que no hay necesidad de preocuparse por eso.
Usted ha preguntado qué problemas podrían surgir con este enfoque. Vamos a ver.
El más obvio: MS cambia el código WPF que está intentando piratear.
Bueno, en ese caso, depende de la naturaleza del cambio.
Cambian el tipo nombre / nombre de campo / tipo de campo: en ese caso, no se realizará el corte y volverá al comportamiento de stock. El código de reflexión es bastante defensivo, no lanzará una excepción, simplemente no hará nada.
Cambian la llamada
Debug.Assert
a una verificación en tiempo de ejecución que está habilitada en la versión de lanzamiento. En ese caso, su aplicación está condenada al fracaso. Cualquier intento de cargar una imagen del disco arrojará. Oops.Este riesgo se ve mitigado por el hecho de que su propio código es más o menos un hack. No tienen la intención de tirar, debería pasar desapercibido. Quieren que se quede quieto y fracase silenciosamente. Dejar que las imágenes se carguen es una característica mucho más importante que no debe verse afectada por algún código de administración de memoria cuyo único propósito es mantener el uso de la memoria al mínimo.
En el caso de su parche original en el OP, si cambian los valores constantes, su hack puede dejar de funcionar.
Cambian el algoritmo mientras mantienen la clase y el campo intactos. Bueno ... cualquier cosa podría suceder, dependiendo del cambio.
Ahora, supongamos que el truco funciona y deshabilita el
GC.Collect
.GC.Collect
llamada con éxito.El riesgo obvio en este caso es un mayor uso de memoria. Como las recolecciones serán menos frecuentes, se asignará más memoria en un momento dado. Esto no debería ser un gran problema, ya que las colecciones seguirían ocurriendo naturalmente cuando se llene gen 0.
También tendría más fragmentación de memoria, esta es una consecuencia directa de menos colecciones. Esto puede o no ser un problema para usted, así que perfile su aplicación.
Menos colecciones también significa que se promueven menos objetos a una generación superior. Esto es algo bueno Idealmente, debería tener objetos efímeros en gen 0 y objetos de larga vida en gen 2. Las colecciones frecuentes en realidad provocarán que los objetos efímeros sean promovidos a gen 1 y luego a gen 2, y terminará con muchos objetos inalcanzables en gen 2. Estos solo se limpiarán con una colección gen 2, causará la fragmentación del montón, y de hecho aumentará el tiempo de GC, ya que tendrá que pasar más tiempo compactando el montón. Esta es en realidad la razón principal por la que llamar a
GC.Collect
usted mismo se considera una mala práctica: usted está derrotando activamente la estrategia GC, y esto afecta a toda la aplicación.
En cualquier caso, el enfoque correcto sería cargar las imágenes, reducirlas y mostrar estas miniaturas en su UI. Todo este procesamiento debe hacerse en un hilo de fondo. En el caso de las imágenes JPEG, cargue las miniaturas incrustadas: pueden ser lo suficientemente buenas. Y use un grupo de objetos para no necesitar instanciar nuevos mapas de bits cada vez, esto pasa por alto por completo el problema de la clase MemoryPressure
. Y sí, eso es exactamente lo que sugieren las otras respuestas;)
.NET 4.6.2 lo arreglará matando la clase MemoryPressure alltogether. Acabo de comprobar la vista previa y mi UI cuelga se ha ido por completo.
.NET 4.6 lo implementa
internal SafeMILHandleMemoryPressure(long gcPressure)
{
this._gcPressure = gcPressure;
this._refCount = 0;
GC.AddMemoryPressure(this._gcPressure);
}
mientras que antes de .NET 4.6.2 tenías esta clase cruda de MemoryPressure que forzaría un GC. Recolecta cada 850 ms (si no se asignaron entre ellos WPF Bitmaps) o cada 30 s independientemente de la cantidad de mapas de bits WPF que hayas asignado.
Como referencia, el mango antiguo se implementó como
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);
}
Esto marca una gran diferencia, ya que puede ver que los tiempos de suspensión de GC disminuyen drásticamente en una aplicación de prueba simple que escribí para reproducir el problema.
Aquí puede ver que los tiempos de suspensión de GC bajaron de 2,71 a 0,86 s. Esto permanece casi constante incluso para montones gestionados de GB múltiples. Esto también aumenta el rendimiento general de la aplicación porque ahora el GC de fondo puede hacer su trabajo donde debería: En segundo plano. Esto evita interrupciones repentinas de todos los hilos gestionados que pueden seguir funcionando felizmente, aunque el GC está limpiando cosas. No muchas personas saben qué antecedentes les brinda GC, pero esto hace una diferencia real de ca. 10-15% para cargas de trabajo de aplicaciones comunes. Si tiene una aplicación administrada de varios GB donde un GC completo puede tomar segundos, notará una mejora dramática. En algunas pruebas, una aplicación tenía una fuga de memoria (5 GB de almacenamiento gestionado, GC completo, tiempo de suspensión de 7 s). ¡Vi retrasos en la interfaz de usuario de 35 segundos debido a estos GC forzados!
A la pregunta actualizada sobre cuáles son los problemas concretos con los que puede encontrarse al utilizar el enfoque de reflexión, creo que @HansPassant fue minucioso en su evaluación de su enfoque específico. Pero, en términos más generales, el riesgo que corres con tu enfoque actual es el mismo riesgo con el que corres usando cualquier reflejo contra código que no posees; puede cambiar debajo de ti en la próxima actualización. Siempre que te sientas cómodo con eso, el código que tienes debe tener un riesgo insignificante.
Para poder responder a la pregunta original, puede haber una forma de solucionar el problema de GC.Collect(2)
minimizando el número de operaciones de BitmapSource
. A continuación se muestra una aplicación de muestra que ilustra mi pensamiento. Similar a lo que describió, utiliza un ItemsControl
virtualizado para mostrar miniaturas del disco.
Si bien puede haber otros, el principal punto de interés es cómo se construyen las imágenes en miniatura. La aplicación crea un caché de objetos WriteableBitmap
por adelantado. Como los elementos de la lista son solicitados por la interfaz de usuario, lee la imagen del disco, utilizando un BitmapFrame
de BitmapFrame
para recuperar la información de la imagen, principalmente datos de píxeles. Un objeto WriteableBitmap
se extrae de la memoria caché, sus datos de píxeles se sobrescribe, y luego se asigna al modelo de vista. A medida que los elementos de la lista existente se WriteableBitmap
de vista y se reciclan, el objeto WriteableBitmap
se devuelve a la memoria caché para su reutilización posterior. La única actividad relacionada con BitmapSource
se incurre durante todo el proceso es la carga real de la imagen desde el disco.
Vale la pena señalar que, la imagen devuelta por el método GetBitmapImageBytes()
debe tener exactamente el mismo tamaño que las del caché WriteableBitmap
para que WriteableBitmap
este enfoque de sobreescritura de píxeles. actualmente 256 x 256. En aras de la simplicidad, las imágenes de mapa de bits que utilicé en mis pruebas ya tenían este tamaño, pero sería trivial implementar las escalas, según sea necesario.
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; }
}
}
Como punto de referencia, (para mí, si no para nadie más, supongo), he probado este enfoque en una computadora portátil de 10 años con un procesador Centrino y casi no tuve problemas con la fluidez en la interfaz de usuario.
Creo que lo que tienes está bien. Bien hecho, buen truco, Reflection es una herramienta increíble para corregir el código de la estructura wonky. Lo he usado muchas veces. Simplemente limite su uso a la vista que muestra el ListView, es demasiado peligroso tenerlo activo todo el tiempo.
Reflejando un poco sobre el problema subyacente, el horrible truco de ProcessAdd () es, por supuesto, muy crudo. Es una consecuencia de que BitmapSource no implementa IDisposable. Una decisión de diseño cuestionable, SO está llena de preguntas al respecto. Sin embargo, sobre todos ellos se trata del problema opuesto, este temporizador no es lo suficientemente rápido para mantenerse al día. Simplemente no funciona muy bien.
No hay nada que puedas hacer para cambiar la forma en que funciona este código. Los valores con los que funciona son declaraciones const . Con base en valores que podrían haber sido apropiados hace 15 años, la edad probable de este código. Comienza en un megabyte y llama a "10s of MB" un problema, la vida era más simple en ese entonces :) Se olvidaron de escribirlo para que se escalara apropiadamente, GC.AddMemoryPressure () probablemente estaría bien hoy. Muy poco y demasiado tarde, ya no pueden arreglar esto sin alterar dramáticamente el comportamiento del programa.
Ciertamente puede vencer al temporizador y evitar su pirateo. Seguramente el problema que tiene ahora es que su intervalo es aproximadamente el mismo que el que un usuario recorre el ListView cuando no lee nada, solo trata de encontrar el registro que le interesa. Ese es un problema de diseño de la interfaz de usuario que es tan común con las vistas de lista con miles de filas, un problema que probablemente no desee abordar. Lo que debe hacer es almacenar en caché las miniaturas y reunir las que probablemente necesitará después. La mejor forma de hacerlo es hacerlo en un hilo de subprocesos. Mida el tiempo mientras lo hace, puede gastar hasta 850 mseg. Sin embargo, ese código no será más pequeño que el que tiene ahora, tampoco mucho más bonito.
Me gustaría poder atribuirme el mérito de esto, pero creo que ya hay una respuesta mejor: ¿cómo puedo evitar que se llame a la recolección de basura cuando llamo a ShowDialog en una ventana xaml?
Incluso desde el código del método ProcessAdd uno puede ver que no se ejecuta nada si _totalMemory es lo suficientemente pequeño. Así que creo que este código es mucho más fácil de usar y con menos efectos secundarios:
typeof(BitmapImage).Assembly
.GetType("MS.Internal.MemoryPressure")
.GetField("_totalMemory", BindingFlags.NonPublic | BindingFlags.Static)
.SetValue(null, Int64.MinValue / 2);
Sin embargo, debemos entender qué se supone que debe hacer el método, y el comentario de la fuente .NET es bastante claro:
/// 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
Así que mi conclusión es que al deshabilitar su código, corre el riesgo de llenar su memoria debido a la forma en que se administran las imágenes. Sin embargo, dado que sabe que la aplicación que utiliza es grande y que podría necesitar GC.Colgar que se le llame, una solución muy simple y segura sería que usted la llamara usted mismo, cuando sienta que puede hacerlo.
El código allí intenta ejecutarlo cada vez que la memoria total utilizada supera un umbral, con un temporizador, por lo que no ocurre con demasiada frecuencia. Eso sería 30 segundos para ellos. Entonces, ¿por qué no llamas a GC.Collect (2) cuando cierras formularios o haces otras cosas que liberarían el uso de muchas imágenes? ¿O cuando la computadora está inactiva o la aplicación no está enfocada, etc.?
Me tomé el tiempo para verificar de dónde viene el valor _TotalMemory y parece que cada vez que crean un WritableBitmap, agregan la memoria para ello a _totalMemory, que se calcula aquí: http://referencesource.microsoft.com/PresentationCore/R/dca5f18570fed771.html como pixelWidth * pixelHeight * pixelFormat.InternalBitsPerPixel / 8 * 2;
y más adelante en métodos que funcionan con Freezables. Es un mecanismo interno para realizar un seguimiento de la memoria asignada por la representación gráfica de casi cualquier control de WPF.
Me parece que no solo podría establecer _totalMemory a un valor muy bajo, sino también secuestrar el mecanismo. De vez en cuando, puede leer el valor, agregarle el gran valor que resta inicialmente y obtener el valor real de la memoria utilizada por los controles dibujados y decidir si desea o no GC.Collection.