가비지 컬렉션

개발조하·2023년 11월 11일
0

C#

목록 보기
11/11
post-thumbnail

1. 가비지 컬렉터란?

c#이 아닌 다른 프로그래밍 언어는 객체를 할당하기 위해 일일이 메모리 공간을 확보해야 하고, 객체를 할당한 후에는 힙을 가리키는 포인터를 잘 유지하고 있다가 객체를 다 사용하면 해당 포인터가 가리키고 있는 메모리를 해제해줘야 한다. 깜빡하고 해제하지 못한 실수가 소스 코드 안에서 많아지면 성능에 문제가 생긴다..

하지만, C#은 CLR이 자동 메모리 관리 기능을 제공하기 때문에 괜찮다! 이 자동 메모리 관리 기능이 바로 '가비지 컬렉션'이다. 이 '가비지 컬렉션'을 담당하는 것이 가비지 컬렉터이다!

가비지 컬렉터: 쓰레기인 것과 쓰레기가 아닌 것을 완벽하게 분리해서 쓰레기들만 조용히 수거해간다.

BUT,
가비지 컬렉터도 소프트웨어이기 때문에 CPU와 메모리같은 컴퓨터 자원을 소모한다..
그렇기에 가비지 컬렉터가 최소한으로 자원을 사용하게 만들어야 프로그램의 성능을 아낀 자원의 양만큼 끌어올릴 수 있다!

⚠️ 가비지 컬렉터가 치우지 못하는 메모리도 있다!
기본적으로 c# 코드는 '관리형 코드'에 속해 CLR의 관리를 받는다. 하지만 만약 unsafe 키워드를 이ㅛㅇ하여 '비관리형 코드'로 작성하면 CLR이 관리하지 못하게 할 수 있다.

2. 가비지 컬렉터의 동작 방식

2.1 CLR이 객체를 메모리에 할당하는 방법

c#으로 작성한 소스 코드를 컴파일해서 실행 파일을 만들고 이 실행 파일을 실행하면, CLR은 이 프로그램을 위한 일정 크기의 메모리를 확보한다. C-런타임처럼 메모리를 쪼개는 것은 하지 않고, 그냥 넓은 메모리 공간을 통째로 확보해서 '관리되는 힙(Managed heap)'을 마련한다. 그리고 CLR은 이렇게 확보한 관리되는 힙 메모리의 첫 주소에 '다음 객체를 할당할 메모리의 포인터'를 위치시킨다.

첫 번째 객체 할당

object A = new object();


두 번째 객체 할당

object B = new object();

이처럼 CLR은 객체가 위치에 메모리를 할당하는 것이 전부이다.

2.2 CLR이 쓰레기를 구분하여 수거하는 방법

2.2.1 쓰레기를 구분하는 방법

if블록 안에서 참조 A는 스택과 힙에 어떻게 존재할까?

if (true)
{
    object a = new object();    
}

실제 객체의 내용물(A) -> 힙
객체 A가 위치하고 있는 힙 메모리의 주소를 찹조하고 있는 a -> 스택
만약 if {블록}이 끝나면 a는 스택에서 사라지고 더 이상 존재하지 않게 된다. a를 잃은 객체 A는 이제 코드의 어디에서도 접근할 수 없기 때문에 더 이상 사용할 수 없다 == 쓰레기 => 가비지 컬렉터가 집어간다!

2.2.2 쓰레기를 수거하는 방법 (루트 Root)

사라져버린 a처럼 할당된 메모리의 위치를 참조하는 객체를 일컬어 루트(Root)라고 한다.
루트는 a처럼 스택에 생성될 수도 있고, static일 경우 힙에 생성될 수도 있다.
.NET 애플리케이션이 실행되면 JIT 컴파일러가 이 루트들을 목록으로 만들고, CLR은 이 루트 목록을 관리하며 상태를 갱신한다. 이때 가비지 컬렉터가 CLR이 관리하던 루트 목록을 참조해서 쓰레기를 수집한다!

💡 가비지 컬렉터가 루트 목록을 이용해서 쓰레기 객체를 정리하는 과정
1. 작업을 시작하기 전에, 가비지 컬렉터는 모든 객체(A,B,C,D,E,F)가 쓰레기라고 가정한다. 즉, 루트 목록 내 어떤 루트도 메모리를 가리키지 않는다고 가정한다.
2. 루트 목록을 순회하면서 각 루트가 참조하고 있는 힙 객체와의 관계 여부를 조사한다. 만약 루트가 참조하고 있는 힙의 객체가 또 다른 힙 객체를 참조하고 있다면 이 역시도 해당 루트와 관계가 있는 것으로 판단한다(A-C, D-F). 이때 어떤 루트와도 관계가 없는 힙의 객체들(B,E)은 쓰레기로 간주된다.
3. 쓰레기 객체가 차지고하고 있는 메모리는 이제 '비어 있는 공간'이다.
4. 루트 목록에 대한 조사가 끝나면, 가바지 컬렉터는 이제 힙을 순회하면서 쓰레기가 차지했던 '비어 있는 공간'에 쓰레기의 인접 객체들을 이동시켜 차곡차곡 채워 넣는다.

3. 세대별 가비지 컬렉션

CLR은 메모리의 구역을 나누어 메모리에서 빨리 해제될 객체와 오래도록 살아남을 것 같은 객체들을 0,1,2의 3개 세대로 나눠 관리한다.

0세대 - 빨리 사라질 것으로 예상되는 객체들
~
2세대 - 오랫동안 살아남을 것으로 예상되는 객체들

❓ CLR은 객체의 수명을 어떻게 예측할까
: 가비지 컬렉션을 겪은 횟수로 판단한다.

0세대 - 가비지 컬렉션을 한번도 겪지 않은 '갓 생성된' 객체들
~
2세대 - 최소 2회에서 수차례 동안 가비지 컬렉션을 겪고도 살아남은 객체들

즉, 2세대는 생명력이 강한 객체들이다.

❓ 가비지 컬렉션이 세대를 나누는 이유: 메모리를 재사용하기 용이하기 때문

가비지 컬렉션이 일어날 때 파편화를 방지하기 위해 메모리를 압축하는 데, 관리되는 힙 전체를 대상으로 하기보다 일부분에서만 수행 하는 게 더 빠르다.
최근에 만들어진 객체일 수록 수명이 짧고 오래 사용된 객체일수록 수명이 길어 재사용할 메모리를 빠르게 분류할 수 있다.
메모리 할당은 0세대에서만 일어나는데 최근에 만들어진 객체끼리 서로 연관되는 경향이 있어 캐싱 측면에서 좋다.
관리되는 힙에는 여러 개의 포인터가 있으며 이를 이용해 세대를 구별한다.
관리되는 힙에는 각 세대의 시작을 가리키는 포인터가 있다.

📝 예시
1. .NET 애플리케이션이 시작되면 CLR은 비어있는 관리되는 힙을 확보한다.
2. 애플리케이션이 일을 시작하면 할당된 객체들로 힙이 차오른다.
3. 0세대 가비지 컬렉션 임계치에 도달하면 가비지 컬렉터는 0세대에 대해 가비지 컬렉션을 수행하고, 여기에서 살아남은 객체들은 1세대로 옮겨진다.
4. 새로 생성된 객체들이 0세대에 할당된다. 또 0세대의 가비지 컬렉션 임계치를 넘어서서 0세대에 대해 가비지 컬렉션을 수행한다.
5. 0세대는 비워지고 또다시 애플리케이션에 의해 새로운 객체들이 할당되는데, 이번에는 1세대 임계치가 초과됐기 때문에 1세대에 대해 가비지 컬렉션을 수행하는데, 이때 가비지 컬렉터는 0세대에 대해서도 함께 수행한다.
5. 0세대에 객체가 차오르고, 각 세대의 메모리 임계치에 따라 가비지 컬렉션이 수행되고, 가비지 컬렉션이 반복됨에 따라 0세대는 1세대로, 1세대는 2세대로 계속 이동한다. 2세대는 2세대에 정착하게 되는데, 2세대의 가비지 컬렉션 임계치에 도달하면 그때 1세대와 0세대 모두 가비지 컬렉션을 수행하게 된다. 이를 '전체 가비지 컬렉션(Full GC)'이라고 부른다.

즉, 힙의 각 세대는 2세대 < 1세대 < 0세대 순으로 가비지 컬렉션 빈도가 높다.
여기서 주의할 점은 생명력이 강한 객체를 애플리케이션 위에 마구 생성해놓으면 얼마 가지 않아 2세대 힙이 가득찬다는 것이다.
그러면 CLR은 애플리케이션의 실행을 멈추고 Full GC를 수행함으로써 여유 메모리를 확보하려 한다. 이때 애플리케이션이 차지하고 있던 메모리가 클수록 Full GC 시간이 길어지므로 애플리케이션이 정지하는 시간도 그만큼 늘어나 프로그램 성능 저하의 문제가 발생한다..

4. CLR의 가비지 컬렉션 성능 최적화 방법

프로그래머는 CLR의 가비지 컬렉션 메커니즘에 대한 이해를 바탕으로 적절한 작전을 수립해야 한다.

  • 객체를 너무 많이 할당하지 말 것.
    객체할당 코드를 작성할 때 필요한 객체인지를 고려하여 적성한다.
    너무 많은 수의 객체는 관리되는 힙의 각 세대에 대해 포화를 초래하여 빈번한 가비지 컬렉션을 수행시키기 때문이다.

  • 너무 큰 객체 할당은 피할 것.
    CLR은 보통 크기의 객체를 할당하는 힙과는 별도로 85KB 이상의 대형 객체를 할당하기 위한 '대형 객체 힙'을 따로 유지한다.
    '대형 객체 힙'은 동작 방식이 소형 객체 힙(일반적인 힙)과 다르다. 소형 객체 힙은 '다음 객체를 할당할 포인터'가 위치한 메모리에 바로 객체를 할당하지만, 대형 객체 힙은 객체의 크기를 계산한 뒤 그만한 여유 공간이 있는지 힙을 탐색하여 할당한다.
    가비지 컬렉션을 수행하고 난 뒤에 소형 객체 힙은 비어있는 메모리 공간에 인접 객체들을 끌어당겨 차곡차곡 정리하지만, 대형 객체 힙은 메모리를 복사하는 비용이 너무 비싸기 때문에 정리하지 않는다. 즉, 큰 공간을 군데군데 낭비하게 되어 대형 객체 힙은 할당 시의 성능뿐만 아니라 메모리 공간 효율도 크게 떨어진다.
    뿐만 아니라 대형객체 힙은 2세대 힙으로 간주하기 떄문에 대형 객체 힙에 있는 쓰레기 객체가 수거되려면 Full GC가 수행되어 애플리케이션의 정지를 불러온다.

  • 너무 복잡한 참조 관계는 만들지 말 것.
    가비지 컬렉터는 가비지 컬렉션 후에 살아남은 객체의 세대를 옮기기 위해 메모리 복사를 수행한다. 이때 참조 관계가 복잡한 객체의 경우에는 단순히 메모리 복사를 하는 데서 끝나는 것이 아니라 객체를 구성하고 있는 각 필드의 객체 간 참조 관계를 일일이 조사해서 참조하고 있는 메모리 주소를 전부 수정한다. 클래스 구조를 간단하게 만들었다면 메모리 복사만으로 끝났을 일을 탐색과 수정으로 끌어들이게 되는 것이다.

  • 루트를 너무 많이 만들지 말 것.
    가비지 컬렉터는 루트 목록을 돌면서 쓰레기를 찾아내기 때문에 루트 목록이 작아진다면 그만큼 가비지 컬렉터가 검사를 수행하는 횟수가 줄어들어 더 빨리 가비지 컬렉션을 끝낼 수 있다.

💡가비지 컬렉션 성능 최적화를 고려하여 코드를 짜는 습관을 들이자!!

📄참고자료
<이것이 c#이다> 3판 - 박상현 지음 (한빛미디어)
가비지 컬렉터가 세대를 나누는 이유

profile
Unity 개발자 취준생의 개발로그, Slow and steady wins the race !

0개의 댓글