
C++과 C#의 가장 큰 차이점은 자동 메모리 관리 기능의 존재 여부이다. C++은 프로그래머가 메모리를 수동으로 관리를 해주어야 하는 반면에 C#에서는 이를 가비지 컬렉터(Garbage Collector)를 이용하여 자동으로 수행한다. 이번 포스트에서는 이에 대해 알아보자.
가비지 컬렉터는 CLR(Common Language Runtime)이라는 가상머신의 구성요소 중 하나이므로 이를 이해하기 위해서는 CLR도 일부 알고 있어야 한다.
CLR이란 C#에서 작성한 소스 코드를 OS 위에 있는 .NET Framework에서 작동하게 해주는 가상머신이다.

이렇게 CLR 내부에 가비지 콜렉터가 존재한다. CLR은 어떠한 프로세스가 실행되면 이를 관리하기 위해 인접한 메모리 주소에 관리 힙(Managed Heap)이라는 별도의 힙 메모리 공간을 만든다.

관리 힙은 주소를 가리키는 포인터도 함께 가진다. 이 포인터가 가르키는 주소를 기준으로 다음 차례에 할당되는 객체가 적재된다. 그리고 할당한 메모리만큼 이동하여 연속된 다음 주소를 가리킨다.

이때 힙 메모리를 참조하는 객체들은 루트 목록(Root Space)으로 관리되며, 동적으로 힙 메모리를 할당할 때마다 루트 목록에 주소가 저장된다.
루트 목록은 힙 메모리 영역을 참조하는 스택, 정적 데이터, 가비지 수집 핸들 등의 정보를 갖는다.
스택 메모리 영역은 함수가 실행되는 순간부터 종료까지 스택 프레임을 통하여 추적되고 관리된다. 하지만 힙 메모리 영역의 경우 동적으로 메모리를 할당한 후 조치를 해주지 않으면 계속 메모리에 올라온 상태로 유지가 된다. 그럼 낭비되는 메모리 공간이 점점 늘어나는 것이다.
예를 들어,
참조형 변수는 스택 메모리에 주소 값을 담아두고 힙 메모리에 접근하는 방식으로 저장되어 있다. 이 참조형 변수가 지역변수로 포함된 함수를 실행한다.
함수가 종료되고 지역 변수가 제거되면서 스택 메모리에서 힙 메모리를 가리키고 있는 일부 주소 값들이 같이 pop된다.
그러면 해당 주소값에 있는 힙 메모리의 데이터에는 더 이상 접근할 수 없는 상태가 된다. 이렇게 참조할 수 없게 된 객체를 가비지(Garbage)라고 부른다.
또 다른 예시를 보자
void static Main()
{
int i = 123; // a value type
object o = i; // boxing
int j = (int)o; // unboxing
}

Main함수가 끝나게 되면 o는 스택 메모리에서 pop된다. 그럼 이것이 가리키는 박싱된 i는 힙 메모리에서 가비지로 남아있게 되는 것이다. 그럼 이와 같은 가비지를 수거하는 과정은 어떻게 진행되는걸까?
기본적으로 Mark & Sweep 알고리즘이 사용된다.


실질적으로 오래 사용되는 객체가 있고 잠깐 사용되는 객체가 있다. 이를 고려하지 않고 매번 관리힙 전체를 순회하는 것은 프로세스 입장에서 상당히 부담되는 일이다.
그렇기에 가비지 컬렉터는 효율적인 수집을 위해 객체들에게 세대를 매긴다. 최근에 생성되어 가비지 컬렉션을 아직 겪은 적이 없는 객체를 0세대, 1번 겪었지만 해제되지 않은 객체를 1세대, 2번 겪었지만 해제되지 않은 객체를 2세대라 한다.

쓰레기가 나올 가능성이 높은 세대를 우선적으로 수집해주고 조건에 따라 다음 세대를 순차적으로 수집해주는 것이다. 가비지 컬렉션을 여러 차례 겪었는데도 메모리에 남아 있다면 계속 사용될 가능성이 높은 영역이므로 수집의 후순위로 두고, 그렇지 않은 영역을 먼저 주목하는 것이다. 따라서 할당된지 얼마 안 된 영역인 0세대 일수록 빈번하게 가비지 컬렉션이 일어난다.
기본적으로 가비지 컬렉팅은 0세대를 대상으로 먼저 시행된다.
그러고도 필요한 메모리를 확보할 수 없을 경우에 1세대까지 포함하여 시행, 1세대까지 포함하여도 메모리 확보가 불가할 때 2세대까지 포함하게 된다.
2세대까지 포함한 모든 가비지 컬렉팅 수행 시(Full GC) 프로세스를 일시정지하고 가비지 컬렉션을 우선적으로 수행하기 때문에, 순간적인 프레임 드랍이 일어나게 된다.
또한 GC는 객체를 그 크기에 따라 85,000바이트(85kb)보다 작으면 SOH(Small Of Heap), 크거나 같으면 LOH(Large Of Heap)로 구분한다. SOH는 위에서 언급한대로 할당 직후 0세대부터 시작하며, LOH의 경우 처음부터 2세대로 등록된다.
가비지 컬렉션을 겪고 생존한 객체를 중요하다고 판단하듯이, 크기가 큰 객체는 중요한 객체라고 판단하는 방식이다. 또한 가비지 컬렉팅 후 SOH들은 메모리 단편화를 없애기 위해 재배치되지만, LOH의 경우 옮기는 과정에 발생하는 오버헤드가 크기 때문에 가비지 컬렉션 후 이동되지 않는다.
자동으로 처리해준다 해도 메모리가 언제 해제되는지 시점을 정확하게 알 수 없어 제어하기 힘들다.
가비지 컬렉션이 동작하는 동안에는 관련 스레드 외 모든 스레드가 동작을 멈추기 때문에 오버헤드가 발생되는 문제점이 있다. (Stop The World 현상)
0세대는 단독으로 가비지 컬렉션이 일어날 수 있지만, 상위 세대는 그럴 수 없다. 2세대 가비지 컬렉션이 일어난다면 반드시 1세대와 0세대도 가비지 컬렉션이 일어난다. 이때 2세대 가비지 컬렉션이 일어나면 모든 세대가 GC의 대상이 되는 것이고, 관리 힙 전체에서 가비지 컬렉션이 일어나는 것이므로 이를 Full GC라 한다. Full GC가 되어 가비지 컬렉션이 수행되면 더 큰 오버헤드가 발생한다.
가비지 컬렉터도 CLR에서 실행되는 일종의 소프트웨어이므로 프로세스에서 사용하는 자원을 함께 공유하고 있다. 또, 루트 목록을 순회하고 메모리 영역을 압축시키며 가비지를 처리하는 과정 속에서 해당 프로세스의 작업을 일시중지 시킬 수 있다. 그렇기 때문에 가비지 컬렉션의 발생을 최소화 하는 일이 프로그램의 성능을 개선하는 일이다.
객체를 많이 할당하지 않는다. 메모리 영역이 가득 차 가비지 컬렉션이 애초에 일어나지 않도록 하는 것이 중요하다.
85kb가 넘는 큰 객체를 할당하면 LOH(Large Object Heap)이라는 특수 케이스로 분류되어 0세대가 아닌 2세대에 할당된다. 따라서 Full GC가 일어날 확률이 높아지므로 대형 객체 할당을 자제하는 것이 좋다.
참조 관계를 최소화한다. 참조 관계가 복잡해질수록 힙에 메모리가 남아 있을 확률이 높아지게 되고, GC를 호출할 확률이 높아진다. 또한, GC 이후 메모리 주소를 변경할 때 참조 관계에 있는 주소를 전부 변경해야 하는데, 이 과정에 대한 오버헤드도 커진다.
루트 목록을 작게하여 GC가 빠르게 끝나게 한다.
가비지 컬렉션 동작 원리 & GC 종류 💯 총정리
C# - 가비지 컬렉션 (Garbage Collection, GC)
[C#] 가비지 컬렉터(Garbage Collector)
[C#] 가비지 컬렉터
[펌] 가비지 컬렉터 (Garbage Collector) 의 원리, 동작 메커니즘