22.1 가비지 컬렉터란?
C/C++ 프로그래밍의 피로도 - 프로그래머 실수 측면
객체 할당을 위해 메모리 공간을 확보하고, 할당한 후에는 포인터를 잘 유지하고 있다가 다 사용하면 해당 포인터가 가리키고 있는 메모리를 해제해줘야 한다.
메모리를 제대로 해제했는데, 해제한 줄도 모르고 그 포인터에 접근해서 코드를 실행하는 경우도 있다.
그 포인터가 가리키고 있던 메모리가 비어 있을 수도 있지만 다른 코드가 그 자리를 대신 차지하고 있을 수도 있기 때문에 어떤 일이 벌어질지 예측하기 힘들다.
C/C++ 프로그래밍의 피로도 - 객체 할당 측면
C/C++ 기반의 프로그램을 실행하는 C-런타임은 객체를 담기 위한 메모리를 여러 개의 블록으로 나눈 뒤, 이 블록을 링크드 리스트로 묶어 관리한다.
- 어떤 객체를 힙에 할당하는 코드가 실행되면, C-런타임은 메모리 링크드 리스트를 순차적으로 탐색하면서 해당 객체를 담을 수 있을 만한 여유가 있는 메모리 블록을 찾는다.
- 적절한 크기의 메모리 블록을 만나면 프로그램은 이 메모리 블록을 쪼개서 객체를 할당하고 메모리 블록의 링크드 리스트를 재조정한다.
단순히 메모리 공간에 데이터를 집어 넣는 것이 아니라 공간을 '탐색'하고 '분할'하고 '재조정'하는 오버헤드가 필요하다.
가비지 컬렉션(Garbage Collection)
- CLR의 자동 메모리 관리(Automatic Memory Management) 기능의 중심
- 더 이상 사용하지 않는 객체를 수거하는 것
가비지 컬렉터(Garbage Collector)
- CLR에서 가비지 컬렉션을 담당하는 소프트웨어
- 더 이상 사용하지 않는 객체를 수거해감
가비지 컬렉터가 치우지 못하는 메모리
기본적으로 C#으로 작성된 모든 코드는 CLR이 관리하는 관리형 코드(Managed Code)에 속한다.
CLR은 실행되는 코드에 대해 메모리 할당, 보안, 스레딩 등의 임무와 함께 쓰레기(더 이상 사용되지 않는 객체)를 치우는 일도 책임지고 있다(쓰레기를 치우는 일을 담당하는 것은 가비지 컬렉터고 CLR의 일부이다).
한편, C#으로 비관리형 코드(Unmanaged Code)도 작성할 수 있다.
비관리형 코드를 작성하기 위해서는 unsafe 키워드를 이용하면 되는데, 이 경우에는 CLR이 제공하는 서비스를 받을 수 없다.
22.2 개처럼 할당하고 정승처럼 수거하라
CLR이 객체를 메모리에 할당하는 방법
C#으로 작성한 소스 코드를 컴파일해서 실행 파일을 만들고 이 실행 파일을 실행하면, CLR은 프로그램을 위한 일정 크기의 메모리를 확보한다. C-런타임처럼 메모리를 쪼개지 않고, 메모리 공간을 통째로 확보해서 하나의 관리되는 힙(Managed Heap)을 마련한다.
그리고 확보한 관리되는 힙 메모리의 첫 번째 주소에 '다음 객체를 할당할 메모리의 포인터'를 위치시킨다.
CLR이 코드를 실행하면 '다음 객체를 할당할 메모리 포인터'가 가리키는 주소에 객체를 할당하고 포인터를 객첵가 차지하고 있는 공간 바로 뒤로 이동시킨다.
object A = new object();
다음 객체를 할당할 때는 '다음 객체를 할당할 메모리 포인터'가 가리키는 곳에 할당하고, '다음 객체를 할당할 메모리 포인터를 새로 할당된 객체가 차지하고 있는 공간 바로 뒤로 이동시킨다.
object B = new object();
CLR은 객체가 위치할 메모리를 할당하기 위해 메모리 공간을 쪼개 만든 링크드 리스트를 탐색하는 시간도 소요하지 않으며, 그 공간을 다시 나눈 뒤에 리스트를 재조정하는 작업도 필요하지 않다. C-런타임에 비해 CLR의 객체 할당 메커니즘은 단순하고 효율적이다.
CLR이 객체를 메모리에서 해제하는 방법
값 형식 객체는 스택에 할당됐다가 자신이 태어난 코드 블록이 끝나면 메모리로부터 바로 사라지고, 참조 형식 객체들만 힙에 할당되어 코드 블록과 관계 없이 계속 살아있게 된다.
if(true)
{
object a = new object();
}
위 객체는 if 블록 안에 있지만 실제 객체는 힙에 할당된다. 아래 그림은 힙에 object 형식 객체가 할당되어 있고 그 주소를 a가 가리키는 위 예제의 모습을 나타낸다.
if 블록이 끝나면 a는 사라지고 다음과 같이 A만 남게 된다.
객체 A는 어디에서도 접근할 수 없기 때문에 더 이상 사용할 수 없다. 쓰레기가 되었기 때문에 가비지 컬렉터가 회수한다.
사라져버린 a처럼 할당된 메모리의 위치를 참조하는 객체를 루트(Root)라고 부른다.
루트(root)
- 할당된 메모리 위치를 참조하는 객체
- 스택에 생성될 수 있고 힙에 생성될 수도 있음
- .NET 애플리케이션이 실행되면 JIT 컴파일러가 이 루트들을 목록으로 만들고, CLR은 이 루트 목록을 관리하며 상태를 갱신
- 가비지 컬렉터가 CLR이 관리하던 루트 목록을 참조해서 가비지 컬렉션을 수행
1. 작업 시작 전, 가비지 컬렉터는 모든 객체(A, B, C, D, E, F)가 쓰레기라고 가정한다. 즉, 루트 목록 내 어떤 루트도 메모리를 가리키지 않는다고 가정한다.
2. 루트 목록을 순회하면서 각 루트가 참조하고 있는 힙 객체와의 관계 여부를 조사한다. 만약 루트가 참조하고 있는 힙의 객체가 또 다른 힙 객체를 참조하고 있다면 이 역시도 해당 루트와 관계가 있는 것으로 판단한다(A, C, D, F). 이때 어떤 루트와도 관계가 없는 힙의 객체들(B, E)은 쓰레기로 간주된다.
3. 쓰레기 객체가 차지하고 있던 메모리는 이제 '비어 있는 공간'이다.
4. 루트 목록에 대한 조사가 끝나면, 가비지 컬렉터는 이제 힙을 순회하면서 쓰레기가 차지했던 '비어 있는 공간'에 쓰레기의 인접 객체들(A, C, D, F)을 이동시켜 차곡차곡 채워 넣는다. 모든 객체의 이동이 끝나면 다음과 같이 깨끗한 상태의 메모리를 얻게 된다.
22.3 세대별 가비지 컬렉션
가비지 컬렉션 세대
CLR은 메모리를 0, 1, 2의 3개 세대로 나눈다.
- 0세대: 가비지 컬렉션을 한 번도 겪지 않은 갓 생성된 객체들이 위치하는 곳 -> 빨리 사라질 것으로 예상
- 1세대: 0세대에서 2세대로 넘어가는 과도기의 객체들이 위치하는 곳
- 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세대에 대한 가비지 컬렉션이 수행되면, 가비지 컬렉터는 1세대와 0세대에 대해서도 가비지 컬렉션을 수행한다.
그래서 2세대 가비지 컬렉션을 Full GC(Full Garbage Collection), 즉 전체 가비지 컬렉션이라고 부르기도 한다.
힙의 각 세대는 2세대 < 1세대 < 0세대 순으로 가비지 컬렉션 빈도가 높다.
이 때문에 2세대의 객체들은 오랫동안 살아남을 확률이 높고, 따라서 가비지 컬렉터도 상대적으로 관심을 덜 주는 편이다.
반면에 0세대의 경우 새롭게 할당되는 객체들은 모두 이 곳에 할당되는 데다가, 빠르게 포화되기 때문에 가비지 컬렉터가 자주 방문하게 된다.
1세대의 경우는 2세대와 0세대의 가운데 있으니 가비지 컬렉터의 활약 빈도도 그 정도 수준이다.
한편, 2세대 힙이 가득 차게 되면, CLR은 애플리케이션을 잠시 멈추고 Full GC를 수행함으로써 여유 메모리를 확보하려 한다.
CLR이 0세대부터 2세대까지의 메모리 전체를 걸쳐 쓰레기를 수집하는데, 애플리케이션이 차지하고 있던 메모리;가 크면 클수록 Full GC 시간이 길어지므로 애플리케이션이 정지하는 시간도 그만큼 늘어나는 문제가 생긴다.
22.4 가비지 컬렉션을 이해했습니다. 뭘 해야할까
가비지 컬렉션의 성능 문제를 푸는 임무는 프로그래머의 몫이다.
CLR의 가비지 컬렉션 메커니즘에 대한 이해를 바탕으로 적절한 코딩을 하는 것이 최선이다.
CRL의 가비지 컬렉션 메커니즘에 근거한 효율적인 코드 작성 지침
- 객체 너무 많이 할당하지 않기
- 너무 큰 객체 할당 피하기
- 너무 복잡한 참조 관계 만들지 않기
- 루트 너무 많이 만들지 않기
22.4.1 객체를 너무 많이 할당하지 않기
가장 기본적인 지침이다.
CLR의 객체 할당 속도가 빠르긴 하지만 너무 많은 수의 객체는 관리되는 힙의 각 세대에 대해 메모리 포화를 초래하고, 이는 빈번한 가비지 컬렉션을 부르는 결과를 낳는다. 즉 단점의 효과가 장점이 주는 효과를 상쇄하게 된다.
객체할당 코드를 작성할 때 필요한 객체인지와 필요 이상으로 많은 객체를 생성하는 코드가 아닌지의 여부를 고려해야 한다.
22.4.2 너무 큰 객체 할당 피하기
CLR은 평소에 사용하는 보통 크기의 객체를 할당하는 힙인 '소형 객체 힙(SOH, Small Object Heap)'과는 별도로 85KB 이상의 대형 객체를 할당하기 위한 '대형 객체 힙(LOH, Large Object Heap)'을 따로 유지한다.
대형 객체 힙 (LOH, Large Object Heap)
- 85KB 이상의 대형 객체를 할당하기 위한 힙
장점
- 소형 객체 힙에 대형 객체를 할당하면 0세대가 빠르게 차오르게 되어 가비지 컬렉션을 더 자주 촉발하여 성능 저하를 초래하기 때문에 별도로 대형 객체 힙을 유지하여 이를 방지함
단점
- 객체의 크기를 계산한 뒤 그만한 여유 공간이 있는지 힙을 탐색하여 할당하기 때문에 소형 객체 힙보다 느림
- 메모리 정리를 하는 소형 객체 힙과는 다르게 큰 용량의 메모리 복사 비용이 비싸서 해제된 공간을 그대로 두어 공간을 낭비하게 됨
- 큰 공간을 낭비하게 되어 할당 시의 성능뿐만 아니라 메모리 공간 효율도 소형 객체 힙에 비해 크게 떨어짐
- 동작 방식도 C-런타임과 비슷하고, 문제점도 역시 비슷
- CLR이 대형 객체 힙을 2세대 힙으로 간주하기 때문에 대형 객체 힙에 있는 쓰레기 객체가 수거되려면 2세대에 대한 가비지 컬렉션이 수행되어야 하므로, 2세대에 대한 가비지 컬렉션은 전 세대에 대한 가비지 컬렉션을 촉발하고 순간이나마 애플리케이션의 정지를 불러옴
22.4.3 너무 복잡한 참조 관계 만들지 않기
가비지 컬렉션 성능이 아닌 코드 가독성을 위해서라도 따라야 한다.
class A
{
public C c;
}
class B
{
public A a;
}
class C
{
public A a;
public B[] b;
}
class D
{
public A a;
public B b;
public C c;
}
참조 관계 재조사 문제
참조 관계가 많은 객체는 가비지 컬렉션 후에 살아남았을 때 문제가 된다.
가비지 컬렉터는 가비지 컬렉션 후에 살아남은 객체의 세대를 옮기기 위해 메모리 복사를 수행한다.
이때 참조 관계가 복잡한 객체의 경우에는 단순히 메모리 복사를 하는 데서 끝나지 않고, 객체를 구성하고 있는 각 필드 객체 간 참조 관계를 일일이 조사해서 참조하고 있는 메모리 주소를 전부 수정한다.
클래스 구조를 간단하게 만들었다면 메모리 복사만으로 끝났을 일을 탐색과 수정까지 끌어들이게 되는 것
Write Barrier 오버헤드 문제
예제의 D 클래스를 생성된지 오래되어 2세대에 있다고 예를 들자.
갑자기 A 타입의 필드 a를 새로 생성한 객체로 업데이트했다고 치면, D의 인스턴스는 2세대에 있고, a 필드가 참조하고 있는 메모리는 0세대에 위치한다.
이때 루트를 갖고 있지 않은 a는 0세대 가비지 컬렉션에 의해 수거될 위험에 노출된다.
하지만, CLR의 쓰기 장벽(Write Barrier)라는 장치를 통해 가비지 컬렉터로 하여금 a필득가 루트를 갖고 있는 것으로 간주하게 해서 0세대 가비지 컬렉션을 모면하게 해준다.
이 쓰기 장벽을 생성하는 데 드는 오버헤드가 문제이다.
참조 관계를 최소한으로 만들면 이런 오버헤드도 줄일 수 있다.
22.4.4 루트 너무 많이 만들지 않기
가비지 컬렉터는 루트 목록을 돌면서 쓰레기를 찾는다.
루트 목록이 작아진다면 그만큼 가비지 컬렉터가 검사를 수행하는 횟수가 줄어들어서 더 빨리 가비지 컬렉션을 끝낼 수 있다.
따라서 루트를 많이 만들지 안흔 것이 성능에 유리하다.
22.4.5 작은 구멍이 댐을 무너뜨린다
대형 애플리케이션이나 고성능을 요구하는 애플리케이션을 만드는 경우가 아니라면, 그냥 코드를 작성하더라도 문제를 만나지 않을 수 있다.
하지만, 언젠가는 골치 아픈 문제가 되어 돌아올 수 있기 때문에 가비지 컬렉션 메커니즘에 근거한 효율적인 코드 작성 지침을 지키는 것이 좋다.
'공부 > C#' 카테고리의 다른 글
이것이 C#이다 Chapter 18 파일 다루기 (0) | 2024.03.17 |
---|---|
이것이 C#이다 Chapter 17 dynamic 타입 (0) | 2024.03.17 |
이것이 C#이다 Chapter 16 리플렉션과 애트리뷰트 (0) | 2024.03.17 |
이것이 C#이다 Chapter 15 LINQ (0) | 2024.03.16 |
이것이 C#이다 Chapter 14 람다식 (0) | 2024.03.16 |