Este artĆculo trata de servir como introducción a la gestión de memoria en .Net, los lĆmites que el Runtime y la plataforma establecen para cada proceso, asĆ como algunos Tips para lidiar con los problemas a los que nos enfrentamos al acercarnos a esos lĆmites.
Memoria disponible por proceso
Como muchos de vosotros sabƩis, por mucha memoria RAM que tenga instalada un ordenador, existen varias barreras impuestas a la cantidad de memoria usable en nuestras aplicaciones.
Por ejemplo, en un sistema de 32 bits no se pueden instalar mĆ”s de 4GB de memoria fĆsica, evidentemente, porque 2^32 (dos elevado a 32) nos proporciona un espacio de direcciones con 4.294.967.296 entradas distintas (4GB). Pero incluso cuando el sistema cuente con 4GB de memoria fĆsica, nuestras aplicaciones se encontrarĆ”n con una barrera de 2GB impuesta por el sistema.
En estos entornos de 32 bits, cada proceso puede acceder a un espacio de direcciones de 2GB como mĆ”ximo, porque el sistema se reserva los otros 2 para las aplicaciones que corren en modo Kernel (aplicaciones del sistema). Este comportamiento por defecto puede cambiarse mediante el uso del flag “/3gb” en el boot.ini del sistema, haciendo que Windows reserve 3GB para las aplicaciones que corren en Modo Usuario y 1GB de memoria para el Kernel.
AĆŗn asĆ, el lĆmite por proceso permanecerĆ” en 2GB, a no ser que explĆcitamente activemos un flag determinado (IMAGE_FILE_LARGE_ADDRESS_AWARE) en la cabecera de la aplicación. A esta combinación de flags en sistemas x86 se le denomina comĆŗnmente: 4GT (4 GigaByte Tuning).
En sistemas de 64 bits sucede algo parecido. Aunque no tienen la misma limitación en cuanto a memoria fĆsica disponible, ni la impuesta por la reserva de direcciones para el kernel (y por lo tanto el flag /3gb no aplica en estos casos), el sistema tambiĆ©n establece un lĆmite por defecto de 2 GB para cada proceso, a no ser que se active el mismo flag en la cabecera de la aplicación (IMAGE_FILE_LARGE_ADDRESS_AWARE).
Activando el flag: IMAGE_FILE_LARGE_ADDRESS_AWARE
- En el caso de aplicaciones nativas (C++), establecer dicho flag es fƔcil, ya que basta con aƱadir el parƔmetro /LARGEADDRESSAWARE a los parƔmetros del Linker dentro de Visual Studio.
- En el caso de aplicaciones .Net:
- Si estƔn compiladas para 64bits, este flag estarƔ activado por defecto, por lo que podrƔn acceder a un espacio de direcciones de 8 TB (dependiendo del S.O.)
- Si estÔn compiladas para 32bits, el entorno de Visual Studio no nos ofrece ninguna opción para activar dicho flag, por lo que tendremos que hacerlo con la utilidad EditBin.exe, distribuida con Visual Studio, la cual modificarÔ el ejecutable de nuestra aplicación (activÔndole dicho flag).
La siguiente tabla, obtenida de esta pĆ”gina, muestra de forma resumida los lĆmites en el espacio de direcciones de la memoria virtual, en función de la plataforma y del tipo de aplicación que estemos desarrollando:

Esta pĆ”gina tiene mucha mĆ”s información sobre los lĆmites de memoria segĆŗn las versiones del S.O.
Los lĆmites del sistema, mĆ”s cerca de lo que crees
Hoy dĆa, la memoria es barata, pero como ya se ha explicado en el apartado anterior, hay un buen nĆŗmero de casos en los que, por mucha memoria que instalemos en el PC, nuestro proceso solo podrĆ” acceder a 2GB de la misma.
AdemÔs de esto, si vuestra aplicación estÔ desarrollada en .Net, os encontraréis con que el propio Runtime introduce un overhead importante en cuestiones de memoria (suele decirse que estÔ en torno a los 600-800 MB), por lo que en una aplicación corriente, es usual empezar a encontrar OutOfMemoryExceptions alrededor de los 1.3 GB de memoria usados. En este blog se discute el tema.
Por lo tanto, si no estamos en uno de esos casos en los que podemos direccionar mĆ”s de 2GB, y ademĆ”s desarrollamos en .Net, independientemente de la memoria fĆsica instalada en el sistema nuestro lĆmite real estarĆ” en torno a 1.3 GB de memoria RAM.
Para el 99% de las aplicaciones diarias, es mĆ”s que suficiente, pero otras que requieren cĆ”lculos masivos, o que se relacionan con bases de datos, muy frecuentemente superarĆ”n ese lĆmite.
Y lo que es peor…
Para complicar todavĆa mĆ”s el asunto, una cosa es tener memoria disponible, y otra muy distinta es tener bloques de memoria contiguos disponibles.
Como todos sabéis, fruto de la gestión que el Sistema Operativo hace de la memoria, de técnicas como la Paginación, y de la creación y destrucción de objetos, la memoria poco a poco va quedando fragmentada. Esto quiere decir que, aunque tengamos suficiente memoria disponible, esta puede estar dividida en muchos bloques pequeños, en lugar de un único hueco con todo el tamaño disponible.
Los Sistemas Operativos modernos, y la propia plataforma .Net, tratan de evitar esto con tĆ©cnicas de Compactación, y aunque reducen notablemente el problema, no lo eliminan por completo. Este completo artĆculo describe en detalle la gestión de memoria del Garbage Collector de .Net, y la labor de compactación que realiza.
¿En quĆ© afecta la fragmentación? En mucho, ya que si vuestra aplicación necesita reservar un Array contiguo de 10 MB, y aunque todavĆa haya 1GB de memoria disponible, si la memoria estĆ” muy fragmentada y el sistema no es capaz de encontrar un bloque contiguo de ese tamaƱo, obtendremos un OutOfMemoryException.
En .Net, la fragmentación y compactación de objetos en memoria guarda una estrecha relación con el tamaño de éstos. Por eso, el siguiente apartado hablarÔ un poco sobre este tema.
Grandes objetos en memoria
A la hora de reservar memoria para un Ćŗnico objeto, la plataforma .Net establece ciertos lĆmites. Por ejemplo, en las versiones de .Net 1.0, 2.0, 3.0, 3.5 y 4.0, ese lĆmite es de 2GB. Tanto para plataformas x86 como x64, ningĆŗn objeto Ćŗnico puede ser mayor de ese tamaƱo. Es asĆ de simple. Ćnicamente a partir de .Net 4.5 este lĆmite puede ser excedido (en procesos x64 exclusivamente). Aunque sinceramente, salvo rarĆsimas excepciones, si necesitas reservar mĆ”s de 2GB de memoria para un Ćŗnico objeto, quizĆ” deberĆas replantearte el diseƱo de tu aplicación.
En el mundo .Net, el Garbage Collector clasifica a los objetos en dos tipos: objetos grandes y objetos pequeƱos. Es una división bastante gruesa, la verdad, pero es asĆ. ¿QuĆ© considera .Net como un objeto pequeƱo? Todo aquel que ocupe menos de 85000 bytes.
Cuando el CLR de .Net es cargado, se reservan dos porciones de memoria diferentes: un Heap para los objetos pequeƱos (tambiƩn llamado SOH, o Small Objects Heap), y otra para los objetos grandes (tambiƩn llamado LOH, o Large Object Heap), y cada tipo de objeto se almacena en su Heap correspondiente.
¿En quĆ© afecta todo esto al tema que estamos tratando? Sencillo, compactar objetos grandes es costoso, y a dĆa de hoy, simplemente no se hace. Los objetos considerados “Grandes”, y que se introducen en el LOH, no se compactan (aunque el equipo de desarrollo advierte que pueden hacerlo algĆŗn dĆa). Como mucho, cuando dos objetos grandes adyacentes son liberados, se fusionan en un Ćŗnico espacio de memoria disponible, pero ningĆŗn objeto es “movido” para realizar tareas de compactación.
Este fantĆ”stico artĆculo contiene muchĆsima mĆ”s información acerca del LOH y su funcionamiento.
Arrays C# en los lĆmites de la memoria
En C#, los Arrays Simples (de una dimensión) son una de las formas mÔs comunes de consumir memoria, y debes saber que el CLR los reserva siempre como bloques continuos de memoria. Es decir, cuando instanciamos un objeto de tipo byte[1024], estamos solicitando al sistema un único bloque continuo de 1KB, y se generarÔ un OutOfMemoryException si no encuentra ningún hueco contiguo de ese tamaño.
Cuando es necesario utilizar un Array de mÔs de una dimensión, C# nos ofrece distintas opciones:
Arrays anidados, o arrays de arrays
Declarados como byte[][], suponen el método clÔsico de implementar arrays multi-dimensionales. De hecho, en lenguages como C++, es el único tipo de array multi-dimensional soportado de forma nativa.
En lo relativo a memoria, se comportan como un array simple (un único bloque de memoria), en el que cada elemento es otro array simple (esta vez del tipo declarado, y que también es un bloque único en memoria, pero distinto a los demÔs). Por lo tanto, en lo que a bloques de memoria se refiere, un array de tipo byte[1024][1024], utilizarÔ 1024 bloques de memoria distintos (cada uno de 1024 bytes).
Arrays Multi-Dimensionales
C# introduce un nuevo tipo de Arrays, soportado de forma nativa: los arrays multi-dimensionales. En el caso de 2 dimensiones, se declaran como byte[,].
Aunque son muy cómodos de utilizar (disponen entre otras cosas de mĆ©todos como GetLength, para saber el tamaƱo de una dimensión), y su instanciación es mĆ”s sencilla, su representación en memoria es diferente a la de los arrays anidados. Ćstos se almacenan como un Ćŗnico bloque de memoria, del tamaƱo total del array.
En el siguiente apartado estableceremos una comparativa entre ambos tipos:
Comparativa: [,] vs [][]
El array 2D [,] (se almacena en un solo bloque):
Ventajas:
- Utiliza menos memoria total (no tiene que almacenar las referencias a los n arrays simples)
- Su creación es mÔs rÔpida: reservar un bloque grande de memoria para para un solo objeto es mÔs rÔpido que reservar bloques mÔs pequeños para muchos objetos.
- Su instanciación es mĆ”s sencilla: una sola lĆnea basta (new byte[128,128]).
- Proporciona métodos útiles, como GetLength, y su uso es mÔs claro y limpio.
Inconvenientes:
- Encontrar un solo bloque de memoria continuo para el array puede ser un problema, si Ʃste es muy grande o nos encontramos cerca del limite de RAM.
- El acceso a los elementos del array es mƔs lento que en arrays anidados (ver abajo)
El array anidado [][] (que se almacena en N bloques):
Ventajas:
- Es mÔs fÔcil encontrar memoria disponible para el array, ya que requiere de n bloques de tamaño mÔs pequeño, lo cual debido a la fragmentación, suele ser mÔs probable que encontrar un único bloque mÔs grande.
- El acceso a los elementos del array es mƔs rƔpido que en los arrays 2D, gracias a las optimizaciones del compilador para manejar arrays simples (en definitiva, un array de arrays se compone de muchos arrays 1D).
Inconvenientes:
- Utiliza mƔs memoria total (tiene que almacenar las referencias a los n arrays simples)
- Su creación es mÔs lenta, ya que hay que reservar N bloques de memoria, en lugar de uno solo.
- Su instanciación es un poco mÔs molesta, ya que hay que recorrer el array instanciando cada uno de sus elementos (ver Tip mÔs abajo).
- No proporciona los mƩtodos disponibles en los arrays 2D, y su uso puede ser un poco mƔs confuso.
Este blog explica muy bien esta comparativa.
Conclusión
Cada usuario debe escoger el tipo de array que mÔs le convenga en función de su experiencia y el contexto concreto en el que esté. No obstante, un desarrollador que habitualmente utilice gran cantidad de memoria, y preocupado por el rendimiento, tenderÔ a escoger siempre arrays anidados (o arrays de arrays [][]).
Tip: código generico para instanciar arrays anidados
Dado que instanciar un array de arrays es un poco molesto y repetitivo (y ya dijimos aqui que no conviene duplicar código), el siguiente método genérico se encargarÔ de esa tarea por vosotros:
public static T[][] Allocate2DArray<T>(int pWidth, int pHeight)
{
T[][] ret = new T[pWidth][];
for (int i = 0; i < pHeight; i++)
ret[i] = new T[pHeight];
return ret;
}
Espero que os Sirva !!!