C#은 강력한 형식(strongly typed), 어휘 범위(lexically scoped), 함수형(functional), 객체 지향(object-oriented) 및 구성 요소 지향(component-oriented) 프로그래밍 언어이다. 이런 C# 코드는 어떻게 컴파일되고 실행되는지, 또한 C#에서의 가비지 컬렉터의 역할을 살펴보고자 한다.
1) C# 코드를 작성한다.
2) C# 컴파일러를 사용해서 코드 컴파일을 진행한다. 이때 컴파일러는 코드에 오류가 있는지 살핀다.
3) 컴파일러의 결과물로 .exe나 .dll이 나온다. (바로 실행할 수 있는 결과물은 아님)
소스 코드는 Common Intermediate Language (공통 중간 언어, CIL) 또는 Intermediate Language Code (중간 언어 코드, ILC 또는 IL코드)라고 하는 중간 코드로 변환된다.
4) Common Language Runtime(공통 언어 런타임, CLR)이라고 알려진 가상 머신 구성요소를 이용하여 CIL 또는 IL 코드를 네이티브 코드 또는 기계가 이해가능한 코드로 변환한다.
- CLR(Common Language Runtime)
마이크로소프트 사의 .NET 프레임워크의 기본이자, .NET 프레임워크 가상머신(Virtual Maction(VM)의 구성요소이다. CLR은 CIL을 운영체제가 이해할 수 있는 코드로 변환하는 역할을 한다.
이 과정을 Just-In-Time(JIT) 컴파일 혹은 Dynamic Compilation 이라고 한다. 이 방식은 프로그램이 실행되는 런타임 동안에만 코드를 컴파일한다.
- JIT Compiler(Just-in-time complation)
프로그램을 실행하는 시점에 기계어로 번역하는 컴파일 기법이다. 이 기법은 CIL을 읽으며 해당 기능에 대응하는 기계어 코드를 생성하면서 그 코드를 캐싱하기 때문에, 같은 함수가 여러 번 불릴 때 매번 기계어 코드를 생성하는 것을 방지한다.
이와 같은 과정을 거쳐 C# 컴파일러는 주어진 C# 코드에 대한 결과를 반환한다.
C#에서는 가비지 컬렉터(이하 GC)라는 메모리의 할당, 해제를 관리하는 관리자가 존재하기 때문에 사용자가 직접 메모리를 해제할 필요가 없다. 메모리 관리가 필요한 C, C++과 같은 언어에 비해서는 상대적으로 편리하지만, 그렇다고 사용자가 메모리 관리에 전혀 신경 쓸 필요가 없다는 뜻은 아니다.
GC 호출 시 다른 스레드들을 일시정지하고 GC가 실행되기 때문에, 너무 잦은 GC 호출은 프로그램의 성능을 하락시킬 수 있다.
가비지 컬렉터(Garbage Collector, GC)
'더이상 참조할 수 없게 된 객체'인 '가비지'를 수집하는 시스템 (C#, 자바에서 사용)
프로그램을 실행하다 보면, 더이상 쓰지 않는 변수나 함수 같은 것이 존재하게 될 것이다. 하지만 메모리라는 것이 데이터를 저장하는 공간이 유한할 것이니, 언젠가는 필요없는 데이터를 지워야 할 때가 올 것이다. 그런 메모리 관리가 필요한 것은 분명함에도, C#에서는 직접적으로 해 본 적은 없었을 것이다.
메모리 관리를 위해 불필요한 데이터를 지워주는 역할을 하는 것이 GC이다.
C# 프로그램은 실행되면 CLR이 해당 어플리케이션을 위한 메모리 공간을 제공하며, CLR에 의해 관리되기 때문에 이를 Managed Heap이라 부른다. 각 어플리케이션은 실행되면서 자신의 Managed Heap의 첫 주소를 가리키는 포인터를 가진다.
이때 힙 메모리를 참조하는 필드는 루트 목록으로 관리되며, 동적으로 힙 메모리를 할당할 때마다 루트 목록과 힙이 연결된다. Managed Heap의 포인터는 메모리를 할당해줄 때마다 할당한 메모리만큼 이동하여 연속된 다음 주소를 가리킨다.
이와 같이 힙 메모리를 할당하면서 메모리 포인터가 이동하기 시작한다. 이런 식으로 데이터가 누적이 되어가면서, 일정 수준 이상 메모리 공간이 찼을 때 GC가 실행된다.
GC는 그 주기가 시작되었을 때 첫 번째로 Marking 단계를 진행한다.
처음엔 모든 Object를 가비지로 정하고, 루트 목록을 돌면서 각 루트의 참조를 마킹한다. 그리고 마킹한 Object에서 다른 Object를 참조하면 또 마킹하는 식으로 위와 같은 그래프를 만든다.
그렇게 모든 마킹을 마치고 나서, GC 루트에 도달할 수 없는 Object를 가비지로 간주한다.
가비지인 Object와 아닌 것을 판별하고 나서, 가비지를 해제(Sweep)하고, 빈 공간만큼의 인접한 Object들을 메모리 복사를 통해 덮어씌운다.
이와 같은 과정을 거치는 동안 다른 프로세스를 멈추기에(오버헤드 발생), GC가 자주 실행되면 소프웨어의 성능하락 문제가 생길 수 있다.
GC는 힙을 순회하면서 Object의 참조를 확인하여 가비지 여부를 판단하는 과정이 있고, 이 부분에서 시간이 많이 소요된다. 따라서 이에 따른 오버헤드를 줄여주기 위해서 사용하는 방법이 Generation 개념이다.
GC는 효율적인 수집을 위해 각 객체들에게 세대를 매긴다.
- 생성 시기가 최근이고 가비지 컬렉팅을 겪지 않은 객체 : 0세대
- 가비지 컬렉팅을 1번 겪었지만 해제되지 않은 객체 : 1세대
- 가비지 컬렉팅을 2번 겪었지만 해제되지 않은 객체 : 2세대
가비지 컬렉팅을 여러 번 겪었지만 해제되지 않은 객체는, 이후로도 해제될 확률이 낮을 거라 판단하는 방식이다.
따라서 가비지 컬렉팅을 할 때 0세대부터 실행하고, 그러고도 필요한 메모리를 확보하지 못하면 0세대와 1세대까지 포함하여 시행, 그럼에도 메모리 확보가 불가능할 시 2세대까지 포함하여 진행하는 방식이다.
GC는 위와 같이 메모리 정리에 대한 프로그래머의 부담을 덜어주는 유용한 시스템이다. 하지만 GC 자체가 많이 작동한다는 것 자체가 프로그램의 성능 하락의 요소가 될 수 있으므로, GC가 많이 발생하지 않도록 관리하는 것이 프로그래머로서의 과제라 할 수 있다.
그렇다면 가비지가 발생하는 원인이 무엇이며 어떻게 예방해야 할까?
- 객체를 지나치게 힙에 많이 할당하는 것.
- 힙에 85,000바이트 이상의 매우 큰 객체를 할당하면 LOH(Large Object Heap)이라는 특수 케이스로 분류되어 2세대로 분류된다. 이는 Full GC의 원인이 되므로 지양하도록 한다.
- 참조 관계 최소화하고, 참조 형식의 데이터(배열, 리스트 등)의 과도한 사용을 피한다.
- 루트를 많이 만들지 말 것. GC가 루트를 순회할 시간을 줄여준다.
위와 같은 4가지 요소와 더불어, 특히나 string 과 + 연산을 쓰지 말아야 할 원인도 여기에 있었다.
string 변수에 + 연산으로 문자 추가 시 원본 뒤에 그대로 추가되는 것이 아니라, 뒤에 붙일 문자열과 합친 새로운 문자열을 생성하고 변수가 새로운 문자열을 가리키게 된다. 이때 원본 문자열에 더이상 접근할 수 없게 되므로 매 실행마다 가비지가 발생하게 된다.
이와 같은 문제를 방지하기 위해서 string 변수 대신에 StringBuilder 클래스를 사용하는 방식도 있다고 한다. 이런 가비지 컬렉터의 기능과 프로그램의 성능과 관련된 부분을 유념해야 할 것이다.
참고자료
(가비지 컬렉터-이미지 참조)
https://velog.io/@cedongne/C-%EA%B0%80%EB%B9%84%EC%A7%80-%EC%BB%AC%EB%A0%89%ED%84%B0
(가비지 컬렉터)
https://sam0308.tistory.com/22