Skip to main content

[C#] 가비지 컬렉션




C, C++의 경우 #

  • C, C++에서는 프로그래머가 수동으로 Heap 영역의 해제를 관리해주어야 한다.
  • 또한 Heap에 객체를 할당하기 위해 비싼 비용을 치루어야한다.
    • C, C++기반의 프로그램을 실행하는 C-런타임은 메모리를 여러 블록으로 나눈 뒤 이 블록들을 링크드 리스트로 묶어서 관리 한다. 따라서 객체를 메모리에 할당하기 위한 코드가 실행되면, 링크드 리스트를 순차적으로 탐색하면서 여유 있는 블록을 찾고, 찾으면 블록을 쪼개서 객체를 할당하고 링크드 리스트를 재조정한다.
    • 따라서 단순히 객체 할당을 하는 것이 아니라 오버헤드가 발생한다.



C#의 경우 #

  • C#은 CLR이 자동으로 메모리를 관리 해준다.
    • 관리형 코드
      • C#으로 작성된 모든 코드는 CLR에 의해 관리된다. CLR은 메모리 할당, 보안, 스레딩, 쓰레기 수거 등의 업무를 한다.
    • 비관리형 코드
      • unsafe 키워드를 이용하면 CLR이 제공하는 서비스를 받을 수 없다.
  • CLR은 객체 할당이 전부이다.
    • C-런타임처럼 메모리를 쪼개지 않고 메모리 공간을 통째로 확보해서 하나의 관리되는 힙(Managed Heap)을 마련한다. 그리고 다음 객체를 할당할 주소를 가리키는 포인터를 옮겨가면서 순서대로 객체를 할당한다.
    • C#의 메모리 할당 방식은 C, C++과 달리 리스트를 탐색하거나 재조정하는 과정이 불필요하기 때문에 속도가 훨씬 빠르다.
  • CLR이 메모리를 관리하는 방법
    • 참조형식의 객체가 할당될 때는 스택영역에 힙의 메모리 주소(a)가, 힙 영역에 실제 값(A)이 할당된다.
    • (a)처럼 할당된 메모리의 위치를 참조하고 있는 객체를 일컬어 루트(Root) 라고 부른다. 루트는 스택에 생성될 수도 있고, 정적 필드처럼 힙에 생성될 수도 있다.
    • .NET 애플리케이션이 실행되면 JIT 컴파일러는 루트들을 목록으로 만들고, CLR이 이 루트 목록을 관리하며 상태를 갱신한다. 가비지 컬렉터는 이 루트 목록을 참조해서 쓰레기 수집을 한다.
    • 여기서 만약에 스택 영역의 메모리가 회수되면 힙 영역 값이 쓰레기가 된다.

가비지 컬렉터 동작 순서 #

  • 가비지 컬렉터는 힙 영역의 임계치에 다다르면루트 목록을 참조해서 쓰레기를 수집한다.
    1. 모든 객체가 쓰레기라고 가정한다.
    2. 루트 목록을 순회하면서 참조하고 있는 힙 객체와 관계 여부를 조사한다. 어떤 루트와도 관계가 없다면 쓰레기로 간주한다.
    3. 쓰레기가 차지하고 있던 메모리를 회수하고 인접 객체들을 이동시켜서 차곡차곡 채워서 정리(Memory Compaction)한다.

세대별 가비지 컬렉션 #

  • CLR의 메모리는 구역을 나눠서 관리한다.
    • 0세대, 1세대, 2세대로 분리하여서 0세대는 빨리 사라질 객체, 2세대는 오래 남아있을 객체를 위치시킨다.
    • 프로그램을 실행하면 0세대부터 할당된 객체들이 차오르기 시작한다.
    1. 0세대 가비지 컬렉션 임계치에 도달하면 0세대에 대해 가비지 컬렉션을 수행한다. 여기서 살아남은 객체는 1세대로 옮겨진다.
    2. 1번 과정을 반복하다보면, 1세대 가비지 컬렉션이 임계치에 도달하게 되고, 1세대에 대해 가비지 컬렉션을 수행한다. 여기서 살아남은 객체는 2세대로 옮겨진다.
    3. 2번 과정도 반복하다보면, 2세대 가비지 컬렉션이 임계치에 도달하게 되고, 이 때에는 0, 1, 2세대 전체 가비지 컬렉션을 수행한다.
    • 2세대 힙이 가득차게 되면 CLR은 응용 프로그램의 실행을 멈추고 전체 가비지 컬렉션을 수행(Full GC) 하면서 메모리를 확보하려고 하기 때문에, 응용 프로그램이 차지하는 메모리가 많을 수록 프로그램이 정지하는 시간도 그 만큼 늘어나게 된다.

85KB이상의 대형 객체의 할당 #

  • 대형 객체의 경우에는 0세대에 할당하면 가비지 컬렉션을 자주 수행하기 때문에 CLR은 85KB 이상의 대형 객체를 할당하기 위해 대형 객체 힙(Large Object Heap; LOH) 을 따로 유지하고 있다.
    • 대형 객체가 할당될 때는 포인터를 사용하지 않고, 힙을 탐색하며 그 크기만큼 들어갈 수 있는 위치를 찾는다.
    • 대형 객체 힙은 해제된 공간을 인접 객체를 이동시켜서 채우는 것이 아니라 그대로 둔다. 왜냐하면 복사하여 옮기는 비용이 너무 비싸기 때문이다. 이렇기 때문에 큰 공간 사이사이의 메모리를 낭비하게 된다.
    • CLR은 대형 객체 힙을 2세대 힙으로 간주한다. 따라서 대형 객체 힙에 있는 쓰레기 객체가 수거되려면 2세대 가비지 컬렉션이 수행되어야 한다. 2세대 가비지 컬렉션은 전 세대에 대한 가비지 컬렉션을 유발하기 때문에 수거되는 메모리의 양이 클수록 어플리케이션이 정지되는 경우가 발생하게된다.

가비지 컬렉션 메서드 #

메서드명 설명
GC.Collect() 모든 세대 GC 즉시 수행
GC.Collect(int) 세대 0에서 부터 지정된 세대까지 GC 즉시 수해
GC.CollectionCount(int) 지정된 세대의 개체에 대해 GC가 수행된 횟수 반환 (GC가 언제 발생하는지 모니터링하는 가장 쉬운 방법)
GC.GetGerneration(object) obj의 현재 세대 반환
GC.MaxGeneration() 시스템에서 현재 지원하는 가장 큰 세대 번호 반환 (시스템에 따라 0~n세대로 나누어 질 수 있음)

주의점 #

  • 객체를 너무 많이 할당하지 않는다.
  • 너무 큰 객체를 할당하는 것을 피한다.
  • 너무 복잡한 참조관계를 피한다.
    • 복잡한 참조 관계를 가진 객체가 가비지 컬렉션 후에 살아 남으면, 세대를 옮기기 위해 메모리 복사를 진행하는데, 참조 관계가 복잡할 경우 객체를 구성하고 있는 각 필드 객체간의 참조관계를 조사하여 메모리 주소를 전부 수정해야 되기 때문에 탐색과 수정의 오버헤드가 발생한다.
    • 또한 A객체가 2세대인데 A객체안에 B객체를 이제 막 생성하여 0세대로 되었다면, A의 인스턴스는 2세대에 있고 B 필드를 참조하는 메모리는 0세대에 위치하게 된다. 이때 0세대 가비지 컬렉션이 수행된다면 B필드가 수거될 수 있다. 하지만 CLR은 쓰기 장벽(Write barrier) 이라는 장치를 통해서 B필드가 루트를 갖고 있는 것으로 간주하게 해서 수거 되지 못하게 한다. 이 쓰기 장벽을 생성하는 데 오버헤드가 크다는 것이 문제가 된다.
  • 루트를 너무 많이 만들지 않는다.
    • 가비지 컬렉터는 루트 목록을 순회하면서 쓰레기를 찾아낸다.
    • 루트 목록이 작아진다면 그만큼 가비지 컬렉터가 검사를 수행하는 횟수가 줄어들므로 더 빨리 가비지 컬렉션을 끝낼 수 있다.



Unity의 경우 #

Boehm-Demers-Weiser의 알고리즘 #

  • 이것은 가비지콜렉터가 가비지 수집을 수행할때 프로그램 코드 실행 및 CPU 메인스레드를 중지하며 전체 힙을 검사하는 방식이다. (Stop the world 방식)
  • 따라서 힙의 모든 오브젝트를 처리한 이후에 어플리케이션 실행을 재개함으로 성능에 영향을 미치는 GC Spike(중단으로 인해 프로파일러 윈도우의 그래프에서 나타나는 큰 스파이크)가 발생한다.
  • 더 이상 할당할 수 있는 메모리가 없다면 Managed Heap의 크기를 2배로 늘린다. 줄어들지는 않는다.
  • 세대 구분이나 SOH, LOH, 메모리 압축같은 것이 없다.

Incremental GC #

  • 부하가 있는 GC를 점진적으로 수행하는 방법이다. 즉, 하나의 작업을 여러 프레임 동안 나눠서 하는 것이다.
  • GC에 드는 시간 총량이 줄어들지는 않지만 워크로드 분산으로 GC Spike 문제점을 개선시킬 수 있다.

유니티 공식 문서
ms 공식 문서



가비지 컬렉션과 string #

  • string 문자열은 변경할 수 없는 객체이다.
    • 그래서 변경하면 내부적으로 항상 새로운 문자열이 만들어진다.
// string + string의 경우 

string str = "one";
str += "two";  // 새로운 객체를 만들고, str이 이것을 참조하도록 한다. 

  • 대안 : StringBuilder 클래스를 사용해보자.
    • string 과 달리 변경 가능한 객체이다.
    • StringBuilder 클래스는 연결될 새 데이터를 수용할 버퍼를 유지한다. 만약 용량을 초과하면 새 공간이 자동으로 할당되고 용량이 두 배로 증가한다.
// StringBuilder의 경우 

StringBuilder sb = new StringBuilder("one");
sb.Append("two");