가비지 수집기(Garbage Collector, GC)는 관리 언어를 배우는데 빠질 수 없는 핵심 개념 중 하나이다.
GC가 무엇인지, 어떤 원리로 작동하는지 이해하고 이를 활용해 메모리를 효율적으로 관리할 방법을 알아보자.
C/C++과 같은 네이티브 언어는 동적으로 메모리를 할당하고 해제해야 되지만 관리 언어는 GC가 힙 메모리 관리를 해준다.
물론 GC가 만능은 아니며, 모든 힙 영역을 관리하는 것 또한 아니다.
GC가 관리하는 힙 영역을 관리 힙이라고 하며 반대로 관리되지 않는 힙 영역도 존재한다.
관리되지 않는 힙은 네이티브 언어와 마찬가지로 사용자가 직접 제어해야 된다.
이와 관련된 내용은 아래에서 후술한다.
우리는 객체를 생성할 때 어떠한 변수에 객체의 주소를 저장한다.
이를 루트 참조라고 한다.
object obj = new object(); // 루트 참조
obj = null;
하지만 위 코드는 힙에 object 객체를 할당하고 루트 참조로 obj 변수를 선언했으나 바로 다음 행에서 obj가 참조를 잃었다.
즉 힙에 할당된 object 객체를 가리키는 루트 참조는 존재하지 않는다.
이렇게 루트 참조가 존재하지 않는 객체는 메모리만 잡아먹는 가비지이기 때문에 GC의 수집 대상이 된다.
GC는 객체를 세대별로 나눠 관리한다.
new를 통해 처음 할당된 객체는 0세대로 시작한다.
이후 0세대 객체가 일정 메모리 이상을 차지하면 GC가 호출되어 가비지를 수집한다.
이 과정에서 살아남은 0세대 객체는 1세대로 승격된다.
1세대 역시 메모리 한도가 존재하며 이를 넘어갈 경우 수집 대상이 된다.
살아남은 1세대는 2세대로 승격되며 그 다음 세대는 존재하지 않는다.
object obj = new object();
Console.WriteLine(GC.GetGeneration(obj)); // 0
GC.Collect();
Console.WriteLine(GC.GetGeneration(obj)); // 1
GC.Collect();
Console.WriteLine(GC.GetGeneration(obj)); // 2
GC.Collect();
Console.WriteLine(GC.GetGeneration(obj)); // 2
중요한 것은 GC가 호출될 때 모든 관리 힙을 순회하며 가비지 수집을 하는 것이 아니라는 것이다.
일반적으로 가비지 수집은 0세대에서 수행되며 그 윗세대는 필요한 경우에만 수행된다.
이렇게 세대를 나누어 관리하는 이유는 오래 사용된 객체는 계속 사용될 가능성이 크다는 통계적 근거가 있기 때문이다.
세대의 구분은 관리 힙의 내부 포인터에 의해 이루어진다.
아래 예시를 보자.
0세대 객체 a, b, c, d, e, f, g가 힙에 할당됐다.
0세대가 차지한 메모리의 마지막 주소를 0세대 포인터가 가리키고 있다.
이제 여기서 d, e 객체가 루트 참조를 잃고 GC가 호출된다고 해보자.
d, e 객체가 비워진 메모리를 f, g 객체를 옮겨 채웠다.
0세대 포인터 역시 줄어든 메모리의 마지막 주소로 옮겨졌다.
추가로 기존의 0세대는 1세대로 승격되었기 때문에 1세대 포인터가 이를 가리킨다.
다시 여기에 h, i, j, k 객체를 할당해보자.
새로 할당된 객체는 0세대에 해당하니 0세대 포인터가 옮겨졌다.
만약 이후에 메모리의 변동 없이 GC가 호출된다면 포인터의 이동만 일어날 것이다.
가비지 수집 과정에서 세대가 증가하는 객체가 많아지면 어쩔 수 없이 윗세대도 물갈이를 해야 되는 순간이 온다.
이를 전체 가비지 수집(Full GC)이라고 한다.
물론 이 순간이 온다면 프로그램은 상당한 오버헤드를 감수해야 될 것이다.
GC 클래스는 사용자가 의도적으로 가비지 수집을 할 수 있도록 Collect 메서드를 제공한다.
GC.Collect()
GC.Collect(int generation)
매개변수가 없을 경우 Full GC를 수행하고, 매개변수로 세대를 지정하면 해당 세대 이하에 대해 GC를 수행한다.
(예를 들어 GC.Collect(1)은 0, 1세대에 대해 GC를 수행한다.)
하지만 마이크로소프트는 이와 같이 GC를 강제로 호출하는걸 권장하지 않으므로 정말 필요한 경우에만 사용하자.
가비지 수집에서 살아남은 객체들은 빈 공간을 채우기 위해 주소 값이 바뀐다.
하지만 대용량의 객체를 이렇게 메모리 공간 내에서 이동시키는건 상당한 오버헤드를 초래한다.
그래서 CLR은 일정 크기 이상의 객체는 별도의 힙 영역에 할당하는데 이를 대용량 객체 힙(Large Object Heap, LOH)이라고 한다.
LOH에 할당된 객체는 GC가 호출돼도 메모리 주소가 바뀌지 않는다.
하지만 이는 메모리 단편화(fragmentation)를 발생시킬 수 있다.
100MB의 LOH에 위와 같이 객체가 할당됐다고 가정해보자.
여기서 20MB 객체가 GC에 의해 수집되어 메모리가 해제되었다.
이후 프로그램은 50MB의 객체를 할당하려고 한다.
하지만 LOH에 50MB의 연속된 메모리 공간이 존재하지 않기 때문에 OutOfMemoryException이 발생한다.
이를 해결하기 위해 .NET 4.5.1부터 LOH의 객체도 이동시키는 기능이 추가되었다고 한다.
GC는 'Mark and Sweep'이라는 알고리즘을 통해 가비지를 식별하고 수집한다.
최상위의 루트 객체에서 시작하여 참조하는 모든 객체를 재귀적으로 순회한다.
만약 루트 참조가 있는 경우 식별(mark)하고 다음 객체로 넘어간다.
여기서 루트 객체란 스택에서의 지역 변수나 매개 변수, 전역 변수나 정적 변수 등이 될 수 있다.
관리 힙 전체를 스캔하여 식별되지 않은 객체를 제거(sweep)한다.
메모리 단편화를 해결하기 위해 필요한 경우 객체의 주소를 당겨서 빈 메모리 공간을 연속적으로 채운다.
지금까지 GC에 대해 이해한 바로는 우리가 수동으로 GC를 호출하지 않는 이상 GC는 우리가 원하는 시점에 객체를 소멸시키지 않는다.
객체가 원하는 시점에 소멸되지 않는다는 것은 자칫 프로그램 오작동의 원인이 될 수도 있다.
class Program
{
class Test
{
~Test()
{
Console.WriteLine("Destroyed");
}
}
static void Main(string[] args)
{
Test test = new Test();
test = null;
while (true) { }
}
}
위 코드를 보면 분명 Test 클래스의 객체가 할당되고 루트 참조를 잃었다.
하지만 아무리 기다려도 종료자는 호출되지 않는다.
왜냐하면 아직 메모리에 여유가 있어 GC가 호출되지 않았기 때문이다.
마이크로소프트는 자원 해제가 필요한 객체는 IDisposable 인터페이스를 상속받을 것을 권장하고 있다.
그렇다고 배열이나 리스트 따위를 다루는 객체가 굳이 IDisposable 인터페이스를 상속받을 필요는 없다.
여기서 자원이라고 하는 것은 GC에 의해 관리되지 않는 자원을 뜻한다.
namespace System
{
//
// 요약:
// Provides a mechanism for releasing unmanaged resources.
public interface IDisposable
{
//
// 요약:
// Performs application-defined tasks associated with freeing, releasing, or resetting
// unmanaged resources.
void Dispose();
}
}
IDisposable 인터페이스의 설명을 보면 관리되지 않는 자원을 해제하는 메커니즘을 제공한다고 적혀있다.
반대로 IDisposable 인터페이스는 자원 해제가 필요한 모든 객체들간의 약속이라는 뜻이기도 하다.
예를 들어 파일 입출력을 담당하는 클래스인 FileStream 역시 IDisposable 인터페이스를 상속받았다.
파일이라는 자원을 할당(open)하고 해제(close)하기 때문이다.
그렇기 때문에 만약 본인이 아래와 같이 FileStream을 사용하는 클래스를 정의했다면 IDisposable 인터페이스를 상속받아 FileStream을 Dispose하는 기능을 구현해줘야 한다.
class TestStream : IDisposable
{
FileStream? fs;
public TestStream(string fileName)
{
fs = new FileStream(fileName, FileMode.Open);
}
public void DoSomething()
{
// ...
}
public void Dispose()
{
fs?.Dispose(); // fs?.Close()를 써도 되긴 한다.
}
}
만약 자원 해제를 하기 전에 예외가 발생한다면 아마 프로그램이 종료되기 전까지 해당 자원을 해제할 방법은 없을 것이다.
그렇기 때문에 Dispose를 호출할 때는 try/finally 예외 처리를 동반하는 것이 관례이다.
class TestStream : IDisposable
{
FileStream? fs;
public TestStream(string fileName)
{
fs = new FileStream(fileName, FileMode.Create);
}
public void DoSomething()
{
// ...
}
public void ThrowException()
{
throw new Exception();
}
public void Dispose()
{
fs?.Dispose();
}
}
class Program
{
static void Main(string[] args)
{
TestStream testStream = null;
try
{
testStream = new TestStream("TestFile.txt");
testStream.DoSomething();
testStream.ThrowException();
}
finally
{
testStream?.Dispose(); // 최악의 경우 testStream이 null이므로 ?. 연산자를 사용한다.
}
}
}
하지만 매번 이렇게 코드를 작성하는건 가독성을 해칠 뿐만 아니라 개발자가 실수로 Dispose를 빼먹을 수도 있다.
그래서 C#은 using 예약어를 지원한다.
using (TestStream testStream = new TestStream("TestFile.txt"))
{
testStream.DoSomething();
testStream.ThrowException();
}
using 예약어를 사용할 경우 Dispose를 명시하지 않아도 블록이 끝나는 시점에 자동으로 호출한다.
실제로 컴파일러 내부에서는 using 예약어가 사용된 코드 블록을 try/finally문과 동일하게 번역한다.
IDisposable 인터페이스를 상속받는 객체가 Dispose를 구현하는 표준 패턴이 이미 존재한다.
.NET 내부 클래스들도 해당 패턴으로 제공된다.
class TestStream : IDisposable
{
FileStream? fs;
public TestStream(string fileName)
{
fs = new FileStream(fileName, FileMode.Create);
}
#region Dispose Pattern
bool disposed = false;
~TestStream()
{
Dispose(false); // 종료자에 의한 호출
}
public void Dispose()
{
Dispose(true); // 사용자에 의한 직접 호출
}
void Dispose(bool disposing)
{
if (!disposed)
{
if (disposing)
{
GC.SuppressFinalize(this);
}
fs?.Dispose();
disposed = true;
}
}
#endregion
public void DoSomething()
{
// ...
}
public void ThrowException()
{
throw new Exception();
}
}
위 코드를 하나씩 뜯어보자.
disposed 변수는 해당 객체의 자원이 해제됐는지 식별할 bool값이다.
만약 이미 해제한 자원을 다시 해제하려고 할 경우 오작동을 일으킬 수 있으므로 확실하게 예외 처리를 해줘야 한다.
종료자와 Dispose 메서드는 같은 메서드를 호출하지만 넘기는 인자가 다르다.
Dispose(bool) 메서드 내부를 보면 매개변수에 따라 동작이 미묘하게 다르고 자원 해제는 동일하게 함을 알 수 있다.
그럼 왜 호출 주체에 따라 동작을 다르게 구현해야 되는지, 애초에 종료자를 왜 사용하는지 더 알아보자.
이전에 종료자에 대해 정리한 포스트에서 종료자가 정의된 경우 GC는 메모리를 정리하는 과정에서 종료자를 호출한다고 했었다.
즉 종료자가 정의된 객체의 메모리 해제 과정은 그렇지 않은 객체와 조금 다를 수 있다.
종료자는 개발자가 실수로 객체를 Dispose 해주지 않았을 때 최후의 방어선으로 작용할 수 있다.
왜냐하면 GC에 의해 언젠가 반드시 호출될 메서드이기 때문이다.
하지만 종료자 자체는 GC에 더 많은 부담을 준다.
이는 GC가 종료자를 처리하는 방식을 이해하면 알 수 있다.
생각해보면 메모리를 해제하는 과정에서 GC가 객체에 종료자가 있는지 알 방법이 없다.
그렇기 때문에 CLR은 객체가 생성될 때 종료자가 있을 경우 종료 큐(finalization queue)라는 내부 자료구조에 객체를 등록한다.
위의 pg라는 객체가 참조를 잃어 GC의 수집 대상이 되었다고 가정해보자.
GC가 호출되더라도 종료 큐가 pg을 참조하고 있기 때문에 pg는 바로 수집되지 않는다.
GC는 pg의 세대를 올리고(올릴 수 있다면) 종료 큐에서 pg를 제거한 다음 Freachable 큐에 등록한다.
CLR은 Freachable 큐를 처리하는 별도의 스레드를 둔다.
이 스레드는 Freachable 큐에 객체가 들어오자마자 객체를 꺼내 종료자를 호출하고 큐에서 제거한다.
이렇게 pg는 종료자가 없는 다른 객체와 같이 완전히 루트 참조를 잃은 상태가 되었다.
이제 다음 GC 호출에서 pg는 정리된다.
GC가 종료자를 가진 객체를 정리하는데 최소 두 번의 호출이 필요하다는 것을 알 수 있었다.
GC 호출 한 번의 오버헤드가 얼마나 큰지 생각해보면 종료자의 오버헤드를 가늠할 수 있다.
GC.SuppressFinalize 메서드는 종료 큐에서 해당 객체의 참조를 제거하는 메서드이다.
즉 종료자를 호출하지 않는 메서드라는 말이다.
GC가 종료자를 처리하는데 얼마나 큰 비용을 감수하는지 알기에 수동으로 Dispose하는 경우에는 종료자 호출을 막는 것이다.
참고 자료
시작하세요! C# 10 프로그래밍 - 정성태