En el artículo anterior introduje brevemente qué mecanismos disponemos para liberar los recursos de los objetos y cómo actúa GC sobre ellos. Normalmente para un desarrollador GC es algo que está ahí pero no necesita trastear. Pero para aplicaciones más avanzadas, sistemas críticos o debug/testeo suele ser muy útil.
Liberando memoria
El GC tiene la función de liberar la memoria de los objetos. Esto puede ocurrir en los siguiente casos:
-
Limitaciones técnicas de la máquina: Tenemos demasiada memoria ocupada y alguien tiene que liberarla. Si es nuestra, el GC se encargará de ello.
-
Los objetos que tenemos en el Heap se acumulan y excede el máximo permitido. El GC se dispara y recolecta.
-
O directamente, porque lo invocamos explícitamente.
Veamos ejemplos de los dos últimos casos.
Para el primer ejemplo, simplemente tenemos el siguiente código:
1: for (int i = 0; i < 100000; i++)
2: {
3: var customObject = new CustomObject();
4:
5: System.Console
6: .Write(string.Format("Iteration {0}; Memory {1}n", i, GC.GetTotalMemory(false)));
7: }
Donde tenemos un bucle que va instanciando los objetos. Al instanciar un objeto, se reserva la memoria necesaria para él y se crea la instancia. Ese objeto permanecerá en memoria hasta que el GC estime oportuno. Vamos a ver cómo a medida que se incrementa el número de iteraciones la memoria asignada por el GC irá aumentando hasta que decida liberarla.
En el segundo ejemplo, forzamos a través de GC.Collect() que cada vez que se crea el objeto, invocamos al GC para que recorra todo su grafo de objetos y elimine aquellos que no se van a usar:
1: for (int i = 0; i < 100000; i++)
2: {
3: var customObject = new CustomObject();
4: GC.Collect();
5: System.Console
6: .Write(string.Format("Iteration {0}; Memory {1}n", i, GC.GetTotalMemory(false)));
7: }
Y como resultado para esta muestra obtenemos la siguiente gráfica:
Donde podemos observar como en el primer ejemplo el GC acumula memoria hasta que la libera según estima oportuno mientras que en el segundo caso la cantidad de memoria ocupada permanece estable. Evidentemente esta recolecta forzada no sale gratis, veamos ahora en escala de tiempo cuánto repercute. Como el coste por iteración es despreciable, a continuación muestro qué ocurre si invocamos el GC o no en bloques de iteraciones:
Podemos ver una diferencia de tiempo de orden exponencial. Es decir, debemos seguir las recomendaciones dadas de dejar que GC trabaje y nosotros no interactuar con él salvo que tengamos razones muy justificadas para ello.
Generaciones
Una vez que hemos visto cuándo se dispara y qué ocurre cuando se dispara, vamos a ver cómo esta distribuido. Aquí introduzco el tema de “Generaciones”, que es la estructura de distribución de objetos que dispone GC:
-
Generación 0: Contiene los objetos que menos duran, como variables temporales. Inicialmente todo objeto irá directamente a este nivel de generación.
-
Generación 1: Contiene objetos de corta duración y actúa de buffer entre objetos de corta y larga duración.
-
Generación 2: Contiene objetos de larga duración. Por ejemplo, variables estáticas a nivel de aplicación.
Todo objeto en función de su uso y otros factores, se van moviendo en las respectivas generaciones. Como ejemplo se puede ver cómo se obtiene la generación de un determinada instancia:
1: GC.GetGeneration(customObject);
Esto devuelve 0,1 ó 2 en función de la generación de esa instancia. Además de el objeto en sí, podemos pasarle una WeakReference directamente.
También podemos conocer cuántas veces se ha invocado el GC en una generación concreta a través de este método:
1: GC.CollectionCount(0);
Los objetos que no son reclamados al GC se llaman “supervivientes”. Si están en la generación 0 y no han sido reclamados, pasan a la 1. Si están en la 2, siguen en la 2. El GC además detecta estos casos como algo particular: si cada vez hay más supervivientes, balancea el algoritmo para lograr un equilibrio entre el consumo de memoria y el tiempo de ejecución del mismo.
El algoritmo sigue el siguiente proceso:
-
En una primera fase se busca y se crea una lista con todos los objetos vivos.
-
Después se actualizan las referencias de los objetos y se relocalizan.
-
Por último, una fase de compactación de memoria que agrupa el espacio disponible junto con el liberado por los objetos que ya no existen. La compactación siempre se hará salvo para aquellos casos en que los objetos sean demasiado grandes. Se puede recurrir a esta propiedad del GC para forzar que los objetos grandes siempre se compacten (sólo disponible a partir del 4.5.1).
Antes de que el GC se dispare, todos los hilos de ejecución de la aplicación se suspenden para poder activarse el hilo dedicado al GC. En ese momento el GC aplica el algoritmo y el proceso mencionado anteriormente. Cuando termina, el resto de hilos prosiguen en su ejecución: