본문 바로가기

.NET/Debugging

StringBuilder 에서의 OutOfMemoryException 오류 원인 분석 [출처] StringBuilder 에서의 OutOfMemoryException 오류 원인 분석|작성자 techshare

http://blog.naver.com/techshare/100143103587

위의 링크에서 퍼온글입니다.

 

오늘 재미있는 글을 하나 읽었습니다.

.NET의 StringBuilder 클래스.. 너무해.. 
; http://madchick.egloos.com/1480819

현상을 봐도, 좀 너무하긴 한 것 같습니다. OOM 오류가 발생한다는 것은 그리 달가운 일이 아니니까요. ^^

하지만, 그 원인이 매우 궁금해지더군요. 명색이, 그래도 제가 성능관리 도구를 만드는 회사에 다니는데 원인 분석이 안되면 좀 서운하지 않겠어요? ^^




우선, 문제 재현을 할 수 있도록 프로그램(CLR 4 / x86)을 만들었습니다.

static void Test()
{
    StringBuilder sb = new StringBuilder();
    int i = 0;

    try
    {
        for (i = 0; i < Int32.MaxValue; i++)
        {
            sb.Append(i.ToString("x2"));
        }
    }
    catch (Exception ex)
    {
        Console.WriteLine(ex.Message + " : " + i); // i == 118,563,151
    }
}

실행하면 다음과 같이 예외가 발생합니다.

D:\temp\stringbuilder>ConsoleApplication1.exe
Exception of type 'System.OutOfMemoryException' was thrown. : 122597567

122,597,567 번째의 루프에서 예외가 발생한 것을 알 수 있고, 아래는 작업관리자로 확인한 메모리 소비상황입니다.



1.6GB 면... 거의 OOM 이 발생할 만한 상황입니다. 일단 재현은 쉽게 되었으니, 이번엔 windbg 로 해당 프로그램을 실행시키고 OOM 예외에서 멈춘 시점부터 살펴보겠습니다.

Heap 상태부터 봐야겠지요.

0:000> .loadby sos clr

0:000> .logopen /t c:\temp\output.txt
Opened log file 'c:\temp\output_28d0_2011-11-11_02-26-36-461.txt'

0:000> !dumpheap -stat
total 0 objects
Statistics:
      MT    Count    TotalSize Class Name
79ba6524        1           12 System.Nullable`1[[System.Boolean, mscorlib]]
79ba5938        1           12 System.Collections.Generic.ObjectEqualityComparer`1[[System.Type, mscorlib]]
79ba4b44        1           12 System.Security.Permissions.ReflectionPermission
... [생략] ...
79b9f92c      699        28864 System.String
79b56ba8      873        45644 System.Object[]
79b9faf8   105054      2941512 System.Text.StringBuilder
79ba1d08   105075   1681842372 System.Char[]
Total 213250 objects

0:000> .logclose
Closing open log file c:\temp\output_28d0_2011-11-11_02-26-36-461.txt  (저장된 로그 파일을 이용해도 됩니다.)

StringBuilder 의 경우에는 Count 는 많으나 TotalSize 면에서 무시할만한 수준이므로 넘어가고, 중요한 것은 char 배열로 105,075 개가 생성되었고 총 크기가 1,681,842,372 bytes 가 되었다는 점입니다.

10만개라니... 예사롭지 않습니다. 다음은 char [] 에 대한 상태를 확인한 것입니다.

0:000> !dumpheap -mt 79ba1d08

 Address       MT     Size
00b74c1c 79ba1d08       84     
...[생략]...
00b77fe4 79ba1d08       16     
00b78654 79ba1d08       44   // 0001020304050607  == (16) * 2 = 32 and + 12 == 44
00b78bec 79ba1d08       44   // 08090a0b0c0d0e0f  == (16) * 2 = 32 and + 12 == 44
00b78c34 79ba1d08       76   // 101112131415161718191a1b1c1d1e1f (32) * 2 = 64 and + 12 == 76
00b78c9c 79ba1d08      140   // 202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f
00b78d44 79ba1d08      268   // 404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636...[생략]...
00b78e6c 79ba1d08      524   // 808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a...[생략]...
00b79094 79ba1d08     1036   // 10010110210310410510610710810910a10b10c10d10e10f1101111121131141151161171...[생략]...
00b794bc 79ba1d08     2060     
00b79ce4 79ba1d08     4108     
00b7ad0c 79ba1d08     8204     
00b7cd34 79ba1d08    16012  
00b80bdc 79ba1d08    16012     
...[10만개 가량의 16,012 byte 배열 목록 생략]...
7e0af280 79ba1d08    16012 
7e0b3128 79ba1d08    16012  
7e0b6fd0 79ba1d08    16012  
7e0cff48 79ba1d08       24     
7e0d015c 79ba1d08       24     
...[생략]...
7e0d2e80 79ba1d08       16     
7e0d2e90 79ba1d08      176     
total 0 objects
Statistics:
      MT    Count    TotalSize Class Name
79ba1d08   105075   1681842372 System.Char[]
Total 105075 objects

이어서, 개별 개체의 내용을 살펴보면 우리가 생성한 StringBuilder 의 처음과 끝을 유추해 낼 수 있습니다.

아래는, 몇 번의 dump 끝에 알아낸 첫번째 StringBuilder 개체이고,

0:000> !dumpobj 00b78654
Name:        System.Char[]
MethodTable: 79ba1d08
EEClass:     798d9878
Size:        44(0x2c) bytes
Array:       Rank 1, Number of elements 16, Type Char
Element Type:System.Char
Content:     0001020304050607
Fields:
None

아래는, 마찬가지 방법으로 찾아낸 (마지막 16,012 배열이라서 요건 좀 쉬웠지요. ^^) 가장 마지막 번째의 StringBuilder 개체입니다.

0:000> !dumpobj 7e0b6fd0 
Name:        System.Char[]
MethodTable: 79ba1d08
EEClass:     798d9878
Size:        16012(0x3e8c) bytes
Array:       Rank 1, Number of elements 8000, Type Char
Element Type:System.Char
Content:     4eacc974eacca74eaccb74eaccc74eaccd74eacce74eaccf74eacd074eacd174eacd274eacd374eacd474eacd574eacd674eacd774eacd874eacd974eacda74e
Fields:
None




위의 결과를 보고, 혹시 궁금한 것이 생기지 않았나요? 저는 보자마자, 코드에서 사용한 StringBuilder는 분명히 하나인데 왜 저렇게 많은 수의 개체가 생겼을까 하는 점이었습니다.

이에 대해서는 .NET Reflector를 통해서 (.NET 4.0) StringBuilder 소스 코드를 살펴보면 해답이 나옵니다.

우선, Append 를 찾아보면 중간에 ExpandByABlock 을 호출하는 것을 볼 수 있습니다.

[SecuritySafeCritical]
internal unsafe StringBuilder Append(char* value, int valueCount)
{
    ...[생략]...
        int minBlockCharCount = valueCount - count;
        this.ExpandByABlock(minBlockCharCount);
        ThreadSafeCopy(value + count, this.m_ChunkChars, 0, minBlockCharCount);
        this.m_ChunkLength = minBlockCharCount;
    }
    return this;
}

이어서 ExpandByABlock 을 살펴보면, 아래와 같이 일종의 Linked List 관계를 유지하면서 새로운 StringBuilder를 생성하는 것을 확인할 수 있습니다.

private void ExpandByABlock(int minBlockCharCount)
{
    ...[생략]...
    int num = Math.Max(minBlockCharCount, Math.Min(this.Length, 0x1f40));
    this.m_ChunkPrevious = new StringBuilder(this);
    this.m_ChunkOffset += this.m_ChunkLength;
    ...[생략]...
}

실제로 windbg 에서 검사해 볼까요? ^^ 아래와 같이 StringBuilder 의 heap 상태에서 2개의 StringBuilder 개체를 선택해고,

0:000> !dumpheap -mt 79b9faf8
...[생략]...
7e0af264 79b9faf8       28 
7e0b310c 79b9faf8       28     
7e0b6fb4 79b9faf8       28     
7e0cff2c 79b9faf8       28     
7e0d0140 79b9faf8       28     
7e0d0320 79b9faf8       28     
7e0d0948 79b9faf8       28     
7e0d0ba4 79b9faf8       28     
7e0d0bec 79b9faf8       28     
7e0d1b38 79b9faf8       28     
7e0d1be8 79b9faf8       28     
7e0d20bc 79b9faf8       28      
total 0 objects
Statistics:
      MT    Count    TotalSize Class Name
79b9faf8   105054      2941512 System.Text.StringBuilder
Total 105054 objects

각각 값을 확인해 보면, m_ChunkPrevious 필드 값이 바로 이전의 StringBuilder 에 대한 주소값을 참조하고 있는 것을 확인할 수 있습니다.

0:000> !dumpobj 7e0b6fb4
Name:        System.Text.StringBuilder
MethodTable: 79b9faf8
EEClass:     798d8cd8
Size:        28(0x1c) bytes
File:        C:\WINDOWS\Microsoft.Net\assembly\GAC_32\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
Fields:
      MT    Field   Offset                 Type VT     Attr    Value Name
79ba1d08  400011d        4        System.Char[]  0 instance 7e0b3128 m_ChunkChars
79b9faf8  400011e        8 ...ext.StringBuilder  0 instance 7e0b310c m_ChunkPrevious
79ba28f8  400011f        c         System.Int32  1 instance     8000 m_ChunkLength
79ba28f8  4000120       10         System.Int32  1 instance 840272192 m_ChunkOffset
79ba28f8  4000121       14         System.Int32  1 instance 2147483647 m_MaxCapacity

0:000> !dumpobj 7e0b310c
Name:        System.Text.StringBuilder
MethodTable: 79b9faf8
EEClass:     798d8cd8
Size:        28(0x1c) bytes
File:        C:\WINDOWS\Microsoft.Net\assembly\GAC_32\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
Fields:
      MT    Field   Offset                 Type VT     Attr    Value Name
79ba1d08  400011d        4        System.Char[]  0 instance 7e0af280 m_ChunkChars
79b9faf8  400011e        8 ...ext.StringBuilder  0 instance 7e0af264 m_ChunkPrevious
79ba28f8  400011f        c         System.Int32  1 instance     8000 m_ChunkLength
79ba28f8  4000120       10         System.Int32  1 instance 840264192 m_ChunkOffset
79ba28f8  4000121       14         System.Int32  1 instance 2147483647 m_MaxCapacity

아마도, StringBuilder 가 용량이 부족하다고 판단되면 2배수를 한다고 알고 계신 분들은 위의 결과를 보고 의아해 하실 수도 있을텐데요. 틀리게 알고 있었다고 미리 걱정하실 필요는 없습니다. ^^ 사실은, .NET 2.0 에 포함된 StringBuilder는 그렇게 동작했지만, .NET 4.0 에 와서 Linked List 방식으로 바뀐 것입니다.

그 외에도 특이한 점이 있는데, StringBuilder 하나가 기본적(default)으로 할당하는 Capacity 의 크기는 16,012 byte (8,000 글자)를 넘지 않도록 디자인 되어 있는 것입니다.

어쨌든, 이것으로 일단 StringBuilder 와 char [] 개체의 수가 왜 10만개 가량으로 늘어났는지에 대한 설명이 되었겠지요. ^^




이제 본격적으로, 왜 OOM 이 발생했는지에 대한 분석을 해보겠습니다.

이를 위해 개발자가 예상하는 바이트 크기를 산정해 봐야 할 필요가 있는데요. 예를 들어, 이 글에서 사용한 예제에서 기대되는 byte 수는 다음과 같이 계산될 수 있습니다.

long totalLength = 0;

for (long i = 0; i < 122597567; i++)
{
    totalLength += i.ToString("x2").Length;
}

Console.WriteLine("expected: " + totalLength);
Console.WriteLine("expected: " + totalLength / 1024 / 1024);

// 출력 결과
expected: 840287289 (bytes)
expected: 801 (MB)

즉, 개발자는 800 MB 짜리 파일을 StringBuilder로 담은 것이나 다름없다고 생각할 수 있는데요. 당연히 OOM 예외가 발생하는 것이 터무니 없다고 생각될 수 있습니다.

하지만, 현실은 어떨까요?

여기서 고려해야 할 점이 2가지가 있습니다.

  1. 닷넷의 경우 '한 글자'는 2byte라는 점과,
  2. 배열 개체는 배열에 포함된 byte 크기 이외에 부가적으로 12byte 를 더 소비!

위와 같은 현실적인 수치를 반영해서 다시 계산을 해보면,

long totalLength = 0;
for (long i = 0; i < 122597567; i++)
{
    totalLength += (i.ToString("x2").Length * 2); // '한 글자' == 2byte
}

totalLength += (105075 * 12); // 각 배열마다 +12 byte 추가 소비

Console.WriteLine("actual: " + totalLength);
Console.WriteLine("actual: " + totalLength / 1024 / 1024);

// 출력 결과
actual: 1681835478 (bytes)
actual: 1603 (MB) == 약 1.6 GB

결과가 이러하니, 이제는 오히려 OOM 예외가 발생한 것은 '당연하다'고 볼 수 있습니다.

자, 그런데 원문(".NET의 StringBuilder 클래스.. 너무해.. ")이 씌여진 시기는 2006년이므로 .NET 4.0 이전이기 때문에 위의 분석이 맞지 않습니다. 중간에 언급했지만 CLR 2.0 에 구현된 StringBuilder 는 2배수 원칙으로 메모리를 늘려갑니다. 이런 현상에 대한 windbg 분석은 예전에도 한번 했었기 때문에,

.NET 64비트 응용 프로그램에서 왜 (2GB) OutOfMemoryException 예외가 발생할까?
; http://www.sysnet.pe.kr/2/0/946

CLR 2.0 환경에서의 StringBuilder에 대한 OOM 예외 분석은 위의 글을 읽어보시면 짐작하실 수 있기 때문에 생략합니다.

단지, 이번에는 그와 다른 상황으로 전개되는 CLR 4.0 을 대상으로 분석을 한 것 뿐이니 오해 없으시기 바랍니다.




이것으로 분석은 마치고, 다시 원문 글(.NET의 StringBuilder 클래스.. 너무해.. )로 돌아가서 생각해 봐야겠습니다.

그 당시 상황이 CLR 2.0 이기 때문에, 2배수로 메모리가 할당되는 규칙으로 인해 마지막 오류 시점에서 생성된 StringBuilder의 내부 버퍼 크기가 500MB 였다면 그 다음으로 1GB 의 메모리 할당을 시도했을 것입니다.

여기서 1GB 의 메모리라는 것은 '연속된 1GB' 공간이어야 한다는 제약이 있습니다. 따라서, 응용 프로그램이 실행되는 시간이 길어지면서 메모리 단편화가 발생했다면 1GB 의 연속된 공간을 찾는 것은 매우 어려울 수 있습니다. 여지 없이 OOM 예외가 발생하는 것입니다.

실제로 이 글에서 제가 테스트 한 Int32.MaxValue 까지의 문자열 누적을 CLR 4.0 에서는 122,597,567 번째의 루프까지 진행이 되었지만, CLR 2.0 에서는 40,904,448 번째의 루프에서 OOM 예외가 발생하는 것을 목격할 수 있습니다. 40,904,448 번째의 루프라면 Stream 의 바이트 수로는 268,435,456 (256 MB) 이고 이를 Unicode 문자열로 확장된 StringBuilder에 싣게 되면 538,131,812 (513 MB) 가 됩니다. 즉, 그 다음 메모리 할당이 1026 MB 로 시도가 되었을 것이고 이 단계에서 실패해 OOM 예외가 발생한 것입니다.

원문 글(.NET의 StringBuilder 클래스.. 너무해.. )에 보면, Capacity 를 지정하는 것으로 해결했다는 내용도 있는데요.

StringBuilder sb = new StringBuilder();
 
sb.Capacity = (int)(stream.Length * 2);
sb.Append(' ', (int)(stream.Length * 2));

(참고로, ==> StringBuilder sb = new StringBuilder(stream.Length * 2); 이렇게 수정될 수 있습니다.)

만약, 변환하려는 Stream 바이트 수가 256 MB 였다면 위의 경우에 StringBuilder의 초기 Capacity 를 약 520 MB 로 잡았다면 오류 없이 정상적으로 문자열 변환이 되었을 것임을 알 수 있습니다.

자... 그럼 CLR 4.0 에서는 어떻게 바뀐 것일까요? 이 글에서 살펴본 것처럼 Linked List로 구성이 바뀌었기 때문에 (기본적으로 최대) 16,012 바이트만을 점유하는 구조로 바뀌면서 메모리 할당이 꾸준히 가능해져서 결국 실제로 메모리가 부족해지는 상황(122,597,567 번째의 루프)에까지 가서야 OOM 예외가 발생한 것입니다.




자... 여기서 원문(".NET의 StringBuilder 클래스.. 너무해.. ") 글의 일부 내용에 대해서 딴지 들어갑니다. ^^

읽어보시면, 중간에 C# 으로 작성되었느냐 / C++ 로 작성되었느냐에 대한 이야기가 나오면서 순수 C# 으로 작성되어졌다면 문제가 없었을 것처럼 이야기가 나오는데요. 다소 근거가 낮은 가정입니다. 사실 성능으로 보면 여전히 C++ 의 도움을 받는 것이 더 낫습니다.

또한, StringBuilder 의 구현에서 unsafe 나 fixed 로 씌여진 것에 대해서 실망을 하는 부분이 나오는데요. 이것 역시 잘못된 편견입니다. 이러한 키워드가 사용된 것은 순전히 '성능' 을 위해서이지, 이것 때문에 'OOM 같은 류의 부작용'이 따르는 것은 아닙니다. 이런 예는 제가 지난 번에 쓴 글에서도 소개가 되었는데요.

string.GetHashCode 는 hash 값을 cache 할까?
; http://www.sysnet.pe.kr/2/0/1152

GetHashCode 에서도, 만약 fixed/unsafe 가 없었다면 루프를 '글자 수'만큼 돌아야 했겠지만 int * 로의 형변환을 했기 때문에 한 번에 '두 글자' 씩 진행되어 성능 향상이 있었던 것입니다.

즉, 설계에서 밀려서 그런 것이 아니고, 플랫폼의 한계도 아니며... C# 언어적인 구조 상의 문제도 아닙니다. 단지, '자동 설정된 내부 동작'이 모든 요구를 충족시키지 못한 것 뿐입니다.

첨부된 파일은 위의 코드를 포함한 예제 프로젝트입니다.