728x90
int* test = (int*)malloc(sizeof(int));
free(test);

메모리는 유한하다.

메모리는 할당이 있으면 해제가 있어야한다.


여러분이 프로그램을 실행중인데 프로그램을 켜놓으면 켜놓을수록 메모리 점유율이 올라가는걸 볼 수 있다.

이는 제때 해제해주지 않고 계속 메모리에 쌓여서 그렇다.

이런걸 메모리 누수(Memory Leak)라고 부른다.


여러분이 C나 C++을 짜면 알겠지만 메모리 해제는 쉽지 않다.

아니, 더 자세히 말하면 쉽지 않은 정도가 아니라 어렵다.

상황이 복잡해 질수록 해제를 하는것도 복잡해진다.

하지만 큰 프로그램을 짤때 누수를 무시할 순 없다.

하나의 struct가 100byte를 잡아먹는다고 가정하자.

10개만 해제 못해도 1KB가 누수된다. 이걸로 자료구조를 짰는데 통째로 날라갔다 생각해봐라. 끔찍하다.


물론 프로그램의 메모리 누수는 결국 프로그램을 종료하면 모두 사라지게 된다.

따라서 프로그램을 오래사용하지 않을 수록, 크기가 작을수록 누수는 체감상 와닿지는 않는다.

그러나 여러분이 그런 작은 프로그램만 개발할건 아니지 않는가?

서버가 누수난다고 껏다킬수 있는가? 그런 용기있는 개발자는 드물 것이다.

게임같은경우에는 한 캐릭터의 메모리가 1MB가 되는 경우도 허다하다. 메모리해제가 제대로 이뤄지지 않는다면 어떻게 될까?


간단한 프로그램이라면 헤제가 명시적이지만 커지면 해제에 대한 예외가 너무 심해진다.

그래서 일정한 패턴이 생기게 되지만 이 패턴으로도 모든 예외를 잡아내지 못한다.


그래서 C나 C++로 프로그래밍을 하면서 메모리 테이블을 만들기 시작한게 현재 Garbage Collection의 시작이다.


출처 - https://medium.com/naukri-engineering/garbage-collection-in-elasticsearch-and-the-g1gc-16b79a447181


Garbage Collection은 쓰레기 수거인을 뜻하는 Garbage Collector에서 따왔다.

자체로는 쓰레기 수거라는 뜻이다. 즉 못쓰게된 메모리를 쓰레기라고 규정한 것이다.


int* test = (int*)malloc(sizeof(int));
test = NULL;

못쓰게된 메모리, 즉 쓰레기의 정의는 위처럼 해제하지 않은 메모리를 뜻한다.

우리는 test에 동적할당했지만 해제하지않고 다른 변수를 가져다 붙혔다.(위의 경우 NULL)

이 경우 우리가 선언했던 메모리는 더 이상 접근도 불가능하면서 메모리는 차지한다.


일단 우리는 두가지 방법의 Garbage 선정 알고리즘에대해서 알아보도록 하자.


갯수를 꾸준히 세면되지 => Referece Counting

가장 간단한 발상이다. 하지만 강력하면서 많이 쓰인다.

출처 - https://plumbr.io/handbook/what-is-garbage-collection/automated-memory-management/reference-counting


C나 C++에서 사실 더 먼저 쓰였던 기법이다. 물론 문법적으로 제공하는건 아니고 옛날 논문을 보면 이 방식이 나온다.

쉽게 말해서 갯수를 세는 것이다.


class Inner:
def __init__(self):
self.data = 10


class Outer:
def __init__(self, data):
self.data = data


inner = Inner()
outer = Outer(inner)
inner = None
outer = None

예제는 파이썬 코드다. 왜 파이썬 코드로 짰냐면 파이썬이 바로 reference counting방식을 쓰기 때문이다.

위의 코드에서 reference counting이 어떻게 동작하는지 보자.


inner = Inner()

일단 이 코드로 인해 Inner()inner에 연결되있다. 즉 자신을 참조하는녀석이 1명이므로 reference counting(이하 rc)는 1이 된다.


outer = Outer(inner)

이 코드로 인해서 Outer(inner)outer와 연결되서 참조하는 녀석이 1이된다. 반면 Inner()는 다시 Outer(inner)와 연결되므로 rc가 2로 증가된다.


inner = None

이제 inner는 null이 되므로 Inner()의 rc는 다시 1로 감소한다. 하지만 아직 gc의 대상은 아닌데 아직 Outer(inner)가 참조하고 있기 때문이다.


outer = None

여기서 outer는 null이 되므로 Outer(inner)의 rc는 0이 되므로 gc의 대상이 된다. 또한 Inner()역시 rc가 0이 되므로 gc의 대상이 된다.


이 방식의 구동하는 방식을보면 알겠지만 몇가지의 장점과 몇가지의 단점을 동시에 얻게된다.


1.reference의 증감을 카운팅해야하기 때문에 변수의 등록할때마다 갱신이 필요하다.

2.순환참조를 알아낼 수 없다.

3.컴파일 시간에 적용하기 용이하다.


컴파일 시간에 쓰기 용이한 이유는 referene를 굳이 카운팅을 하지 않아도 미리 컴파일 시간에 변수들을 다 찾아내서 해제하는 로직이 가능하다.

이를 이용한 방식이 바로 swift이다.


하지만 가장 큰 단점은 순환참조를 알아낼 수 없다는 것이다.

이는 치명적으로 결국 Java에서 Reference Counting을 포기하게된 이유기도 하다.

수많은 장점이 있지만 왜 이러한 방식이 문제가 되는지 알아보도록 하자.


class Node:
def __init__(self, data, n):
self.data = data
self.n = n


a = Node(10, None)
b = Node(20, a)
a.n = b

print(a.n.data)
print(b.n.data)

a = None
b = None

보면 알겠지만 a는 b를 가르키고 b는 a를 가르킨다.

둘다 None을 카르키는 것 같지만 사실은 Node(10, None)Node(20, a)이 서로 참조하고있다.

이 둘은 다른 애들이 다 지워도 reference count는 절대 0이 되지 않는다. 따라서 영원히 메모리에 상주하고 있다.


이 문제는 꽤 심각한데 여러분은 이러한 상황이 자주 일어나지 않을 것이라고 생각하는것 같다.

그러나 이런 체인은 생각보다 자주 형성되며 심지어 위의 예제처럼 짧은게 아니라 엄청길게 생성될 수 있다.

a->b->c->d->e->f->a같은 체인이 형성되면 디버그 하면서 찾기도 힘들 뿐더러 이들이 참조하는 다른 메모리 역시 헤제되지 않는다.

위의 체인에서 b가 배열 크기 1000짜리의 20개의 변수를 담은 class라고 가정해보자. 이 역시 영원히 지워지지 않는다.


게다가 위의 이러한 체인은 보통 배열이나 linked list같은 자료구조에서 일어나는 빈도가 높다.

물론 class나 struct에서도 자주 발생하는 경향이 있다.


따라서 위 방식의 언어들은 순환참조를 없애기위해서 서로만의 방식을 갈구한다.

ptyhon처럼 runtime시에 동작하는 언어들(인터프리터)은 일종의 mark-sweap알고리즘을 사용해서 제거한다. 이는 아래서 설명.

swift처럼 compile시에 동작하는 언어들은 키워드를 제공해서 해당사항 발생시 reference counting을 세지 않는 방식을 제공한다.

자세한 설명은 여기서 다루지 않겠다. 너무 길어지기도 하고.


그냥 처음부터 다 찾아내지 뭐 => Mark Sweep Algorithm


출처 - naver D2


그래서 등장한것이 Mark Sweep 알고리즘이다.

이 알고리즘은 root set이라는 최상단에서 부터 시작해서 운행하면서 지울 녀석과 아닌 녀석을 마크한다.

여기서 말하는 root set은 일반적으로 stack, global, native method 세종류의 메모리를 의미한다.


이 방식의 장점은 어떤 경우에라도 쓰레기를 모두 찾아낼 수 있다는 것이다.

순환역시 문제없이 찾아낼 수 있으므로 기존 GC의 문제를 해결할 수 있다.

물론 native method내에서 시스템콜로 생성된 메모리는 해제할 수 없지만 그건 위의 GC역시 마찬가지이다.


하지만 여러분이 읽어봤다면 알겠지만 이방법은 크나큰 딜레마를 가지게 된다.


1.프로그램 초반기에 메모리를 적게 점유한다면 GC를 돌라니 안돌리나 별 효과가 없다.(이건 어떤 종류의 GC나 마찬가지)

2.프로그램 후반기에 이 모든 메모리를 다 검색한다는건 비용이 많이 드는 작업이다.

3.멀티 스레드 상황에서 동기화를 보장하기가 힘들어 진다.

4.1과 2와 3이 어우러져서 후반기에 GC가 한번 동작할때마다 프로그램이 심각하게 멈추게 된다.


쉽게 말하면 1번 때문에 어짜피 초반에는 GC가 안 일어난다.

그런데 2번 때문에 후반에 하려면 시간이 좀 걸린다.


그리고 대망의 3번... 동기화를 보장하기 힘들기 때문에 결국 특단의 조치를 내리게 된다.


Stop The World => 모든 스레드 정지


Stop The world는 필자가 지어낸 말이아니라 정말로 정식명칭이 저거다.

컴돌이의 센스가 담긴 용어인데 적당히 중2병 같은게 멋있는거 같다.

동기화를 보장하기위해서는 모든 스레드를 정지할 필요성이 생기므로 모든 스레드를 락을 걸고 gc만 한 스레드에서돌린다.

결국 모든 프로그램이 정지하게된다.


자 그럼 1번 + 2번 + 3번이 합쳐서 4번의 결과를 내보내게 된다는 것이다....

그럼 사용자가 체감이 될정도로 어플리케이션이 느려지게 된다.


이건 한 때 결국 Java를 기피하게 되었던 원인중의 하나이다.

현재도 이 문제에서 결국 자유롭지 않아서 GC 튜닝이라는 해결책까지 도입되었다.

이 GC에 대한 문제는 조금 복잡해서 여러가지 알고리즘과 해결 기법들이 나와 있으나 이는 내용이 아주 길어지므로 추후 포스팅에서 언급하도록 하겠다.






물론.... 능력 있으면 C나 C++로 짜서 갓갓 인간 두뇌로 전부 해제해버리면된다.

+ Recent posts