1. 가비지 컬렉션의 개요
가비지 컬렉션은 메모리 수거를 의미하며 .NET CLR의 가비지 컬렉터가 가비지 컬렉션을 수행하여 메모리를 관리합니다. C나 C++에서는 객체를 위한 메모리 공간을 확보하고 객체를 할당한 후에 해당 객체의 작업이 종료되면 할당했던 메모리의 힙을 가리키는 포인터를 통해 메모리를 해제하는 작업을 직접 구현해야 합니다. 문제는 이 과정에서 메모리 해제를 제대로 해주지 않으면 메모리가 낭비되는 결과를 초래하게 된다는 것입니다.
C/C++런타임이 객체를 메모리에 할당하는 과정을 보면 일단 여러 개의 블록으로 나누어진 메모리를 링크드 리스트로 연결해 관리하는데 만약 객체를 할당해야 하는 순간이 오면 해당 객체를 할당할만한 적당한 메모리 블록을 찾은 뒤 해당 블록을 다시 객체에 맞게 나누어 객체를 할당합니다. 그런 후에는 메모리 블록의 링크드 리스트를 할당된 상황에 맞게 다시 수정하는 과정을 거치게 됩니다. 요점은 할당한다는 작업 자체도 복잡하게 이루어진다는 것입니다.
뿐만 아니라 포인터를 통해 메모리를 해제시켜 준 후에라도 이를 인지하지 못하고 해제된 포인터에 다시 접근하여 코드를 실행하게 되면 다른 엉뚱한 코드가 할당되어 있을 수 있기 때문에 언제든 프로그램이 오동작하는 상황이 생길 위험이 있습니다.
가비지 컬렉션은 이러한 메모리 관리의 문제점들을 해결하고자 등장하게 되었습니다. 개발자는 메모리 상황에는 특별히 신경 쓸 것 없이 객체를 생성하고 사용하기만 하면 됩니다. 가비지 컬렉션의 가비지 컬렉터가 필요하지 않은 객체의 메모리 할당을 해제하면서 메모리 관리를 지속적으로 수행해 주기 때문입니다.(단, 비관리코드는 관리의 대상이 아닙니다.)
2. 메모리 할당 및 수거과정
.NET프로그램을 실행하게 되면 CLR은 우선 프로그램을 위한 힙 메모리를 일정 크기로 확보합니다. 그리고 확보된 메모리의 처음 부분에 포인터를 위치시키고 객체를 위해 메모리를 할당해야 하는 순간이 오면 포인터가 가리키는 처음 영역에 메모리를 할당하고 포인터를 할당된 영역 뒤로 이동시켜둡니다. 그런 뒤 다시 다른 객체를 위한 메모리 할당이 필요하면 동일한 방법으로 메모리를 할당한 후 포인터를 그 뒤로 이동시키는 과정을 계속 거치게 되는 것입니다. C/C++에 비해서는 비교적 단순한 방법으로 메모리를 할당합니다.
스택 메모리는 자신이 생성된 코드 블록이 끝나면 소멸합니다. 하지만 힙은 그렇지 않습니다. 대신 힙 메모리를 가리키는 스택이 소멸됩니다.
class Program
{
static void Main(string[] args)
{
if (true)
{
object o = new object(); //if가 끝나면 객체 o의 힙메모리 주소를 참조하는 스택이 소멸
}
}
}
이렇게 되면 어느 누구도 힙을 참조하는 상태가 아니기 때문에 이 힙 영역은 곧 가비지 컬렉터에 의해 메모리가 수거될 것입니다. 하지만 여기에서 끝나지 않고 중간에 메모리가 수거되면 수거된 공간을 뒤에 있는 다른 힙 영역을 이동시켜 앞에서부터 다시 빈틈없이 채우게 되는 과정을 거치게 됩니다. 이렇게 되면 객체가 차지하고 있던 힙 메모리 영역의 주소가 바뀌게 되는데 기존에 주소를 참조 중이던 스택 값을 바뀐 주소로 모두 변경하는 작업도 추가로 진행됩니다.
한편 CLR은 메모리 영역을 0, 1, 2세대로 구분하여 관리합니다. 대체로 새롭게 생성된 객체는 0세대로, 가비지 컬렉션이 한번 실행되고 살아남은 객체는 1세대, 두 번 실행되고 살아남은 객체는 2세대로 구분합니다. 가비지 컬렉션이 실행되는 주기는 세대별로 메모리의 임계치에 도달할 때인데 만약 0세대에서 임계치가 도달하는 경우 가비지 컬렉션이 0세대를 대상으로 실행하게 되고 여기서 수거되지 않은 객체들은 1세대 영역으로 다시 이동시켜 관리하게 됩니다.
1세대도 마찬가지입니다. 1세대의 메모리 영역이 임계치에 도달하면 가비지 컬렉션이 실행되고 여기서 살아남은 객체는 2세대로 이동시켜 관리에 들어갑니다. 2세대는 더 이상의 이동이 없고 그대로 존재하게 되는데 만약 2세대의 메모리 영역이 임계치에 도달하면 그때는 0세대와 1세대를 대상으로 가비지 컬렉터가 수행되고 2세대를 위한 영역을 더 확보하게 됩니다. 이 때문에 이를 Full GC(Full Garbage Collection)라고 하는데 Full GC는 0세대와 1세대를 모두 타깃으로 하다 보니 해당 영역이 크면 클수록 비교적 프로그램의 동작에 영향을 많이 미치게 됩니다. 게다가 이미 언급한 것처럼 변경된 힙 메모리를 스택에 수정하는 과정도 필요한데 0세대와 1세대에서 이런 과정을 거치려면 그만큼 많은 부담이 가해질 수밖에 없습니다.
사실 어지간해서는 이러한 GC의 동작에 크게 신경 쓰지 않고 원하는 대로 객체를 생성하고 사용해도 큰 무리는 없습니다. GC의 성능도 초기보다 점점 더 좋아져서 굳이 GC존재 자체를 의식하면서 프로그램을 만들 필요는 없는 것이죠. 물론 되도록이면 객체를 최소한으로 생성하고 하나의 객체가 다른 객체를 참조하는 복잡한 관계는 만들지 않도록 하는 것이 GC의 동작에 대한 오버헤드를 줄이는 데는 도움이 될 수 있습니다.
'.NET' 카테고리의 다른 글
[.NET] 닷넷 - 2. .NET components (0) | 2022.06.24 |
---|---|
[.NET] 닷넷 - 1. .NET 6 개요 (0) | 2022.06.24 |
[ASP.NET Core] IIS 배포 (게시) (0) | 2021.12.15 |
[Visual Studio IDE] Visual Studio IDE의 Registry 설정 (0) | 2021.11.27 |
.NET과 C# 시작하기 (0) | 2021.10.28 |