[C# 7.1] 4. 힙과 스택 그리고 GC

RisingJade의 개발기록·2022년 6월 3일
0

C# 7.1 정리

목록 보기
4/4

5.4 힙과 스택


일반적으로 프로그램을 실행하면 프로그램의 코드는 메모리에 적재된다. 메모리 상의 코드는 CPU에 의해 하나씩 읽혀지면서 실행되는데, 이 과정에서 프로그램은 자연스럽게 데이터를 위한 메모리가 필요해진다. 따라서 메모리는 코드와 데이터로 채워진다.
힙과 스택은 데이터를 위한 메모리이라는 공통점을 가지지만, 용도에 따라 구분된다.


5.4.1 스택

스택은 스레드가 생성되면 기본적으로 1MB의 용량으로 스레드마다 할당되고, 이름에서 알 수 있듯이 자료구조에서 다루는 스택과 동작방식이 같다. 이 스택 공간을 활용해서 스레드는 메서드의 실행, 해당 메서드로 전달하는 인자, 메서드 내에서 사용되는 지역 변수를 처리한다.

참조 : https://ko.wikipedia.org/wiki/%EC%8A%A4%ED%83%9D (위키피디아 - 하드웨어 스택)

스택 Work Flow

  • 위의 그림과 같이 스레드는 메서드의 코드를 실행하기 전에 지역변수를 위한 메모리를 추가로 스택에 할당하는 과정을 거친다.
  • 메서드의 실행이 완료되면 이제 차례대로 스택에서 값들이 제거되는 과정을 거친다.
  • 메서드 실행이 완료한 CPU는 "메소드를 호출한 뒤 실행될 주소"(메소드 후출부 뒷부분 코드)값을 스택에서 꺼낸 후 실행을 이어간다.

위와 같이 스택은 그것이 속한 스레드가 메서드 호출을 할 때마다 증가하고 줄어드는 과정을 반복한다. 즉, 스택 자료구조 하나만으로 인자 전달과 지역 변수, 메서드의 실행 흐름을 제어할 수 있게 된 것이다.

5.4.1.1 스택 오버플로

위에서 말했듯 스택은 기본적으로 1MB 공간이 할당된다. 1MB는 상당히 큰 용량이지만 경우에 따라 부족할 수 있다. 이런 스택의 1MB를 전부 소비해버리고 더 쓰려고 한다면 스택 오버플로가 발생한다.

이 예외는 try/catch 유무에 상관 없이 비정상적으로 종료된다.
또한 스택 오버플로 예외가 정말 무서운 점은 콜 스택 정보와 소스코드의 라인 정보가 예외에 출력되지 않는다는 점이다!.
당연한 말이지만 이미 스택메모리가 모두 소비됐기 때문에 그 상황에서 오류 상황을 알리는 메서드를 호출할 수 없기 떄문이다!.
그래서 스택 오버플로우 예외는 한번 발생하면 원인을 파악하기가 매우 어렵다.

일반적인 상황에서는 1MB를 넘길 일이 없지만 재귀호출 상황에서 EndPoint를 정확히 명시하지 않으면 콜 스택이 과다하게 쌓여 1MB를 넘길 수 도 있으니 조심하자


5.4.2 힙

힙의 경우 별도로 명시하지 않는 한 CLR이 관리하는 관리 힙(managed heap)을 가리킨다. 관리 힙이란 CLR의 가비지 수집기(GC: Garbage Collector)가 할당/해제를 관리하기 때문에 붙여진 이름이다.
C# 기준으로 new로 할당되는 모든 참조형 객체는 힙에 할당된다.

5.4.2.1 박싱/언박싱

값 형식을 참조 형식으로 변환하는 것을 박싱(boxing)이라고 하며, 그 반대를 언박싱(unboxing)이라고 한다.
이런 변환 과정은 object 타입과 System.ValueType을 상속받은 값 형식의 인스턴스를 섞어 쓰는 경우에 발생한다.

static void Main(string[] args)
{
	int a =4;
    object obj = a; // 박싱: 값 형식인int를 찹조 형식인 object에 대입(object가 부모라 암시적 변환 가능)
    int b = (int)obj; // 언 박싱: 참조 형식인 object를 값 형식인 int에 대입
}

위의 경우 어떤 상황이 벌어지는지를 살펴보자.

    1. int a = 4; 코드에서 a는 지역변수로 스택 메모리에 4라는 4byte값이 저장된다.
    1. object obj = a; 코드에서 obj는 지역 변수고 스택에 할당된다. 하지만 object가 참조형이기 때문에 힙에도 메모리가 할당되고 변수 a의 값이 들어간다. 즉, 박싱이 발생한 것이다. 이때, obj 지역변수(스택 메모리에 있는것)는 힙에 할당된 주소를 가리킨다.
    1. int b = (int) obj; 코드에서 b는 지역 변수로 스택 메모리에 b영역이 있고, 힙 메모리에 있는 값을 스택 메모리로 복사한다.(언박싱)

값 형식을 object로 형변환 하는 것은 힙에 메모리를 할당하는 작업을 동반한다. 이와 유사하게 메서드에 인자를 전달 때도 발생한다.

static void Main(string[] args)
{
	int a = 1;
    int b = 2;
    
    int c = GetMaxValue(a,b);// 인자로 건네준 a,b는 int기 때문에 object로 박싱이 발생(관리 힙에 할당됨!)    
}
private static int GetMaxValue(object v1, object v2)
{
	int a = (int) v1;
    int b = (int) v2;//언박싱
    if(a>=b)
    	return a;
        
    return b;
}

위와 같은 경우 매개변수가 int였다면 그저 스택의 값 복사만으로 끝날 수 있는 문제였지만 박싱으로 인해 관리 힙을 사용하게 됐고, 해당 함수가 실행이 끝난 이후 v1,v2를 삭제하기 위하여 GC가 언젠간 호출되어야 하는 상황이 발생한다. 즉, GC에게 일을 시키게 만든다.
박싱이 빈번할수록 GC는 바빠지고 프로그램의 수행 성능은 그만큼 떨어진다. 따라서 박싱을 과다하게 발생시킬 수 있는 코드는 최대한 줄이자.

5.4.2.2 가비지 수집기

CLR의 힙은 세대(generation)로 나뉘어 관리한다. 처음 new로 할당된 객체는 0세대(generation 0)에 속하고 이는 GC.GetGeneration(obj)으로 알아 낼 수 있다.
처음 할당되는 객체는 모두 0세대에 속한다. 0세대 객체의 총 용량이 일정 크기를 넘어가면 GC는 가비지 수집을 한다. 사용되지 않는 0세대 객체가 있으면 없애고, 그 시점에서도 사용되는 있는 객체는 1세대로 승격한다.
프로그램이 실행되면서 이런 가비지 수집 작업은 반복되고 1세대로 승격된 객체의 총 용량도 일정 크기를 넘어가게 된다. 그럼 GC는 0세대와 1세대에 모두 가비지 수집을 한다. 1세대의 객체가 그 시점에도 사용되고 있으면 2세대로 승격한다. 만약 2세대로 승격된 객체의 총 용량도 일정 크기를 넘어가게 되면 0~2세대에 걸쳐 모든 객체를 가비지 수집한다. 하지만 이번에는 2세대의 객체가 계속 사용된다고해서 3세대로 승격되지는 않는다. 이후 2세대의 메모리 공간은 시스템이 허용하는 한 계속 커지게 된다.

  • 힙 객체를 찹조하는 스택 변수, 레지스터, 또 다른 힙 객체를 루트 참조(root reference)라고 한다. 가비지 수집에서 살아남을 수 있는 객체란 다른 말로 루트 참조가 있는 것을 의미한다.

5.4.2.3 전체 가비지 수집

GC가 세대를 나눈 이유는 프로그램 실행 도중 0세대에 할당되고 수집하는 비융이 매우 높다는 통계적인 근거를 기반으로 한다. 따라서 0세대 객체가 꾸준히 할당되어 가비지 수집이 될 기준을 넘어서면 GC는 모든 세대에 걸쳐 가비지 수집을 하지 않고 우선 0세대 힙에 대해서만 빠르게 수행한다. 하지만 점점 0,1세대가 차고 2세대까지 가득 차 버리면 어쩔 수 없이 전체 세대에 걸쳐 가비지 수집을 하는 전체 가비지 수집(FULL GC)가 발생하게 된다.
(당연히 0,1세대 GC보다 매우 느리다 -> 자주 일어나지 않는 것이 좋다.)

5.4.2.4 대용량 객체 힙

가비지 수집의 칼날에서 부터 살아남은 객체는 다음 세대로 이동한다고 했는데 이런 식의 가비지 수집은 대용량 객체에게는 부담이 된다. 예를 들어, 개발자가 20MB 크기의 객체를 생성했는데, 가비지 수집마다 20MB 메모리를 이동하는 것은 GC 입장에서는 매우 큰 성능 손실에 해당한다. 이 때문에 CLR은 일정 크기 이상의 객체는 별도로 대용량 객체 힙(LOH: Large Object Heap)이라는 특별한 힙에 생성한다.

객체의 크기가 85000바이트 이상인 경우 LOH에 할당된다. 이 크기는 내부적으로 정의된 것이므로 마이크로소프트에 의해 언제든 바뀔 수 있다.

LOH에 할당된 객체는 가비지 수집이 발생해도 메모리 주소가 바뀌지 않는다.(닷넷 4.5.1부터 LOH 힙의 객체도 이동시켜 축약하는 기능이 추가되긴 했다.) 이 때문에 LOH에 객체를 생성/해제하다 보면 필연적으로 메모리 파편화(fragmentation) 현상이 발생한다. 따라서 용량을 크게 차지하는 객체는 주의 깊게 사용해야 한다.

5.4.2.5 자원 해제

GC의 단점 중 하나는 객체가 소멸되는 시점을 개발자가 알 수 없다는 점이다. GC가 언제 동작할지는 CLR 내부에 의해 결정된다. 경우에 따라서는 관리 힙에 객체들이 자주 생성되지 않는다면 오랜 시간 동안 객체가 소멸되지 않을 수도 있다.
자원 해제와 관련해서 흔한 예는 파일 처리에도 있다. 닷넷에서는 파일은 FileStream을 통해 조작할 수 있는데, 이를 통해 파일을 열어두면 위도우 탐색기를 통해 파일을 지우려고 해도 FileStream객체가 여전히 관리 힙에 남아 있고 그 파일을 독점적으로 소유하고 있어 잠겨있기 때문에 삭제가 되지 않는다.
이처럼 닷넷에서도 GC만 믿고 재원 해제를 소홀히 하는 것은 프로그램 운영에 장애를 가져올 수 있다. 따라서 명시적인 자원 해제가 필요한 클래스를 만드는 개발자의 경우 CLose 같은 이름의 멤버 메서드를 함께 제공한다. FileStream의 경우 다음과 같다.

private static void FileCreate()
{
	FileStream fs = new FileStream("output.log", FileMode.Create);
    fs.Close();
}

마이크로소프트에서는 자원 해제가 필요하다고 판단되는 모든 객체는 개발자로 하여금 IDisposable 인터페이스를 상속받도록 권장하고 있다. 이 인터페이스에 정의된 메서드는 Dispose() 단 하나다. (위의 fs.Close()또한 fs.Dispose()해도 된다.)
이는 인터페이스를 통한 약속으로 자원 해제를 명시해야 할 것이 있다면 IDisposavle인터페이스를 구현하는 것이 좋다.

  • try/finally 구문을 통해 Dispose()를 반드시 호출하도록 할 수도 있다.
  • try/finally가 다소 번거롭다면 C#에서 부가적으로 지원하는 using예약어를 통해 똑같은 방법으로 쓸 수 있다.

ex)

// 블록 밖에 나가지면 FileLogger 클래스 내부에 정의된 Dispose메소드가 자동으로 호출된다.
using(FileLogger log = new FileLogger("sample.log")
{
	log.Write("Start");
    log.Write("Finish");
}

위와 같이 using을 통해 Dispose()호출 없이도 자동으로 호출되게 사용할 수 있다.(C# 컴파일러가 자동으로 try/finally로 변환해준다. 즉, usingtry/finally에 대한 간편 표기법에 해당된다.)

5.4.2.6 소멸자 (어렵다...)

소멸자(destructor)란 객체가 관리 힙에서 제거될 때 호출되는 메서드다. 소멸자를 만들려면 클래스와 동일한 이름으로 ~(틸드: tilde)기호만 붙이면 된다.

class UnmanagedMemoryManager
{
	~UnmanagedMemoryManager()//소멸자
    {
    	Console.WriteLine("수행됨"); //관리힙에서 제거될 때 수행
    }
}

관리 힙에 할당된 객체의 루트 찹조가 없어지면 언젠가는 GC의 실행으로 메모리가 반드시 해제된다. 그러나 이건 "관리 힙"인 경우에 한해서다. "비관리 메모리"에 할당하는 메모리 자원, 또는 윈도우 운영체제와 연동되는 핸들(HANDLE)과 같은 자원은 GC의 관리 밖의 일이기 때문에 개발자가 직접 해제를 담당해야 한다.
따라서 비관리 메모리의 경우 직접 Dispose()를 구현하고 호출해서 메모리 해제를 해야하는데 개발자도 사람이다보니 실수로 안 적어둘때가 있다. 이럴때 소멸자를 통해 안정적인 클래스 구현이 가능하다.

소멸자 깊은 이야기

소멸자에 대한 기본적인 설명은 저기까지지만 어떻게 구현되는지를 알아보자
GC는 소멸자가 구현된 객체를 생성하면 특별히 종료 큐(finaliation queue)라는 내부 자료구조에 객체를 등록한다.

위와 같은 그림과 같이 되있는 상태에서 객체 i에 대한 루트 객체가 없어졌다고 가정해보자. 객체 i 소멸자가 없는 객체였다면, GC에 의해 곧바로 관리 힙에서 없어진ㄴ다. 하지만 소멸자가 있기 때문에 GC는 종료 큐로부터 객체 i를 꺼내 별도으 ㅣFreachable 큐에 또 다시 객체를 보관해 둔다.

Freachable 큐에 있는 객체의 소멸자는 CLR에 의해 미리 생성해 둔 스레드가 호출해 준다. 이 스레드는 Freachable 큐에 항목이 들어올 때마다 해당 객체를 꺼내서 소멸자를 실행한다.
그래서 보통 위 그림과 같은 상황이 되면 바로 Freachable큐는 비어있는 상태가 된다.

이렇게 되면 이제 일반 객체와 같은 상황으로 바뀌게 되고 다음 번 GC에서 수거되어 사라진다.

고로, 소멸자가 구현된 클래스는 GC에게 일을 더 많이 시키게 된다. 따라서 특별한 이유없이 소멸자를 추가하지 말자.

  • 소멸자 팁:

    • 개발자가 Dispose()를 명시적으로 썼을 때도 굳이 GC에서 Freachable 큐로 옮기는 과정이 실행되면 이 역시 자원낭비가 되므로 명시적인 자원 해제가 됐다면 종료 큐에서 객체를 제거하는 GC.SuppressFinalize 메소드를 사용해보자
    • ex)
    class UnmanagedMemManager : IDisposable
    {
      void Dispose(bool disposing)
      {
        if(_disposed == false)
        {
            Marshal.FreeCoTaskMem(pBuffer);
            _disposed = true;//두번 해제되는것을 막기 위해 설정
        }
        if(disposing == false)
        {
            //disposing이 false인 경우란 명시적으로 Dispose()을 호출한 경우이다.
            //따라서, 종료 큐에서 자신을 제거해 GC의 부담을 줄이자(일반 관리힙 해제랑 비슷하게 된다.)
            GC.SuppressFinalize(this);
        }    
      }
    	public void Dispose()
      {
      	Dispose(false);
      }
      ~UnmanagedMemManager()
      {
      	Dispose(true);
      }
    
    }
profile
언제나 감사하며 살자!

0개의 댓글