En este artículo terminaremos de revisar algunos de los conceptos más importantes que atañen al GC en .NET: Finalización, Generaciones y manipulación del GC.
Finalización
Nótese que decimos finalizadores y no destructores, como en terminología C++. Esto se debe a que no se corresponde al mismo concepto, ya que el montículo se gestiona de una forma muy diferente y la finalización no es determinística.
Existen objetos que requieren de ciertas tareas de limpieza previamente a la liberación por parte del GC. Esto es cierto en contadas y muy concretas ocasiones, pues en la mayoría de los casos la liberación de un objeto en memoria es perfectamente posible sin intervención explícita por parte del usuario.
Para esto existen métodos de finalización como Finalize() y Dispose() (o Close()). No explicaré en detalle lo que hacen estos métodos; simplemente apuntaré que la diferencia entre el primero y el segundo es que no sabemos con certeza en qué momento se invocará un Finalize() (cuando el GC libere memoria), mientras que un Dispose() fuerza a liberar el recurso referido. Además, el uso de Dispose implica que el objeto referido debe implementar la interfaz IDisposable.
La diferencia entre Dispose() y Close() es más conceptual que funcional. Se suele utilizar Dispose cuando el objeto no se va a utilizar más definitivamente. Un ejemplo muy común: los elementos gráficos tales como controles y formularios WinForms); mientras que un Close indica que el objeto puede volver a utilizarse pero de momento se libera. Un ejemplo serían las conexiones a bases de datos o las conexiones de red.
Existe una sentencia en C# muy útil que permite utilizar un recurso en un ámbito muy concreto y liberarlo al final. Ésta es la sentencia using:
using (MyResource res = new MyResource())
{
//uso del recurso
}
Cuando el recurso sale del ámbito de la sentencia using, automáticamente se invoca su método Dispose que libera el recurso. Esto implica que el objeto debe implementar la interfaz IDisposable, y por tanto implementar un método Dispose. La sentencia using equivale al uso de un try-finally como el siguiente:
MyResource res;
try
{
res = new MyResource();
//uso del recurso
}
finally
{
res.Dispose();
}
Sin embargo, hay que anotar que aunque se invoque explícitamente el método Dispose de un objeto, éste puede no ser liberado inmediatamente. Como dijimos en el artículo anterior, la liberación de memoria en .NET no es determinística.
También existe un fenómeno curioso denominado “resurrección” y que consiste en que un objeto puede volver a memoria después de haberse recolectado. Para más información al respecto, les remito a las referencias que incluyo al final de este artículo.
Generaciones
En la práctica, los objetos se almacenan en el montículo según ciertas “categorías”. Estas categorías se basan en las siguientes premisas, que en la mayoríade los programas se cumplen:
- Cuanto más nuevo es un objeto, más corta será su vida.
- Cuanto más viejo es un objeto, más larga será su vida.
- Recolectar una porción del montículo es más rápido que recolectar todo el montículo.
Cuando se inicializa el montículo, todos los objetos que se van añadiendo a dicha estructura conforman lo que se conoce como generación 0. El CLR inicializa dicha generación con un tamaño determinado; digamos que ese tamaño es 256Kb.
Cuando el montículo se queda sin espacio para albergar más objetos, se ejecuta el GC. Los objetos que superan la criba del GC pasan a formar parte de la generación 1. Este sector (por llamarlo de alguna manera) del montículo tiene un tamaño más grande; digamos que de 2Mb.
Los nuevos objetos que se crean a partir de este momento se almacenan en la generación 0. Cuando la generación 0 se vuelve a llenar, el GC vuelve a eliminar lo que no es necesario y los objetos que sobreviven pasan a formar parte de la generación 1, quedando nuevamente la generación 0 vacía.
Llegará un momento en el cual la generación 1 también se llene y el GC tendrá que actuar nuevamente. Es interesante fijarse en que, puesto que esta generación tiene un espacio más grande, tardará más en llenarse y el GC tardará más en actuar aquí. Además, si suponemos la premisa 1 como cierta entonces liberaremos aquellos objetos nuevos transitorios rápidamente y eficientemente recorriendo secciones bien pequeñas.
Como decíamos, llegará un momento en que la generación 1 se llene. En ese momento el GC recorrerá la generación 1 y todos aquellos objetos que sobrevivan a dicha recolección pasarán a la generación 2, que será aún más grande. Y vuelta a empezar. Normalmente las implementaciones de montículo de .NET soportan hasta 3 generaciones: 0, 1 y 2, con lo cual aquí hemos terminado.
Como vemos, se cumplen las tres premisas, porque:
- Los nuevos objetos se almacenan en la generación 0, que es la más pequeña.
- Los objetos más viejos se almacenan en generaciones más grandes, que tardan más en llenarse y por tanto en limpiarse.
- Casi nunca se recorre todo el montículo, sino solamente sectores (generaciones). Solamente se recorre todo el montículo cuando se intenta instanciar en memoria un nuevo objeto y tanto la generación 0 como la generación 1 y la 2 están llenas, lo cual es realmente muy poco frecuente.
Manipulación del GC
Cuando dije que la liberación de recursos en .NET es automática no fui totalmente sincero. Es posible forzar una pasada del GC, pero esto solamente es conveniente realizarlo cuando sabemos a ciencia cierta que un conjunto de recursos costosos que se han estado utilizando no se van a utilizar más y por tanto deberían ser liberados. El método a utilizar para forzar al GC a liberar recursos es System.GC.Collect().
La clase System.GC es la que implementa el recolector de basura. Algunos métodos útiles son:
- GC.MaxGeneration(), que devuelve el numeral de la máxima generación en el GC soportada por el montículo.
- GC.WaitForPendingFinalizers(): Este método suspende el hilo hasta que se procesen los finalizadores pendientes.
- GC.GetGeneration(): Que permite manipular las distintas generaciones del GC.
Algunas sencillas prácticas que permiten sacar partido del GC
A pesar de que la gestión de la memoria es un proceso bastante automático en .NET, es muy conveniente entender cómo funciona entre bastidores porque sí que hay ciertas prácticas que permiten sacar provecho de su funcionamiento. Por ejemplo:
- Limitar el uso de objetos estáticos, ya que constituyen raíces que no se pueden recolectar.
- Utilizar la localidad de referencia en la medida de lo posible, es decir: declarar los objetos lo más cerca posible de su primer uso. Esto evitará la creación de objetos mucho antes de su uso.
- Tratar de concentrar las operaciones sobre un objeto en una porción pequeña de código, pues el GC se basa en la premisa de que el uso de un objeto está concentrado en unas pocas líneas de código y después no se vuelve a utilizar (nuevamente, localidad de referencia).
- Usar convenientemente Close() y Dispose() para liberar recursos como conexiones, buffers, streams, etc. que ya no se van a utilizar, invocándolos lo antes posible, una vez que los objetos no van a ser utilizados más.
- A la hora de implementar un destructor, primero: plantéese si realmente es necesario hacerlo y segundo: no codifique pensando en el orden que van a seguir las operaciones a la hora de liberar recursos, recuerde: la función del GC es no determinística. Para los que vengan de un lenguaje C++, recuerden que el uso de destructores es mucho menos frecuente en .NET que en C++, sencillamente porque el manejo de la memoria es muy diferente. Básicamente es automático.
Esto es todo. Espero que esta serie de dos artículos les haya parecido útil o al menos interesante a muchos programadores de .NET.
Referencias:
Applied Microsoft .NET Framework programming, Jeffrey Richter
CLR via C#, Jeffrey Richter
Professional C#, Simon Robinson et al.




Comentarios recientes