자료/C샵

가비지 콜렉터의 기본과 성능 힌트

네오블루 2007. 12. 12. 20:21

Rico Mariani
Microsoft Corporation

2003년 4월

요약: .NET 가비지 콜렉터는 효율적인 메모리 사용, 장기적인 단편화 해소, 고속의 메모리 할당 서비스를 제공합니다. 이 글에서는 가비지 콜렉터의 기능을 설명하고, 가비지 수집을 실시하는 환경에서 발생 가능한 성능 문제에 대해 설명합니다.

적용 대상 :
   Microsoft® .NET Framework

목차

시작
단순화 된 모델
가비지 수집
성능
소멸화
마무리

시작

가비지 콜렉터의 효과적인 활용 방법과 가비지 수집 환경에서 발생 가능한 성능 문제를 이해하려면, 가비지 콜렉터가 어떻게 기능하는지, 프로그램 실행에 어떠한 영향을 주는지 먼저 이해하는 것이 좋습니다.

이 글의 전반부는 공통 언어 런타임 (CLR)의 가비지 콜렉터의 특성을 단순한 모델을 사용하여 대략적으로 설명하고, 후반부는 그 구조에 수반되는 성능 문제를 설명합니다.

단순화 된 모델

그림1은 단순한 관리형 힙 메모리 모델을 사용 했으며 실제 구현 과는 차이가 있다는 점에 주의하세요.

그림 1. 단순 매니지 힙 모델

단순 모델에는 몇가지 규칙이 있습니다.

  • 가비지 수집의 대상이 되는 모든 오브젝트에는 연속된 주소 공간을 할당됩니다.
  • 힙은 복수의 " 제너레이션" (자세한 것은 뒤에서 설명)으로 분할되기 때문에, 힙의 일부를 확인하여 대부분의 가비지를 제거할 수 있습니다.
  • 하나의 제너레이션내의 오브젝트는 모두 거의 같은 세대입니다.
  • 상위번호의 제너레이션에 의해 나타나는 힙 영역에는 오래된 오브젝트가 포함되어 있습니다. 오래된 오브젝트는 새로운 개체에 비해 안정적입니다.
  • 가장 오래된 오브젝트는 최하위로 주소를 지정하고, 새로운 오브젝트가 작성될 때마다 주소가 증가합니다. 따라서 그림 1과 같이 하위에 주소가 증가합니다.
  • 새로운 오브젝트를 위한 할당 포인터는 사용되는 메모리 영역 (할당된 메모리 영역)과 사용 가능한 메모리 영역 (할당되지 않은 메모리 영역)과의 경계를 나타냅니다.
  • 힙은 정기적으로 압축되어 불필요한 오브젝트를 삭제하고, 필요한 오브젝트를 힙의 하위 주소에 두어 조정합니다. 이렇게 하여 그림의 최하위에 새로운 오브젝트가 작성하는 미사용의 영역이 확장됩니다.
  • 메모리내의 오브젝트 순서는 작성된 순서대로 유지됩니다.
  • 힙의 오브젝트 사이에 틈은 없습니다.
  • 남아 있는 공간의 일부만이 할당됩니다. 필요한 경우, 예비 주소의 오퍼레이팅 시스템으로부터 추가 메모리 취득합니다.

가비지 수집

가장 알기 쉬운 전체 압축 가비지 컬렉션에 대해 먼저 설명합니다.

전체 가비지 수집

전체 가비지 수집에서는 프로그램의 실행을 중지하고, GC 힙의 " 루트"를 모두 찾아낼 필요가 있습니다. 이러한 루트에는 다양한 형태가 있지만, 스택 변수나 글로벌 변수가 가장 대표적 예입니다. 루트에서 시작해 모든 오브젝트를 확인하여, 오브젝트에 포함된 모든 오브젝트 포인터를 찾아, 오브젝트를 표시 해 갑니다. 이런 방법으로 가비지 콜렉터는 접근 가능한 모든 오브젝트를 찾아냅니다. 그 외의 " 도달 불가능한" 오브젝트는 " 폐기" 됩니다.

그림 2. GC 힙 루트

도달 불가능한 오브젝트를 특정하여, 다음에 사용할 수 있도록 그 영역을 회수할 필요가 있습니다. 이 시점의 가비지 콜렉터 목적은 " 필요한" 오브젝트를 이동하고, 쓸데 없는 영역을 없애는 것입니다. 프로그램의 실행은 중단되었기 때문에, 모든 오브젝트를 안전하게 이동할 수 있습니다. 그 후 이동처의 새로운 장소에서 모든 오브젝트가 올바르게 링크되도록 모든 포인터를 수정합니다. 남은 오브젝트의 제너레이션 번호가 하나 올라갑니다. (즉, 제너레이션의 경계가 갱신됩니다). 그 이후 실행이 재개됩니다.

부분 가비지 수집

전체 가비지 수집을 매번 실행하려면 비용이 크게 듭니다. 그래서 여기에서는 제너레이션이 어떻게 도움이 되는지 설명합니다.

우선 운이 좋은 경우를 예를 들어보겠습니다. 최근 완전 가비지 수집이 실행되었기 때문에 힙이 제대로 압축되어 있고, 그 후 프로그램의 실행이 재개되어 메모리가 할당되었습니다. 그 후에도 할당이 많이 진행되어, 메모리 관리 시스템이 가비지 수집을 실행할 시기가 되었다고 판단됩니다.

앞서 했던 가비지 수집 이후의 프로그램 실행으로 오래된 오브젝트에 대한 쓰기가 모두 행해지지 않고, 새롭게 할당 가능한 " 제너레이션 0" (gen0) 의 오브젝트에 쓰기만 했다고 합니다. 이러한 상황은 가비지 수집 프로세스를 큰 폭으로 간소화할 수 있기 때문에 매우 편리합니다.

이러한 상황에서는 일반적인 전체 가비지 수집을 실시하는 대신에 오래된 오브젝트(gen1이나 gen2) 는 아직 필요하다라고 볼 수 있습니다. 굳이 확인할 필요가 없을 정도 많은 오브젝트가 아직 필요할 것입니다. 앞서 말한 것처럼, 오브젝트에 대한 쓰기는 전혀 하지 않았기 때문에, 오래된 오브젝트로부터 새로운 오브젝트에의 포인터도 없습니다. 따라서 우선 통상대로 모든 루트를 확인해, 오래된 오브젝트를 나타내는 루트는 무시합니다. 그 외의 루트 ( gen0)을 가리키는 루트)는 통상대로 모든 포인터를 찾습니다. 포인터가 오래된 오브젝트를 나타낼 경우 그 포인터는 무시합니다.

이 프로세스가 완료되면, 오래된 제너레이션의 오브젝트를 모두 확인하는 일 없이 gen0 이 필요한 오브젝트를 모두 확인한 것이 됩니다. 그 후gen0 의 오브젝트를 통상대로 폐기하고, 그 만큼의 메모리 영역을 늦출 수 있습니다. 오래된 오브젝트에는 아무 영향도 없습니다.

여기서 상정한 상황은 불필요한 영역의 대부분이 움직임이 새로운 오브젝트에 있는 것을 알 수 있기 때문에 좋은 상황입니다. 많은 클래스는 반환 값을 위한 일시 오브젝트, 일시적인 문자열 및 그 외의 다양한 유틸리티 클래스 (열거자 등)를 작성합니다. gen0 만을 확인한다고 하는 방법을 사용하는 것에 의해서 극히 몇 안 되는 오브젝트를 확인하는 것만으로 불필요한 영역의 대부분을 간단하게 회수할 수 있습니다.

아쉽지만, 실제로 이 방법을 사용하는 것은 쉽지 않습니다. 적어도 일부의 오래된 오브젝트는 변경되어 새로운 오브젝트를 나타냅니다. 그러한 경우 오래된 오브젝트를 단순하게 무시할 수는 없습니다.

제너레이션과 Write Barriers의 제휴

위의 알고리즘을 실제로 사용 하려면, 오래된 오브젝트의 어떤 것이 변경되어 있는지를 구별할 필요가 있습니다. 변경된 오브젝트의 장소를 기억하기 위해서," 카드 테이블"로 불리는 데이터 구조를 사용합니다. 또, 이 데이터 구조를 유지하기 위해서, " write barriers"를 사용합니다. write barriers은 매니지 코드의 컴파일러에 의해 생성됩니다. 제너레이션 베이스의 가비지 수집에서는 이 두 가지 개념이 성공의 열쇠가 됩니다.

카드 테이블을 실행하려면 다양한 방법이 있지만, 비트의 배열이 가장 간단합니다. 카드 테이블의 각 비트는 힙의 메모리 범위를 나타냅니다. 여기에서는 이 메모리 범위를 128 바이트로 합니다. 프로그램이 어딘가의 주소에 오브젝트를 쓸 때마다, write barriers 코드가 128 바이트 청크(Chunk)에 쓰기 되었는지 계산하여, 카드 테이블의 대응하는 비트를 설정합니다.

가비지 수집의 다른 알고리즘을 보겠습니다. gen0 의 가비지 수집을 실행하는 경우는 오래된 제너레이션의 포인터는 모두 무시하면서, 위에서 설명한 대로 알고리즘을 사용할 수 있습니다. 그러나 완료되면 카드 테이블로 변경이 끝난 상태로서 표시 된 체크에 있는 모든 오브젝트의 모든 오브젝트 포인터도 찾아내야 합니다. 이러한 포인터는 루트와 같이 취급할 필요가 있습니다. 이렇게 포인터도 고려할 경우, gen0 오브젝트만의 가비지 수집을 올바르게 실시할 수 있습니다.

이 방법은 카드 테이블이 항상 가득 차 있는 경우는 전혀 도움이 되지 않습니다. 그러나 실제로 오래된 제너레이션에서 포인터가 변경되는 것은 비교적 적기 때문에, 이 방법을 사용하여 상당한 비용을 절약할 수 있습니다.

성능

가비지 수집의 동작의 기본적인 모델을 파악했으므로, 다음은 그 동작에 영향을 주는 몇 가지 문제에 대해 검토하여 가비지 콜렉터의 성능을 최대한 높이려면 어떠한 일을 피하면 좋을지 알 수 있습니다.

할당 회수 문제

이것은 가장 기본적인 문제입니다. 가비지 콜렉터에 의한 새로운 메모리의 할당은 매우 고속으로 위의 그림 2 에서 보듯이, 일반적으로는 할당 포인터를 이동시키고, 새로운 오브젝트를 위한 영역을 " 할당완료"에 작성하는 것만으로 끝납니다. 이보다 더 빠른 것은 없지만 조만간 가비지 수집이 필요하게 됩니다. 모든 조건이 같을 경우, 가비지 수집을 하는 것은 늦추는 것이 바람직합니다. 따라서, 새로운 오브젝트를 작성할 때 비록 그대로 작성하는 것이 빨라도 그것이 정말로 필요하고 적절한가를 확인할 필요가 있습니다.

이미 알고 있는 계시겠지만, 실제로 단지 1 행의 코드를 쓰는 것만으로 얼마나 많은 할당이 실행되었는지 잊어버리기 쉽습니다. 예를 들어, 하등의 비교 함수를 쓰고 있고, 키워드 필드를 가지는 오브젝트의 키워드를, 차례로 대문자와 소문자를 구별하지 않고 비교한다고 합니다. 이 경우, 최초의 키워드가 매우 짧을 가능성이 있기 위해, 키워드 문자열 전체를 단순하게 비교할 수는 없습니다. String.Split를 사용해 키워드 문자열을 분할해, 분할 후의 각 단편을 대문자와 소문자를 구별하지 않는 통상의 비교를 사용해 차례로 비교하면 잘 될 것 같습니다. 그렇게 생각하는 분도 많지 않을까요?

그러나 실제로 좋은 방법은 아닙니다. String.Split은 문자열 배열을 짜르는 역할을 합니다. 즉, 키워드 문자열내의 원으로부터 모든 키워드에 새로운 문자열 오브젝트 하나와 배열을 위한 오브젝트 하나가 작성됩니다. 이것이 문제입니다. 만일 이것을 컨텍스트에서 한다면 비교를 통해 대량의 일시적인 오브젝트가 작성되고 갑자기 가비지 콜렉터가 활발하게 일하기 시작합니다. 가비지 수집의 구조가 아무리 우수해도 대량의 클린 업을 하지 않으면 안됩니다. 할당을 전혀 필요로 하지 않는 비교 함수를 쓰는 것이 좋을 것입니다.

할당 크기 문제

malloc()등의 종래의 할당자를 사용할 때, 할당의 비용이 비교적 높은 것을 알고 있는 프로그래머는 대부분 코드로 malloc()의 호출을 가능한 줄이려고 합니다. 그 결과, 할당의 합계 회수를 줄일 수 있도록 필요한 오브젝트를 예측해 사전에 할당하는 등, 체크마다 할당을 합니다. 할당이 끝난 오브젝트를 하등의 풀에서 수동으로 관리하여 일종의 고속 커스텀 할당자를 작성할 수 있습니다.

매니지 코드의 세계에서는 다음과 같은 이유로 이 방법을 사용하는 필요성은 훨씬 적게 됩니다.

우선, 할당 비용가 매우 낮아지고 있습니다. 종래의 할당자와 같이 비어있는 블록을 찾을 필요가 없고, 단지 비어 영역과 할당이 끝난 영역의 사이의 경계를 움직이는 것만으로 끝납니다.

다음으로 사전 할당을 해야 할 경우, 당연히 곧바로 필요한 양보다 많은 메모리를 할당하게 됩니다. 그 결과 불필요한 가비지 수집을 하게 됩니다.

마지막으로, 가비지 콜렉터는 프로그래머가 수동으로 재이용하고 있는 오브젝트의 영역을 회수할 수 없습니다. 글로벌인 시점에서 보면 이러한 오브젝트는 모두 (현재 사용되어 있지 않은 것도 포함해) 아직 "필요" 오브젝트이기 때문입니다. 사용되어 있지 않은 오브젝트를 곧바로 사용할 수 있도록 수중에 놓아둠으로, 대량의 메모리를 낭비할 가능성이 있습니다.

사전 할당이 모든 상황에서 문제가 되는 것은 아닙니다. 예를 들어, 몇 개의 특정 오브젝트를 최초로 정리해 할당하는 경우 등에, 사전 할당을 이용할 수 있습니다. 그러나 일반적인 방법으로는 비관리형 코드 정도의 필요성은 없습니다.

너무 많은 포인터 문제

포인터가 그물코와 같이 둘러진 데이터 구조를 작성하는 경우, 두 가지의 문제가 있습니다. 하나는 오브젝트에의 쓰기가 대량으로 행해집니다 (아래의 그림 3참조). 두 번째는 그 데이터 구조에 대해서 가비지 수집을 할 때에, 가비지 콜렉터가 모든 포인터를 찾아 오브젝트 이동이 있으면, 그러한 포인터가 모두 변경되게 됩니다. 데이터 구조의 존속 기간이 길고, 별로 변경되지 않는 경우, 가비지 콜렉터가 이러한 포인터를 모두 확인하는 것은 전체 가비지 수집 (gen2 레벨의 가비지 수집) 으로만 끝납니다. 그러나 이러한 데이터 구조를 일시적으로 작성하는 경우 (트랜잭션(transaction) 처리의 일부로서 작성하는 경우는 이 비용이 빈번히 발생하게 됩니다.

그림 3. 대량의 포인터를 포함한 데이터 구조

대량의 포인터를 포함한 데이터 구조에는 가비지 수집 시기와는 관계없는 다른 문제도 있습니다. 앞에서 말한 대로, 오브젝트가 작성될 때는 차례로 연속하고 메모리를 할당할 수 있습니다. 예를 들어 파일로부터 정보를 복원하는 등, 거대하고 복잡한 데이터 구조를 작성하는 경우에 좋습니다. 다양한 데이터가 혼재하고 있었다고 해도, 메모리내에서 모든 오브젝트가 한곳에 정리되어 프로세서가 오브젝트에 고속으로 액세스 할 수 있게 됩니다. 그러나, 시간이 경과해 데이터 구조가 변경되면, 새로운 오브젝트를 오래된 오브젝트에 붙여야 합니다. 이러한 새로운 오브젝트는 상당히 나중에 작성되었기 때문에 원래 메모리내에 있던 오브젝트의 가까운 곳에는 없습니다. 비록 가비지 콜렉터에 의해서 메모리가 압축되어도, 오브젝트가 메모리내에서 여기저기에 작동되는 것은 아니고, 단지, 쓸데 없는 영역을 없애기 위해서 "늦추어진다" 뿐입니다. 그 결과 생기는 불규칙한 줄이 시간의 경과와 함께 악화되고, 결국, 정연하게 압축된 상태를 되찾기 위해서 데이터 구조 전체를 다시 새롭게 작성하게 되는 경우도 있습니다. 이 경우, 오래된 불규칙한 데이터 구조는 가비지 콜렉터에 의해서 폐기됩니다.

너무 많은 루트 문제

가비지 수집 시에는 당연히 루트에 대해서는 특별한 취급이 필요하게 됩니다. 가비지 콜렉터는 루트를 열거해, 충분히 검토합니다. 대량의 루트가 있으면, 비록 gen0 의 가비지 수집에서도 재빠르게 실행할 수 없습니다. 로컬 변수간의 오브젝트 포인터를 많이 포함한 깊은 재귀 함수를 작성하면, 실제로 엄청난 비용이 발생합니다. 그것은 단지 그러한 루트를 모두 검토해야 한다는 것만이 아닙니다. 그러한 루트로 비교적 짧은 동안에 막대한 수의 gen0 오브젝트가 보관 유지될 가능성도 있습니다 (이하 참조).

오브젝트에의 쓰기가 너무 많은 문제

앞서 말한 것처럼, 매니지 프로그램이 오브젝트 포인터를 변경할 때마다 write barriers의 코드가 방아쇠가 됩니다. 이것은 다음과 같은 두 가지 이유로 문제가 됩니다.

첫째는 write barriers의 비용은 원래 최초로 실시하려 하고 있던 조작의 비용에 필적하는 경우가 있습니다. 예를 들어, 하등의 열거자 클래스에서 단순한 조작을 실시하는 경우에, 각 스텝 마다 일부의 키 포인터를 메인 수집으로부터 열거자로 이동할 필요가 있습니다. 포인터를 복사하는 비용은 write barriers에 의해서 사실상 배가 됩니다. 또 각 루프마다 1회 이상 발생할 가능성이 있습니다.

두번째는 실제로 오래된 오브젝트에 쓰기를 했을 경우, write barriers을 방아쇠 하는 것은 이중적 의미로 문제가 됩니다. 오래된 오브젝트가 변경되면 위에서 설명한 것처럼, 사실상 루트가 작성되고, 다음 가비지 수집 시에 확인해야 하는 루트가 증가하게 됩니다. 어느 정도 이상의 수의 오래된 오브젝트가 변경되면, 가비지 수집의 대상을 가장 새로운 제너레이션에만 한정하는 것에 의해서 통상 얻을 수 있는 속도의 개선이 사실상 없어지게 됩니다.

두 가지 이유와 더불어, 모든 종류의 프로그램에서 너무 많은 쓰기를 하지 않으려는 이유도 있습니다. 모든 조건이 같은 경우, 프로세서의 캐시를 보다 효율 좋게 이용하려면, 읽기와 쓰기 양쪽 모두에 대해서, 메모리에는 손대지 않게 하는 것을 추천합니다.

중간적인 존속 기간의 오브젝트가 너무 많은 문제

마지막으로, 제너레이션 베이스의 가비지 콜렉터의 가장 큰 함정으로서 엄밀하게는 일시 오브젝트와도 존속 기간이 긴 오브젝트라고도 말할 수 없는 오브젝트가 많이 작성되는 문제가 있습니다. 이러한 오브젝트는 많은 문제를 일으킬 가능성이 있습니다. 이러한 오브젝트는 가장 비용이 낮은 gen0 의 가비지 수집에서는 클린 업 되지 않고 (아직 필요하다면), 경우에 따라서는 gen1 의 가비지 수집된 뒤 나머지는(아직 사용된다면) 그 후 곧바로 불필요하게 되기 때문입니다.

여기서 문제가 되는 것은 일단 오브젝트가 gen2 레벨이 되면 전체 가비지 수집만으로는 없앨 수는 없습니다. 전체 가비지 수집은 비용이 크기 때문에, 가비지 콜렉터는 가능한 한 그것을 늦추려고 합니다. 따라서 "중간적인 존속 기간" 의 오브젝트가 다수 있으면, gen2 이 (경우에 따라서는 놀라울 정도의 속도로) 비대화 하는 경향이 있습니다. gen2 은 좀처럼 클린 업 되지 않고, 최종적으로 클린 업 될 때는 예상을 아득하게 넘는 비용이 수반합니다.

이런 종류의 오브젝트를 피하려면, 이하와 같은 대책이 있습니다.

  1. 사용하고 있는 일시 영역의 크기에 충분히 주의하고, 할당하는 오브젝트를 가능한 한 줄인다.
  2. 존속 기간이 비교적 긴 오브젝트의 크기를 최소한으로 유지한다.
  3. 스택상의 오브젝트 포인터 (루트) 수를 가능한 한 줄인다.

이러한 대책을 세우면, gen0 의 가비지 수집이 효율적에 행해질 가능성이 높아져, gen1 도 그만큼 빠르게 커질 것은 없습니다. 그 결과, gen1 의 가비지 수집의 빈도를 억제됩니다. 또 gen1 의 가비지 수집을 실시해야 할 때가 왔을 때에는 중간적인 존속 기간의 오브젝트는 이미 불필요하게 되어 있습니다. 이 시점에서 회복할 수 있으면, 그만큼의 비용은 발생하지 않습니다.

잘 되면 gen2 의 크기는 조작이 비교적 안정된 동안은 전혀 증가하지 않습니다.

소멸화

지금까지는 단순화 한 할당 모델을 사용해 몇 개의 토픽에 대해 설명해 왔습니다. 여기에서는 이야기가 약간 복잡하게 되었지만, 이제 하나의 중요한 현상인 Finalizer와 소멸화의 비용에 대해 설명합니다. 간단하게 말하면, Finalizer는 어느 클래스에도 지정할 수 있는 옵션 멤버이며, 불필요한 오브젝트의 메모리가 가비지 콜렉터에 의해 회수될 때 반드시 사전에 호출합니다. C#에서 ~Class 구문을 사용해 Finalizer를 지정합니다.

소멸화가 가비지 수집에게 주는 영향

가비지 콜렉터가 본래라면 불필요한 것이 소멸화가 필요한 오브젝트를 최초로 찾아냈을 때에는 아직 그 오브젝트의 영역을 회수할 수 없습니다. 가비지 콜렉터는 대신에 그 오브젝트를 소멸화가 필요한 오브젝트의 리스트에 추가합니다. 게다가 소멸화가 완료할 때까지, 그 오브젝트내의 모든 포인터를 유효한 상태에 유지할 필요도 있습니다. 즉, 소멸화가 필요한 모든 오브젝트는 기본적으로 가비지 콜렉터에 있어서는 일시적인 루트 오브젝트와 같은 것이 됩니다.

가비지 수집이 완료되면," 소멸화 스레드" 하여, 소멸화가 필요한 오브젝트의 리스트를 순회해, Finalizer를 호출합니다. 이 조작이 완료하면, 오브젝트가 재차 불필요한 상태가 되어, 통상대로 가비지 수집을 합니다.

소멸화와 성능

소멸화의 기본적인 이해를 통해, 아래의 중요한 문제를 이끌어낼 수 있습니다.

우선, 소멸화가 필요한 오브젝트는 그 외의 오브젝트보다 존속 기간이 길어진다는 문제가 있습니다. 실제로 오래 존속할 가능성이 있습니다. 예를 들어, 소멸화가 필요한 오브젝트가 gen2 에 있었다고 합니다. 소멸화가 스케줄 되어도, 오브젝트는 gen2 에 있기 위해, 한번 더 가비지 수집을 하기까지 다음 번의 gen2 의 가비지 수집을 기다려야 합니다. 지금까지의 기간은 매우 길어지는 경우가 있습니다. 실제로 잘 진행될수록 길어집니다. 왜냐하면 gen2 의 가비지 수집은 비용이 크기 때문에, 별로 빈번히 행해지지 않는 것이 바람직하기 때문입니다. 경우에 따라서는 소멸화가 필요한 오래된 오브젝트의 영역이 회수 될 때까지, gen0 의 가비지 수집이 몇 십 회, 몇 백 회로 행해지기도 합니다.

소멸화가 필요한 오브젝트는 주위에도 영향을 주는 문제가 있습니다. 내부의 오브젝트 포인터를 유효한 상태로 유지할 필요가 있기 위해, 직접 소멸화를 필요로 하는 오브젝트가 메모리 내에 머무를 뿐만 아니라, 그 오브젝트가 직접적 및 간접적으로 참조 하여 모든 오브젝트도 메모리 내에 남습니다. 소멸화가 필요한 하나의 오브젝트가 거대한 오브젝트 트리의 루트가 되면, 그 트리 전체가, 경우에 따라서는 장기간에 걸쳐서, 메모리 내에 머무르게 됩니다. 따라서 Finalizer를 사용하지 않고, 사용할 경우에는 내부의 오브젝트 포인터가 가능한 한 적은 오브젝트에 배치하는 것이 중요합니다. 예를 들어 앞서 트리의 예에서는 소멸화가 필요한 자원을 다른 오브젝트로 옮기고, 그 오브젝트에의 참조를 트리 루트에 남겨 이 문제를 간단하게 회피할 수 있습니다. 이와 같이 몇 가지를 변경하여, 메모리 내에 남는 오브젝트가 하나가 되어 (작은 오브젝트라면 더욱 바람직) 소멸화 비용을 최소한으로 억제할 수 있습니다.

마지막으로, 소멸화가 필요한 오브젝트는 Finalizer 스레드의 작업을 작성하는 문제가 있습니다. 소멸화의 프로세스가 복잡한 경우, 하나밖에 없는 Finalizer 스레드에서는 이런 스텝을 실행하는데 시간이 오래 걸려, 처리 못한 작업이 발생할 가능성이 있습니다. 그 경우, 소멸화를 기다리게 되고, 보다 많은 오브젝트가 메모리내에 남게 됩니다. 따라서, Finalizer의 작업은 가능한 한 줄이는 것이 중요합니다. 또, 소멸화의 사이 모든 오브젝트 포인터가 유효한 상태에 유지된다고는 해도, 이미 소멸화가 완료한 오브젝트를 포인터가 가리키고 있을 가능성도 있다고 하는 점에도 주의해야 합니다. 그 경우, 포인터는 전혀 도움이 되지 않습니다. 일반적으로 소멸화의 코드에서는 비록 포인터가 유효해도, 오브젝트 포인터를 더듬는 것은 피하는 것이 안전합니다. 가장 바람직한 것은 안전하고 짧은 소멸화 코드 패스입니다.

IDisposable 과 Dispose

IDisposable인터페이스를 실행하면, 많은 경우 본래는 소멸화가 필요한 오브젝트로 그 비용을 회피할 수 있습니다. 자원의 존속 기간을 프로그래머가 알고 있는 경우는 가비지 수집 대신에 이 인터페이스를 사용해 자원을 회수할 수 있습니다. 실제로 그러한 경우는 자주 있습니다. 물론, 오브젝트가 단순하게 메모리만을 사용하고 있고, 소멸화나 처리가 필요 없는 것이면, 거기에 넘었던 적은 없습니다. 그러나 소멸화가 필요하고, 오브젝트를 명시적으로 관리하는 것이 간단하고 유효한 경우가 많으면 IDisposable 인터페이스를 실행하여, 소멸화의 비용을 최소화할 수 있습니다.

C# 에서는 다음과 같이 사용합니다. 이 패턴은 매우 편리합니다.

class X:  IDisposable
{
   public X(…)
   {
   … initialize resources … 
   }

   ~X()
   {
   … release resources … 
   }

   public void Dispose()
   {
// this is the same as calling ~X()
        Finalize(); 

// no need to finalize later
System.GC.SuppressFinalize(this); 
   }
};

Dispose를 수동으로 호출하여 가비지 콜렉터가 오브젝트를 유지하고 Finalizer를 호출할 필요가 없어집니다.

마무리

.NET 가비지 콜렉터는 메모리의 효율적으로 사용하여 장기적인 단편화를 해소하고, 고속의 메모리 할당 서비스를 제공합니다. 최적의 성능을 얻기 위해 유의해야 할 점들이 있습니다.

할당자를 최대한으로 활용하려면 몇 가지 유의할 점이 있습니다.

  • 특정의 데이터 구조로 사용하는 메모리는 모두 (또는 가능한 한 많이) 동시에 할당한다.
  • 일시적인 할당은 복잡하지 않는 범위에서 가능한 한 회피한다.
  • 오브젝트 포인터에의 쓰기 (특히 오래된 오브젝트에 쓰기) 의 회수를 가능한 한 줄인다.
  • 데이터 구조의 포인터의 밀도를 줄인다.
  • Finalizer는 가능한 한 사용하지 않는다. 사용하는 경우도 "리프" 오브젝트 이외에는 사용하지 않고, 필요한 경우 오브젝트를 분할한다.

주요한 데이터 구조를 재검토하거나 할당 프로파일러등의 툴을 사용해 메모리 사용의 프로 파일링을 실시하는 일상적인 대책도 메모리를 효과적으로 사용하거나 가비지 콜렉터를 최대한으로 활용하는데 많이 도움이 됩니다.