Memory limits in a .Net process
Available memory for a process
As you already know, no matter how much physical memory you install in a computer. Your application will face several issues that will limit the actual memory available for it.For instance, a 32 bit system cannot have more than 4 GB of physical memory. Needless to say that 2^32 will give you a virtual address space with 4.294.967.296 different entries, and that’s precisely where the 4GB limit comes from. But even having those 4GB available on the system, your application will actually be able to see 2GB only. Why?
Because on 32 bits systems, Windows splits the virtual address space into two equal parts: one for User Mode applications, and another one for the Kernel (system applications). This behavior can be overridden by using the “/3gb” flag in the Windows boot.ini config file. If we do so, the system will then reserve 3GB for user applications, and 1 GB for the kernel.
However, that won’t change the fact that we will be able to see only 2GB from our application, unless we explicitly activate another flag in the application image header: IMAGE_FILE_LARGE_ADDRESS_AWARE. The combination of both flags on a 32bit Operating System is commonly known as: 4GT (4 GigaByte Tuning).
Surprisingly on 64 bit environments, the issue is pretty similar. Even though these systems don’t suffer from the same limitations about physical memory or reserved address space for the kernel (in fact, in those systems the /3gb flag doesn’t apply), processes hit with the same wall when trying to address more than 2 GB. Unless the same flag is set for the executable (IMAGE_FILE_LARGE_ADDRESS_AWARE), the limit will be always the same by default.
Activating the flag: IMAGE_FILE_LARGE_ADDRESS_AWARE
- In native, Visual C++ application, it’s pretty straightforward to set that flag, as Visual Studio have an option for that. You just need to set the /LARGEADDRESSAWARE Linker parameter, and you are ready to go.
- In C#, .Net applications:
- Applications compiled as 64bit will have that flag set by default, so you will already have access to a 8TB address space (depending on O.S. versions)
- Applications compiled as 32bits will need to be modified with the tool called EditBin.exe (distributed with Visual Studio). This tool will set the appropriate flag to your EXE, allowing your application to access a 4GB address space if running in a 64bit Windows, or to a 3GB address space if running in a 32bit Windows with the 4GT tuning enabled.
This page has much more info on the issue.
System memory limits. Closer than you expect
Nowadays, memory is cheap. However, as explained in the previous chapter, there are many situations where you will end up having only 2 GB available, despite the total amount of physical memory installed in your PC.In addition to that, if your application is being developed in .Net, you will find that the Runtime itself introduces a remarkable memory overhead (around 600-800 MB). So, it’s not strange to start receiving OutOfMemory exceptions when reaching 1.2 or 1.3 GB of memory used. This blog talks further about this.
So, if you are not in one of those cases, where the address space is expanded beyond 2 GB, and your are developing in .Net, your actual memory limit will be around 1.3 GB.
That’s more than enough for 99% of applications, but others, like intensive computing apps or those related to databases, may need more. Way more…
And things get even worse…
To make things even more complicated, you will soon learn that one thing is having some amount of memory available, and another, completely different story is to find a contiguous block of memory available.As you all know, as a result of O.S. memory management, techniques like Paging and the creation and destruction of objects, memory gets more and more fragmented. That means that even though there is a certain amount of free memory, it is scattered through a bunch of small holes, instead of having a single, big chunk of memory available.
Modern Operating Systems and the .Net platform itself apply methodologies to prevent fragmentation, like the so called Compaction (moving objects in memory to fuse several free chunks of memory into a single, bigger one). Although these techniques reduce the impact of fragmentation, they do not eliminate it completely. This article describes in detail the .Net Garbage Collector (GC) memory management, and the compaction task it performs.
In the context of this article, fragmentation is a big issue, because if you need to allocate an 10 MB contiguous array, even if there’s 1 GB of free memory available for your process, you will receive an OutOfMemory exception if the system cannot find a contiguous chunk of memory for the array. And this happens more frequently than you may expect when you deal with big arrays.
In .Net, fragmentation and compaction of objects is tightly related to object’s size, so let’s talk a bit about that too:
Allocation of big objects
Maybe you don’t know it, but all versions of .Net until the last one (1.0, 2.0, 3.0, 3.5 and 4.0) have a limit on the maximum size a single object can have: 2 GB. No matter if you are running in a 64bit or 32bit process, you cannot create anything bigger than that, in a single object. It’s only since version 4.5 when that limit has been removed (for 64 bit processes only). However, besides very few exceptions, you are very likely applying a wrong design pattern to your application if you need to create such a big objects.In the .Net world, the GC classifies objects into two categories: small, and large objects. Where you expecting something more technical? Yeah, me too… But that’s it. Any object smaller than 85000 bytes is considered small, and any object larger than that is considered large. When the CLR is loaded, the Heap assigned for the application is divided into two parts: the SOH (Small Objects Heap) and the LOH (Large Objects Heap). Each kind of object is stored on it’s correspondent Heap.
It’s also remarkable to say that Large object’s compaction is very expensive, so it’s directly not done in current versions of .Net (developers said that this situation might change in the future). The only operation similar to compaction done with Large objects is that two adjacent dead objects are fused together into a single chunk of free memory, but no Large object is currently moved to reduce fragmentation.
This fantastic article has much more information about the LOH.
C# Arrays when reaching memory limits
Simple Arrays (or 1D arrays) are one of the most common ways of consuming memory in C#. As you probably know, the CLR always allocates them as single, contiguous blocks of memory. In other words, when we instantiate an object of type byte[1024], we are requesting 1024 bytes of contiguous memory, and you will get an OutOfMemory exception if the system cannot find any chunk of contiguous, free memory with that size.When dealing with multi-dimensional arrays, C# offers different approaches:
Jagged arrays, or arrays of arrays: [][]
Declared as byte[][], this is the classical solution to implement multi-dimensional arrays. In fact, it’s the only approach natively supported in languages like C++.With regards to memory allocation, they behave as a simple array of elements (one block of memory), where each one of them is another array (another, different block of memory). Therefore, an array like byte[1024][1024] will involve the allocation of 1024 blocks of 1024 bytes memory each.
Multi-Dimensional Arrays: [,]
C# introduces a new kind of arrays: multi-dimensional arrays, declared like byte[,].Although they are very comfortable to use and easy to instantiate, they behave completely different with regards to memory allocation, as they are allocated in the Heap as a single block of memory, for the total size of the array. In the previous example, an array like byte[1024, 1024] will involve the allocation of one single, contiguous block of 1 MB.
In the next chapter we will make a quick comparison of both types of arrays:
Comparison: [,] vs [][]
2D array [,] (allocated as a single block of memory):Pros:
- Consumes less memory (no need to store references to all N blocks of memory)
- Faster allocation (allocating a bigger, single block of memory is faster than allocating N, smaller blocks)
- Easier instancing (enough with one single line: new byte[128, 128])
- Useful tool methods, like GetLength(). Cleaner and easier usage.
- Finding a single block of contiguous memory for them might be a problem, specially if dealing with big arrays, or when reaching memory limits for your process
- Accessing elements in the array is slower than in jagged arrays (see below)
Pros:
- It’s easier to find available memory for this kind of arrays, because due to fragmentation, it’s more likely that there will be N blocks of smaller size available than a single, contiguous block of the full size of the array.
- Accessing elements in the array is faster than in 2D arrays, mostly because the optimizations in the compiler for handling simple, 1D arrays (after all, a jagged array is composed of several 1D arrays).
- Consumes a bit more memory than 2D arrays (need to store references to the N simple arrays).
- Allocation is slower, as it needs to allocate N elements instead of a single block
- Instancing is uncomfortable, as you need to loop through array elements to instantiate them too (see below for tip)
- Doesn’t provide with tool methods, and might be a bit more complex to read and understand
Conclusion
Each user should decide which kind of array fits best the specific case he is dealing with. However, a developer that usually needs big amounts of memory, and who cares more about performance than comfort, ease of use or readability, will probably decide to use Jagged arrays ([][]).Tip: code to automatically instantiate a 2D, jagged array
Instancing a multi-dimensional jagged array can be disturbing, and repetitive. This generic method will do the work for you:{
T[][] ret = new T[pWidth][];
for (int i = 0; i < pHeight; i++)
ret[i] = new T[pHeight];
return ret;
}
Los límites de la memoria
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:
{
T[][] ret = new T[pWidth][];
for (int i = 0; i < pHeight; i++)
ret[i] = new T[pHeight];
return ret;
}
Espero que os Sirva !!!