힙(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