프로그래밍/C#

[C#] 쓰레기 수집기 (Garbage Collector)

갓똥 2020. 5. 16. 19:59
728x90
반응형

1. 사전 학습

using System;

class Program {
    public static void Foo() {
        int n2 = 0;
        
        // 1초 후
    }
    static void Main() {
        int n1 = 0;
        
        // 1시간 경과
        Foo();
    }
}

Q. 누가 더 오래 살 수 있을까?

코드를 보기 전 위의 Case를 살펴보자.
Case 1의 경우 사람의 경우이다.
사고를 제외하고 객관적으로 오래 살 수 있는건 A이다.
사람은 보통의 경우 젊은 사람이 더 오래 살 것이다.

Case 2의 경우 프로그램의 경우이다.
위의 1번 Case는 최근에 태어난 사람이 오래 살게 되는데 프로그램은 어떨까?
위의 코드에서 Main에서 n1을 만들고 1시간이 경과한 후 Foo가 실행 됐다고 생각해보자.
Foo를 호출해도 n1은 계속 살아있을 것이다.
하지만 n2는 Foo를 호출한 당시에 생성되어 n1보다는 최근에 생성되었지만
Foo함수의 동작이 끝나면 파괴될 것이다.
즉, 프로그램의 세계에서는 생성된지 오래된 변수들이 오래 살 가능성이 높다는 것이다.

왜냐하면, 만든지 오래되었다는 이야기는
1. Main에서 만든 변수이거나
2. 멤버데이터 이거나
3. C언어 같은 경우 전역변수일 수 있다.

결론적으로 인간과 프로그램의 수명은 반대라고 할 수 있다.

 ① 사람은 최근에 태어난 사람이 오래 살지만 변수는 오래전에 만든 변수가 더 오래 살 수 있다.

 


2. 핵심 정리

using System;

class A { }
class B { }
class C { }

class Program {
    static void Main() {
        A a1 = new A();
        B b1 = new B();
        C c1 = new C();
        
        b1 = null;
        
        A a2 = new A();
    }
}

 

위의 코드와 그림을 통해 Garbage Collector의 동작을 살펴보자.
위의 코드에선 A, B, C의 클래스가 비어있지만 멤버가 있고, 크기가 다 다르다고 생각하자.

먼저 9번 라인의 A a1 = new A(); 를 실행한 모습이다.
Heap에는 A가 만들어지고 a1이라는 스택변수가 그를 가리킬 것이다.

 

다음은 다음은 어디서부터 할당하면 될지 내부적으로 포인터를 하나 할당한다.

 

그리고 10번 라인을 실행하면 위와 같은 모습이 된다.

여기서 첫 번째 특징이
C와 C++의 경우 메모리가 특정크기의 블럭들로 나눠놓고 적합한것을 찾아나가는 작업을 한다.
그러나 C#의 메모리 할당의 경우 Heap을 연속적으로 쓴다. 그래서 속도가 빠르다는 장점이 있다.

 

여기까지가 11번 라인을 실행한 모습이다.

 

여기까지가 13번 라인을 실행한 모습이다.
B는 더 이상 쓰이지 않으므로 나중에 GC에 의해 수집대상이 된다.

메모리가 가득 찼다거나, 사용자가 직접 실행하여 쓰레기 수집이 이루어졌다고 해보자.
그럼 모든 변수들을 찾아가서 쓰고 있는것과 안 쓰고 있는것을 찾는다.
A와 C는 쓰고 있으므로 수집 대상이 아니고, B는 안 쓰므로 수집 대상이 된다.

 

쓰레기 수집 후 B는 사라지고 C는 앞으로 당겨진다.
그리고 다시 메모리 할당은 C의 뒤부터 이어진다.

근데 여기서 생각해야 할 것이 있다.
A와 C는 쓰레기 수집을 했는데도 아직 살아있다.
그럼 C#의 GC가 생각하기에 메모리를 수집했음에도 불구하고 살아있는 것을 보니 더 오래 살 수 있겠다라고하여
A와 C를 1세대 힙으로 분리하고 그 다음 메모리들을 0세대 힙으로 구별하게 된다.

이렇게 하는 이유는 쓰레기 수집을 할 때 전체에 대해 매번하게 되면 시간이 너무 오래 걸리게 된다.
그래서 1세대 힙은 0세대 힙이 가득차 자리가 부족하지 않는 한 검사를 하지 않는다.
0세대 힙이 가득 차 검사 후 살아남은 힙들은 1세대 힙으로 가게 된다.
그렇게 1세대 힙이 또 가득 찰 수 있는데 이 때 검사 후 살아남게 되면 2세대 힙으로 또 옮겨진다.
이러한 과정을 반복하여 오래 살 가능성이 높은 변수는 제외하고 검사를 하는 방식이다.

 

위와 같은 그림이 된다.
2세대가 되면 하위 세대가 꽉 차지 않는 한 검사를 하지 않기 때문에
무조건 세대를 높이는 것도 좋은 것은 아니다.
더 이상 쓰지 않을 수 있는데 검사를 안 할 수 있기 때문이다.

 

using System;

class A { }
class B { }
class C { }

class Program {
    static void Main() {
        A a1 = new A();
        B b1 = new B();
        C c1 = new C();
        
        Console.WriteLine(GC.GetGeneration(a1)); // 0
        GC.Collect(0);
        
        b1 = null;
        
        Console.WriteLine(GC.GetGeneration(a1)); // 1
        GC.Collect(0);
        
        Console.WriteLine(GC.GetGeneration(a1)); // 1
        GC.Collect(0);
        
        A a2 = new A();
    }
}

 

위의 그림을 확인할 수 있는 코드이다.
GC의 멤버로 세대를 확인할 수 있는 GetGeneration이 있다.

처음 a1을 찍어보면 당연히 0세대가 나올 것이다.
그리고 쓰레기 수집을 하게 되면 살아남았으니 1세대로 올라간 모습을 볼 수 있다.

그리고 두 번째 쓰레기 수집을 하게 되면 여전히 살아있으니 2세대가 되야할 것 같지만
쓰레기 수집을 0세대에 대해서만 했으므로 1세대는 무관하니 1세대 그대로 남게 된다.

2세대로 올리려면 1세대에 대한 쓰레기 수집을 하면 된다.

 

using System;

class A { }
class B { }
class C { }

class Program {
    static void Main() {
        A a1 = new A();
        B b1 = new B();
        C c1 = new C();
        
        Console.WriteLine(GC.GetGeneration(a1)); // 0
        GC.Collect(0);
        
        b1 = null;
        
        Console.WriteLine(GC.GetGeneration(a1)); // 1
        GC.Collect(1);
        
        Console.WriteLine(GC.GetGeneration(a1)); // 2
        GC.Collect(0);
        
        a1 = null;
        
        A a2 = new A();
    }
}

 

24번 라인에 a1 = null;이 추가되었다.
분명 a1은 더 이상 쓰이지 않는 변수지만 쓰레기가 수집되려면
0세대 힙이 가득 차 정리 후 1세대로 보냈는데
1세대 힙이 가득 차 다시 정리 후 2세대로 보냈는데
2세대 힙이 가득 차 다시 정리를 할 때 수집된다...

따라서 사용자가 직접 GC를 돌리는 것은 결코 좋지 않다고 할 수 있다.
세대가 높아질 수 있기 때문이다.

 ① C#은 메모리 할당 시 Heap을 연속적으로 쓴다.

    => 속도가 빠르다.

 

 ② GC의 멤버

    => GC.GetGeneration(변수명); - 변수가 가리키는 힙의 세대 확인

    => GC.Collect(세대) - 입력한 세대에 대해서만 쓰레기 수집 실행

 

 ③ 사용자가 직접 GC를 돌리는 것은 좋지 않다.

728x90
반응형