1. 동적 메모리 할당(Dynamic Memory Allocation)이란?
프로그램이 실행되는 동안 메모리의 크기가 변할 수 있는 변수나 데이터 구조에 메모리를 할당하는 과정을 동적 메모리 할당이라 한다.
이를 통해 런타임에 필요한 메모리 양을 결정하고, 더 이상 필요하지 않은 메모리를 반환하여 재사용할 수 있게 한다.
동적 할당을 통해 메모리 사용을 최적화하고, 프로그램 유연성을 개선할 수 있다.
우선 개념적인 얘기부터 보고, 그 다음에 실제로 어떻게 구현하는지 살펴보자.
동적 vs 정적 메모리 할당
역사적으로 볼 때, 정적 메모리 할당이 먼저 있었고, 프로그램의 요구사항과 컴퓨팅 환경이 발전함에 따라 동적 메모리 할당이 필요해지고 후행적으로 등장했다고 볼 수 있다.
초기에는 정적 메모리 할당이 더 일반적이었다. 프로그램이 작고 단순했으며, 메모리 자체도 매우 제한적이었기 때문.
그러나 프로그램의 복잡성이 증가하고 메모리가 확장되면서 동적 메모리 할당이 필요하게 되었다.
정적 메모리 할당은 프로그램의 컴파일 시점에 모든 메모리 사용이 결정되고,
동적 메모리 할당은 런타임에 메모리를 할당하고 해제할 수 있도록 해준다.
그럼 왜 동적 메모리 할당인가?
- 런타임 전에는 자료구조의 크기를 알 수 없는 경우들이 많다. 프로그램이 돌아가는 동안 데이터가 유동적으로 들어오고 나가는 경우는 대개 그럴 것이다.
- 정적 할당은 컴파일타임에 배열 크기가 확정됨. 그리고 런타임에 free()로 메모리 반환할수가 없음. 즉, 프로세스 종료 때에야 메모리가 반환됨. 그러므로 정적 할당은 필요한 메모리 크기를 아는 경우에 써야하겠지?
- 동적 메모리 할당은 프로그램이 실행 중에 데이터의 크기가 변할 수 있는 경우에 매우 유용하다. 예를 들어, 사용자의 입력에 따라 변할 수 있는 데이터 구조(예: 연결 리스트, 트리, 그래프 등)를 다룰 때 주로 사용된다.
정적 메모리 할당 (Static Memory Allocation)
- 특징:
- 메모리 크기가 고정되어 있어, 실행 중에는 변경할 수 없다.
- 할당과 해제의 오버헤드가 없어, 실행 시간이 빠르다.
- 스택 메모리(stack memory) 또는 데이터 세그먼트(data segment)에 할당된다.
- 전역 변수(global variables)와 정적 변수(static variables)가 이 방식을 사용한다.
- 장점:
- 관리가 간단하며, 메모리 할당 실패의 위험이 없다.
- 실행 시간이 예측 가능하다.
- 단점:
- 유연성이 부족하여, 실행 중 메모리 크기를 조정할 수 없다.
- 프로그램이 요구하는 최대 메모리를 항상 유지해야 하므로, 메모리 낭비가 발생할 수 있다.
정적 메모리 할당은 프로그램이 컴파일되는 시점에 메모리의 크기가 결정되고, 실행 파일이 생성될 때 해당 메모리 공간이 할당된다.
정적으로 할당된 메모리, 즉 컴파일 시간에 할당된 메모리는 프로그램이 실행되는 전체 기간 동안 고정되어 있으며, 프로그램의 실행이 끝날 때 운영 체제에 의해 자동으로 해제된다.
따라서, 정적 메모리나 스택 메모리(stack memory)에 할당된 변수들은 프로그래머가 직접 해제할 필요가 없으며, 이를 시도하는 것은 프로그램의 정상적인 동작을 방해할 수 있다.
동적 메모리 할당 (Dynamic Memory Allocation)
동적 메모리 할당은 프로그램이 실행되는 동안, 즉 런타임에 필요에 따라 메모리를 할당하고 해제하는 방식이다.
이 방식을 사용하면 프로그램은 실행 중에 필요한 만큼의 메모리를 할당받을 수 있으며, 더 이상 필요하지 않은 메모리는 free()를 통해 해제하여 시스템에 반환할 수 있다.
- 특징:
- malloc, calloc, realloc, free와 같은 함수를 사용하여 힙 메모리(heap memory)에 메모리를 할당하고 해제한다.
- 할당된 메모리의 크기를 실행 중에 변경할 수 있다.
- 메모리는 사용자가 명시적으로 관리해야 한다.
- 장점:
- 필요한 메모리만큼만 사용할 수 있어, 메모리 사용이 효율적이다.
- 실행 중에 메모리의 크기를 조정할 수 있어, 유연성이 높다.
- 단점:
- 제대로 관리하지 않으면 메모리 누수(memory leak), 단편화와 같은 오류가 발생할 수 있다.
- 메모리 할당과 해제에 오버헤드가 발생할 수 있다.
malloc(), calloc(), realloc() 등의 함수로 힙에 할당된 메모리는 프로그래머가 명시적으로 관리해야 하며,할당된 메모리만 free()로 해제할 수 있다.
2. 동적 메모리 할당 원리
가상 주소공간 구조
프로세스에 할당된 가상 메모리는 크게 네 부분으로 나눌 수 있다. 다른 프로그래밍 언어와 실행 환경도 비슷한 구조를 사용한다.
- 코드 영역 (Code Segment): 프로그램의 기게어 코드가 저장되는 영역, 실행 가능한 바이너리 코드가 위치한다.
- 데이터 영역 (Data Segment): 전역 변수, 정적 변수 등이 저장되며, 프로그램의 런타임동안 유지된다. 데이터 영역은 초기화된 데이터(전역 변수 등)를 위한 영역과 초기화되지 않은 데이터(Block Started by Symbol)를 위한 영역으로 나뉜다.
- 스택 영역 (Stack Segment): 함수의 매개변수, 리턴 주소, 로컬 변수들이 저장되는 영역.
- 힙 영역 (Heap Segment): 프로그램이 실행 중에 필요한 양만큼 동적으로 메모리를 할당하고 해제하는 영역.
간단한 C 코드를 통해 각 구성요소가 어디에 저장되는지 확인해보자
#include <stdio.h>
#include <stdlib.h>
int initialized_global_var = 5; // 데이터 세그먼트
static int static_initialized_var = 10; // 데이터 세그먼트
int uninitialized_global_var; // BSS 세그먼트
static int static_uninitialized_var; // BSS 세그먼트
void my_function(int function_param) {
static int static_var_in_function = 3; // 데이터 세그먼트
static int static_uninitialized_var_in_function; // BSS 세그먼트
int local_var = 2; // 스택
int *heap_var = malloc(sizeof(int)); // 힙 (포인터는 스택에 저장됨)
*heap_var = 4; // 힙에 저장된 값
printf("Local variable = %d\n", local_var);
free(heap_var); // 할당된 힙 메모리를 해제
}
int main() {
int local_var_in_main = 1; // 스택
my_function(local_var_in_main);
return 0;
}
힙(Heap)과 스택(Stack) 메모리
프로그램이 사용하는 메모리 영역 중에서도, 2가지 주요 영역인 힙과 스택에 대해서 짚고 넘어가자.
스택 메모리: OS에 의해 자동적으로 관리되는 메모리 영역으로, 함수 호출과 함께 로컬 변수를 저장하는데 사용된다. 함수가 호출될 때 스택 프레임이라는 메모리 블록이 할당되고, 스택 프레임에는 로컬 변수, 매개변수, 리턴 주소 등을 포함한다. 함수가 종료될 때 스택 프레임은 해제된다. 메모리 관리가 간단하고 빠르다는 장점이 있다.
- 빠르지만, 비교적 작고, 프로시저의 원활한 실행을 위해 OS가 알아서 관리하는 영역. (커널 영역에서 관리)
힙 메모리: 프로그래머가 직접 관리해야 하는 메모리 영역으로, 동적 메모리 할당에 사용된다. 힙은 프로그램의 런타임 동안 필요에 의해 메모리를 할당하고 해제할 수 있도록 허용한다. 힙 메모리는 스택에 비해 관리가 복잡하고 속도도 느릴 수 있다. 그러나 더 큰 메모리 할당이 가능하고, 할당된 메모리의 생명주기가 함수 호출에 종속되지 않는다.
- 느리지만, 크고, 프로그래머가 비교적 자유롭게 사용 가능한 영역. (사용자(user) 영역에서 프로그래밍적으로 관리)
때문에 동적 메모리 할당이란 주제는 프로그래머가 힙 영역을 어떻게 제어하고 활용할 건지에 대한 이야기이기도 하다.
이쯤에서 포인터와 메모리 주소에 대해서 다시 짚어보자.
포인터는 메모리 주소를 저장하는 변수로, C에서 동적 메모리를 사용할 때 중요한 역할을 한다.
힙에 메모리를 할당하면, 그 시작 주소가 포인터 변수에 저장된다.
미리 보자면 이런 식이다.
int main() {
int *p = NULL; // 포인터 초기화
// int 크기의 메모리를 하나 할당받는다.
p = (int*)malloc(sizeof(int));
...
};
때문에 포인터를 따라 해당 메모리를 참조하고 조작할 수 있게 되는 것이다. 포인터를 사용하면 복잡한 자료구조를 효율적으로 관리하고, 메모리의 재할당 및 해제를 Kernel이 아닌 User 레이어 단에서 제어할 수 있다.
메모리 누수(Memory Leak)와 단편화(Fragmentation)
메모리 누수(Memory leak)
Memory leak은 프로그램이 동적으로 할당한 메모리를 필요하지 않음에도 불구하고 해제하지 않을 때 발생한다.
이러한 상황은 포인터를 사용하여 할당된 메모리에 접근했다가, 그 포인터가 다른 값을 가리키도록 변경되었거나 scope를 벗어난 경우에 주로 발생한다.
Memory leak이 쌓이면, 프로그램이 실행되는 동안 점진적으로 시스템의 사용 가능한 메모리를 소진하게 만든다. 이는 결국 시스템의 성능을 저하시키고, 이 정도가 커지고 길어진다면 시스템의 안정성에 심각한 영향을 줄수도 있다.
메모리 단편화(Fragmentation)
메모리 단편화는 할당과 해제가 반복될 때 메모리 공간이 작은 블록으로 나뉘어지는 현상이다.
단편화는 두 가지 유형으로 나뉜다: 1) 내부 단편화, 2) 외부 단편화
1. 내부 단편화(Internal Fragmentation)
할당된 메모리 블록 내부에 사용되지 않는 공간이 생기는 경우이다.
이는 메모리 블록이 요청된 크기보다 약간 더 크게 할당되었을 때 발생할 수 있다.
2. 외부 단편화(External Fragmentation)
불연속적으로 메모리가 할당되고 해제되면서 생기는, 사용가능하지만 연속되지 않은 작은 메모리 공간이다.
모아서 보면 충분히 큰데, 각각은 작아서 하나의 큰 메모리 블록을 할당해야 하는 상황에선 써먹을 수 없는 그런 상황이다.
단편화는 시스템의 메모리 이용률을 저하시킨다. 특히 장기간 실행되는 프로그램의 경우, 메모리 단편화를 어떻게 최소화할 것인가가 중요한 전략이다.
Unmanaged vs Managed
결국 이 문제들은 메모리 할당과 해제를 잘하라는 교훈을 준다.
이를 해결하기 위한 다양한 레이어에서의 해결책이 있지만, 그 중 하나로 언어 자체에서 메모리 관리를 제공하기도 한다.
자바와 파이썬 등이 대표적인데, 해당 측면에서 이러한 언어들을 managed language라고 부른다.
C와 같은 low-level에 가까운 언어는 프로그래머가 힙 할당과 해제를 명시적으로 관리해야 한다. 때문에 코드를 잘 짠다면 메모리 최적화가 매우 잘 되지만, 잘못하면 메모리 누수, 단편화 문제가 발생할 수 있다.
반면 high-level 언어들은 세부적인 메모리 관리가 추상화되어 있다. 자바, 파이썬 등 managed language는 가비지 컬렉터가 동적 메모리 할당과 해제를 관리한다. 객체 사용이 끝나면 가비지 컬렉터가 자동으로 메모리를 회수한다. 따라서 메모리 누수, 단편화 문제를 비교적 걱정하지 않아도 된다.
3. 동적 메모리 할당 방법
위에서 힙 영역은 프로그래머가 직접 메모리를 제어하는 영역이라고 했다.
그럼 이 '프로그래밍적인 메모리 제어'라는 걸 어떻게 하는 걸까?
C에서는 malloc, calloc, realloc, free 함수를 통해 메모리 할당과 해제를 수행한다.
함수 | 설명 | 특징 |
malloc() | 요청한 크기의 메모리를 할당하고, 할당된 메모리 블록의 시작 주소를 반환한다. | 할당된 메모리는 초기화되지 않는다. |
calloc() | 요청한 크기의 메모리를 할당하고, 할당된 메모리를 0으로 초기화한다. | 메모리는 초기화되어 0으로 설정된다. |
realloc() | 이미 할당된 메모리 블록의 크기를 변경한다. | 크기를 조정하고, 필요하면 메모리 블록을 새로운 위치로 이동시킨다. |
free() | 할당된 메모리를 해제하고, 시스템에 반환한다. | 메모리 누수를 방지하기 위해 사용한 후에는 반드시 메모리를 해제해야 한다. |
메모리 할당 선언 (malloc, calloc)
1. malloc (Memory Allocation)
malloc은 메모리 할당의 기본이 되는 함수다.
요청한 크기의 메모리를 바이트 단위로 할당하고, 할당된 메모리 블록의 시작 주소를 반환한다.
할당된 메모리의 초기 내용은 정의되지 않는다.
즉, '이 방은 너가 써~ 근데 방청소는 안해놨어~' 이거다.
이전 프로그램의 명령어가 들어있을 수도 있다.
int *ptr = (int *)malloc(10 * sizeof(int)); // 10개의 정수를 저장할 수 있는 메모리 할당
이 한 줄을 해석하면 다음과 같다.
- int *ptr
- 포인터 변수 ptr을 선언하는 부분. 이 변수는 int 형식의 데이터를 가리키는 포인터이다.
- 즉, ptr은 int 형식 데이터를 저장할 메모리 주소를 저장할 수 있는 변수이다.
- (int *)
- 캐스트(형변환) 연산자.
- malloc 함수는 기본적으로 void * 타입을 반환하는데, 이는 모든 타입의 포인터로 변환될 수 있는 범용 포인터이다.
- C언어에서는 보통 반환된 void * 포인터를 사용하려는 포인터 타입으로 형변환한다. 이 경우엔 int *로 형변환하여 반환된 메모리 주소가 int 타입의 값들을 저장하는데 적합하다는 것을 명시해주는 것이다.
- malloc(10 * sizeof(int))
- malloc이 하는 일은 1) 지정된 크기 만큼의 메모리 블록 할당 2) 할당된 메모리의 시작 주소 반환 이다.
즉 이 한 문장의 뜻은 다음과 같다.
int 사이즈 10개만큼의 메모리 블록을 할당한 뒤, 대입 연산자(=)를 통해 해당 블록의 시작 주소를 ptr에 담는다.
그런데 그 전에 int *로 타입을 일치해줘야 하기 때문에 (malloc은 void *타입을 반환하므로) int *로 형변환을 해주었다.
2. calloc (Contiguous Allocation)
calloc은 연속적인 할당이라는 뜻이다. malloc과 유사하지만 두가지 주요한 차이점이 있다.
1) 할당할 항목의 수와 각 항목의 크기를 인자로 받는다.
2) 할당된 메모리를 0으로 초기화한다.
int *ptr = (int *)calloc(10, sizeof(int)); // 10개의 정수를 저장할 수 있는 메모리 할당 및 0으로 초기화
malloc은 한 개의 인자(메모리 크기)를 받는다 -> 10 * int 사이즈
calloc은 두 개의 인자(항목의 개수, 각 항목의 크기)를 받는다 -> 10, int 사이즈
[malloc vs calloc]
1. malloc할당된 메모리는 초기화되지 않습니다.
즉, 메모리에는 예측할 수 없는 임의의 데이터가 들어있을 수 있습니다.
메모리 초기화가 필요 없을 때 사용하거나, 할당된 메모리에 곧바로 새로운 데이터를 쓸 경우에 선호됩니다.
성능상의 이점이 있을 수 있습니다. 왜냐하면 메모리를 0으로 초기화하는 추가적인 작업을 수행하지 않기 때문입니다.
2. calloc
할당된 메모리는 0으로 초기화됩니다. 이것은 모든 비트를 0으로 설정하는 것을 의미하며, 대부분의 경우 이는 숫자적으로 0이거나 NULL 포인터를 나타냅니다.
배열을 할당하고 모든 요소를 0 또는 NULL로 초기화하려는 경우에 사용됩니다.
calloc은 메모리를 0으로 초기화하는 시간이 필요하므로 malloc보다 약간 느릴 수 있습니다.
3. 실제 사용 선호도
초기화가 필요한 경우: calloc을 사용하면 코드가 간결해지고, 초기화에 대한 실수를 방지할 수 있습니다.
-> 보안상의 이유 & 모든 변수를 명확한 초기값으로 설정함으로써 프로그램 행동을 예측가능하게 만듦
초기화가 필요 없는 경우: malloc이 선호되며, 이는 성능을 조금 더 최적화할 수 있기 때문입니다.
-> 사용자 입력, 파일로부터 데이터를 읽어 메모리에 바로 값을 저장할 예정인 경우, 굳이 초기화 한번 거칠 필요 없음.
개발자는 주로 성능과 안정성의 균형을 찾아야 합니다. 배열이나 대규모의 데이터 구조를 다룰 때 초기화된 메모리를 원한다면 calloc이 더 편리할 수 있습니다. 하지만, 초기화에 추가적인 시간을 들이고 싶지 않다면 malloc을 사용하고 수동으로 필요한 부분만 초기화하는 것이 더 좋을 수 있습니다.
메모리 재할당 (realloc)
앞서 말했듯, 사용자가 정의한 구조체는 복잡한 경우가 많기 때문에 사후적으로 메모리 할당 정도를 조정해줄 경우가 많이 생긴다.
새로 메모리를 할당하는 것이 아니라, 재조정을 할 때는 realloc() 함수를 쓴다.
realloc()은 이미 할당된 메모리 블록의 크기를 변경한다. 크기를 늘리고 줄이는 것 모두 가능하며, 공간의 한계로 새로운 메모리 영역으로 이사해야 할 경우, 기존의 데이터를 새로운 위치로 복사한다.
ptr = (int *)realloc(ptr, 20 * sizeof(int)); // 메모리 크기를 20개의 정수를 저장할 수 있도록 조정
이때 20*int 만큼의 바이트를 추가하는 것이 아니다. 명시한 값으로 기존 크기를 바꾸는 것이다.
추가 요청이 아니라, 말 그대로 '재할당'이다.
메모리 해제 (free)
메모리를 다 쓰고 나면, 필요없는 메모리를 해제해줘야 한다. 그래야 Memory leak이 발생하지 않는다.
free(ptr); // 할당된 메모리 해제
ptr = NULL; // 포인터를 NULL로 설정하여 더 이상 유효하지 않은 메모리를 가리키지 않도록 한다.
4. 동적 메모리 할당 예제
1차원 배열을 동적 메모리 할당으로 선언해보자!
int *arr1 = (int *)malloc(10 * sizeof(int)); // int 10개 크기의 배열 할당
arr1[0] = 1;
printf("%i \n", arr1[0]); // 1
2차원 배열도 선언해보자!
// 2차원 배열 동적 할당
int **arr2 = (int **)calloc(10, sizeof(int *)); // 포인터의 배열 할당
for(int i = 0; i < 10; i++){
arr2[i] = (int *)calloc(10, sizeof(int));
};
arr2[1][5] = 15;
printf("%i \n", arr2[1][5]); // 15
printf("%i \n", arr2[1][1]); // 0 (할당되지 않음)
조금(많이) 헷갈린다.
포인터의 배열을 만든 뒤, 각 인덱스에 배열을 가리키는 포인터를 또 담는 방식이다.
모든 행을 출력해보고 싶으면 다음과 같이 하면 된다.
// 2차원 배열 할당
int **arr2 = (int **)calloc(10, sizeof(int *)); // 포인터의 배열 할당
for(int i = 0; i < 10; i++) {
arr2[i] = (int *)calloc(10, sizeof(int));
}
arr2[1][5] = 15;
// 각 행을 출력하기 위한 루프
for(int i = 0; i < 10; i++) {
for(int j = 0; j < 10; j++) {
printf("%d ", arr2[i][j]);
}
printf("\n"); // 각 행의 끝에서 줄바꿈
}
// 할당된 메모리 해제
for(int i = 0; i < 10; i++) {
free(arr2[i]);
}
free(arr2);
'Computer Science > 컴퓨터 구조 (Computer Architecture)' 카테고리의 다른 글
동적 메모리 할당 | Malloc Lab | (2) 과제 소개 (0) | 2023.11.11 |
---|---|
동적 메모리 할당 | Malloc Lab | (1) Malloc은 어떻게 구현되는가? (2) | 2023.11.10 |
CS:APP | Chapter 3 | (2) 프로그램의 기계수준 표현 (1) | 2023.11.01 |
CS:APP | Chapter 3 | (1) 프로그램의 기계수준 표현 (0) | 2023.10.31 |
CS:APP | Chapter 1 | 컴퓨터 시스템 overview (4) | 2023.10.26 |