가비지 컬렉션
더 많은 작업
- Garbage collection; 가비지 컬렉션, 쓰레기 수집
- 프로그램이 더 이상 쓰지 않는 동적 메모리를 런타임이 자동으로 찾아 회수하는 메모리 관리 방식
가비지 컬렉션(garbage collection, GC)은 프로그램이 동적으로 할당했지만 더 이상 참조하지 않는 메모리(가비지, 쓰레기)를 런타임이 알아서 찾아 해제하는 자동 메모리 관리 방식이다. 이 일을 하는 구성 요소를 가비지 컬렉터(garbage collector)라고 한다. 우리말로 쓰레기 수집이라고도 한다. 존 매카시가 1959년 무렵 LISP의 리스트 메모리를 관리하려고 고안했고, 1960년 LISP 논문에 공개했다.[1]
C처럼 프로그래머가 malloc으로 할당하고 free로 직접 해제하는 언어에서는 사람의 실수가 곧 버그가 된다. 해제를 잊으면 메모리 누수, 해제한 메모리를 다시 쓰면 Use After Free, 두 번 해제하면 이중 해제(double free)가 생기고, 이는 단순한 비정상 종료를 넘어 원격 코드 실행 취약점으로 이어진다. 마이크로소프트는 자사가 CVE를 부여해 고치는 보안 취약점의 약 70%가 이런 메모리 안전성 문제라고 밝혔다.[2] 메모리 버그는 원인이 생긴 곳에서 멀리 떨어진 곳에서 증상이 나타나고 재현이 어려워 디버깅도 힘들다. GC는 프로그래머 대신 해제 시점을 정해 이런 부류의 오류를 없앤다.
대가는 성능이다. 컬렉터가 어떤 메모리를 해제할지 판단하는 데 CPU와 메모리를 쓰고, 방식에 따라 프로그램이 잠깐 멈춘다. 또 GC는 메모리만 관리할 뿐 파일, 소켓, 데이터베이스 연결, 창 핸들 같은 다른 자원은 따로 닫아야 한다. Java, C#, Go, JavaScript, Python, Ruby, Lisp, Haskell, OCaml 등 고수준 언어 대부분이 GC를 쓴다.
| 방식 | 해제 시점 | 순환 참조 | 실행 비용 | 멈춤 | 예 |
|---|---|---|---|---|---|
| 수동 관리 | 프로그래머가 정함 | 프로그래머 책임 | 가장 적음 | 없음 | C의 malloc/free, C++의 new/delete |
| 참조 카운팅 | 참조가 0이 되는 즉시 | 따로 처리해야 해제됨 | 대입할 때마다 조금씩 | 거의 없음(연쇄 해제 제외) | CPython, PHP, Swift·Objective-C의 ARC, C++ shared_ptr |
| 추적 GC | 컬렉터가 돌 때 | 자동 해제 | 모아서 한 번에 | 방식에 따라 수 밀리초 이하~수 초 | JVM, .NET, Go, V8, Ruby, Haskell |
| 소유권(컴파일 시점 결정) | 소유자가 유효 범위를 벗어날 때 | 참조 카운팅 타입을 쓰면 프로그래머 책임 | 거의 없음 | 없음 | Rust, C++ unique_ptr와 RAII |
엄밀히 말해 참조 카운팅도 자동 메모리 관리의 한 방식이어서 GC의 일종으로 분류하지만, 일상적으로 "GC"라고 하면 추적 GC를 가리키는 경우가 많다.
GC는 "앞으로 쓰이지 않을 객체"를 직접 알아낼 수 없다. 어떤 객체가 다시 쓰일지 정확히 판정하는 일반적인 알고리즘은 정지 문제로 귀결되어 존재하지 않는다. 그래서 대신 "더 이상 도달할 수 없는 객체"를 가비지로 본다.
- 루트(root): 언제나 살아 있다고 가정하는 참조의 집합이다. 현재 호출 중인 함수들의 지역 변수와 인자(호출 스택), 전역 변수와 정적 필드, CPU 레지스터, 네이티브 코드가 쥔 핸들 등이다.
- 도달 가능성(reachability): 루트에서 참조를 따라 닿을 수 있는 객체는 살아 있고, 닿을 수 없는 객체는 가비지다. 도달 가능성은 참조 관계의 추이적 폐포(transitive closure)다.
이 정의는 보수적이다. 다시는 쓰지 않을 객체라도 어딘가에서 참조가 남아 있으면 해제되지 않는다. 컬렉터가 수거하는 것은 프로그램이 문법적으로 닿을 수 없는 가비지(syntactic garbage)이고, 닿을 수는 있지만 실제로는 다시 쓰지 않는 의미상의 가비지(semantic garbage)는 수거하지 못한다. GC 언어에서도 메모리 누수가 생기는 이유다.
컬렉터가 메모리의 어떤 값이 포인터인지 정확히 알면 정밀(precise, exact) 컬렉터, 모르면 보수적(conservative) 컬렉터라 한다. 보수적 컬렉터는 포인터처럼 보이는 정수를 모두 참조로 취급하므로 일부 가비지를 놓치지만, 타입 정보가 없는 C·C++에도 쓸 수 있다. Boehm-Demers-Weiser GC가 대표적이다.
참조 카운팅(reference counting)은 객체마다 자신을 가리키는 참조의 개수를 세어 두고, 참조가 생기면 1을 더하고 사라지면 1을 빼서 0이 되는 순간 해제하는 방식이다. 조지 E. 콜린스가 1960년에 발표했다.[3]
장점은 다음과 같다.
- 마지막 참조가 사라지는 즉시 해제되어 해제 시점이 결정적이다. 소멸자에서 자원을 닫는 코드를 믿고 쓸 수 있어 C++, Python, Swift처럼 소멸자가 있는 언어와 잘 맞는다.
- 일이 조금씩 분산되어 긴 멈춤이 없다.
- 이미 캐시에 있는 객체만 건드리므로 메모리 계층에 주는 부담이 적다.
- 구현이 단순해 C 라이브러리에서도 흔하다. 파일 시스템의 inode 링크 수(0이 되면 파일이 사라짐), 윈도우 커널 오브젝트의 참조 수(
ObDereferenceObject로 줄임), COM의AddRef/Release, GObject, 안드로이드 프레임워크 C++ 코드의 RefBase가 모두 참조 카운팅이다.
단점도 뚜렷하다.
- 순환 참조: A가 B를, B가 A를 가리키면 바깥에서 아무도 둘을 참조하지 않아도 카운트가 0이 되지 않아 영원히 남는다.
- 대입마다 드는 비용: 참조를 복사하거나 버릴 때마다 카운트를 고쳐야 한다. 특히 감소 쪽은 0인지 검사하고 해제 함수를 부르는 분기가 붙는다.
- 원자적 연산: 여러 스레드가 공유하는 객체는 카운트를 원자적으로 고쳐야 해 멀티코어에서 비싸다. CPython의 GIL도 이 문제와 얽혀 있어, 자유 스레드 빌드는 편향 참조 카운팅(biased reference counting)을 쓴다.[4]
- 연쇄 해제: 거대한 자료 구조의 마지막 참조가 끊기면 그 아래 객체가 줄줄이 해제되면서 예측하기 어려운 긴 작업이 생길 수 있다.
- 객체마다 카운트를 저장할 공간이 필요하다.
순환 참조를 푸는 방법은 두 가지다. 하나는 약한 참조(weak reference)다. 카운트를 올리지 않는 참조로, 대상이 해제되면 null(nil)로 바뀌어 매달린 포인터가 되지 않는다. 부모와 자식이 서로 가리킬 때 자식이 부모를 약하게 가리키게 하는 식이다. C++의 weak_ptr, Swift의 weak와 unowned, Rust의 Weak가 이것이다. 다른 하나는 순환만 찾는 추적 컬렉터를 보조로 두는 것이다. CPython의 gc 모듈, PHP 5.3부터의 순환 수집기(Bacon과 Rajan의 알고리즘을 따른 동기식 방식)가 그렇다.[5] Perl 5는 순환 수집기가 없어 약한 참조(weaken)로 직접 끊어야 한다.
추적 GC(tracing garbage collection)는 루트에서 참조를 따라가며 살아 있는 객체를 모두 찾고, 나머지를 가비지로 회수한다. 가장 널리 쓰이는 방식이다.
가장 단순한 추적 방식이다. 표시(mark) 단계에서 루트부터 그래프를 순회하며 닿는 객체마다 표시 비트를 켜고, 쓸기(sweep) 단계에서 힙 전체를 훑으며 표시되지 않은 객체를 해제한다.
def collect(roots, heap):
stack = list(roots)
while stack: # 표시 단계
obj = stack.pop()
if not obj.marked:
obj.marked = True
stack.extend(obj.references)
for obj in heap: # 쓸기 단계
if obj.marked:
obj.marked = False # 다음 주기를 위해 지움
else:
heap.free(obj)
단순한 구현은 표시하는 동안 프로그램이 참조를 바꾸면 결과가 틀어지므로 프로그램 전체를 멈추고(stop-the-world) 수집한다. 객체를 옮기지 않으므로 해제된 자리가 여기저기 흩어져 메모리 단편화가 생긴다.
표시한 뒤 살아 있는 객체를 힙 한쪽으로 밀어 모으고(compact), 옮긴 객체를 가리키던 모든 참조를 새 주소로 고친다. 단편화가 없어지고 이후 할당은 빈 공간의 시작 포인터만 올리면 되는 범프 할당(bump allocation)이 된다. 대신 객체를 옮기고 참조를 고치는 비용이 든다. .NET의 GC가 수집 뒤 압축하며, 큰 객체는 옮기는 비용 때문에 보통 압축하지 않는다.[6]
힙을 같은 크기의 두 공간(from 공간, to 공간)으로 나누고 한쪽에만 할당한다. 그쪽이 차면 살아 있는 객체만 반대쪽으로 복사하고, 원래 공간은 통째로 비운다. 반공간(semi-space) 방식은 1969년 Fenichel과 Yochelson이 제시했고, 1970년 체니(C. J. Cheney)가 복사한 영역 자체를 너비 우선 탐색의 큐로 써서 재귀 없이 복사하는 알고리즘을 발표했다.[7]
- 장점: 살아 있는 객체 수에만 비례해 일하고, 복사가 끝나면 단편화가 없으며, 서로 참조하는 객체가 가까이 놓여 캐시 효율이 좋다. 할당은 포인터만 올리면 되어 스택 할당만큼 빠르다.
- 단점: 메모리를 두 배로 잡아야 하고, 객체 주소가 바뀌므로 포인터 연산을 허용할 수 없다. 네이티브 코드에 객체 주소를 넘겨야 할 때는 객체를 고정(pinning)해 옮기지 않게 한다.
Scheme 구현 CHICKEN은 이 방식을 특이하게 쓴다. C로 컴파일된 함수가 반환하지 않고 계속 호출만 해서 C 스택에 객체를 쌓다가, 스택이 차면 살아 있는 객체를 힙으로 복사하고 longjmp로 스택을 비운다. 헨리 베이커가 1995년에 제안한 "Cheney on the M.T.A." 기법으로, 스택이 곧 젊은 세대 역할을 하고 C에 없는 꼬리 호출 최적화도 얻는다.
많은 프로그램에서 새로 만든 객체는 대부분 금방 버려지고, 오래 살아남은 객체는 계속 산다. 이를 세대 가설(generational hypothesis)이라 한다. 세대별 GC는 힙을 나이에 따라 젊은 세대(young, new)와 늙은 세대(old, tenured)로 나누고, 가비지가 많은 젊은 세대만 자주 수집한다.[8] 젊은 세대만 수집하는 것을 마이너 GC, 전체를 수집하는 것을 메이저 GC(또는 전체 GC)라고 한다.
데이비드 웅가의 세대 청소(generation scavenging, 1984)는 오늘날 JVM 설계의 원형이다. 젊은 세대를 큰 에덴(eden)과 작은 생존자 공간(survivor space) 둘로 나누고, 마이너 GC 때마다 에덴과 한쪽 생존자 공간의 살아 있는 객체를 다른 생존자 공간으로 복사한다. 여러 번 살아남은 객체는 늙은 세대로 승격(tenuring)한다.[9]
세대별 GC의 핵심 과제는 늙은 세대에서 젊은 세대를 가리키는 참조다. 마이너 GC는 늙은 세대를 훑지 않으므로, 이런 참조를 따로 기록해 두었다가(remembered set, 카드 테이블) 루트처럼 취급한다. 기록은 참조를 쓸 때마다 실행되는 쓰기 장벽이 맡는다. 이 참조가 너무 많으면 기록 비용이 커지고, 실제로는 죽은 늙은 객체가 젊은 객체를 붙잡아 함께 살아남는 문제도 생긴다. 다행히 보통 프로그램에서는 이런 참조가 드물다. 불변 자료만 쓰는 순수 함수형 언어에서는 오래된 객체가 새 객체를 가리킬 수 없어 이 부담이 거의 없다.
세대 수와 경계는 구현마다 다르다. .NET은 0, 1, 2세대 셋을 두고, 85,000바이트 이상인 큰 객체는 처음부터 별도의 큰 객체 힙(LOH)에 넣어 2세대와 함께 수집한다.[6] 큰 객체는 옮기는 비용이 크고 오래 사는 경향이 있기 때문이다.
| 방식 | 설명 |
|---|---|
| 전체 정지(stop-the-world) | 수집하는 동안 프로그램 스레드를 모두 멈춘다. 구현이 쉽고 총처리량이 좋지만 멈춤이 길다 |
| 병렬(parallel) | 멈춘 동안 여러 스레드가 나눠서 수집해 멈춤 시간을 줄인다 |
| 증분(incremental) | 수집을 잘게 쪼개 프로그램 실행 사이사이에 조금씩 한다 |
| 병행(concurrent) | 프로그램이 도는 동안 별도 스레드가 수집한다. 스택 스캔 같은 짧은 구간에서만 멈춘다 |
증분·병행 방식은 멈춤이 짧은 대신 전체 작업량이 늘고, 수집 도중 프로그램(뮤테이터, mutator)이 참조를 바꾸는 것을 추적하는 장벽이 필요해 총처리량이 줄어든다. 에츠허르 데이크스트라와 레슬리 램포트 등이 1978년 발표한 온더플라이(on-the-fly) 수집은 컬렉터가 프로그램과 동시에 도는 최초의 병행 GC 알고리즘으로 꼽힌다.[10] 같은 해 헨리 베이커는 읽기 장벽을 이용한 실시간 복사 수집을 발표했다.[11]
증분·병행 표시를 설명하는 표준 틀이 삼색 표시(tri-color marking)다. 객체를 세 집합으로 나눈다.
- 흰색: 아직 도달 여부를 모르는 객체. 수집이 끝났을 때 흰색이면 가비지다.
- 회색: 도달 가능하다고 확인했지만, 그 객체가 가리키는 객체들은 아직 살펴보지 않은 객체.
- 검은색: 도달 가능하고, 가리키는 객체도 모두 살펴본 객체.
처음에는 루트가 직접 가리키는 객체를 회색으로, 나머지를 흰색으로 둔다. 회색 객체를 하나 골라 그것이 가리키는 흰색 객체를 회색으로 만들고 자신은 검은색으로 바꾸는 일을 회색이 없어질 때까지 반복한다. 회색 집합만 남겨 두면 언제든 중단했다가 이어 갈 수 있어 증분 수집이 된다. 이 과정은 "검은색 객체는 흰색 객체를 직접 가리키지 않는다"는 삼색 불변식을 지킨다.
문제는 표시 도중 프로그램이 참조를 바꿀 때다. 예를 들어 이미 검은색인 A가 흰색 C를 새로 가리키게 되고, C를 가리키던 유일한 회색 객체의 참조가 지워지면 C는 살아 있는데도 흰색으로 남아 해제된다. 이를 막으려고 참조를 쓸 때 끼어드는 쓰기 장벽(write barrier)이나 읽을 때 끼어드는 읽기 장벽(read barrier)을 둔다.
- 삽입 장벽(데이크스트라 방식): 새로 저장하는 대상 객체를 회색으로 칠한다. 위 예에서 C가 회색이 되어 살아남는다.
- 삭제 장벽(유아사 방식, SATB): 지워지는 옛 참조의 대상을 회색으로 칠해 "표시를 시작한 순간의 스냅숏"을 지킨다. G1과 Shenandoah가 이 계열을 쓴다.
- 읽기·적재 장벽: 참조를 읽을 때 객체가 옮겨졌는지 확인하고 새 주소로 고친다. 객체를 옮기면서도 프로그램을 멈추지 않는 ZGC 같은 병행 압축 컬렉터가 쓴다.
보통 읽기가 쓰기보다 훨씬 잦으므로 장벽은 쓰기 쪽에 두는 경우가 많다. 참조를 고쳐 쓴 결과 이미 끊긴 객체가 이번 주기에 살아남을 수는 있지만(떠다니는 가비지), 다음 주기에 회수된다.
OpenJDK HotSpot은 용도에 따라 여러 컬렉터를 제공한다.
| 컬렉터 | 특징 | 이력 |
|---|---|---|
| Serial | 단일 스레드, 세대별, 전체 정지 | 작은 힙과 단일 코어용 |
| Parallel | 여러 스레드로 전체 정지 수집, 처리량 중심 | JDK 8까지 서버 기본값 |
| CMS(Concurrent Mark Sweep) | 늙은 세대를 병행 표시·쓸기, 압축하지 않음 | JDK 9에서 폐지 예고(JEP 291), JDK 14에서 제거(JEP 363)[12] |
| G1(Garbage-First) | 힙을 같은 크기 영역(region)으로 나누고 가비지가 많은 영역부터 수집, 멈춤 목표 시간을 지정 | 2004년 논문[13], JDK 9부터 기본값(JEP 248)[14] |
| ZGC | 적재 장벽과 색칠된 포인터로 객체를 병행 이동, 멈춤이 힙 크기와 무관하게 1밀리초 안팎 | JDK 11 실험(JEP 333), JDK 15 정식(JEP 377), JDK 21 세대별 모드(JEP 439), JDK 23 세대별이 기본(JEP 474), JDK 24 비세대 모드 제거(JEP 490)[15][16] |
| Shenandoah | 병행 압축으로 멈춤을 힙 크기와 무관하게 유지, Red Hat이 개발 | JDK 12 실험(JEP 189), JDK 15 정식(JEP 379), 세대별 모드 JDK 24 실험(JEP 404)과 JDK 25 정식(JEP 521)[17][18] |
| Epsilon | 할당만 하고 회수하지 않는 무동작 컬렉터. 성능 측정과 수명이 아주 짧은 작업용 | JDK 11(JEP 318) |
이 밖에 IBM의 실시간 컬렉터 Metronome, Azul의 C4 같은 상용 컬렉터가 있다. Java는 약한 참조를 세분해 메모리가 모자랄 때만 회수되는 SoftReference, 일반 약한 참조 WeakReference, 객체가 회수된 뒤 알림만 받는 PhantomReference를 제공한다. 객체가 수거될 때 불리는 finalize()는 실행 시점과 실행 여부가 보장되지 않고 GC 성능을 떨어뜨려 JDK 18에서 제거 예정 폐지가 되었으며(JEP 421), 대신 try-with-resources와 Cleaner를 쓴다.[19]
.NET CLR의 GC는 세대별(0, 1, 2세대) 마크 컴팩트 방식이다. 수집을 시작하면 수집을 일으킨 스레드를 빼고 관리 스레드를 모두 멈춘 뒤, 표시, 참조 재배치, 압축 단계를 거친다. 루트는 스택 변수, 정적 필드, CPU 레지스터, GC 핸들, 종료 큐다.[6] 클라이언트용 워크스테이션 GC와 코어마다 힙을 두는 서버 GC가 있고, 2세대를 백그라운드에서 수집하는 백그라운드 GC가 있다. 관리되지 않는 자원은 IDisposable과 using 문으로 명시적으로 해제한다.
Go의 GC는 추적, 비이동(non-moving), 병행 마크 앤 스위프 방식이며 세대를 나누지 않는다. 표시 단계에서 쓰기 장벽을 쓴다. GOGC(기본값 100)가 다음 수집을 시작할 힙 크기를 정하고, Go 1.19부터 GOMEMLIMIT으로 전체 메모리 상한(소프트 한도)을 줄 수 있다.[20] Go 1.5(2015)에서 병행 컬렉터로 바뀌며 멈춤이 크게 줄었다.[21] Go 1.25에서 객체 대신 메모리 페이지 단위로 훑어 캐시 효율을 높인 Green Tea 컬렉터가 실험 기능으로 들어왔고, Go 1.26에서 기본값이 되었다.[22][23]
CPython은 참조 카운팅이 기본이고, gc 모듈의 순환 수집기가 이를 보조한다. 순환 수집기는 컨테이너 객체만 추적하며, 순환 참조를 만들지 않는다고 확신하면 끌 수 있다.[24] 객체를 살아남은 횟수에 따라 세 세대로 나누고, 할당 수에서 해제 수를 뺀 값이 문턱값을 넘으면 수집한다. gc.freeze()는 지금까지의 객체를 영구 세대로 옮겨, fork()한 자식 프로세스에서 쓰기 시 복사(copy-on-write)가 일어나지 않게 한다. Python 3.14.0에서 순환 수집기가 두 세대의 증분 방식으로 바뀌었다가, 운영 환경에서 메모리 사용량이 크게 늘었다는 보고가 이어져 3.14.5부터 3.13의 세대별 방식으로 되돌아갔다.[25] PyPy는 참조 카운팅 없이 세대별 추적 GC를 쓴다.
JavaScript 명세는 GC 방식을 정하지 않으며 엔진마다 다르다. V8의 Orinoco 프로젝트는 GC를 순차적인 전체 정지 방식에서 대부분 병렬·병행으로 바꾸었다. 젊은 세대는 병렬 스캐빈저(복사 수집)로, 늙은 세대는 배경 스레드에서 병행 표시한 뒤 마크 컴팩트로 수집하고, 브라우저가 프레임을 그리고 남는 유휴 시간에 GC 작업을 끼워 넣는다.[26] 언어 차원에서는 WeakMap, WeakSet, ES2021의 WeakRef와 FinalizationRegistry가 GC와 상호작용하는 기능이다.
Apple 플랫폼은 추적 GC 대신 컴파일러가 참조 카운팅 코드를 넣는 ARC(Automatic Reference Counting)를 쓴다. ARC는 컴파일할 때 retain과 release 호출을 알맞은 자리에 삽입하는 방식이어서, 실행 중에 도는 컬렉터가 없고 해제 시점이 결정적이다.[27] Swift에서 ARC는 클래스 인스턴스에만 적용되고 구조체와 열거형 같은 값 타입은 해당하지 않는다. 강한 참조 순환은 weak(대상이 해제되면 nil로 바뀌는 옵셔널)나 unowned로 끊어야 한다.[28] Objective-C는 2007년 Mac OS X 10.5에서 추적 GC를 도입했지만, 2011년 Xcode 4.2와 함께 ARC가 나온 뒤 OS X 10.8에서 GC가 폐지 예고되었고,[27] iOS에는 처음부터 GC가 없었다.
Rust는 GC가 없다. 모든 값에는 소유자가 하나 있고, 소유자가 유효 범위를 벗어나면 컴파일러가 넣은 해제 코드(drop)가 실행된다. 값은 기본적으로 복사가 아니라 이동되며, 빌려준 참조는 원래 값보다 오래 살 수 없다는 규칙을 컴파일러의 빌림 검사기가 확인한다. 이 덕분에 실행 비용 없이 Use After Free와 이중 해제를 컴파일 시점에 막지만, 규칙이 엄격해 배우기 어렵다. 여러 소유자가 필요하면 참조 카운팅 타입 Rc(단일 스레드), Arc(원자적)와 Weak를 쓰며, 이때 순환 참조는 여전히 누수가 된다.[29]
C++은 생성자에서 자원을 얻고 소멸자에서 해제하는 RAII 관용구와 스마트 포인터로 수동 해제를 대신한다. unique_ptr는 단일 소유, shared_ptr는 참조 카운팅, weak_ptr는 카운트를 올리지 않는 참조다. RAII는 예외가 나도 소멸자가 불려 누수를 막는다. C++11은 표준 라이브러리에 최소한의 GC 지원(포인터 안전성 관련 함수)을 넣었지만 실제로 구현한 컴파일러가 없어 C++23에서 제거되었다.[30] 필요하면 Boehm GC 같은 라이브러리를 쓴다. 언리얼 엔진은 C++ 위에 자체 GC를 두어, UPROPERTY로 노출된 참조를 리플렉션으로 따라가는 도달 가능성 분석으로 UObject를 회수한다.[31]
| 언어·런타임 | 방식 |
|---|---|
| Erlang(BEAM) | 프로세스마다 힙이 따로 있고, 체니 알고리즘 기반 세대별 복사 수집으로 프로세스별로 수집한다. 전역 정지가 없어 한 프로세스의 GC가 다른 프로세스를 멈추지 않는다[32] |
| Lua 5.4 | 증분 마크 앤 스위프(기본)와 세대별 모드 중 선택[33] |
| Ruby(CRuby) | 세대별·증분 마크 앤 스위프, 압축 지원 |
| Kotlin/Native | 초기에는 지연 참조 카운팅이었으나 1.7.20부터 기본인 새 메모리 관리자는 세대 구분 없는 병행 마크 앤 스위프 추적 GC다[34] |
| PHP | 참조 카운팅 + 순환 수집기(5.3부터) |
| Haskell(GHC), OCaml | 세대별 복사 수집. OCaml은 마이너 힙과 메이저 힙으로 나눈다 |
| D | GC가 기본이지만 수동 해제나 GC 비활성화를 허용 |
| BASIC, Logo | 문자열·리스트 같은 가변 길이 자료에 GC를 썼다. Applesoft BASIC의 문자열 GC는 알고리즘이 비효율적이어서 몇 초에서 몇 분까지 멈추는 일이 있었다 |
Ada, Modula-3, C++/CLI처럼 수집되는 힙과 수동 관리 힙을 따로 두어 두 방식을 함께 쓰는 언어도 있다.
- 성능 비용: GC는 어떤 방식이든 실행 중에 일을 하므로 비용이 없을 수 없다. 참조 카운팅은 비용이 늘 조금씩 들고, 추적 GC는 비용을 모아 한꺼번에 치르며 그 간격과 길이만 조절할 수 있다. 2005년 연구는 세대별 컬렉터가 이상적인 수동 관리와 같은 속도를 내려면 힙이 약 5배 필요하고, 3배면 평균 17%, 2배면 거의 70% 느려진다고 측정했다.[35] 다만 비교 대상은 프로파일로 최적 해제 시점을 찾아 넣은 가상의 프로그램이다.
- 예측하기 어려운 멈춤: 게임, 오디오, 금융 거래, 실시간 운영체제 위의 제어 프로그램처럼 지연에 민감한 분야에서는 GC 멈춤이 문제가 된다. 저지연 컬렉터, 할당을 줄이는 코딩(객체 풀), GC 없는 언어로 대응한다.
- GC가 있어도 생기는 누수: 컬렉션에 계속 쌓기만 하는 캐시, 해제하지 않은 이벤트 리스너, 정적 필드에 남은 참조, 클로저가 붙잡은 큰 객체처럼 도달 가능한 채로 버려진 객체는 회수되지 않는다. 약한 참조 기반 자료 구조(WeakHashMap, WeakMap)나 명시적인 해제 호출로 막는다.
- 메모리 외 자원: 파일, 소켓, DB 연결, 쓰기 버퍼는 GC만 믿으면 안 된다. 종료자(finalizer)는 실행 시점이 늦고, 대부분의 언어가 실행 자체를 보장하지 않으며, 프로그램이 끝날 때는 GC를 돌리지 않고 운영체제가 메모리를 거두는 경우가 많아 버퍼를 비우지 못할 수 있다. 종료자 안에서 교착 상태가 생기면 복구가 어렵다. Java의 try-with-resources, C#의 using, Python의 with, Go의 defer로 명시적으로 닫는다.
- 네이티브 코드 연동: 객체를 옮기는 컬렉터는 네이티브 코드에 넘긴 주소를 무효로 만들 수 있으므로 고정하거나 핸들을 거쳐야 한다.
- 탈출 분석: JIT와 컴파일러는 함수 밖으로 나가지 않는 객체를 스택에 할당하거나 아예 없애 GC 부담을 줄인다. Go 컴파일러와 HotSpot C2가 이렇게 한다.
- 임베디드와 실시간 환경: 자원이 적은 기기에서는 GC를 드물게 쓰지만, Java ME, .NET nanoFramework처럼 GC를 갖춘 임베디드 플랫폼도 있다.
| 연도 | 사건 |
|---|---|
| 1959~1960 | 존 매카시가 LISP에 GC를 도입하고 논문으로 발표[1]. LISP은 GC를 넣은 첫 언어다 |
| 1960 | 조지 콜린스가 참조 카운팅 발표 |
| 1969 | Fenichel과 Yochelson의 반공간 복사 수집 |
| 1970 | 체니의 비재귀 복사 알고리즘 |
| 1978 | 데이크스트라 등의 온더플라이 병행 수집, 베이커의 실시간 복사 수집 |
| 1983~1984 | 리버먼과 휴잇의 세대별 수집 제안, 웅가의 세대 청소(Berkeley Smalltalk) |
| 1988 | Boehm과 Weiser의 C용 보수적 GC |
| 1995 | GC를 전제로 한 Java 출시 |
| 2004 | G1 논문 |
| 2007, 2011 | Objective-C 2.0 GC 도입, 이어 ARC 도입 |
| 2015 | Go 1.5 병행 컬렉터 |
| 2017~2020 | JDK의 G1 기본화(9), ZGC(11), Shenandoah(12), CMS 제거(14) |
| 2019 | V8 Orinoco 발표 |
| 2023 | C++23에서 GC 지원 제거, JDK 21 세대별 ZGC |
| 2026 | Go 1.26에서 Green Tea GC 기본값 |
- ↑ 1.0 1.1 John McCarthy, "Recursive Functions of Symbolic Expressions and Their Computation by Machine, Part I", CACM 3(4), 1960
- ↑ MSRC, "We need a safer systems programming language", 2019-07-18
- ↑ George E. Collins, "A method for overlapping and erasure of lists", CACM 3(12), 1960
- ↑ PEP 703: Making the Global Interpreter Lock Optional in CPython
- ↑ PHP Manual, Collecting Cycles
- ↑ 6.0 6.1 6.2 Microsoft Learn, Fundamentals of garbage collection
- ↑ C. J. Cheney, "A nonrecursive list compacting algorithm", CACM 13(11), 1970
- ↑ Henry Lieberman, Carl Hewitt, "A real-time garbage collector based on the lifetimes of objects", CACM 26(6), 1983
- ↑ David Ungar, "Generation Scavenging: A non-disruptive high performance storage reclamation algorithm", 1984
- ↑ Dijkstra, Lamport, Martin, Scholten, Steffens, "On-the-fly garbage collection: an exercise in cooperation", CACM 21(11), 1978
- ↑ Henry G. Baker, "List processing in real time on a serial computer", CACM 21(4), 1978
- ↑ JEP 363: Remove the Concurrent Mark Sweep (CMS) Garbage Collector
- ↑ Detlefs, Flood, Heller, Printezis, "Garbage-first garbage collection", ISMM 2004
- ↑ JEP 248: Make G1 the Default Garbage Collector
- ↑ JEP 439: Generational ZGC
- ↑ JEP 490: ZGC: Remove the Non-Generational Mode
- ↑ JEP 379: Shenandoah: A Low-Pause-Time Garbage Collector (Production)
- ↑ JEP 521: Generational Shenandoah
- ↑ JEP 421: Deprecate Finalization for Removal
- ↑ A Guide to the Go Garbage Collector
- ↑ The Go Blog, "Go GC: Prioritizing low latency and simplicity", 2015
- ↑ The Go Blog, "The Green Tea Garbage Collector"
- ↑ Go 1.26 Release Notes
- ↑ Python documentation, gc: Garbage Collector interface
- ↑ What's New In Python 3.14
- ↑ V8 blog, "Trash talk: the Orinoco garbage collector", 2019
- ↑ 27.0 27.1 Apple, Transitioning to ARC Release Notes
- ↑ The Swift Programming Language, Automatic Reference Counting
- ↑ The Rust Programming Language, What Is Ownership?
- ↑ P2186R2: Removing Garbage Collection Support
- ↑ Epic Games, Unreal Object Handling in Unreal Engine
- ↑ Erlang/OTP, Erlang Garbage Collector
- ↑ Lua 5.4 Reference Manual, 2.5 Garbage Collection
- ↑ Kotlin documentation, Kotlin/Native memory management
- ↑ Matthew Hertz, Emery D. Berger, "Quantifying the performance of garbage collection vs. explicit memory management", OOPSLA 2005