다양한 언어가 어떻게 메모리를 관리하는지 알아봅시다.
얼마전까지 메모리 관리는 C처럼 직접 메모리를 할당/해제해주거나 JAVA처럼 가비지 컬렉터로 관리하는 것만 있는 줄 알고 있었습니다.
그런데 Reference Counting이라는 방식이 있다는 것을 새로 알게 되어 이번 기회에 언어별 다양한 메모리 관리 방식에 대해 정리해보고자 합니다.
메모리 관리 방식은 크게 개발자가 수동으로 메모리를 관리해야 하는 것과 자동으로 관리되는 것으로 나눌 수 있습니다.
그리고 자동 관리 방식은 다시 Reference Counting 방식과 Tracing 방식 두 가지로 분류될 수 있습니다.
개발자가 메모리 할당과 해제를 직접 관리하는 방식입니다.
이는 메모리 사용을 최적화할 수 있지만, 개발자의 실수로 메모리 누수나 잘못된 포인터 접근 같은 문제를 발생시킬 위험이 있습니다.
malloc
, calloc
(C), new
(C++)free
(C), delete
(C++)Stop-the-World
stop-the-world란 GC을 실행하기 위해 JVM이 애플리케이션 실행을 멈추는 것입니다. stop-the-world가 발생하면 GC를 실행하는 쓰레드를 제외한 나머지 쓰레드는 모두 작업을 멈추고, GC 작업을 완료한 이후에야 중단했던 작업을 다시 시작합니다.
참조 카운팅은 각 객체가 몇 번 참조되고 있는지를 카운트하여, 참조가 0이 되면 해당 객체가 더 이상 사용되지 않는 것으로 판단하고 메모리를 해제하는 방식입니다.
멀티 스레드 환경에서 동기화 비용의 의미
참조 카운팅은 "참조가 추가되거나 제거될 때마다" 카운트 값을 수정해야 함.그런데 멀티스레드 환경에서는 여러 스레드가 동시에 같은 객체를 참조하거나 해제할 수 있음.
→ 따라서 카운트 값 변경이 원자적(atomic) 으로 일어나야 함
(동시에 두 스레드가 카운트 수정을 하면, 값이 꼬일 수 있기 때문!)참조 카운트 연산을 스레드 안전하게 만들려면
락(lock) 을 걸거나 원자적 증가/감소(atomic increment/decrement) 같은 CPU 수준 명령어를 써야 함.
→ 연산 병목이 되어 성능 저하 가능성 有#include <atomic> std::atomic<int> counter(0); // atomic counter 변수 선언 void incrementCounter() { counter++; // atomic하게 counter 변수를 증가시킵니다. } void decrementCounter() { counter--; // atomic하게 counter 변수를 감소시킵니다. }
현재 흔히 Garbage Collection라고 부르는 것으로, 프로그램 실행 중 특정 타이밍에 현재 할당된 모든 메모리를 조사하여 그것이 현재 접근 가능한지 불가능한지 분류한 뒤 접근이 불가능한 메모리를 해제하는 방식입니다. 개발자가 메모리 해제를 직접 신경 쓸 필요 없이, GC가 백그라운드에서 주기적으로 실행되어 사용되지 않는 객체를 정리합니다.
기법 | 특징 | 장점 | 단점 |
---|---|---|---|
수동 메모리 관리 | 개발자가 메모리 할당과 해제를 직접 처리 | 성능 최적화, 세밀한 제어 가능 | 메모리 누수, 댕글링 포인터 위험 |
참조 카운팅 | 객체가 참조될 때마다 카운트를 증가, 해제 시 감소 | 자동 메모리 관리, 단순성 | 순환 참조 문제, 성능 오버헤드 |
가비지 컬렉터 | 사용되지 않는 객체를 자동으로 회수 | 자동화된 메모리 관리, 순환 참조 처리 가능 | 성능 오버헤드, 예측 불가능한 정지 시간 |
수동 관리 방식에서는 메모리를 잘못 해제하거나 중복 해제하면 치명적인 오류가 발생할 수 있습니다.
여기서 중요한 개념들은 메모리 누수, 댕글링 포인터(Dangling Pointer), 이중 해제(Double Free) 오류입니다.
메모리 누수는 할당한 메모리를 해제하지 않아서 더 이상 사용하지 않는 메모리가 시스템에 남아 있는 상태입니다.
이런 메모리는 시스템의 메모리 리소스를 점차적으로 고갈시키며, 프로그램이 오래 실행되면 메모리 부족 문제를 일으킬 수 있습니다.
free()
또는 delete
호출을 잊어버리는 경우#include <stdio.h>
#include <stdlib.h>
void allocateMemory() {
int *arr = (int *)malloc(10 * sizeof(int)); // 메모리 할당
// 메모리를 해제하지 않음 -> 메모리 누수 발생
}
int main() {
allocateMemory();
return 0;
}
메모리를 할당한 후에는 반드시 free()
(C) 또는 delete
(C++)를 사용하여 해제해 주어야 합니다. 또한 예외가 발생할 수 있는 경우, 메모리 해제를 보장하는 코드를 작성해야 합니다.
댕글링 포인터는 이미 해제된 메모리를 가리키는 포인터입니다. 해당 메모리가 해제되었는데, 포인터가 여전히 그 주소를 가리키고 있는 상태를 말합니다.
댕글링 포인터를 통해 메모리에 접근하려 하면 프로그램 크래시, 데이터 손상 등 예기치 않은 동작이 발생할 수 있습니다.
free()
또는 delete
후, 그 메모리를 가리키던 포인터를 계속 사용하려 할 때#include <iostream>
void danglingPointerExample() {
int *ptr = new int(10); // 메모리 할당
delete ptr; // 메모리 해제
// 포인터는 여전히 해제된 메모리를 가리킴
std::cout << *ptr << std::endl; // 댕글링 포인터 사용 (예기치 않은 결과)
}
int main() {
danglingPointerExample();
return 0;
}
nullptr
로 설정하거나 NULL
로 초기화하여 더 이상 해당 메모리를 참조하지 않도록 해야 합니다.delete
또는 free()
후에는 포인터를 안전하게 nullptr
또는 NULL
로 설정하세요.delete ptr; // 메모리 해제
ptr = nullptr; // 댕글링 포인터 방지
이중 해제는 같은 메모리 블록을 두 번 이상 해제하려 할 때 발생하는 오류입니다. 이중 해제를 시도하면 undefined behavior가 발생하고, 프로그램이 예기치 않게 동작하거나 크래시할 수 있습니다.
free()
또는 delete
를 호출하는 경우delete
한 후 다른 포인터로 다시 delete
를 호출하는 경우#include <iostream>
void doubleFreeExample() {
int *ptr = new int(10); // 메모리 할당
delete ptr; // 첫 번째 해제
delete ptr; // 두 번째 해제 (이중 해제 시도) -> 정의되지 않은 동작
}
int main() {
doubleFreeExample();
return 0;
}
delete
나 free()
를 호출한 후에는 해당 포인터를 nullptr
로 설정하여, 다시 해제하지 않도록 해야 합니다.delete ptr;
ptr = nullptr; // 이중 해제 방지
C와 C++에서 메모리를 잘못 해제하거나 중복 해제하는 오류는 프로그램의 안정성을 심각하게 위협할 수 있습니다. 이를 방지하려면:
nullptr
로 설정하여 댕글링 포인터를 방지하고,메모리 관리를 철저히 하지 않으면 프로그램에서 예기치 않은 동작이나 크래시가 발생할 수 있습니다. 메모리 관련 오류를 추적하기 위한 도구(Valgrind, AddressSanitizer 등)를 사용하는 것도 좋은 방법입니다.
참조가 생성되고 제거될때마다 reference count를 증가,감소 시킨다면 오버헤드가 큽니다. 이를 감소시키기 위한 다양한 방법이 있습니다.
각 스레드가 자기만의 로컬 참조 카운트를 따로 가지고 있다가,
필요할 때만(ex. 객체를 해제할 때) 글로벌 카운트로 합쳐(sync)주는 방식
스레드 A: 로컬 카운트 +1
스레드 B: 로컬 카운트 +1
→ 필요할 때 전체 합쳐서 글로벌 카운트 = 로컬 A + 로컬 B
즉시 참조 카운트를 수정하지 않고 참조 변경 이벤트를 큐(queue)에 넣어두었다가,
나중에 한꺼번에 처리하는 방식
(참조 변경 발생) → (이벤트 큐에 기록) → (나중에 배치로 카운트 정리)
같은 객체에 대해 여러 번 참조 추가/해제를 빠르게 연속해서 할 경우,
변경사항을 합쳐서 한 번에 처리하는 방식
+1, +1, -1, +1, -1, -1 → 결과적으로 +0 → 아무것도 하지 않아도 됨
최적화 기법 | 핵심 아이디어 | 장점 | 단점 |
---|---|---|---|
Thread-Local Counting | 스레드마다 로컬 카운트 유지 | 빠른 참조 연산 | 합칠 때 복잡 |
Deferred Counting | 나중에 일괄 카운트 반영 | 참조 조작 최소화 | 해제 지연 가능성 |
Coalescing Updates | 참조 변경을 병합 처리 | 오버헤드 극소화 | 상황에 따라 불가능 |
Epoch Reclamation | 에포크 기반으로 안전 해제 | 락 없는 고성능 가능 | 구현 매우 복잡 |
참고로 현대 언어들에서도 이런 기법들이 실제로 쓰임
crossbeam
라이브러리에서 Thread-local 참조 카운팅 + 에포크 기반 메모리 정리 기법 제공std::shared_ptr
은 기본적으로 atomic 연산 기반인데, 최적화된 구현체는 로컬 캐시를 활용하기도 함.주요 언어별 메모리 관리 방식은 다음과 같습니다.
malloc
, calloc
, free
(C)new
, delete
(C++)gc
모듈로 수동 제어 가능borrow checker
가 데이터의 소유권 및 수명을 검사가비지 컬렉터도 여러 버전(G1 GC, ZGC, Shenandoah GC 등)이 있는데 다음 글에서는 각각에 대한 특징과 차이점을 정리해보고자 한다.
또한 Rust는 Ownership 기반의 수동 방식 메모리 관리 시스템을 사용한다고 하는데,
이에 대해 자세히 조사해보고자 한다.