내용 보기

작성자

관리자 (IP : 172.17.0.1)

날짜

2022-03-22 09:16

제목

[C#] 다시 한번 기초 다지기! - GC(가비지 컬렉션)


Garbage Collection
.Net에서 만들어진 객체는 Managed Heap(관리 힙)에 할당되고,
더 이상 필요없을 시 CLR(GC 지원)이 해제.
Managed Heap이 가득차면 모든 객체를 Garbage로 가정하고 조사를 시작한다.
-> C/C++과 달리 프로그래머는 메모리 해제에 있어 자유로워짐(더이상 new-delete, malloc-free 안해도 됨!!)

GC동작 메커니즘
-> 선형메모리 할당 & 사용하지 않는 메모리 해제

1. 새로운 객체를 적재할 경우 메모리가 부족하지 않으면 새로운 객체를 현재까지 적재된 바로 다음 위치에 적재하고, '0'으로 초기화 한 후 NextObjPtr을 증가(선형할당)
2. 객체 적재 중 메모리가 부족하다고 판단되면 메모리 수집을 시작하기 위해 모든 객체를 Garbage로 가정하고, GC Thread를 제외한 다른 Thread를 중단.(동작 그만!! GC Thread는 단일 Thread임.)
-------메모리 수집-------------------------------------------------------------------
3. 적재가 시작된 메모리부터 순차적으로 객체 순회 시작
4. 참조가 이뤄지고 있는 객체에 대해 Graph 작성(Reference Graph)
5. 순회중 객체가 다른 객체를 참조하고 있으면 그 객체도 Graph에 추가.
6. 같은 방식으로 도달 가능한 모든 객체를 재귀적으로 순회
7. 순회중 Graph에 추가된 객체를 만날 경우 순회 중단.
8. 모든 객체를 한번씩만 순회하여 최적화
9. 순회가 끝나면 Graph에 존재하지 않는 객체를 Managed Heap에서 해제
------메모리 컴팩션(메모리 재배치)------------------------------------------------
10. 남은 나머지 데이터들을 빈틈 없이 재배열(단편화 제거)
11. Application 영역에서 참조하고 있는 객체 및 객체간 참조가 이뤄지고 있는 포인터를 수정
12. NextObjPtr 업데이트

*선형할당방식이란?
C/C++은 빈 곳이 있는 지 처음부터 순회하여 빈 곳에 할당하는 방식을 썼지만,
C#은 GC 가 재배열을 통해 빈 곳을 제거(단편화 제거)하기 때문에 마지막으로 메모리가 할당된 곳부터 추가로 할당하는 방식이라 "선형할당 방식"이라고 일컫는다.


*GC 스레드는 왜 단일 스레드일까?
GC가 하는 역할이 메모리가 부족해지면 필요없는 메모리를 해지하는데,
전체를 재귀적으로 순회하면서 살려둘 참조 Graph를 그린다.
그리고 나서 필요없는 메모리를 해지한 뒤
재배열을 실행하기 때문에 메모리에서 참조 위치가 바뀔 수 있다. 
결국 GC 스레드가 가동될 때 다른 스레드도 움직이게 되면 원치않는 참조오류등이 발생되고
이는 치명적인 프로그램 오류로 이어질 수 있다.
따라서 GC 스레드가 움직일 땐 다른 스레드는 모두 멈추는 '단일 스레드'형태가 된다.


Generation

오버헤드를 줄이자!


가비지 컬렉션의 효율성을 위해 만들어진 개념.

GC 덕분에 프로그래머의 수고는 덜게 되었지만,
GC의 수집 동작 자체는 무시하기 힘든 수준의 작업량을 지님
이러한 GC의 오버헤드를 최소화 하기 위한 몇 가지 기법 존재.
=> 그 중 하나가 세대별 가비지 컬렉션임.
(*오버헤드란?
어떤 처리를 하기 위해 들어가는 간접적인 처리 시간, 메모리 등을 말함.
ex) A라는 처리를 단순하게 실행한다면 10초.
But, 안정성을 고려하고 부가적인 B라는 처리를 추가한 결과 처리시간 15초.
=> 오버헤드는 5초임.)

*가비지 컬렉션이 가지는 가정 3
1. 최근에 새롭게 생성된 객체의 수명은 짧을 것이다.(LIFO)
2. 오래 전에 생성된 객체의 수명은 길 것이다.
3. Managed Heap의 전체를 대상으로 가비지 수집을 수행하는 것보다는 일부분을 대상으로 하는 것이
더 빠를 것이다.

CLR은 0세대, 1세대, 2세대로  Managed Heap의 객체를 나누어 관리한다.(*최대 2세대까지만 )
처음 프로세스가 시작되면 Managed Heap에는 어떠한 객체도 없을 것.
이 시점 이후 추가되는 객체들을 '0세대'라고 부름.
-> 즉, '0세대' 객체들은 새롭게 추가된 객체이면서 아직까지 GC의 수집작업에 한 번도 대상이 되지 않았던 객체들을 의미.

CLR이 초기화 될 때 CLR은 '0세대' 용량 미리 결정.
만약 새로운 객체가 생성되면서 '0세대'의 용량을 초과하게 되면 GC가 '0세대' 수집 시작
이때 수집되지 않고 살아남은 객체들은 '1세대'로 분리되어 '0세대' 힙은 초기화됨.


CLR이 초기화되면 '0세대'의 크기 결정. 이 시점에 CLR은 '1세대' 크기도 함께 결정되며 '0세대' 컬렉션만 수행.
-> 가정 1을 떠올려보라. '최근에 새롭게 생성된 객체의 수명은 짧을 것이다.'
-> '0세대'에는 많은 수집 대상 객체가 있을 것이고 이것을 수집하게 되면 메모리 측면에서 많은 이익 얻을 수 있음. & '1세대'에 포함된 객체 수집 작업을 하지 않아도 되므로 수행 속도 상승
=> 실제로 성능이 향상되는 이유는,
Managed Heap의 객체를 모두 탐색하지 않아도 되기 때문이다.
만일 참조되고 있는 객체가 '1세대'의 객체라면 가비지 수집기는 이 객체가 참조하고 있는 객체 모두 무시가능.(GC 메모리 관리시 일어나는 탐색과정에서 Reference Graph를 생성하는 작업을 단순화시킬 수 있음)
'0세대'의 가비지 컬렉션을 수행하는 데1/1000초 미만으로 소요되므로 자주 일어나도 괜찮음.



0세대와 1세대를 기준으로 계속해서 Managed Heap을 사용하게 되면 언젠가는 1세대의 크기가 점점 커져서 한계점에 도달하게 됨.
이렇게 수차례 0세대 수집이 반복되는 동안 1세대가 가득 차게 되면 GC는 1세대와 0세대 모두에 수집 작업을 시작. 이 시점에서 1세대에서 수집 대상에 속하지 않은 객체들은 2세대로 구분되어 별도로 관리.
2세대 역시 CLR이 초기화 되는 시점에 최대 용량 결정.
-> 0세대, 1세대, 2세대 세대가 높아질수록 메모리 크기 커짐.

GC는 스스로 성능 최적화가 가능하다

GC가 수집을 할 때마다 만들어진 프로세스의 동작 방식을 학습한다.
1. 만일 0세대에서 살아남는 객체가 거의 없다면 GC는 0세대의 한계 용량을 축소.
-> 메모리의 크기 자체가 줄기 때문에 GC의 수집이 자주 발생할 수 있으나, 반대로 수집 시작에 더 적은 스트레스를 받게 됨.
2. 이와 반대로 GC가 0세대 수집을 진행한 결과 객체들이 너무 많이 살아남았을 경우, GC는 0세대 한계용량 확대.
->GC의 횟수는 줄어들지만 수집 시점에 더 많은 스트레스 받게 됨.
-> 이러한 방식으로도 충분한 메모리가 확보되지 않으면 OutOfMemoryException이 발생하여 이전 모든 세대를 대상으로 수집 작업 수행.



Heap

메모리를 생각한다면 Heap보다 Stack!

Value Type은 스택에 저장,  Reference Type은 힙에 저장
스택 메모리는 해당 함수가 종료되면 할당된 값도 사라짐.
그러나 힙은 모름.(GC의 영역)

클래스를 인스턴스하려면 반드시 new를 해줘야한다. (이때 Heap에 메모리할당)
특정 메소드안에서 new로 생성된 인스턴스는 메소드를 빠져나오면 더 이상 사용하지 않게 돼
가비지로 처리된다. 이러한 패턴의 메소드가 자주 호출될수록 가비지도 많이 발생된다.
=> 클래스를 구조체(struct)로 바꿔보면 new연산자로 인스턴스를 만들어도 Heap영역에 메모리가
할당되지 않는다 구조체 역시 Value Type이기 때문에 Stack영역에 할당되며 메소드를 빠져나갈경우
자동으로 삭제된다. Heap영역이 아니므로 가비지컬렉션의 대상이 되지않는다.
구조체도 싫다면 멤버변수로 사용한다.

ex) class와 struct 정의 예시

public class Book { public decimal price; public string title; public string author; }
public struct Book { public decimal price; public string title; public string author; }





WeakReference

약한 참조로 가비지화 시키자!

의도하지 않은 참조 없애기
가비지란 더 이상 참조가 없는 메모리를 뜻함.
그런데 의도치 않은 참조가 일어나면 가비지화되지 못하고 메모리만 늘어나게 됨.(누적..)
이러한 참조를 지닌 개체를 System.WeakReference 클래스를 사용하여 강한 참조를 약한 참조로 변환시켜주어 개체가 필요없어졌을 경우 의도치 않은 참조를 없애준다.
-> 객체를 직접 생성하고 삭제하는 모듈이 아닌 이상 WeakReference 사용 권장.

System.WeakReference는 가비지 컬렉션에 의한 객체 회수를 허용하면서 객체를 참조
->스턴스를 참조하려면 WeakReference.Target으로 접근
->원본 인스턴스가 가비지 컬렉터에 의해 회수되면 WeakReference.Target은 null이 반환된다.
(WeakReference Target 값을 보관하면 강한 참조가 일어나 GC가 회수하지 못하니 주의)

public class Sample { private class Fruit { public Fruit(string name) { this.Name = name; } public string Name { private set; get; } } public static void TestWeakRef() { Fruit apple = new Fruit("Apple"); Fruit orange = new Fruit("Orange"); Fruit fruit1 = apple; // 강한 참조 // WeakReference를 이용 WeakReference fruit2 = new WeakReference(orange); Fruit target; target = fruit2.Target as Fruit; // 이 경우 결과는 애플과 오렌지가 나오게 됩니다. Console.WriteLine(" (1) Fruit1 = \"{0}\", Fruit2 = \"{1}\"", fruit1.Name, target == null ? "" : target.Name); // 모두 참조하지 않도록 null값을 넣어줍니다. apple = null; orange = null; // 가비지 컬렉터를 작동시킨다 System.GC.Collect(0, GCCollectionMode.Forced); System.GC.WaitForFullGCComplete(); // 그 후 같은 방법으로 결과를 확인해보면 // fruit1과 fruit2의 값을 바꾼 적은 없지만, fruit2의 결과가 달라집니다. target = fruit2.Target as Fruit; // 결과는 애플만 나오게 된다. // 오렌지는 가비지 컬렉터에게 회수되버렸기때문입니다 Console.WriteLine(" (2) Fruit1 = \"{0}\", Fruit2 = \"{1}\"", fruit1 == null ? "" : fruit1.Name, target == null ? "" : target.Name); } }

Fruit2가 참조하고 있던 orange 인스턴스는 가비지 컬렉터에 의해 회수돼 null이 됨.
(실제로 돌렸는데 2번째에서도 Fruit2="Orange"가 나온다..ㄷㄷ)

Disposable

원하는 시점에 메모리 해제!

MS는 관리되지 않는 메모리(리소스)를 해제하는 용도로 System.IDisposable이라는 인터페이스를 제공한다.
IDisposable 인터페이스를 상속받은 클래스라면 용도에 맞게 Dispose() 메소드를 구현하여 비관리자원을 해제할 수 있음.
-> 객체를 사용하고, 모든 사용이 끝나면 Dispose()를 호출시켜 GC를 기다리지 않고 자원해제 가능.

* using문은 자동으로 Dispose()호출(명시적 호출X)
-> try 블록 내에 개체를 배치한 다음 finally 블록에서 Dispose()메소드 호출하는 것과 동일한 결과

using (Font font1 = new Font("Arial", 10.0f)) { byte charset = font1.GdiCharSet; }
{ Font font1 = new Font("Arial", 10.0f); try { byte charset = font1.GdiCharSet; } finally { if (font1 != null) ((IDisposable)font1).Dispose(); } }


원하는 시점에 메모리를 해제하려면,
Weak Reference와 IDisposable을 함께 사용하는 것이 좋다.



*코딩지침
1. 객체를 너무 많이 할당하지 말 것.
   1)+ 연산자 대신 StringBuilder의  append()사용
   2)메소드 안에 생성한 객체 <- 이러한 메소드가 자주 호출될 경우 클래스 대신 구조체(스택 영역에 할당)로 바꿔준다. (class -> struct)
   3) Boxing 피하기 -> 제네릭 컬렉션 사용 권함

<리스트 8> Generic collection class Example { static public void BadCase() { ArrayList list = new ArrayList(); int evenSum = 0; int oddSum = 0; for (int i = 0; i < 1000000; i++) list.Add(i); foreach (object item in list) { if (item is int) { int num = (int)item; if(num % 2 ==0) evenSum += num; else oddSum += num; } } Console.WriteLine("EvenSum={0}, OddSum={1}", evenSum, oddSum); } static public void GoodCase() { List<int> list = new List<int>(); int evenSum = 0; int oddSum = 0; for (int i = 0; i < 1000000; i++) list.Add(i); foreach (int num in list) { if (num % 2 == 0) evenSum += num; else oddSum += num; } Console.WriteLine("EvenSum={0}, OddSum={1}", evenSum, oddSum); } }

2. 너무 큰 객체 할당을 피할 것.
3. 너무 복잡한 참조 관계는 만들지 말 것.(물리고 물리는 관계 no no~)
4. 루트를 너무 많이 만들지 말 것.


출처1

https://blog.naver.com/sam_sist/220991901842

출처2