OutOfMemoryException al manejar StringBuilder en un proceso de 64 bits

 

¿Es posible tener un OutOfMemory con un proceso que tenga menos de 5GB de memoria virtual, sin fragmentación aparente y que se (obviamente) se ejecute en 64 bits?

Gracias a determinados usos de StringBuilder sí podemos tenerlo. Con este pequeño código conseguiremos tener nuestro deseado OutOfMemory y el proceso tendrá un consumo de 4,5 GB

  string void main(string[] args)

   {

            const string lala = "lalalalalalalalalalalalalalalalalalalalala";

            StringBuilder sb = new StringBuilder();

            for (int i = 0; i < int.MaxValue/2; i++)

            {

                sb.Append(lala);

                Console.WriteLine(string.Format("Longitud de la cadenaEnter username: {0} ", sb.ToString().Length));

            }

   }

 

¿Cuál es el motivo si tenemos más de 127 TB libres?. Para descubrirlo, tenemos que ahondar en lo que ocurre cuando salta la excepción. Vemos el método que causa la excepción ExpandByABlock, que se usa cuando el objeto StringBuilder piensa que se ha llenado el último chunk de información y tenemos que pasar a uno nuevo:

0:000> kc
# Call Site
00 clr!IL_Throw
01 mscorlib_ni!System.Text.StringBuilder.ExpandByABlock(Int32)
02 mscorlib_ni!System.Text.StringBuilder.Append(Char*, Int32)
03 mscorlib_ni!System.Text.StringBuilder.AppendHelper(System.String)
04 mscorlib_ni!System.Text.StringBuilder.Append(System.String)
05 consoleapplication2!ConsoleApplication2.Program.Main(System.String[])
06 clr!CallDescrWorkerInternal
07 clr!CallDescrWorkerWithHandler
08 clr!MethodDescCallSite::CallTargetWorker
09 clr!MethodDescCallSite::Call
0a clr!RunMain
0b clr!Assembly::ExecuteMainMethod
0c clr!SystemDomain::ExecuteMainMethod
0d clr!ExecuteEXE
0e clr!_CorExeMainInternal
0f clr!_CorExeMain
10 mscoreei!_CorExeMain
11 mscoree!ShellShim__CorExeMain
12 mscoree!_CorExeMain_Exported
13 kernel32!BaseThreadInitThunk
14 ntdll!RtlUserThreadStart

 

Estupendo, pero esto no explica el motivo por el que falla. Así que tenemos que ver qué hace este método. La información pública la tenemos aquí: https://referencesource.microsoft.com/#mscorlib/system/text/stringbuilder.cs,bb3473794f7408f5 

             ///<summary>
/// Assumes that 'this' is the last chunk in the list and that it is full. Upon return the 'this'
/// block is updated so that it is a new block that has at least 'minBlockCharCount' characters.
/// that can be used to copy characters into it.
///</summary>
private void ExpandByABlock(int minBlockCharCount)
{
            Contract.Requires(Capacity == Length, "Expand expect to be called only when there is no space left"); // We are currently full
            Contract.Requires(minBlockCharCount > 0, "Expansion request must be positive");

            VerifyClassInvariant();

if ((minBlockCharCount + Length) > m_MaxCapacity)
throw new ArgumentOutOfRangeException("requiredLength", Environment.GetResourceString("ArgumentOutOfRange_SmallCapacity"));

// Compute the length of the new block we need
// We make the new chunk at least big enough for the current need (minBlockCharCount)
// But also as big as the current length (thus doubling capacity), up to a maximum
// (so we stay in the small object heap, and never allocate really big chunks even if
// the string gets really big.
int newBlockLength = Math.Max(minBlockCharCount, Math.Min(Length, MaxChunkSize));

// Copy the current block to the new block, and initialize this to point at the new buffer.
            m_ChunkPrevious = new StringBuilder(this);
            m_ChunkOffset += m_ChunkLength;
            m_ChunkLength = 0;

// Check for integer overflow (logical buffer size > int.MaxInt)
if (m_ChunkOffset + newBlockLength < newBlockLength)
{
                m_ChunkChars = null;
throw new OutOfMemoryException();
}
            m_ChunkChars = new char[newBlockLength];

            VerifyClassInvariant();
}

 

Si vamos paso a paso en nuestro proceso, veremos que cuando falla, los valores del StringBuilder son estos. Nos vamos a fijar especialmente en m_ChuckLength:

Name: System.Text.StringBuilder

Fields:
Type VT Attr Value Name
System.Char[] 0 instance 0000000000000000 m_ChunkChars
...ext.StringBuilder 0 instance 00000234eae27fc8 m_ChunkPrevious
System.Int32 1 instance 0 m_ChunkLength
System.Int32 1 instance 2147481216 m_ChunkOffset
System.Int32 1 instance 2147483647 m_MaxCapacity

 

Tenemos un apartado en el que nos dice que la suma de m_ChunkOffset + newBlockLenght sea menor que newBlockLength. Estamos frente a otra incongruencia, ¿cómo es posible que la suma de un valor positivo más otro valor positivo sea menor que el primer valor?. No es tan incongruente, pensemos que hemos sobrepasado el límite máximo de un valor entero, por lo que el valor resultante, cogiendo sólo los dígitos de int64, es inferior.

Vemos los valores que vamos a ir teniendo m_ChunkOffset tiene un valor de 2147481216 y trans comprobar la operación de Math.Max tenemos que el valor de newBlockLength es 8000. Esto implica que la suma de ambos es 2147489216, pero como el valor es mayor que int.MaxValue, nos hemos pasado y nos vamos a quedar con un valor real de 5569, que es menor que los 8000 de newBlockLength, así que acabamos lanzando un OutOfMemoryException porque hemos llegado al límite de StringBuilder.

Tened en cuenta que estamos tratando con un valor máximo de un objeto de 2GB, por lo que nuestro String debería ser más pequeño. Para objetos más grandes, tenemos esta propiedad gcAllowVeryLargeObjects que nos permite que los arrays superen el límite de 2GB.

Espero que os sirva de ayuda

- José Ortega Gutiérrez