원래 이 시리즈(?)의 계획상, 3번째 글로는 가비지 컬렉션이 언제 발생하는지에 대한 글이고 그 다음이 LOH(Large Object Heap)에 대한 글이었습니다. 그런데 글을 쓰다 보니 LOH를 먼저 이야기 하는 것이 좋을 것 같아서 순서를 바꾸었습니다. LOH에 대한 내용은 월간 마이크로소프트웨어 2004년 10월에 기고한 원본 글에서는 이름만 살짝 언급한 수준이었습니다. 제 블로그 글들에서도 처음으로 상세하게 언급하는 토픽이므로 LOH에 대해 처음 접하시는 분이라면 알아두시면 정신/육체건강에 아주 초큼 도움이 되실 겁니다. LOH에 대해 이미 어느 정도 알고 계신 분들은 MSDN Magazine에서 Large Object Heap Uncovered를 읽어 보시길 권장합니다. 매우 상세하게 LOH에 대한 내용을 다루고 있으며 LOH 상의 문제 파악 방법들도 설명되어 있습니다(이 글은 저도 읽기에 좀 빡세더군요. 내용도 어려운데다가 필자가 구사하는 영어가 초큼 거시기 합니다).

Introduction to Large Object Heap

Large Object Heap, 줄여서 LOH라 불리는 힙은 85,000 바이트 이상 상대적으로 커다란 객체가 할당되는 관리되는 힙(managed heap)의 한 부분이다. 닷넷 프레임워크 런타임, 즉 CLR(Common Language Runtime)은 힙을 크게 두 부분으로 나누어 별도로 관리한다. SOH(Small Object Heap)과 LOH가 바로 그것이다. SOH는 이미 지난 글들(위에 링크를 주렁주렁 달아놨으니 함 눌러주자. 응?)에서 살펴본 대로 Gen 0, Gen 1, Gen 2 힙을 말하는 것으로 85,000 바이트 이하의 객체들이 할당되고 나이를 먹다가 죽어가는 곳이다. LOH는 약 83KB(85,000 바이트) 이상의 크기를 갖는 객체들이 할당되는 힙으로써 SOH와는 별도의 공간에 할당되고 관리된다.

LOH에는 누가 사는가?

LOH가 지금까지 우리가 살펴보았던 SOH와 어떻게 다른가를 하나씩 살펴보도록 하자. 먼저 LOH에 할당되는 객체들은 83KB 보다 큰 객체들이다. 여기서 우리가 주의할 것은 단일 객체의 크기가 83KB 보다 큰 경우에만 LOH에 객체가 할당된다는 것이다. 예를 들어 다음 MyObject 클래스의 인스턴스는 LOH에 할당되지 않는다.

class MyObject
{
private byte[] Data1 = new byte[32*1024]; // 32KB array
private byte[] Data2 = new byte[92*1024]; // 92KB array
}

객체 지향이나 닷넷을 처음 배우면서 그리도 강조하던 객체 참조의 관점에서 생각해 보면 간단하다. MyObject 객체가 언뜻 보기엔 124KB 짜리 객체로 보이지만 실제로는 16 바이트(32bit x86 기준 기본 8바이트 + 참조 2개 4바이트) 크기 밖에 되지 않으며 MyObject 객체가 32KB 배열 객체와 92KB 배열 객체에 대한 참조를 가지고 있을 뿐이다. 이 말이 이해가 안 된다면 닷넷 기본 서적이나 C# 기본 자료를 다시 꺼내 보아야 할 시기가 온 것이라 생각하기 바란다.

비슷하게 응용해서… 2000건의 레코드를 가진 DataSet이 LOH에 할당 될까? 정답은 “아니오” 이다. DataSet은 하나의 객체가 아니라 DataTable, DataColumn, DataRow 그리고 각 레코드의 컬럼 값들이 모여 있는 객체일 뿐 하나의 단일 객체가 아니기 때문이다. 이쯤 되면 눈치 빠른 독자라면 쉽게 추측할 수 있을 것이다. 그렇다. LOH에 할당되는 대부분은 배열들이 되겠다. 바이트 배열, 객체의 배열 혹은 앞서 살펴보았던 MyObject 의 배열 등등… 물론 이 배열의 크기가 커서 85000 바이트가 넘어가야 LOH에 할당됨은 당연 빤쑤 되겠다.

LOH는 어떻게 정리되는가?

LOH에 대해서도 GC는 가비지 컬렉션을 수행한다. 이 때 GC는 Gen 0(어린 것들), Gen 1(청소년), Gen 2(중장년 및 노년층)의 관리되는 힙을 컬렉션 할 때와 전혀 다르게 작동한다. 이 씨리즈의 첫 번째 글에서 소개했었던 C/C++의 메모리 할당 및 해제 방식을 기억하는가? 그렇다. LOH는 그런 방식과 유사하게 메모리 할당과 가비지 컬렉션을 수행한다는 것이다. 예를 들어, 어플리케이션이 커다란 메모리 할당을 요청하면 LOH 상에서 그 메모리를 할당 할 수 있는 빈 공간을 찾아(Free memory list 검사) 할당하며, GC가 LOH를 컬렉션 할 때에는 더 이상 사용하지 않는 객체들을 제거하는 스웹(sweap)만 할 뿐 객체들을 밀어 붙여서 빈 공간을 늘이는 컴팩션(compation)을 수행하지 않는다는 말이 되겠다.

왜 LOH는 스웹만을 하고 컴팩션은 하지 않는 걸까? 커다란 객체들을 컴팩션 하기 위해 객체들을 메모리 상에서 복사하는 비용이 꽤나 높기 때문이다. LOH와 85000 바이트라는 숫자의 관계는 아조 세밀한 쑈부의 결과이다. Microsoft의 수많은 테스터와 개발자들이 숏 빠지게 많은 성능 테스트와 튜닝을 거쳐서 내린 결과가 “85000 바이트 보다 큰 객체들은 컴팩션으로 얻을 수 잇는 장점보다 메모리 복사로 인한 단점이 더 많다”는 것이고 이로 인해 SOH와는 작동 방식이 다른 LOH가 탄생한 것이 되겠다.

image

그림1. LOH 메모리 할당과 컬렉션 작동 모습

그림1은 LOH 상에 할당되는 메모리와 LOH에 대한 컬렉션을 보여 준다. GC는 요청된 메모리가 85,000 바이트보다 크면 LOH 상에서 비어있는 메모리 공간을 찾아 메모리를 할당한다. 그림1에서 2.5MB 크기의 객체 D는 중간에 뚫려있는 메모리 공간을 사용할 수 없기 때문에 힙의 끝에 할당이 된다. LOH에 대해 가비지 컬렉션이 수행되면 Gen0, Gen1, Gen2의 객체들과 동일한 방법으로 더 이상 사용 중이 아닌 객체들을 LOH에서도 검색하게 된다. 그리고 더 이상 사용 중이 아닌 객체는 사라지게 된다. 이렇게 컬렉션에 의해 LOH 상의 객체가 제거되면서 남게 되는 메모리는 주위의 자유 메모리 공간과 합쳐져서 보다 큰 자유 메모리 공간이 사용될 수 있도록 한다. 그림1에서 객체 B가 가비지 컬렉션에 의해 제거 된 후 보다 큰 자유 메모리 공간이 생성되었음에 주목하자.

그림1에서 설명한 LOH의 가비지 컬렉션은 컴팩션을 수행하지 않는다고 했었다. 그런데 언제까지나 GC가 LOH에 대해서 컴팩션을 하지 않는다고 보장할 수는 없다. 나중에 하드웨어 기술이 발전하여 CPU가 무지무지하게 빨라지고 조낸 빠른 메모리가 등장한다면 LOH에 대해서도 컴팩션을 수행할 수도 있다는 것이다. 뭐, 그때 되면 VLOH(Very Larage Object Heap)이 생기지 않을까 생각도 해보지만 말이다.

LOH에는 늙은이만 산다!

LOH에 할당되는 객체들은 기본적으로 2세대 객체로 간주된다. LOH에 대한 컬렉션이 GC 2가 발생할 때에만 수행되기 때문이다. LOH에 대한 할당이 실제로 2세대 객체로 간주되는지 알아보자.

// 간단한 객체들이 어느 세대인가를 보여준다.
private static void ShowObjectGeneration()
{
    Console.WriteLine("Before Create DataSet -- GC Count={0}:{1}:{2}",
        GC.CollectionCount(0), GC.CollectionCount(1), GC.CollectionCount(2));
 
    DataSet ds = CreateDataSet();
 
    Console.WriteLine("After Create DataSet -- GC Count={0}:{1}:{2}",
        GC.CollectionCount(0), GC.CollectionCount(1), GC.CollectionCount(2));
 
    MyObject obj = new MyObject();
 
    Console.WriteLine("DataSet Gen={0}", GC.GetGeneration(ds));
    Console.WriteLine("MyObject Gen={0}", GC.GetGeneration(obj));
    Console.WriteLine("MyObject.Data1 Gen={0}", GC.GetGeneration(obj.Data1));
    Console.WriteLine("MyObject.Data2 Gen={0}", GC.GetGeneration(obj.Data2));
}
 
// 많은 레코드를 가진 데이터 셋을 생성하여 반환한다.
private static DataSet CreateDataSet()
{
    DataSet ds = new DataSet();
    DataTable dt = new DataTable();
    dt.Columns.Add("ID", typeof(int));
    dt.Columns.Add("Column1", typeof(string));
    dt.Columns.Add("Column2", typeof(DateTime));
    dt.Columns.Add("Column3", typeof(decimal));
 
    int recordCount = 10000;
    DateTime now = DateTime.Now.AddDays(-recordCount);            
    for (int i = 0; i < recordCount; i++)
    {
        DataRow row = dt.NewRow();
        row["ID"] = i;
        row["Column1"] = "This is string data #" + i.ToString();
        row["Column2"] = now.AddDays(i);
        row["Column3"] = (decimal)Math.PI / (i+1);
        dt.Rows.Add(row);
    }
    dt.AcceptChanges();
    ds.Tables.Add(dt);
 
    return ds;
}
 
class MyObject
{
    public byte[] Data1 = new byte[32 * 1024];     // 32KB
    public byte[] Data2 = new byte[92 * 1024];     // 94KB
}

GC.GetGeneration 메서드는 주어진 객체의 세대를 반환해 주는 메서드로서 거의 사용할 일은 없지만 테스트 상황에서 유용하게 사용할 수 있다. 위 코드는 10,000개의 레코드를 가진 DataSet 객체를 생성하고 이 객체가 몇 세대인어 표시하고 있다. DataSet 객체를 생성하면서 GC 가 발생했는가를 확인하기 위해 각 세대별 GC의 수행 여부도 표시한다. 어찌 되었건 위 코드를 수행시켜 보면 다음과 같은 결과를 얻을 수 있다.

Before Create DataSet -- GC Count=0:0:0
After Create DataSet -- GC Count=0:0:0
DataSet Gen=0
MyObject Gen=0
MyObject.Data1 Gen=0
MyObject.Data2 Gen=2

10,000 개의 레코드를 가진 데이터 셋이 생성되는 동안 GC가 발생하지 않았으므로 데이터 셋 객체의 세대는 0이다. 앞서 언급한 대로 DataSet은 모든 레코드들을 포함하는 덩어리가 아니라 테이블, 테이블에 포함된 컬럼 및 레코드, 레코드에 포함된 컬럼 값들 등 객체들의 집합체이기 때문이다. 만약 데이터 셋을 생성하는 동안 GC가 발생했다면 데이터 셋 객체의 세대는 올라갈 수도 있다(필자의 컴퓨터에서는 레코드 개수의 크기를 40,000 개로 올리면 GC 0가 3회, GC 1이 2회 GC 2가 1회 발생한다. 그리고 이 횟수는 이 코드가 수행될 때의 메모리 상황에 따라 달라질 수 있다).

MyObject 객체 자체는 16바이트 크기 밖에 되지 않으므로 Gen 0에 할당되며 Data1 필드에 의해 참조되는 배열은 32 KB이므로 역시 Gen 0에 할당된다. 반면에 Data2 필드에 의해 참조되는 배열은 무려 94KB 크기이므로 85,000 바이트(약 83KB)를 초과하므로 LOH에 할당된 것이다. (GetGeneration 메서드의 반환 값은 객체의 세대를 알려주지만 그것이 LOH인지 SOH인지는 알 수 없다)

위 테스트 코드 수행 결과는 컴퓨터에 설치된 메모리 양이나 현재 시스템에서 사용 중인 메모리 양에 따라 달라질 수 있음에 주의 해야 한다. 예를 들어, Console.WriteLine 메서드가 수행되는 동안 재수 없게 GC 가 발생되면 객체들의 세대가 달라질 수 있다는 말이다.

LOH 사용 시 주의 사항

LOH는 상대적으로 큰 배열들을 조금이라도 효율적으로 관리하기 위해 태어난 것이라고 보아도 무방하다. 하지만 LOH는 SOH에 대한 할당에 비해 빠르지 않다. 첫째로 LOH 상에서 빈 메모리 공간을 검색해야 하며 둘째로, 상대적으로 커다란 할당된 메모리의 내용을 0으로 초기화 해야 하기 때문이다. 빈 메모리 공간을 찾는 것은 그다지 큰 댓가를 요구하지 않지만 0으로 초기화하는 과정은 메모리 크기에 따라서 상당한 시간을 요구할 수도 있다. 컴퓨터마다 성능 차이는 있겠지만 2GHz의 CPU를 가진 컴퓨터에서 16MB 메모리를 LOH에서 할당하기 위해 무려 16 msec 이상을 요구한다고 한다. 이 시간의 대부분은 16MB의 메모리를 0으로 초기화하는데 소요되는 시간이다.

앞서 LOH의 가비지 컬렉션은 2 세대 가비지 컬렉션인 GC 2의 일부로써 수행된다고 했었다. 따라서 GC가 SOH에 대해 GC 2가 필요하다고 판단이 될 때 LOH도 덩달아 가비지 컬렉션이 되는 것이다. 여기서 고려할 사항은 LOH에 할당된 객체는 동일한 시점에서 할당된 작은 객체들에 비해 더 오랫동안 메모리 공간을 차지하게 된다. SOH에 할당된 객체는 상대적으로 자주 발생하는 GC 0나 GC 1에 의해 저 세상으로 갈 확율이 높지만 LOH에 할당된 객체는 GC 2가 발생해야만 정리가 되기 때문이다. LOH에 많은 객체가 할당되면 상대적으로 메모리 효율이 떨어질 수도 있다는 것이다.

반대로 LOH에 대해 많은 메모리 할당이 이루어져도 GC 2가 발생하기도 한다. GC 2는 Gen 0, Gen 1을 포함하여 (상대적으로 커다란) Gen 2 힙과 LOH에 대한 가비지 컬렉션이기 때문에 상당한 시간이 소요될 수도 있다. 예를 들어, 커다란 Gen 2에 아주 많은 객체가 존재하는 상황에서 GC 2가 발생한지 얼마 안 된 후 LOH에 임시로 사용할 객체들이 많이 할당됨으로써 GC 2가 발생할 수도 있다. 몹시 좃지 못한 이 상황은 Gen 2의 크기는 얼마 줄지 않으면서도 가비지 컬렉션을 수행하는데 상당한 시간을 소비하게 된다 . 일반적으로 GC 2가 발생하는 주기는 GC 0 주기의 100 배 혹은 그 이상이 될 수도 있다. 하지만 LOH에 대한 빈번한 할당이 발생하면 더 짧은 주기로 GC 2가 수행되고 이로 인해 어플리케이션의 성능이 저하될 수도 있다는 점을 알아 두자.

LOH는 SOH와 달리 메모리 조각(fragmentation)이 자주 발생한다(사실 SOH에서도 메모리 조각은 발생할 수 있다). LOH의 메모리 조각화는 by Design 현상이므로 일반적으로 큰 문제가 되지 않는다. 그럼에도 불구하고 부주의한 메모리 할당은 문제를 유발할 수도 있다. 앞서 그림1에서 살펴보았듯이 LOH의 중간 중간에는 사용 가능한 메모리 공간이 존재할 수 있어서 전체적으로는 충분한 LOH 공간이 있음에도 불구하고 연속적인 메모리 공간을 요구하는 커다란 배열의 할당이 실패할 수도 있다. 따라서 LOH를 조금이라도 효율적으로 사용하고자 한다면 어플리케이션 초장에 커다란 배열을 한번에 미리 할당해 놓고 이 메모리의 부분을 버퍼로써 반복적으로 재사용 하는 것이 좋다. 예를 들어 256KB 짜리 배열을 어떤 작업을 할 때마다 할당하고 버리는 것이 아니라 2560K 짜리 배열을 할당하고 이 배열을 256KB 씩 10개로 쪼개어 버퍼 풀을 구성한 후 반복적으로 재사용하는 것이 좋다는 말이다. 어플리케이션이 구동한 초기에는 조각화 현상이 별로 없을 것이므로 2.5MB의 할당은 성공할 것이기 때문이다. 물론, 이러한 구현은 어플리케이션 수행 동안 버퍼를 자주 사용하는 경우에만 유용할 것이다.

시리즈 다음 글 예고

가비지 컬렉션 시리즈 다음 글은 가비지 컬렉션이 얼마나 자주 일어나는가에 대한 내용이 되겠다. 물론, 가비지 컬렉션이 언제 일어난다고 예상할 수는 없지만 개략적으로 가비지 컬렉션이 어느 시점에 발생되며 그에 따른 고려 사항들을 살펴보도록 하겠다.


경고 : 이 글을 무단으로 복제/스크랩하여 타 게시판, 블로그에 게시하는 것은 허용하지 않습니다.