뇌를 자극하는 C# 4.0 프로그래밍/ 가비지 컬렉션

가비지 컬렉터를 아시나요

  • C와 C++의 메모리 관리 문제점
    • 객체를 할당하기 위해 일일이 메모리 공간을 확보해야 하며, 객체를 할당한 후에는 힙을 가리키는 포인터를 잘 유지하고 있다가 객체를 다 사용하고 나면 해당 포인터가 가리키고 있는 메모리를 해제해줘야 한다. 이 때 프로그래머들이 실수로 객체의 메모리를 해제하는 것을 잊으면 메모리 누수가 발생한다.
    • 한편 메모리를 제대로 해제 했는데, 해제한 줄 모르고 그 포인터에 접근해서 코드를 실행하는 실수가 발생하는 경우도 많다. 그 포인터가 가리키는 메모리가 비어있을 수도 있지만 엉뚱한 코드가 그 자리를 대신 차지하고 있을 수도 있기 때문에 어떤 일이 벌어질지 예측하기가 어렵다.
  • 한편 프로그래머의 실수와는 별도로 C와 C++은 힙에 객체를 할당하기 위해 비싼 비용을 치뤄야 한다는 문제도 있다.
    • C, C++ 기반의 프로그램을 실행하는 C-런타임은 객체를 담기 위한 메모리를 여러 개의 블록으로 나눈 뒤, 이 블록을 링크드 리스트로 묶어서 관리한다. 가령 어떤 객체를 힙에 할당하는 코드가 실행되면 C-런타임은 메모리 링크드 리스트를 순차적으로 탐색하면서 해당 객체를 담을 만한 여유가 있는 메모리 블록을 찾느나. 적절한 크기의 메모리 블록을 만나면 프로그램은 이 메모리 블록을 쪼개서 객체를 할당하고 메모리 블록의 링크드 리스트를 재조정한다.
    • 정리하자면 단순히 메모리 공간에 데이터를 집어 넣는 것이 아니라 공간을 ‘탐색’하고 ‘분할’하고 ‘재조정’하는 오버헤드가 발생한다는 것
  • 반면 C#은 CLR이 자동 메모리 관리를 해주기 때문에 이런 문제들로부터 자유롭다. 이 자동 메모리 관리 기능의 중심에는 가비지 컬렉션이 있다. 가비지 컬렉션은 프로그래머가 무한한 메모리를 갖고 있는 것처럼 간주하고 코드를 작성할 수 있도록 한다.
  • 가비지 컬렉션을 담당하는 것을 가비지 컬렉터라고 하는데, 프로그래머가 객체를 할당해서 일을 하면 가비지 컬렉터는 객체 중에 쓰레기인 것과 쓰레기가 아닌 것을 완벽하게 분리해서 쓰레기들만 수거해 간다.
  • 가비지 컬렉터가 똑똑하게 일을 하지만 가비지 컬렉터 자체도 소프트웨어이기 때문에 CPU와 메모리 같은 컴퓨팅 자원을 소모한다. 우리가 작성한 코드도 사용해야 하는 그 자원을 가비지 컬렉터도 같이 사용해야 한다는 이야기. 만약 가비지 컬렉터가 최소한으로 이 자원을 사용하게 만들 수 있다면 프로그램의 성능을 아낀 자원의 양만큼 끌어올릴 수 있게 된다.
  • 참고) 기본적으로 C#으로 작성된 모든 코드는 CLR에게 관리되는 관리형 코드(Managed Code)에 속한다. 한편 비관리형 코드(Unmanaged Code)도 작성할 수 있는데, unsafe 키워드를 사용하면 된다. 대신 이 경우에는 가비지 컬렉션을 포함하여 CLR이 제공하는 서비스를 받을 수 없다.

개처럼 할당하고 정승처럼 수거하라

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

  • 비어 있는 힙에 첫 번째 객체를 할당하면 –object A = new object()– CLR은 ‘다음 객체를 할당할 메모리의 포인터’가 가리키는 주소에 A 객체를 할당하고 포인터를 A 객체가 차지하고 있는 공간 바로 뒤로 이동시킨다.

  • 여기서 객체를 또 할당하면 첫 번째 객체의 바로 뒤에 새로운 객체가 할당된다.

  • CLR은 객체가 위치할 메모리를 할당하기 위해 메모리 공간을 쪼개 링크드 리스트를 탐색하는 시간도 소요하지 않으며, 그 공간을 다시 나눈 뒤에 리스트를 재조정하는 작업도 필요로 하지 않는다. C-런타임에 비해 객체 할당 메커니즘이 단순한데다 효율적이다.
  • 값 형식 객체는 스택에 할당 되었다가 자신이 태어난 코드 블록이 끝나면 메모리로부터 바로 사라지고 참조 형식 객체들만이 힙에 할당되어 코드 블록과 관계없이 살아남는다. 만일 참조 객체를 만들면 객체의 내용물은 힙에 할당되지만 스택에 힙 메모리 주소의 참조가 생성된다.

  • 위 그림과 같은 상황에서 객체가 담긴 코드 블록이 끝나서 스택이 사라지면 힙에는 더는 접근할 수 없어서 사용할 수 없는 메모리가 남게 된다.

  • 객체를 잃은 채 힙에 남아 있는 객체는 쓰레기가 되고 이 쓰레기는 가비지 컬렉터가 집어가게 된다.
  • 한편 사라져 버린 객체처럼 할당된 메모리의 위치를 참조하는 객체를 루트(Root)라고 부르는데 루트는 스택에 생성될 수도 있고 정적 필드처럼 힙에 생성될 수도 있다. .NET 응용 프로그램이 실행되면 JIT 컴파일러가 이 루트들을 목록으로 만들고, CLR은 이 루트 목록을 관리하며 상태를 갱신한다. 이 루트가 중요한 이유는 가비지 컬렉터가 CLR이 관리하고 있던 루트 목록을 참조해서 쓰레기 수집을 하기 때문이다.

  • 가비지 컬렉터가 루트 목록을 이용해서 쓰레기 객체를 정리하는 과정은 다음과 같다.
    1. 작업을 시작하기 전에 가비지 컬렉터는 모든 객체가 쓰레기라고 가정한다. 즉, 루트 목록 내의 어떤 루트도 메모리를 가리키지 않는다고 가정한다.
    2. 루트 목록을 순회하면서 각 루트가 참조하고 있는 힙 객체와의 관계 여부를 조사한다. 막약 루트가 참조하고 있는 힙의 객체가 또 다른 힙 객체를 참조하고 있다면 이 역시도 해당 루트와 관계가 있는 것으로 판단한다. 이때 어떤 루트와도 관계가 없는 힙의 객체들은 쓰레기로 간주된다.
    3. 쓰레기 객체가 차지하고 있던 메모리는 ‘비어 있는 공간’이 된다.
    4. 루트 목록에 대한 조사가 끝나면 가비지 컬렉터는 이제 힙을 순회하면서 쓰레기가 차지하고 있던 ‘비어 있는 공간’에 쓰레기의 인접 객체들을 이동시켜 차곡차곡 채워 넣는다. 모든 깨체의 이동이 끝나면 깨끗한 상태의 메모리를 얻게 된다.

세대별 가비지 컬렉션

  • 버스에서 빨리 내리려는 승객은 출입구 쪽에 있고 늦게 내리려는 승객은 출입구 반대편에 있는 것처럼 CLR의 메모리도 구역을 나누어 메모리에서 빨리 해제될 객체와 오래 살아남을 것 같은 객체들을 따로 담아 관리한다.
  • CLR은 메모리를 0, 1, 2의 3개 세대로 나누고 0세대에는 빨리 사라질 것으로 예상되는 객체들을, 2세대에는 오랫동안 살아남을 것으로 예상되는 객체들을 위치시킨다.
  • CLR은 객체의 나이가 어릴수록 메모리에서 빨리 사라지고, 나이가 많을수록 메모리에서 오래 살아 남는다고 간주한다. 여기서 나이는 가비지 컬렉션을 겪은 횟수를 의미하는데, 따라서 0세대에서는 가비지 컬렉션을 한 번도 겪지 않은 ‘갓 생성된’ 객체들이 위치하고 2세대에는 최소 2회에서 수차례 가비지 컬렉션을 겪고도 살아남은 산전 수전 다 겪은 객체들이 위치한다.
  • .NET 응용 프로그램이 시작되면 CLR은 다음과 같은 (비어 있는) 관리되는 힙을 확보한다. 이 힙에는 아직 어떤 객체도 할당되지 않은 상태이다.

  • 응용 프로그램이 일을 시작함에 따라 할당된 객체들로 힙이 차오른다.

  • 할당된 객체들의 총 크기가 0세대 가비지 컬렉션 임계치에 도달하면 가비지 컬렉터는 0세대에 대해 가비지 컬렉션을 수행하고 여기서 살아남은 객체들을 1세대로 옮긴다. 이로서 0세대는 깨끗하게 비워지며, 2세대는 아직 깨끗한 상태로 남아 있게 된다.

  • 응용 프로그램은 여전히 객체를 생성해서 일을 하고 새로 생성된 객체들은 0세대에 할당된다.

  • 다시 0세대 객체의 용량이 0세대 가비지 컬렉션 임계치를 넘어서면 가비지 컬렉터가 다시 가비지 컬렉션을 수행한다.

  • 0세대는 깨끗히 비워졌지만 또다시 응용 프로그램에 의해 새로운 객체들이 할당된다. 이번에는 1세대의 임계치가 초과됐기 때문에 1세대에 대해 가비지 컬렉션을 수행한다. 이때 가비지 컬렉터는 하위 세대에 대해서도 가비지 컬렉션을 수행하기 때문에 0세대와 1세대에 대한 가비지 컬렉션이 수행된다. 여기서 살아남은 0세대 객체들은 1세대로, 2세대에서 살아남은 객체들은 2세대로 옮겨 간다.

  • 다시 응용 프로그램이 일을 수행하고 0세대가 객체들로 차오른다. 각 세대의 메모리 임계치에 따라 가비지 컬렉션이 수행되고, 가비지 컬렉션이 반복됨에 따라 0세대의 객체들은 1세대로, 1세대의 객체들은 2세대로 계속 이동한다. 2세대로 옮겨간 객체들은 더는 다른 곳으로 옮겨가지 않고 그곳에 정착한다.
  • 2세대도 포화되어 2세대에 대한 가비지 컬렉션이 수행되면 가비지 컬렉터는 동시에 1세대와 0세대에 대해서도 가비지 컬렉션을 수행한다. 그래서 2세대 가비지 컬렉션을 전체 가비지 컬렉션이라고 부르기도 한다.

  • 2세대의 힙이 가득차게 되면 CLR은 응용 프로그램의 실행을 ‘잠시 멈추고’ 전체 가비지 컬렉션을 실행하여 여유 메모리를 확보하려고 하는데, 이때 응용 프로그램이 차지하고 있던 메모리가 크면 클수록 Full GC 시간이 길어지므로 응용 프로그램이 정지하는 시간도 그만큼 늘어나게 된다.

가비지 컬렉션을 이해했습니다. 우리는 뭘 해야 하죠?

객체를 너무 많이 할당하지 마세요

  • CLR의 객체 할당 속도가 빠르긴 하지만 너무 많은 수의 객체가 관리되는 힙의 각 세대애 대해 메모리 포화를 초래하고 이는 빈번한 가비지 컬렉션을 부르는 결과를 낳는다.

너무 큰 객체 할당을 피하세요

  • CLR은 보통 크기의 객체를 할당하는 힙과는 별도로 85kb 이상의 대형 객체를 할당하기 위해 ‘대형 객체 힙(Large Object Heap, LOH)’을 따로 유지한다. 커다란 객체를 소형 객체 힙에 할당하면 0세대가 빠르게 차오르므로 이는 좋은 방법이다.
  • 그런데 이 대형 객체 힙은 메모리를 0 바이트의 낭비도 없이 사용하는 소형 객체 힙(Small Object Heap, SOH)과 달리 큰 공간을 군데군데 낭비하게 된다. 또한 CLR이 LOH를 2세대 힙으로 간주하기 때문에 LOH에 있는 쓰레기 객체가 수거되려면 2세대에 대한 가비지 컬렉션이 수행되어야 한다. 2세대에 대한 가비지 컬렉션은 전 세대에 대한 가비지 컬렉션을 뜻하기 때문에 조심해야 한다.

너무 복잡한 참조 관계는 만들지 마세요

  • 위 그림과 같은 클래스간 복잡한 참조 관계는 가독성을 떨어뜨리기 때문에 지양해야 한다.
  • 한편 이렇게 참조 관계가 많은 객체는 가비지 컬렉션 후 살아 남았을 때 문제가 된다. 가비지 컬렉터는 가비지 컬렉션 후에 살아 남은 객체의 세대를 옮기기 위해 메모리 복사를 수행하는데, 이때 참조 관계가 복잡한 객체의 경우 단순히 메모리 복사를 하는데 끝나지 않고 객체를 구성하고 있는 각 필드 객체 간의 참조 관계를 일일이 조사해서 참조하고 있는 메모리 주소를 전부 수정하게 된다. 클래스 구조를 간단하게 만들었으면 메모리 복사만으로 끝났을 일이 탐색과 수정까지 하게 되는 것.
  • 또한 복잡한 참조 관계에서 쓰기 장벽을 통해 가비지 컬렉션을 당하면 안되는 객체를 보호하는 일을 하게 된느데 이 때 쓰기 장벽을 생성하는데 드는 오버 헤드가 크기 때문에 참조 관계는 최소한으로 만드는 것이 좋다.

루트를 너무 많이 만들지 마세요

  • 가비지 컬렉터는 루트 목록을 돌면서 쓰레기를 찾아내는데, 루트 목록이 적어지면 그만큼 가비지 컬렉터가 검사를 수행하는 횟수가 줄어드므로 더 빠릴 가비지 컬렉션을 끝낼 수 있다.
It's only fair to share...Share on FacebookShare on Google+Tweet about this on TwitterShare on LinkedIn

The author

지성을 추구하는 디자이너/ suyeongpark@abyne.com

댓글 남기기