Kuma's Curious Paradise
[java] stack 영역과 heap 영역 특징, 메모리 구조 들여다보기 본문
목차
1. stack과 heap, 왜 알아야 할까?
2. stack과 heap 영역, 그리고 메모리 구조
1) stack 영역
2) heap 영역
3) 메모리 구조 그림이 뒤집힌 이유
3. 메모리 단편화(Memory Fragmentation)
1. stack과 heap, 왜 알아야 할까?
스택과 힙은 메모리에서 중요한 역할을 맡은 두 영역이다. 자바에서는 객체들을 heap 영역에 동적으로 할당하고, 메서드 호출 및 지역 변수(lv)를 스택에 저장한다. 가바지 컬렉터는 더 이상 사용되지 않는 객체들, 즉 불필요해 보이는 메모리를 자동으로 해제한다. 따라서 개발자가 메모리를 직접 해제하는 데 관여하지는 않지만, 이 두 영역을 이해하는 것은 효율적으로 메모리를 관리하는 데 도움을 주기 때문에, 오늘은 stack과 heap을 자세히 살펴보려고 한다.
2. stack과 heap 영역, 그리고 메모리 구조
1) stack 영역
- 구조 특징 1 : 선형 데이터 구조(linear data structure)를 지닌 메모리 공간
선형 데이터 구조는 데이터를 일렬로 나란히 저장하는 방식을 말한다. 이 구조에서는 각 데이터 요소가 직전, 직후 데이터와 관계를 가지며, 서로를 가리키거나 참조한다. stack은 선형 데이터 구조 중 하나로, 데이터를 선형으로 쌓아 올린 형태로 저장한다. - 구조 특징 2 : 후입선출(LIFO, Last-In-First-Out)구조
가장 마지막에 넣은 데이터가 가장 먼저 제거된다. - 사용 : 주로 함수 호출과 관련된 정보를 저장하는 데 사용. (지역 변수, 매개변수, 함수 호출 및 반환 주소 등을 저장)
주로 금방 일하고 사라지는 데이터들을 저장한다. 지역 변수는 함수가 끝나는 순간 사라지기 때문에 후입선출 구조를 가진 stack에 아주 적합하다. - 크기 : 일반적으로 고정 크기가 할당되며, 적은 메모리 공간을 차지한다.
- 속도 : 접근 속도가 빠르며, 데이터에 직접 접근이 가능하다.
- 할당 및 해제 : 컴파일 타임에 결정되며, 자동으로 할당 및 해제된다.
'컴파일 타임에 결정된다'는 말은 코드 실행 전에 데이터에 메모리를 어떻게 할당할지, 크기는 얼마가 될지 결정한다는 뜻이다.
2) heap 영역
- 구조 특징 : 자유롭게 할당/해제할 수 있는 동적 메모리 공간.
프로그램이 실행되는 동안 필요한 메모리를 런타임 시점에서 할당할 수 있다. 이는 필요한 메모리의 크기가 미리 알려지지 않았거나, 실행 중에 변하는 경우에 유용하다. 예를 들어, 객체들을 생성하거나 배열을 동적으로 할당할 때 힙 영역을 사용한다. - 사용 : 대부분의 객체들은 이곳에 할당됨.
- 크기 : 운영체제 및 사용 가능한 전체 메모리에 따라 크기가 조절되며, 이론상 크기에 제한이 없다.
- 속도 : stack보다 접근 속도가 느리지만, 유연하게 메모리를 할당할 수 있다.
- 할당 및 해제 : runtime 때 할당되며, garbage collector가 더 이상 프로그램에 접근할 수 없는 객체들을 찾아내 메모리를 해제한다.
3) 메모리 구조 그림이 뒤집힌 이유
구글에 "stack heap 영역"이라고 검색하면 다음과 같은 이미지들이 나온다. 살펴보면, 메모리 영역은 크게 1) 코드 영역(코드가 저장되는 곳), 2) 데이터 영역( static이 붙은 어디서든 사용 가능한 변수들이 거주하는 영역), 3) heap 영역, 4) stack 영역으로 나누어진다는 것을 알 수 있다. 영역이 적힌 순서에 주목하자.
이 그림들은 어디서부터 시작된 것일까? 해외에서도 이 그림을 자주 사용할까? 궁금하여 해외 자료를 찾아보기 시작했다. 그런데 이상하게 영역이 적힌 순서가 거꾸로다. 아래는 Data Segment라는 키워드를 위키피디아에 검색하면 나오는 그림이다. 그림 제목은 'Program Memory Layout'. 'Linux.com'에 개재된 기사에서도 아래 그림과 같은 방식으로 메모리 구조를 묘사한다.
결론적으로 한국어로 검색했을 때 나오는 그림이 틀린 그림은 아니다. 다만, 왜 이렇게 뒤집힌 그림이 나오게 되었는지, 그림 속 화살표들의 의미는 무엇인지 궁금했다.
먼저, RAM(Random Access Memory, 랜덤 액세스 메모리)은 컴퓨터가 프로그램을 실행할 때 사용하는 주 기억장치로, 스택과 힙은 모두 RAM 내 메모리 영역의 일부분을 차지한다. 프로그램이 실행되면, 스택과 힙은 RAM 내에서 각각 할당된 영역을 사용한다. 위의 모든 그림들은 그 모습이 어떻게 되어 있는지를 묘사한다.
이때 스택은 보통 메모리의 높은 주소부터 할당되고 힙은 낮은 주소부터 할당된다. 이 말을 이해하려면, 메모리 주소를 알아야 한다.
메모리 주소(Address)는 보통 0부터 시작해서 메모리의 크기에 따라 증가하는 값으로 표현된다. 여기서 낮은 주소, 높은 주소는 메모리 주소의 방향을 나타내는 데 사용되며, 높은 주소는 메모리 상단, 낮은 주소는 메모리의 하단을 가리킨다. 일반적으로 프로그램 코드와 정적 데이터(ex. 전역 변수)는 메모리의 낮은 주소부터 할당된다. CPU의 명령어가 메모리 주소의 낮은 부분부터 시작하여 실행되기 때문이다.
그렇다면 금방 쓰고 사라지는 stack을 밑에 있는 상대적으로 무게감 있는 메모리 영역과 겹치지 않도록 하는 것이 중요해진다. 따라서 stack은 높은 주소부터 아래로 내려가는 방향으로 할당된다. 이렇게 하여 코드와 stack이 충돌하는 상황을 피할 수 있고 메모리 관리도 효율적으로 이루어진다.
반면, heap은 낮은 주소부터 할당되어 높은 주소쪽으로 올라가는 모양세다. heap은 runtime 때 필요한 크기가 결정되기 때문에 필요에 따라 메모리 공간이 증가, 축소될 수 있어야 한다. 메모리가 할당되고 해제되기를 반복하면서 '메모리 단편화(Memory Fragmentation)' 라는 현상이 발생할 수 있는데, 낮은 주소부터 할당하면 이 현상을 최소화할 수 있다.
어디서부터 시작됐는지 모르겠지만, 한국으로 건너오며 메모리 구조 그림이 뒤집힌 것이 분명하다. 아마도 설명을 할 때 중요도 순으로 구분하다 보니 (코드 -> 널리 쓰이는 변수 -> 객체 -> 지역 변수) 이런 그림이 나오게 된 것이 아닐까 싶다. 뒤집힌 그림에서 높은 주소가 왜 아래쪽에 있는지 이해하기 어려웠던 분들도 계셨을 것 같다. 이제 이해가 됐다고 여기서 글을 멈출 수는 없고, 메모리 단편화가 무엇인지 알아보자.
3. 메모리 단편화 (Memory Fragmentation)
메모리 단편화는 메모리가 잘게 잘게 쪼개져서, 메모리 자체는 충분하지만 할당이 불가능할 때 일어난다. 크게 외부 단편화와 내부 단편화로 나뉜다.
1) 외부 단편화(External Fragmentation): 메모리에 여러 조각들이 존재하지만, 이를 합쳐서 큰 메모리 공간을 가지기는 어려운 상황을 말한다.
2) 내부 단편화(Internal Fragmentation): 메모리 블록 내에서 실제로 사용되지 않는 공간이 발생하는 경우를 말한다. 예를 들어, 할당된 메모리 블록이 5인데, 실제로 필요한 크기가 1이라면 4만큼의 빈 공간이 발생한다.
메모리 단편화는 프로그램 성능에 영향을 미치기 때문에 주의가 필요하다. 코드에서 메모리 단편화를 최소화하는 방법으로는 '메모리 풀(memory pool)'을 사용하거나, 재할당 횟수를 최소화하는 방법 등이 있다. 오늘은 재할당 횟수를 줄인다는 말이 무슨 의미인지 살짝만 소개하며 글을 마치겠다.
public class AllocatingExample {
public static void main(String[] args) {
ArrayList<Integer> numbers = new ArrayList<>(); // 여기서 10000을 미리 넣어준다면?
for (int i = 0; i < 10000; i++) {
numbers.add(i);
}
}
다음과 같은 코드가 있다고 가정해 보자. ArrayList에 계속해서 요소가 추가되면서, 기존 배열보다 크기가 더 큰 새로운 배열을 생성하고, 기존 요소들을 새 배열로 복사하는 과정이 생겨난다. 다시 말해 ArrayList의 내부 배열이 재할당되고 있다. 물론 가비지 컬렉터가 사용하지 않는 메모리를 재활용할 수 있도록 관리하기 때문에 이 정도로 메모리 파편화가 발생하지는 않는다. 하지만 이 예시는 ArrayList를 만들 때 10000이라는 크기를 미리 지정한다면, 재할당 횟수를 줄일 수 있음을 보여준다.