Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일
(연관된 글이 5개 있습니다.)

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 bytes 배열 목록 생략]...
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 bytes (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)

즉, 개발자는 800MB짜리 파일을 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(256MB)이고 이를 Unicode 문자열로 확장된 StringBuilder에 싣게 되면 538,131,812(513MB)가 됩니다. 즉, 그다음 메모리 할당이 1026MB로 시도가 되었을 것이고 이 단계에서 실패해 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 바이트 수가 256MB였다면 위의 경우에 StringBuilder의 초기 Capacity를 약 520MB로 잡았다면 오류 없이 정상적으로 문자열 변환이 되었을 것임을 알 수 있습니다.

자... 그럼 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# 언어적인 구조상의 문제도 아닙니다. 단지, '자동 설정된 내부 동작'이 모든 요구를 충족시키지 못한 것뿐입니다.

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




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

[연관 글]






[최초 등록일: ]
[최종 수정일: 9/8/2021]

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

비밀번호

댓글 작성자
 



2011-11-14 09시36분
[스포너] 잘읽었습니다. 성태님이 쓰신 글은 거의 항상 유용하군요. 간지러운 등귀를 긁어주시는 것 같습니다.
근데.. 블로그 테마가 윈도XP라 타이틀(제목란)를 마우스로 자꾸 옮기려고 하는것 아세요? ㅎㅎ
[guest]
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분
[금재용] 잘 읽고 갑니다. 역시 최고이십니다. ^^
[guest]

[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
13598정성태4/16/2024207닷넷: 2240. C# - WAV 파일 포맷 + LIST 헤더파일 다운로드1
13597정성태4/15/2024292닷넷: 2239. C# - WAV 파일의 PCM 데이터 생성 및 출력파일 다운로드1
13596정성태4/14/2024507닷넷: 2238. C# - WAV 기본 파일 포맷파일 다운로드1
13595정성태4/13/2024498닷넷: 2237. C# - Audio 장치 열기 (Windows Multimedia, NAudio)파일 다운로드1
13594정성태4/12/2024733닷넷: 2236. C# - Audio 장치 열람 (Windows Multimedia, NAudio)파일 다운로드1
13593정성태4/8/2024934닷넷: 2235. MSBuild - AccelerateBuildsInVisualStudio 옵션
13592정성태4/2/20241185C/C++: 165. CLion으로 만든 Rust Win32 DLL을 C#과 연동
13591정성태4/2/20241152닷넷: 2234. C# - WPF 응용 프로그램에 Blazor App 통합파일 다운로드1
13590정성태3/31/20241067Linux: 70. Python - uwsgi 응용 프로그램이 k8s 환경에서 OOM 발생하는 문제
13589정성태3/29/20241131닷넷: 2233. C# - 프로세스 CPU 사용량을 나타내는 성능 카운터와 Win32 API파일 다운로드1
13588정성태3/28/20241184닷넷: 2232. C# - Unity + 닷넷 App(WinForms/WPF) 간의 Named Pipe 통신파일 다운로드1
13587정성태3/27/20241132오류 유형: 900. Windows Update 오류 - 8024402C, 80070643
13586정성태3/27/20241261Windows: 263. Windows - 복구 파티션(Recovery Partition) 용량을 늘리는 방법
13585정성태3/26/20241087Windows: 262. PerformanceCounter의 InstanceName에 pid를 추가한 "Process V2"
13584정성태3/26/20241042개발 환경 구성: 708. Unity3D - C# Windows Forms / WPF Application에 통합하는 방법파일 다운로드1
13583정성태3/25/20241143Windows: 261. CPU Utilization이 100% 넘는 경우를 성능 카운터로 확인하는 방법
13582정성태3/19/20241218Windows: 260. CPU 사용률을 나타내는 2가지 수치 - 사용량(Usage)과 활용률(Utilization)파일 다운로드1
13581정성태3/18/20241365개발 환경 구성: 707. 빌드한 Unity3D 프로그램을 C++ Windows Application에 통합하는 방법
13580정성태3/15/20241131닷넷: 2231. C# - ReceiveTimeout, SendTimeout이 적용되지 않는 Socket await 비동기 호출파일 다운로드1
13579정성태3/13/20241493오류 유형: 899. HTTP Error 500.32 - ANCM Failed to Load dll
13578정성태3/11/20241620닷넷: 2230. C# - 덮어쓰기 가능한 환형 큐 (Circular queue)파일 다운로드1
13577정성태3/9/20241850닷넷: 2229. C# - 닷넷을 위한 난독화 도구 소개 (예: ConfuserEx)
13576정성태3/8/20241539닷넷: 2228. .NET Profiler - IMetaDataEmit2::DefineMethodSpec 사용법
13575정성태3/7/20241662닷넷: 2227. 최신 C# 문법을 .NET Framework 프로젝트에 쓸 수 있을까요?
13574정성태3/6/20241552닷넷: 2226. C# - "Docker Desktop for Windows" Container 환경에서의 IPv6 DualMode 소켓
[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...