최근에 지하철을 타면 피곤해서 잠만자던도중...
잠을 깨기위해 볼 유튜브를 찾다가 궁금하게 만드는 제목을 발견했다.
만일 스택이 매우 커질 수 있다면 힙은 불필요할까? 유튜브 링크
이 제목이 나를 영상으로 이끌었고, 덕분에 지하철의 30분이 시간가는 줄 몰랐다.... 단순한 질문에 대한 답변 뿐만아니라, 어떤걸 묻는지에 대한 분석과 꼬리에 꼬리를 무는 답변까지 보면 30분이 순삭되는 영상이므로 한번씩 보는걸 추천한다.
댓글도 개발자들의 다양한 시각을 볼 수 있어서 재밌다
이 영상을 보고 배운점들을 정리하고, 궁금한점이 더 생겼기 때문에 블로그 글을 통해 정리하고자 한다.
먼저 이 질문에 답하기 위해서는 먼저 스택과 힙의 근본적인 차이를 이해해야 한다. 스택은 LIFO(Last-In-First-Out) 구조로 정적이고 자동적인 메모리 할당 방식을 사용한다. 반면 힙은 동적으로 메모리를 할당하고 임의의 순서로 해제할 수 있는 유연성을 제공한다.
결론적으로, 스택의 크기가 무한히 커진다 하더라도 힙은 여전히 필요하다. 그 이유는 다음과 같다.
다음 간단한 예제는 스택과 힙의 사용 차이를 보여준다:
void stackExample() {
int stackVar = 42; // 스택에 할당
// 함수 종료 시 stackVar은 자동으로 해제됨
}
void heapExample() {
Integer* heapVar = new Integer(42); // 힙에 할당
// 명시적으로 해제하지 않으면 메모리 누수 발생
delete heapVar;
}
스택과 힙에 대한 이해가 깊어지면서 자연스럽게 힙 메모리의 파편화 문제에 대해 생각하게 되었다. 힙 메모리 파편화는 메모리 할당과 해제의 반복 과정에서 발생하는 현상으로, 크게 두 가지 유형이 있다:
여러 개의 작은 메모리 블록들이 할당된 메모리 사이에 흩어져 있어, 개별적으로는 작지만 합치면 큰 메모리 블록을 할당할 수 있는 상황이다.
예를 들어:
[ 사용 중 10KB ][ 빈 5KB ][ 사용 중 20KB ][ 빈 10KB ][ 사용 중 15KB ][ 빈 15KB ]
이 상태에서 25KB 메모리 할당을 요청하면, 총 30KB의 빈 공간이 있음에도 불구하고 할당할 수 없다.
할당된 메모리 블록 내부에서 실제로 사용되지 않는 공간이 발생하는 현상이다.
예를 들어:
// 메모리 할당자가 8바이트 단위로 할당한다고 가정
byte[] data = new byte[22]; // 22바이트 요청
// 실제로는 24바이트(8바이트 경계에 맞춤)가 할당됨
// → 2바이트의 내부 파편화 발생
파편화는 다음과 같은 영향을 미친다:
사용 중인 메모리 블록을 한쪽으로 모아 연속된 큰 빈 공간을 만든다. 이는 외부 파편화를 효과적으로 해결하지만, 메모리 복사 비용이 크고 프로세스 실행을 일시 중단해야 한다.
void compactMemory() {
Address destination = startOfHeap;
for (Block block : heap) {
if (block.isInUse()) {
moveBlock(block, destination);
destination += block.size;
}
}
markAsFree(destination, endOfHeap - destination);
}
더 이상 참조되지 않는 객체를 자동으로 탐지하여 메모리를 해제한다. 이는 많은 현대 프로그래밍 언어(Java, Python 등)에서 사용된다.
요청 크기에 가장 가까운 빈 블록을 선택하여 내부 파편화를 줄인다.
Block* allocateBestFit(size_t size) {
Block* bestBlock = NULL;
size_t bestSize = SIZE_MAX;
for (Block* block = freeList; block != NULL; block = block->next) {
if (block->size >= size && block->size < bestSize) {
bestBlock = block;
bestSize = block->size;
}
}
if (bestBlock != NULL) {
bestBlock->inUse = true;
removeFromFreeList(bestBlock);
}
return bestBlock;
}
동일한 크기의 객체를 위한 메모리를 미리 할당하여 관리한다.
public class MemoryPool<T> {
private final Object[] pool;
private final boolean[] inUse;
public MemoryPool(int capacity) {
this.pool = new Object[capacity];
this.inUse = new boolean[capacity];
}
public T allocate() {
for (int i = 0; i < pool.length; i++) {
if (!inUse[i]) {
inUse[i] = true;
return (T) pool[i];
}
}
throw new OutOfMemoryError("Memory pool is full");
}
public void free(T object) {
for (int i = 0; i < pool.length; i++) {
if (pool[i] == object) {
inUse[i] = false;
return;
}
}
throw new IllegalArgumentException("Object not from this pool");
}
}
스택과 힙은 각기 다른 목적을 가진 중요한 메모리 관리 구조이다. 아무리 스택이 커지더라도 동적 데이터 관리와 유연성을 제공하는 힙은 일반적으론 필요하다. 이는 단순히 메모리 크기의 문제가 아닌, 구조적인 차이와 목적의 차이 때문이다.
특히 멀티스레드 환경에서는 여러 스레드가 공유할 수 있는 메모리 공간이 필요한데, 스택은 본질적으로 스레드 의존적이고 지역적인 특성을 가지므로 이러한 용도로는 부적합하다. 또한 객체의 수명 관리에 있어서도 힙은 스택보다 훨씬 유연한 방식을 제공하기 때문이다.
물론 힙 메모리는 파편화 문제와 같은 단점이 있지만, 이는 가비지 컬렉션, 메모리 풀, 메모리 압축 등 다양한 기법으로 해결할 수 있다. 현대 프로그래밍 언어들은 이러한 메모리 관리를 자동화하여 개발자의 부담을 줄이고 있다.
여기까지 공부하다보니 내가 주로 쓰는 JVM이 어떻게 메모리 파편화 문제를 해결하고 효율적인 메모리 관리를 구현하는지 더 자세히 알아보고 싶어졌다.
다음 글에서는 이어서 JVM의 메모리 관리 시스템, 특히 가비지 컬렉션의 세대별(Generational) 메모리 관리 방식에 대해 더 자세히 살펴 볼 예정이다.
Computer Systems: A Programmer's Perspective (3rd Edition)
The Garbage Collection Handbook: The Art of Automatic Memory Management (2nd Edition)
Modern Operating Systems (4th Edition)
Memory Management in Java: Stack vs Heap & Garbage Collection
Java Garbage Collection Best Practices