가비지 컬렉션

문승현·2022년 10월 29일
0

BeDev_4

목록 보기
3/5
post-thumbnail

회사에서 주요하게 사용하는 프로그래밍 언어는 Microsoft사의 C#이다.
Java 사용이 압도적으로 우세한 국내에서는 다소 생소할 수 있지만, Java 만큼이나 훌륭한 언어이다.
특히, 가비지 컬렉션을 지원하여 메모리 관리 측면에서 큰 편리함을 제공한다.
CLR(Common Language Runtime)이 자동 메모리 관리 기능을 제공하기 때문이다.
예를 들어, C/C++과 달리 프로그래머가 메모리 할당과 해제를 직접 수행하지 않아도 된다.

사실 메모리 할당과 해제는 매우 귀찮은 일임과 동시에 아주 비싼 비용을 치루는 행위이다.
C/C++ 기반의 프로그램을 실행하는 C-런타임은 객체를 담기 위한 메모리를
여러 개의 블록으로 나누고 이를 링크드 리스트로 묶어서 관리한다.

만약 어떤 객체를 힙에 할당하고자 코드를 실행한다면 C-런타임은 링크드 리스트를 순차적으로 탐색하며
해당 객체를 담을 수 있을 만한 적절한 크기의 메모리 블록을 찾는다.
메모리 블록을 찾으면 프로그램은 해당 블록을 쪼개서 객체를 할당하고 링크드 리스트를 재조정한다.
정리하자면 메모리 할당은 단순히 메모리 공간에 데이터를 집어넣는 것이 아니라 할당이 가능한 공간을
탐색하고 해당 공간을 분할, 재조정하는 오버헤드가 필요한 행위인 것이다.

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

CLR이 다음과 같은 코드를 실행한다고 가정하자.

object A = new object();

포인터가 가리키는 주소에 A 객체를 할당하고 포인터를 A 객체가 차지하고 있는 메모리 공간 뒤로 이동시킨다.
그럼 이제 다음의 코드를 실행하여 또 다른 객체를 생성해보자.

object B = new object();

생성된 B 객체는 A 객체의 메모리 공간 바로 뒤에 할당된다.
해당 과정에서 CLR은 객체에 메모리를 할당하기 위해 메모리 공간을 쪼개서 만든 링크드 리스트를 탐색하거나
그 공간을 다시 나눈 뒤에 리스트를 재조정하는 작업도 필요로 하지 않는다.
그저 객체에 메모리를 할당하는 것이 전부이다. C-런타임과 비교하면 그 메커니즘이 굉장히 단순하다.

그런데 할당된 객체들은 언제, 그리고 어떻게 메모리에서 해제되는 것일까?
값 형식 객체는 스택에 할당되었다가 자신이 태어난 코드 블록이 끝나면 메모리로부터 바로 사라지고,
참조 형식 객체들만 힙에 할당되어 코드 블록과 관계없이 계속 살아남는다.

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

위의 예시에서 변수 a는 객체가 위치하고 있는 힙 메모리 주소를 참조한다.
그런데 if 블록이 끝나면 a라는 변수는 스택에서 사라지고 더 이상 존재하지 않는다.
a가 참조하고 있던 객체는 어떤 변수도 참조하고 있지 않는 쓰레기(Garbage) 상태가 된다.

할당된 메모리의 위치를 참조하는 a를 일컬어 루트(Root)라고 부른다.
루트는 a의 경우 처럼 스택에 생성될 수도 있고 정적 필드처럼 힙에 생성될 수도 있다.
.NET 애플리케이션이 실행되면 JIT(Just in Iime) 컴파일러가 이 루트들을 목록으로 만든다.
그리고 CLR이 이 루트 목록을 관리하며 가비지 컬렉션을 통해 그 상태를 계속해서 갱신한다.

구체적으로 가비지 컬렉터가 루트 목록을 이용해서 쓰레기 객체를 정리하는 과정은 아래와 같다.

1) 작업을 시작하기 전에 가비지 컬렉터는 모든 객체가 쓰레기라고 가정한다.

2) 루트 목록을 순회하면서 각 루트가 참조하고 있는 객체가 있는지 조사한다. 만약 어떤 루트가
참조하고 있는 객체가 또 다른 객체를 참조하고 있다면 해당 객체도 루트와 관계가 있는 것으로 판단한다.
이때, 어떤 루트와도 관계가 없는 객체들은 쓰레기로 간주된다.

3) 쓰레기 객체가 차지하고 있던 메모리를 해제한다.

4) 루트 목록에 대한 조사가 끝나면 가비지 컬렉터는 힙을 순회하면서 쓰레기 객체가 차지했던 공간에
쓰레기의 인접 객체들을 이동시켜 채워 넣는다. 모든 객체의 이동이 끝나면 가비지 컬렉션을 종료한다.

C#은 가비지 컬렉션의 성능을 높이기 위해 세대별 가비지 컬렉션 알고리즘을 이용한다.
CLR은 메모리 구역을 나누어 메모리에서 빨리 해제될 객체와 오래도록 살아남을 것 같은 객체들을 구분한다.
객체의 나이가 어릴수록 메모리에서 빨리 사라지고 나이가 많을수록 오랫동안 살아남는다고 간주한다.
이때 나이라 함은 가비지 컬렉션을 겪은 횟수를 의미하며, 0, 1, 2의 3개의 세대로 나눈다.
0세대에는 가비지 컬렉션을 한 번도 겪지 않은 객체들이 위치하고
2세대에는 최소 2회에서 수차례동안 가비지 컬렉션을 겪고도 살아남은 객체들이 위치한다.
1세대에는 0세대에서 2세대로 넘어가는 과도기의 객체들이 위치한다.

각 세대의 메모리 임계치에 따라 가비지 컬렉션이 수행되고 가비지 컬렉션이 반복됨에 따라
0세대의 객체들은 1세대로, 1세대의 객체들은 2세대로 이동한다.
그런데 만약 2세대도 포화되어 2세대에 대한 가비지 컬렉션이 수행되면
가비지 컬렉터는 1세대와 0세대에 대해서도 가비지 컬렉션을 수행한다.
그래서 2세대 가비지 컬렉션을 Full GC, 즉 전체 가비지 컬렉션이라고 부르기도 한다.

0세대에서 가비지 컬렉션이 수행될 경우 1세대와 2세대의 가비지 컬렉션은 수행되지 않는다.
이처럼 힙의 각 세대는 2세대 < 1세대 < 0세대 순으로 가비지 컬렉션 빈도가 높다.
이 때문에 2세대의 객체들은 오랫동안 살아남을 확률이 높고 가비지 컬렉터도 상대적으로 관심을 덜 갖는다.
반면, 0세대의 경우 새롭게 할당되는 객체들로 인해 빠르게 포화되어 가비지 컬렉터가 자주 방문한다.

생명력이 강한 객체를 애플리케이션에 마구 생성하면 2세대 힙이 금방 가득찰 것이다.
그런데 Full GC가 발생하면 애플리케이션의 실행을 잠시 멈춘다.
이때 애플리케이션이 차지하고 있던 메모리가 클수록 Full GC의 시간이 길어지므로
애플리케이션 정지 시간도 그만큼 늘어나는 문제가 생긴다.

너무 많은 수의 객체는 힙의 각 세대에 대해 메모리 포화를 초래하고 빈번한 가비지 컬렉션을 발생시킨다.
따라서 객체 할당 코드를 작성할 때 꼭 필요한 객체인지, 필요 이상으로 많은 객체를 생성하는 것은 아닌지 고려해야 한다.

또한 너무 큰 크기의 객체를 할당하는 것도 주의해야 한다.
CLR은 85KB 이상의 대형 객체를 할당하기 위한 대형 객체 힙을 따로 유지한다.
평소 사용하는 힙은 대형 객체 힙에 대비되는 개념으로 소형 객체 힙이라고 부르기도 한다.
만약 대형 객체를 소형 객체 힙에 할당하면 0세대가 빠르게 포화되어 카비지 컬렉션을 보다 자주 촉발하게 된다.
이는 애플리케이션의 성능 저하를 초래하기 때문에 크기가 큰 객체는 대형 객체 힙에 할당한다.
문제는 대형 객체 힙에서는 객체의 크기를 계산한 뒤 여유 공간이 있는지 힙을 탐색하고 할당한다.
그리고 할당을 해제한 공간은 그대로 둔다. 큰 크기의 메모리를 복사하는 비용이 비싸기 때문이다.
즉, 메모리를 낭비 없이 사용하는 소형 객체 힙과는 달리 큰 공간을 군데 군데 낭비하는 것이다.
결국 대형 객체 힙은 할당 시의 성능 뿐만 아니라 메모리 공간 효율도 소형 객체 힙에 비해 크게 떨어진다.
그리고 가비지 컬렉터는 대형 객체 힙을 2세대 힙으로 간주한다.
따라서 대형 객체 힙에 있는 쓰레기 객체를 수거하려면 Full GC가 일어나야 한다는 점에서 유의해야 한다.

앞서 언급하였듯 가비지 컬렉터는 루트 목록을 돌며 쓰레기를 찾는다. 루트가 많으면 해당 작업이 오래 걸린다.
루트를 불필요하게 만들지 않음으로써 루트 목록을 최소화 한다면 더 빨리 가비지 컬렉션을 끝낼 수 있다.
그리고 참조 관계가 많은 객체를 만드는 것도 지양해야 한다. 참조 관계의 객체를 쓰레기로 간주하지 않기 위한 쓰기 장벽을 만드는데 오버헤드가 크기 때문이다.

0개의 댓글