본문으로 바로가기

힙(Heap)

앞서 스택은 메모리에 데이터를 차곡차곡 쌓아놓은 형태를 보인다고 했다. 반대로 Heap을 영어사전에 검색하면 "(아무렇게나 쌓아 놓은) 더미"라는 뜻이 첫 번째로 나온다. 

스택에 할당되는 데이터는 컴파일 타임에 어디에, 얼마나 저장될지 명확히 알 수 있다. 반면 힙은 힙이라는 거대한 공간 어디에 할당될지, 얼마나 할당될지 알 수 없다. 뿐만 아니라 스택은 정해진 위치에 컴파일 타임에 정해진 크기만큼 할당 및 해제 하기 때문에 빠르다. 반면, 힙은 프로그램 실행 중에 필요한 공간의 크기를 파악하여, 빈 공간을 찾아야 하기 때문에 상대적으로 느릴 수밖에 없다. 

힙을 사용하는 이유

그럼에도 많은 힙을 사용하는 이유는 다음과 같다.

  • Life cycle
  • Large size
  • Dynamic allocation

Life cycle

stack에 쌓이는 데이터는 반드시 함수가 반환되면서 해제되어야 한다. 아래의 코드를 보자. 

void A()
{
    int a;
}
void B()
{
    int b;
}

int main ()
{
    A();
    B();
    return 0;
}

A를 호출하고 B함수를 호출하는 프로그램에서 main 함수나 B함수에서는 A함수에서 할당한 변수 a에 접근할 방법이 없다. a 변수의 Life cycle은 함수 A가 종료할 때 끝나기 때문이다. 반면 Heap에 할당된 오브젝트는 개발자가 별도로 해제하거나 프로그램이 종료되기 전까지 해제되는 일이 없기 때문에 오브젝트의 Life Cycle을 개발자가 직접 관리할 수 있다.

 

Large size

Stack에는 큰 오브젝트를 할당하면 Stack overflow가 발생한다. 애초에 stack이 감당할 수 있는 크기와 Heap이 감당할 수 있는 크기의 차이는 상당히 크다. 만약 굉장히 큰 오브젝트를 생성해야 한다면 heap을 활용하는 것이 좋다. 큰 오브젝트의 기준은 아키텍처에 따라 다르지만 약 1MB 이상의 오브젝트를 stack에서 사용했다가 자칫 Stack overflow로 이어질 수도 있다. 반면 Heap에서는 수 백 MB 혹은 GB까지도 할당이 가능하다. 우리는 거대한 오브젝트의 주소 값 하나만을 스택 변수에서 관리하면서 큰 오브젝트를 자유자재로 다룰 수 있기에 효율적이다.

Dynamic allocation

Call Stack에 데이터가 쌓이기 위해서는 Stack frame에 어떤 데이터가 몇 개 쌓여야 할지는 모두 컴파일 타임에 결정되어야 한다. 하지만 Heap은 무엇을 몇 개 할당할지, 그리고 해제할지는 런타임에 결정해도 된다. 때문에 동적으로 메모리를 관리하기 위해서는 heap을 사용해야 한다.

Heap Memory의 사용

C++을 사용하면서 Heap Memory를 사용하는 방법은 크게 3가지가 있다. 하나씩 살펴보자.

  • C Style
  • C++ Style
  • Safer C++ Style

C style

int main()
{
    // 단일 변수
    int *ip = (int*) malloc(sizeof(int));   // heap 메모리 할당   
    *ip = 10;
    free(ip);   // heap 메모리 해제

    // 배열
    int *iap = (int*)malloc(size(int) * 5);
    *iap = 10;
    free(iap);
    return 0;
}

C style malloc / free 함수는 오직 메모리 할당 작업만 수행한다. 만약 mallco과 free로 객체를 생성한다면 해당 객체의 생성자와 소멸자가 호출되지 않는다.

class A
{
public:
    A() { std::cout << "A()" << endl; }
    ~A() { std::cout << "~A()" << endl; }

private:
    int num;
};
int main()
{
    // 단일 변수
    A* ap = (A*) malloca(sizeof(A));
    free(ap);

    // 배열
    A* aap = (A*) malloc(sizeof(A) * 5);
    free(aap);
    return 0;
}

출력 결과 (아무것도 출력되지 않는다.)


  

때문에 mallco과 free는 C++에서는 사용할 일도 없고, 사용하지 않는 것이 바람직하다.

C++ style

class A
{
public:
    A() { std::cout << "A()" << endl; }
    ~A() { std::cout << "~A()" << endl; }

private:
    int num;
};

int main()
{
    // 단일 변수
    A* ap = new A();
    delete ap;

    // 배열
    A* aap = new A[5];
    delete[] aap;
}

출력 결과 (생성자와 소멸자가 한 번씩 호출됨)

A()
~A()
A()
A()
A()
A()
A()
~A()
~A()
~A()
~A()
~A()

Safer C++ style

만약 동적으로 할당한 메모리를 개발자가 실수로 해제하지 않는다면, 해당 메모리는 프로세스가 종료될 때까지 메모리에 존재하게 된다.

int main()
{
    // 단일 변수
    A* ap = new A();
    //delete ap;

    // 배열
    A* aap = new A[5];
    //delete[] aap;
}

출력 결과 (생성자만 호출됨)

A()
A()
A()
A()
A()
A()

더욱이 스택 프레임이 해제되면서 메모리의 주소 값을 가지고 있던 포인터 변수가 해제된다면, 동적 할당된 메모리 공간은 더 이상 해제할 수도 없다. 이러한 문제를 메모리 누수(Memory Leak)라고 한다. 이를 방지하기 위해서는 스마트 포인터를 활용하는 것이 안전하다.

int main()
{
    // 단일 변수
    std::unique_ptr<A> ap = std::make_unique<A>;
    //delete ap;

    // 배열
    std::vector<A> as(5);
    //delete[] aap;
}

출력 결과 (생성자와 소멸자가 한 번씩 호출됨)

A()
A()
A()
A()
A()
A()
~A()
~A()
~A()
~A()
~A()
~A()

 

이상으로 Stack과 Heap의 특징 장단점 사용 시 주의할 점을 알아보았다. 다음에는 Heap을 더욱 안전하게 사용하기 위한 스마트 포인터에 대해서 더 다뤄본다.

 


참조:

유튜브 채널 - 코드없는 프로그래밍: https://www.youtube.com/channel/UCHcG02L6TSS-StkSbqVy6Fg