안녕하세요 마수리입니다. 오늘은 StringBuilder
의 소스코드에 쓰여져있던 주석을 읽으면서 새로운 개념을 하나 공부하게 되어 공유하게 되었습니다. StringBuilder
에는 특정한 크기의 버퍼가 있는데 버퍼가 꽉차면 새로운 버퍼를 만들게 되어있습니다. 이때 버퍼의 크기를 정해야하는데 버퍼의 크기는 아래와 같이 정해지고 있습니다.
// Compute the length of the new block we need
// We make the new chunk at least big enough for the current need (minBlockCharCount)
// But also as big as the current length (thus doubling capacity), up to a maximum
// (so we stay in the small object heap, and never allocate really big chunks even if
// the string gets really big.
// minBlockCharCount: 부족한 버퍼의 크기
// Length: 모든 버퍼 크기의 합
// MaxChunkSize: 버퍼의 최대 값.
internal const int MaxChunkSize = 8000;
int newBlockLength = Math.Max(minBlockCharCount, Math.Min(Length, MaxChunkSize));
이 글의 시작점은 바로 이 코드였고 코드에 달려있는 주석이였습니다.
코드를 보시면 Math.Min()
이라는 함수를 사용해서 newBlockLength
가 절대로 8000
을 넘을 수 없도록 하고 있습니다. 그 이유는 주석에 설명 되어있습니다. 주석의 의미를 한글로 풀면 아래와 같습니다.
필요한 새 버퍼의 길이를 계산합니다. 새 버퍼를 최소한 현재 필요한 만큼(minBlockCharCount) 크게 만들되, 현재 모든 버퍼의 총 길이만큼(따라서 용량을 두 배로) 최대로 만듭니다(따라서 문자열이 정말 커지더라도 small object heap을 유지하고 정말 큰 버퍼는 할당하지 않습니다.) 문자열이 정말 커지더라도 큰 버퍼는 할당하지 않습니다.
이 코드는 버퍼가 small object heap
이라는 곳에 할당 되도록 큰 버퍼는 할당하지 않는다고 설명하고 있습니다. 그럼 large object heap
도 있을까요? 네! 있습니다! 😎 다만, large object heap
을 알기위해 다른 개념이 먼저 등장해야 합니다.
LOH
를 이해하기 위해선 간단하게.NET
의 가비지 컬렉팅 방식을 이해해야합니다.
이 글에서 다루긴 너무 큰 내용이라 큼지막하게 개념만 훑고 가겠습니다.
.NET
의 GC
는 3개의 세대라는 것을 기준으로 좀 더 오래 살아남을 데이터와 그렇지 않을 데이터를 구분합니다. 예를들어 0세대의 데이터는 2세대의 데이터보다 더 자주 수집됩니다. 한번 수집이 이루어질 때 사용 중이라 수집되지 못한 데이터는 다음 세대로 승격 되어 좀 더 오래 메모리 공간에 살아있게 됩니다.
2세대의 가비지를 수집한다는 것은 0세대와 1세대도 모두 수집한다는 것을 의미합니다. 이 의미는 모든 세대의 GC
가 트리거 된다는 소리고 결국 2세대 가비지 수집은 전체GC 라고 불리기도 합니다. 전체GC의 비용은 큽니다.
LOH
는 위와 같은 GC
의 특징으로부터 안정적인 성능을 보장하기 위해 새로운 세대로 지칭되는 물리적으로 새로운 3세대
공간입니다. 이 공간은 2세대 GC
가 발생하면 같이 수집됩니다.
GC
이후 메모리의 파편화를 막기위해 압축 과정이 있는데 이때 큰 오브젝트의 복사비용이 크게 발생합니다. LOH
는 GC
후 메모리 공간을 압축하지 않습니다. OS
에 요청해 할당받고 이러한 공간을 세그먼트
라고 합니다.LOH
를 위한 공간이 필요하면 새로운 세그먼트
를 요청할 수 있습니다. 세그먼트
라는 공간은 여러개가 생길 수 있습니다.LOH
공간이 필요하면 계속해서 필요한 공간을 할당 받기 위해 전체GC를 하지 않고 세그먼트
를 할당받아 그곳에 적재합니다세그먼트
내의 파편화가 심해 새로운 세그먼트
를 계속 요청하게 되다보면 OS
에서 세그먼트
로 내줄 수 있는 메모리 공간이 부족해지고 이땐 어쩔 수 없이 전체GC를 하게 됩니다.결국 아주 큰 메모리 공간을 필요로하는 메모리들은 물리적으로 새로운 공간인 세그먼트
라는 것으로 관리하고 GC
를 할 때 메모리 압축을 하지 않는 등의 최적화를 위해서 설계되었다.
전체GC 때만 LOH
의 GC
를 진행하고 할당할 메모리가 부족할 경우 바로 GC
를 진행해 할당 가능한 메모리를 만드려고 하지 않고 OS
에 요청해 아예 새로운 LOH
의 전용 메모리 공간인 세그먼트
를 할당 받는 등 최대한 GC
의 빈도를 더 적게 가져갑니다.
이러한 GC
의 특징으로인해 물리적으로 새로운 3세대
공간에 세그먼트
라는 개념을 두어 따로 관리한 것입니다.
정리를 해보겠습니다. 오늘의 주제는 왜 StringBuilder
에서는 특정 크기 이상으로 메모리 할당을 제한하려 했는가에 대한 대답은 LOH
의 개념 때문이였고 StringBuilder
의 특징상 버퍼로 잡았던 모든 공간은 사용자가 ToString()
함수를 호출해서 최종적으로 string
객체를 뽑아 냈을 때 모두 사라져도 되는 메모리 공간이 되므로 이러한 임시 버퍼의 공간을 LOH
에 할당하고 싶지 않았던 것입니다. LOH
를 삭제하기 위해선 전체GC
가 트리거 되어야하고 이것은 성능상 문제가 되기 때문입니다. 그렇다고 전체GC
를 하지 않기엔 StringBuilder
에서 사용했던 모든 버퍼는 앞으로 사용되지 않는 공간이기 때문에 삭제 되지 않고 있다면 메모리릭
이 발생한 것이 될 것입니다.