오늘은 가비지 컬렉션(Garbage
Collection)에 대해 알아보겠습니다.
가비지 컬렉션은 .NET 기반 언어에서 사용자의 직접 처리없이 메모리를 자동으로 관리해주는 메커니즘입니다.
[ 1. C/C++에서의 메모리 관리 (Native Code) ]
C/C++언어로 프로그래밍을 할 때 객체를 할당하기 위해 일일이 메모리 공간을 확보해야 하며, 객체를 할당한 후에는 힙을 가리키는 포인터의 사용이 끝나면 메모리를 해제해주어야 합니다. 프로그래밍을 짜다보면 메모리해제에 대해 깜빡하게되버리면 곧 누수가 되고, 사용자들에게 문제를 야기할 수 도 있답니다.
또한 해제한 메모리를 사용하려고한 경우 큰 문제가 발생하는 경우도 있습니다. 이렇듯 메모리를 직접 다루고 관리하는 일은 까다롭고도 위험하답니다.
또한 C/C++언어는 힙에 객체를 할당하기 위해 비싼 비용을 치루어야 합니다. C/C++기반의 프로그램을 실행하는 C-런타임은 객체를 담기 위한 메모리를 여러 개의 블록으로 나눈 뒤, 이 블록을 링크드 리스트로 묶어 관리하게됩니다. 객체를 메모리에 할당하기 위한 코드가 실행이 되면, C-런타임은 메모리 링크드 리스트를 순차적으로 탐색하면서 해당 객체를 담을 수 있을 만한 여유가 있는 메모리 블록을 찾습니다. 적절한 크기의 메모리 블록을 만나면, 프로그램은 이 메모리 블록을 쪼개서 객체를 할당하고 메모리 블록의 링크드 리스트를 재조정하게 됩니다.
단순히 메모리 공간을 할당하는 것이 아니라 공간을 탐색하고 분할하고 재조정하는 오버헤드가 필요하다는 것입니다.
[ 2. C#에서의
메모리 관리 (Managed Code) ]
C#에서는 C/C++에서 골치아프게 했던 이런 문제들로부터 자유로워졌습니다.
CLR이 자동 메모리 관리 기능을 제공하기 때문이지요. 자동 메모리 관리 기능의 중심에는 가비지 컬렉션이 있습니다. 가비지 컬렉션은 프로그래머로 하여금 컴퓨터가 무한한 메모리를 갖고 있는 것처럼 간주하고 코드를 작성할 수 있게 해줍니다.
가비지 컬렉션에는 우리가 작성한 프로그램이 객체를 메모리에 할당해서 사용하고 있을 때, 객체 중 쓰레기인것과 쓰레기가 아닌 것을 완벽하게 분리해서 쓰레기들을 수거하는가비지 컬렉터라는 것이 존재합니다. 이름이 비슷하지요.
C/C++에서 저희가 직접 했던 일을 C#에서는 가비지 컬렉터가 알아서 해주니 정말 편하겠군요.. 하지만 가비지 컬렉터도 소프트웨어이기 때문에 CPU와 메모리같은 자원소모를 합니다. 저희가 코드작성에 필요한 자원을 가비지 컬렉터와 같이 사용해야 한다는 것이지요.
그래서 저흰 가비지 컬렉터가 큰 편의성을 주는 반면, 가비지 컬렉터가 최소한으로 자원을 사용할 수 있게 메모리 관리도 이루어져야 합니다. 그러기 위해서는 가비지 컬렉터의 메커니즘을 이해하고 있어야 한답니다.
[ 3. C#에서 객체가 메모리에 할당되는 과정 ]
가비지 컬렉터가 어떻게 쓰레기를 수집하는지 알아보겠습니다.
1.1 아무것도 할당되지 않은 관리되는 힙 |
C#으로 작성한 소스 코드를 컴파일해서 실행 파일을 만들고 이 실행 파일을 실행하면, CLR은 프로그램을 위한 일정 크기의 메모리를 확보합니다. C-런타임처럼 메모리를 쪼개는 일은 하지 않습니다. 그냥 메모리 공간을 통째로 확보해서 하나의 관리되는 힙을 마련합니다. 그리고 CLR은 이렇게 확보한 관리되는 힙 메모리의 첫번째 주소에 '다음 객체를 할당할 메모리의 포인터'를 위치시킵니다.
1
|
Object A = new Object();
| cs |
아무것도 할당되지 않은 메모리 공간에 첫 번째 객체를 할당해보겠습니다. CLR이 다음 코드를 실행하면 '다음 객체를 할당할 메모리의 포인터'가 가리키고 있는 주소에 A객체를 할당하고 포인터를 A객체가 차지하고 있는 공간 바로 뒤로 이동시킵니다.
1
|
Object B = new Object();
| cs |
객체를 또 하나 만들어 보겠습니다. 두 번째로 만드는 객체는 첫 번째 객체의 바로 뒤, '다음 객체를 할당할 메모리의 포인터'가 가리키고 있는 곳에 할당됩니다.
객체가 메모리에 할당되는 과정을 알아보았습니다. 그러면 할당된 객체들이 쓰레기인지 어떻게 판단하고 가비지 컬렉터가 수집해가는지 알아보겠습니다.
4. 가비지 컬렉터(Garbage Collector)의 원리
저희가 이미 알고 있는 것처럼 값 형식 객체는 스택에 할당되었다가 자신이 위치한 코드 블록이 끝나면 메모리로부터 사라집니다. 참조 형식 객체들만이 힙에 할당되어 코드 블록과 관계없이 계속 남아있겠지요. 잠깐 코드를 한번 보겠습니다.
1
2
3
4
|
if(true)
{
object a = new object();
}
| cs |
위와 같은 코드가 있을 때 a는 어디에 존재할까요, a는 스택에 할당되고 a가 참조하고 있는 메모리는 힙 메모리의 주소를 참고하고 있답니다.
만약 if문이 끝나면 a는 어떻게 될까요?
a변수는 if문이 끝나자마자 스택에서 사라지고 더 이상 존재하지 않는 변수가 됩니다.
a변수는 if문이 끝나자마자 스택에서 사라지고 더 이상 존재하지 않는 변수가 됩니다.
이제 힙을 가리키고 있던 a의 객체는 사라지고 힙에 할당되었던 메모리는 어디에서도 접근할 수 없게됩니다. 즉 쓰레기가 되버린것입니다. 이제 이 쓰레기는 가비지 컬렉터가 수집을 할 대상이 되어버린거지요.
여기서 가비지 컬렉터가 어떻게 쓰레기인지 구분하는가 하면은, 먼저 a변수처럼 힙에 할당된 메모리의 위치를 참조하고 있는 객체들을 '루트(Root)'라고 합니다.
루트는 경우에 따라 스택에 할당될 수 도 있고, 힙에 할당될 수 도 있습니다. .NET 애플리케이션이 실행되면 JIT컴파일러가 이 루트들을 목록으로 만들고, CLR은 이 루트 목록을 관리하며 상태를 갱신합니다. 이 루트가 중요한 이유는 가비지 컬렉터가 CLR이 관리하고 있던 루트목록을 참조해서 쓰레기 수집을 하기 때문입니다.
가비지 컬렉터가 루트 목록을 이용해서 쓰레기 객체를 정리하는 과정을 다시 정리하자면,
첫번째로 작업을 시작하기 전에 가비지 컬렉터는 모든 객체가 쓰레기라고 가정합니다. 즉, 루트 목록내의 어떤 루트도 메모리를 가리키지 않는다고 가정하지요.
그리고 나서 루트 목록을 순회하면서 각 루트가 참조하고 있는 힙 객체와의 관계 여부를 조사합니다. 루트가 참조하고 있는 힙의 객체가 또 다른 힙 객체를 참조하고 있다면 이 또한 해당 루트와 관계가 있는 것으로 판단합니다. 이 때 어떤 루트와도 관계가 없는 힙의 객체들은 쓰레기로 간주됩니다.
쓰레기 객체가 차지하고 있던 메모리는 이제 비어있는 공간으로 간주됩니다.
루트 목록에 대한 조사가 끝나면, 가비지 컬렉터는 이제 힙을 순회하면서 쓰레기가 차지하고 있던 '비어있는 공간'에 인접한 객체들을 이동시켜 차곡차곡 채워 넣습니다. 모든 객체의 이동이 끝나면 다음과 같이 깨끗한 상태의 메모리를 얻게된답니다.
[ 5. 세대별 가비지 컬렉션 ]
가비지 컬렉션의 동작 방식에 대해서 알아보았습니다. 이번에는 가비지 컬렉션의 성능을 높이기 위한 '세대별 가비지 컬렉션 알고리즘'에 대해 알아보겠습니다.
CLR은 메모리를 구역별로 나누어 메모리에서 빨리 해제가 될 객체와 오래남아있을 객체들을 따로 담아 관리합니다. 구체적으로 이야기 하면 CLR은 메모리를 0, 1, 2의 3개 세대로 나누고 0세대에는 빨리 사라질 것으로 예상되는 객체들을, 2세대에는 오랫동안 남아있을 객체들을 위치시킵니다. CLR이 객체의 수명을 예측하는 방법은, 객체가 가비지 컬렉션을 겪은 횟수에 따라 나뉘는데요 횟수가 높을수록 메모리에서 오랫동안 남아있는 객체로 간주되고, 횟수가 낮을 경우 빨리 사라질것이라고 간주됩니다.
따라서 0세대에는 가비지 컬렉션을 한 번도 겪지 않은 갓 생성된 객체들이 위치하게 되고 2세대에는 최소 2회에서 수 차례동안 가비지 컬렉션을 겪고도 남은 객체들이 위치하게 됩니다.
.NET 애플리케이션이 실행이 되면 CLR은 위와같이 비어있는 힙을 확보합니다. 어떠한 객체도 할당되지 않은 상태이지요.
애플리케이션이 진행이 되면 차츰차츰 객체들이 힙에 할당이 됩니다. 할당된 객체들의 총 크기가 0세대 가비지 컬렉션 임계치에 도달하면 가비지 컬렉터는 0세대에 대해 가비지 컬렉션을 수행을 합니다.
그리고 나서 살아남은 객체들은 1세대로 옮겨지게 되지요. 이로써 0세대는 깨끗하게 비워지며, 2세대도 아직까지는 깨끗한 상태로 남아있게되죠.
여전히 어플리케이션은 객체를 힙에 할당을 합니다. 새로 생성된 객체들은 당연히 0세대에 할당이 이루어집니다. 1세대에는 이전 가비지 컬렉션에서 살아남은 객체들이, 0세대에는 새로 생성된 객체들이 존재하게 됩니다. 이제 0세대 객체의 용량이 0세대 가비지 컬렉션 임계치를 넘어서게되면, 가비지 컬렉터는 다시 움직이기 시작합니다. 가비지 컬렉터는 또 다시 0세대에 대해 가비지 컬렉션을 수행합니다.
0세대는 깨끗이 비워졌지만 또 다시 어플리케이션에 의해 새로운 객체들이 할당이 됩니다. 이번에는 1세대의 임계치가 초과되었기 때문에 1세대에 대해 가비지 컬렉션을 수행합니다.
이 때 가비지 컬렉터는 하위 세대에 대해서도 가비지 컬렉션을 수행하기 때문에 0세대와 1세대에 대한 가비지 컬렉션이 수행됩니다. 이 때 0세대에서 살아남은 객체는 1세대로,
1세대에서 살아남은 객체는 2세대로 옮겨집니다.
그리고 계속 어플리케이션이 종료가 되기 전까지 객체가 생성이 되고 힙 메모리에 채워지게됩니다. 그러면 또 다시 0세대에 새로운 객체들로 채워지기 시작하지요. 각 세대의 메모리 임계치에 따라 가비지 컬렉션이 수행이 되고, 가비지 컬렉션의 반복됨에 따라 0세대의 객체들은 1세대로, 1세대의 객체들은 2세대로 계속 이동을 합니다.
하지만 2세대로 옮겨간 객체들은 더 이상 다른 곳으로 옮겨가지 않습니다. 2세대도 포화되어 2세대에 대한 가비지 컬렉션이 수행이 되면 0세대 1세대 모두 가비지 컬렉션을 수행합니다. 전체 가비지 컬렉션이라고 부르기도 하지요.
하지만 2세대로 옮겨간 객체들은 더 이상 다른 곳으로 옮겨가지 않습니다. 2세대도 포화되어 2세대에 대한 가비지 컬렉션이 수행이 되면 0세대 1세대 모두 가비지 컬렉션을 수행합니다. 전체 가비지 컬렉션이라고 부르기도 하지요.
하지만 0세대 가비지 컬렉션이 수행될 때, 1세대 2세대는 수행되지 않습니다. 1세대때에도 마찬가지로 2세대는 수행되지 않지요. 이 처럼 힙의 각 세대는 2세대 < 1세대 < 0세대 순으로 가비지 컬렉션 빈도가 높습니다. 이 떄문에 2세대 객체들은 오랫동안 살아남을 확률이 높고, 따라서 가비지 컬렉터도 상대적으로 덜 관심을 주는 편입니다.
만약, 2세대 임계치에 도달하게 될 경우 어떤일이 발생할까요?,
CLR은 어플리케이션의 실행을 잠시 멈추고 전체 가비지 컬렉션을 수행함으로써 여유 메모리를 확보하려 합니다. CLR이 Full GC를 할 때는 0세대부터 2세대까지의 전체 메모리를 거쳐 쓰레기를 수집하기 때문에 어플리케이션이 차지하고 있던 메모리가 크면 클수록 Full GC 시간이 길어지므로 어플리케이션이 정지되어있는 시간도 그만큼 늘어나게 됩니다.
이 문제는 가비지 컬렉션을 이해해야 하는 가장 중요한 이유중 하나이기도 합니다.
다음 내용에서는 가비지 컬렉션의 효율을 높이기 위해 조심해야할 사항에 대해 알아보겠습니다.