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번째의 루프에서 예외가 발생한 것을 알 수 있고, 아래는 작업관리자로 확인한 메모리 소비상황입니다.
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가지가 있습니다.
- 닷넷의 경우 '한 글자'는 2byte라는 점과,
- 배열 개체는 배열에 포함된 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# 언어적인 구조상의 문제도 아닙니다. 단지, '자동 설정된 내부 동작'이 모든 요구를 충족시키지 못한 것뿐입니다.
첨부된 파일은 위의 코드를 포함한 예제 프로젝트입니다.
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]