C# - GC(가비지 컬렉터)

김도현·2023년 11월 28일
0

TIL

목록 보기
67/76

Garbage Collector

객체지향 프로그래밍 언어는 메모리를 동적으로 할당한다는 개념이 존재하고, 런타임에 동적으로 할당된 메모리를 해제해줄 필요도 있다. C++는 Modern C++이 되어서야 스마트 포인터라는 기술의 등장으로 이를 비교적 나은 형태로 처리할 수 있게 되었지만, 아직 '자동'으로 해준다고 보기는 어렵다.

C#으로 작성한 코드는 .NET 위에서 실행되며 CLR을 통해 자동으로 필요없는 동적 메모리를 해제하는 GC(Garbage Collector)를 지원한다. 이번 포스팅에선 C#의 GC 메커니즘을 알아보자.

C#의 메모리 할당 방식

C# 어플리케이션은 실행되면 CLR이 해당 어플리케이션을 위한 메모리 공간을 제공하며, CLR에 의해 관리되기 때문에 이를 Managed Heap이라 부른다. 각 어플리케이션은 실행되면서 자신의 Managed Heap의 첫 주소를 가리키는 포인터를 가진다.

이때 힙 메모리를 참조하는 필드는 루트 목록으로 관리되며, 동적으로 힙 메모리를 할당할 때마다 루트 목록과 힙이 연결된다. Managed Heap의 포인터는 메모리를 할당해줄 때마다 할당한 메모리만큼 이동하여 연속된 다음 주소를 가리킨다.

이러한 식으로 주어진 힙 메모리를 차근차근 채워간다.

C#의 GC

각 어플리케이션의 Managed Heap은 그 공간이 유한하기 때문에 메모리 영역을 관리해주어야 한다. C# 어플리케이션의 메모리는 주어진 공간을 순차적으로 채우는 특징 때문에 할당을 반복하면 힙의 마지막 주소에 도달하게 된다. 이때 GC가 필요없는 메모리를 수집하여 해당 공간의 할당을 해제하는데, 이것이 Garbage Collection이다.

아직 빈 공간이 남아 있지만, 일정 수준 이상 메모리 공간이 차서 GC가 실행되는 환경이라고 가정하자.

위와 같이 메모리 할당이 이루어진 상태라고 할 때, 메모리 공간 B, E를 참조하는 루트가 없는 상황이다. 이때 B와 E는 Garbage로 취급되고, GC의 대상이 된다. C#의 GC는 다음의 과정으로 Garbage를 처리한다.

  1. Managed Heap의 모든 메모리 영역을 Garbage로 간주하여 초기화한다.
  2. 루트 목록을 돌며 루트가 가리키고 있는 영역과 그렇지 않은 영역을 구분한다. 이때 할당된 메모리 영역이 다른 영역을 가리킬 수도 있으며, 그 영역도 루트와 관련된 영역으로 취급한다.
  3. 루트와 관련 없는 영역만을 Garbage로 남기고, 해당 메모리 영역을 해제(Sweap)한다.
  4. Managed Heap을 순차적으로 탐색하며 해제되어 Empty가 된 영역을 채우도록 앞선 영역을 당겨 연속된 메모리 공간으로 만든다.

앞선 과정을 거쳐 B, E 영역이 제거된 Managed Heap은 위와 같이 정리된다.

이때 GC도 CLR 위에서 실행되는 일종의 소프트웨어이므로 작업할 때의 오버헤드가 발생한다. 루트 목록을 살피고 Managed Heap을 순회하는 것이 꽤나 큰 시간을 소요하게 될 수 있는데, 다른 어플리케이션의 메모리를 정리하는 것이므로 GC가 쓰레기를 수집하는 동안 해당 어플리케이션이 일시적으로 중지될 수 있다.

Generation of GC

위 문제를 개선하기 위해 도입된 것이 Generation 개념이다. 쓰레기가 나올 가능성이 높은 세대를 우선적으로 수집해주고 조건에 따라 다음 세대를 순차적으로 수집해주는 것이다. 그 기준은 가비지 컬렉션을 겪은 횟수이다. 가비지 컬렉션을 여러 차례 겪었는데도 메모리에 남아 있다면 계속 사용될 가능성이 높은 영역이므로 수집의 후순위로 두고, 그렇지 않은 영역을 먼저 주목하는 것이다. 따라서 할당된지 얼마 안 된 영역일수록 빈번하게 가비지 컬렉션이 일어난다.

처음 메모리를 할당한 영역은 0세대가 되고, GC의 최우선 대상이 된다.

1회의 가비지 컬렉션을 거치고 살아남은 메모리는 1세대로 승격되고, 새롭게 할당된 메모리가 0세대가 된다. 이후 GC는 0세대만 수집을 하게 된다. 0세대의 영역들을 수집해 살아남은 영역은 다음 세대로 승격되고, 다음 세대에 할당된 영역을 초과했을 때 0세대 이후의 영역에 가비지 컬렉션을 해주는 것이다. 전체 Managed Heap을 각 세대에 적절히 분배하고, 해당 영역에 Overflow가 일어났을 때 GC의 대상이 된다.

C#은 0 ~ 2세대가 존재하고, 2세대는 최소 2회 이상의 GC의 대상이 되었던 메모리 영역, 1세대는 0세대와 2세대의 사이 과도기에 존재하는 영역, 0세대는 한 번도 GC의 대상이 된 적 없는 신생 메모리 영역으로 분류된다.

GC의 효율을 높이는 방법

0세대는 단독으로 가비지 컬렉션이 일어날 수 있지만, 상위 세대는 그럴 수 없다. 2세대 가비지 컬렉션이 일어난다면 반드시 1세대와 0세대도 가비지 컬렉션이 일어난다. 이때 2세대 가비지 컬렉션이 일어나면 모든 세대가 GC의 대상이 되는 것이고, Managed Heap 전체에서 가비지 컬렉션이 일어나는 것이므로 이를 Full GC라 한다. GC의 핵심은 이 Full GC를 최소화하는 것이다.

따라서 Full GC를 방지하기 위한 주의점은 다음과 같다.

너무 많은 객체를 할당하지 않는다. 메모리 영역이 가득 차 GC가 애초에 일어나지 않도록 하는 것이 가장 중요하다.
매우 큰 객체를 할당하면 LOH(Large Object Heap)이라는 특수 케이스로 분류되어 0세대가 아닌 2세대에 할당된다. 따라서 Full GC가 일어날 확률이 높아지므로 대형 객체 할당을 자제하는 것이 좋다.
참조 관계를 최소화한다. 참조 관계가 복잡해질수록 힙에 메모리가 남아 있을 확률이 높아지게 되고, GC를 호출할 확률이 높아진다. 또한, GC 이후 메모리 주소를 변경할 때 참조 관계에 있는 주소를 전부 변경해야 하는데, 이 과정에 대한 오버헤드도 커진다.
루트 목록이 작을수록 GC가 빠르게 끝난다.

참고

  • [Youtube] 한빛미디어 [이것이 C#이다] 가비지 컬렉션
  • [Programming/C#] 가비지 컬렉션(Garbage Collection)
  • C#에서의 GC(Garbage Collector), 가비지 컬렉터
  • cedongne의 Velog

0개의 댓글