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)
13506정성태12/29/20232210닷넷: 2190. C# - 닷넷 코어/5+에서 달라지는 System.Text.Encoding 지원
13505정성태12/27/20232772닷넷: 2189. C# - WebSocket 클라이언트를 닷넷으로 구현하는 예제 (System.Net.WebSockets)파일 다운로드1
13504정성태12/27/20232334닷넷: 2188. C# - ASP.NET Core SignalR로 구현하는 채팅 서비스 예제파일 다운로드1
13503정성태12/27/20232195Linux: 67. WSL 환경 + mlocate(locate) 도구의 /mnt 디렉터리 검색 문제
13502정성태12/26/20232308닷넷: 2187. C# - 다른 프로세스의 환경변수 읽는 예제파일 다운로드1
13501정성태12/25/20232102개발 환경 구성: 700. WSL + uwsgi - IPv6로 바인딩하는 방법
13500정성태12/24/20232187디버깅 기술: 194. Windbg - x64 가상 주소를 물리 주소로 변환
13498정성태12/23/20232869닷넷: 2186. 한국투자증권 KIS Developers OpenAPI의 C# 래퍼 버전 - eFriendOpenAPI NuGet 패키지
13497정성태12/22/20232300오류 유형: 885. Visual Studiio - error : Could not connect to the remote system. Please verify your connection settings, and that your machine is on the network and reachable.
13496정성태12/21/20232320Linux: 66. 리눅스 - 실행 중인 프로세스 내부의 환경변수 설정을 구하는 방법 (gdb)
13495정성태12/20/20232328Linux: 65. clang++로 공유 라이브러리의 -static 옵션 빌드가 가능할까요?
13494정성태12/20/20232510Linux: 64. Linux 응용 프로그램의 (C++) so 의존성 줄이기(ReleaseMinDependency) - 두 번째 이야기
13493정성태12/19/20232613닷넷: 2185. C# - object를 QueryString으로 직렬화하는 방법
13492정성태12/19/20232302개발 환경 구성: 699. WSL에 nopCommerce 예제 구성
13491정성태12/19/20232239Linux: 63. 리눅스 - 다중 그룹 또는 사용자를 리소스에 권한 부여
13490정성태12/19/20232360개발 환경 구성: 698. Golang - GLIBC 의존을 없애는 정적 빌드 방법
13489정성태12/19/20232144개발 환경 구성: 697. GoLand에서 ldflags 지정 방법
13488정성태12/18/20232075오류 유형: 884. HTTP 500.0 - 명령행에서 실행한 ASP.NET Core 응용 프로그램을 실행하는 방법
13487정성태12/16/20232393개발 환경 구성: 696. C# - 리눅스용 AOT 빌드를 docker에서 수행 [1]
13486정성태12/15/20232207개발 환경 구성: 695. Nuget config 파일에 값 설정/삭제 방법
13485정성태12/15/20232093오류 유형: 883. dotnet build/restore - error : Root element is missing
13484정성태12/14/20232168개발 환경 구성: 694. Windows 디렉터리 경로를 WSL의 /mnt 포맷으로 구하는 방법
13483정성태12/14/20232307닷넷: 2184. C# - 하나의 resource 파일을 여러 프로그램에서 (AOT 시에도) 사용하는 방법파일 다운로드1
13482정성태12/13/20232888닷넷: 2183. C# - eFriend Expert OCX 예제를 .NET Core/5+ Console App에서 사용하는 방법 [2]파일 다운로드1
13481정성태12/13/20232276개발 환경 구성: 693. msbuild - .NET Core/5+ 프로젝트에서 resgen을 이용한 리소스 파일 생성 방법파일 다운로드1
13480정성태12/12/20232636개발 환경 구성: 692. Windows WSL 2 + Chrome 웹 브라우저 설치
1  2  3  4  [5]  6  7  8  9  10  11  12  13  14  15  ...