약한 세대 가설
세대별 가비지 컬렉션은 새로 생성된 객체일 수록 수명이 짧을 확률이 높다는 것과 오래된 객체일 수록 앞으로도 계속 살아남을 확률이 높다는 것 그리고 오래된 객체가 새로운 객체를 참조하는 경우는 드물다라는 것과 같이 경험적으로 증명된 가설을 바탕으로 설계되었다.
- 가설 의의: 가비지 컬렉션은 전체 힙을 매번 뒤지는 대신, 새로운 객체가 모인 영역만 자주 청소함으로써 성능을 높인다.
세대별 구분과 특징
.NET의 관리되는 힙은 크게 3세대로 나뉜다.
-
0세대(Generation 0), 신생아 영역: 방금 new 키워드로 생성된 객체들이 대상이며, 가장 빈번하게 가비지 컬렉션이 발생한다. 메모리 할당 속도와 수집 속도가 빠르다. 이 세대에서 가비지 컬렉션이 일어날 때 대부분의 객체는 가비지로 간주되어 사라진다.
- 임계치: 보통 수 MB 정도로 작게 유지된다.
-
1세대(Generation 1), 완충 지대: 0세대 가비지 컬렉션에서 살아남은 객체들이 승격되어 이동하는 곳으로 0세대와 2세대 사이의 단기 보관소 역할을 한다. 0세대애서 살아남았지만 곧 죽을 가능성이 있는 객체들을 한 번 더 걸러내는 필터링 구역이기도 하다.
-
2세대(Generation 2), 장수 객체 영역: 1세대 가비지 컬렉션에도 살아남은 객체들이 대상이며, 수집 빈도가 가장 낮고 2세대 가비지 컬렉션이 발생하면 이를 풀 가비지 컬렉션이라고 부른다. 힙 전체를 검사하기 때문에 애플리케이션에 상당한 부하를 줄 수 있다.
객체의 승격 과정
객체는 가비지 컬렉션이 발생할 때 살아남으면 한 단계 높은 세대로 이동한다.
-
할당: 객체는 처음에 항상 0세대에 할당되며, 0세대 메모리가 꽉 차면 가비지 컬렉션이 가동된다. 살아남은 객체는 1세대로 승격된다. 그리고 1세대 메모리가 임계치에 도달하면 0세대와 1세대를 동시에 수집하여 살아남은 객체를 2세대로 승격시킨다.
-
정착: 2세대로 올라간 객체는 더 이상 올라갈 곳이 없으므로 2세대 가비지 컬렉션이 발생할 때까지 그 자리에 머문다.
-
주의: 승격은 객체를 메모리의 다른 위치로 복사하는 작업을 동반한다. 따라서 객체가 계속 살아남아 세대를 이동하는 것은 CPU 비용이 발생한다.
쓰기 장벽과 카드 테이블
오래된 객체는 새로운 객체를 참조하는 경우가 드물다. 만약 2세대에 있는 어떤 객체가 0세대의 객체를 참조하고 있고 0세대만 검사하고 싶은데, 2세대 객체들이 0세대를 가리키는지 확인하기 위해 2세대를 전부 뒤져야 한다면 세대별 구분은 의미가 없다. 따라서 이를 해결하기 위해 쓰기 장벽(Write Barrier)이라는 기술을 사용한다.
-
카드 테이블(Card Table): 힙 메모리를 특정 크기의 카드로 나눈 비트맵이다.
-
작동 원리: 2세대 객체가 0세대 객체를 참조하도록 코드가 실행되면, JIT 컴파일러는 쓰기 장벽 코드를 삽입하여 해당 위치의 카드 테이블 비트를 Dirty 기호로 표시한다.
-
이점: 0세대 가비지 컬렉션은 2세대 전체를 스캔하지 않고, 카드 테이블에서 Dirty 표시가 된 영역만 확인하여 0세대 객체의 생존 여부를 판단한다.
LOH
85,000 바이트 이상의 큰 객체는 0세대에 할당되지 않고 바로 LOH(Large Object Heap)이라는 특별한 영역에 할당된다.
-
이유: 큰 객체를 세대 간 복사하는 것은 비용이 너무 크다.
-
특징: LOH는 논리적으로 2세대의 일부로 취급한다. 초기 .NET에서는 LOH의 메모리 파편화를 정리하지 않았으나, 최신 .NET 버전에서는 옵션을 통해 LOH 압착이 가능해졌다.
동역학
가비지 컬렉션은 동적으로 세대별 크기를 조절한다. 만약 0세대 가비지 컬렉션 후에 살아남은 객체가 너무 많다면, 가비지 컬렉션은 애플리케이션의 객체 수명 자체가 길다고 판단하여 차후 0세대 임계치를 늘려 빈도를 줄인다. 반대로 살아남은 객체가 거의 없다면 임계치를 줄여 더 자주, 더 빠르게 메모리를 회수한다.