Microsoft MVP성태의 닷넷 이야기
글쓴 사람
홈페이지
첨부 파일

StringBuilder 에서의 OutOfMemoryException 오류 원인 분석

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

.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 번째의 루프에서 예외가 발생한 것을 알 수 있고, 아래는 작업관리자로 확인한 메모리 소비상황입니다.

stringbuilder_oom_1.png

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 예외가 발생할까?
; https://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 할까?
; https://www.sysnet.pe.kr/2/0/1152

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

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

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




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]

[연관 글]





[최초 등록일: ]
[최종 수정일: 4/25/2013 ]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer@outlook.com

비밀번호

댓글 쓴 사람
 



2011-11-14 09시36분
[스포너] 잘읽었습니다. 성태님이 쓰신 글은 거의 항상 유용하군요. 간지러운 등귀를 긁어주시는 것 같습니다.
근데.. 블로그 테마가 윈도XP라 타이틀(제목란)를 마우스로 자꾸 옮기려고 하는것 아세요? ㅎㅎ
[손님]
2011-11-14 11시46분
이렇게 따분하고, 긴 글을 읽어주시는 분이 정말 있군요. ^^

(참... 제 웹 사이트에 그런 부작용이 있는지 몰랐습니다. ^^)
정성태
2013-04-25 12시54분
좀더 자세한 설명은 다음의 글을 참고하세요. ^^

StringBuilder 다시 보기
; http://www.simpleisbest.net/post/2013/04/24/Review-StringBuilder.aspx
정성태
2013-04-25 05시29분
[금재용] 잘 읽고 갑니다. 역시 최고이십니다. ^^
[손님]

1  [2]  3  4  5  6  7  8  9  10  11  12  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
12055정성태11/17/2019109개발 환경 구성: 463. Visual Studio의 Ctrl + Alt + M, 1 (Memory 1) 등의 단축키가 동작하지 않는 경우
12054정성태11/15/2019327.NET Framework: 869. C# - 일부러 GC Heap을 깨뜨려 GC 수행 시 비정상 종료시키는 예제
12053정성태11/15/201997Windows: 164. 윈도우 10 - 명령행 창(cmd.exe) 속성에 (DotumChe, GulimChe, GungsuhChe 등의) 한글 폰트가 없는 경우
12052정성태11/15/201985오류 유형: 578. Azure - 일정(schedule)에 등록한 runbook이 1년 후 실행이 안 되는 문제(Reason - The key used is expired.)
12051정성태12/2/2019157개발 환경 구성: 462. 시작하자마자 비정상 종료하는 프로세스의 메모리 덤프 - procdump
12050정성태11/14/2019122Windows: 163. AcLayers의 API 후킹과 FaultTolerantHeap
12049정성태11/13/2019106.NET Framework: 868. (닷넷 프로세스를 대상으로) 디버거 방식이 아닌 CLR Profiler를 이용해 procdump.exe 기능 구현
12048정성태11/12/2019123Windows: 163. GUID 이름의 볼륨에 해당하는 파티션을 찾는 방법
12047정성태11/12/2019258Windows: 163. 안전하게 eject시킨 USB 장치를 물리적인 재연결 없이 다시 인식시키는 방법
12046정성태11/9/2019176오류 유형: 577. windbg - The call to LoadLibrary(...\sos.dll) failed, Win32 error 0n193
12045정성태10/27/2019153오류 유형: 576. mstest.exe 실행 시 "Visual Studio Enterprise is required to execute the test." 오류 - 두 번째 이야기
12044정성태10/27/2019191오류 유형: 575. mstest.exe - System.Resources.MissingSatelliteAssemblyException: The satellite assembly named "Microsoft.VisualStudio.ProductKeyDialog.resources.dll, ..."
12043정성태10/27/2019170오류 유형: 574. Windows 10 설치 시 오류 - 0xC1900101 - 0x4001E
12042정성태10/26/2019155오류 유형: 573. OneDrive 하위에 위치한 Documents, Desktop 폴더에 대한 권한 변경 시 "Unable to display current owner"
12041정성태10/23/2019120오류 유형: 572. mstest.exe - The load test results database could not be opened.
12040정성태10/23/2019284오류 유형: 571. Unhandled Exception: System.Net.Mail.SmtpException: Transaction failed. The server response was: 5.2.0 STOREDRV.Submission.Exception:SendAsDeniedException.MapiExceptionSendAsDenied
12039정성태10/22/2019142스크립트: 16. cmd.exe의 for 문에서는 ERRORLEVEL이 설정되지 않는 문제
12038정성태10/17/2019128오류 유형: 570. SQL Server 2019 RC1 - SQL Client Connectivity SDK 설치 오류
12037정성태10/15/2019327.NET Framework: 867. C# - Encoding.Default 값을 바꿀 수 있을까요?파일 다운로드1
12036정성태10/21/2019473.NET Framework: 866. C# - 고성능이 필요한 환경에서 GC가 발생하지 않는 네이티브 힙 사용파일 다운로드1
12035정성태10/13/2019254개발 환경 구성: 461. C# 8.0의 #nulable 관련 특성을 .NET Framework 프로젝트에서 사용하는 방법파일 다운로드1
12034정성태10/12/2019343개발 환경 구성: 460. .NET Core 환경에서 (프로젝트가 아닌) C# 코드 파일을 입력으로 컴파일하는 방법 [1]
12033정성태10/11/2019331개발 환경 구성: 459. .NET Framework 프로젝트에서 C# 8.0 컴파일러를 사용하는 방법
12032정성태11/25/2019241.NET Framework: 865. .NET Core 2.2/3.0 웹 프로젝트를 IIS에서 호스팅(Inproc, out-of-proc)하는 방법 - AspNetCoreModuleV2 소개
12031정성태10/7/2019124오류 유형: 569. Azure Site Extension 업그레이드 시 "System.IO.IOException: There is not enough space on the disk" 예외 발생
12030정성태11/12/20191334.NET Framework: 864. .NET Conf 2019 Korea - "닷넷 17년의 변화 정리 및 닷넷 코어 3.0" 발표 자료 [1]파일 다운로드1
1  [2]  3  4  5  6  7  8  9  10  11  12  13  14  15  ...