Garbage Collection이 개발자 대신 메모리를 관리해 준다는 건 알고 있었지만, 정확히 어떻게 동작하고 있는지는 모르고 개발하고 있었다. Java에서 너무 편리한 기능을 제공해 주다 보니 안일해진 것이 아닌가 싶기도 하다. 너무 깊게 이해할 필요도 없어 보이지만 간단한 원리 정도는 알 필요가 있어 보인다.
메모리 관리 기법 중의 하나로, 프로그램이 동적으로 할당했던 메모리 영역 중에서 필요없게 된 영역을 해제하는 기능이다.
C나 C++ 같은 언어를 사용하면 메모리가 할당된 데이터들에 대해 개발자가 직접 해제를 해야 한다. 하지만 Java, Python, Javascript 등과 같이 특정 언어들은 Garbage Collection이라는 개념을 도입해 이런 과정을 자동으로 제공한다. ( Java에서는 Garbage Collector가 주기적으로 메모리를 비워준다. )
여러가지 응용프로그램을 관찰한 결과 대부분의 객체는 금방 접근 불가능한 상태(=Unreachable)가 되고, 오래된 객체에서 새로운 객체로의 참조는 많지 않다는 특징을 발견했다. 이러한 특징을 전제로 하여 객체의 생존 기간에 따라 JVM의 Heap영역을 물리적으로 분리해 Young과 Old, 총 2가지 영역을 갖도록 설계했다.
Java는 모든 데이터가 객체이므로 Heap영역만 관리해 주면 됨. Primitive 타입의 데이터들은 스택 영역에 존재하기 때문에, 메소드가 수행될 때마다 알아서 사라짐. (객체의 Property로 존재하는 Primitive타입은 객체와 함께 Heap에 저장되고 객체와 묶이는 개념이기 때문에 별개)
Heap의 구조와 GC의 동작 원리는 GC의 종류에 따라 조금씩 다르기 때문에 가장 기본적인 Serial GC에 대해 정리해 보겠다.
새롭게 생성된 객체들이 할당되는 영역이다.
대부분의 객체들은 금방 Unreachable해지기 때문에, 많은 객체들이 이 영역에서 살아남지 못 하고 사라진다.
Young 영역에서 발생하는 가비지 컬렉션(Garbage Collection)을 Minor GC라고 부른다.
Young영역에서 오래동안 살아남은 객체가 복사되는 영역이다.
Young 영역보다 영역이 크고 기본적으로 오래 살아남은 객체들이 오는 곳이기 때문에 Garbage도 적고 GC도 적게 발생한다. 하지만 모든 시간이 오래 걸린다.
Old 영역에 대한 가비지 컬렉션(Garbage Collection)을 Major GC라고 부른다.
예외적인 상황으로 Old 영역에 있는 객체가 Young 영역의 객체를 참조하는 경우도 존재할 것이다. 이러한 경우를 대비하여 Old 영역에는 512 bytes의 덩어리(Chunk)로 되어 있는 카드 테이블(Card Table)이 존재한다.
카드 테이블에는 Old 영역에 있는 객체가 Young 영역의 객체를 참조할 때 마다 그에 대한 정보가 표시된다. 카드 테이블이 도입된 이유는 간단한다. Young 영역에서 가비지 컬렉션(Minor GC)가 실행될 때 모든 Old 영역에 존재하는 객체를 검사하여 참조되지 않는 Young 영역의 객체를 식별하는 것이 비효율적이기 때문이다. 그렇기 때문에 Young 영역에서 가비지 컬렉션이 진행될 때 카드 테이블만 조회하여 GC의 대상인지 식별할 수 있도록 하고 있다.
가비지 컬렉션을 수행하기 위해 애플리케이션을 멈추는 작업이다. GC를 수행하는 쓰레드를 제외한 모든 쓰레드의 작업이 중단되고, GC가 완료되면 재개된다.
Minor GC의 경우 객체들의 수명이 짧고 많은 객체를 검사하지 않기 때문에 속도가 빨라 애플리케이션에 큰 영향은 없지만, Major GC는 모든 객체를 검사해야 하기 때문에 시간이 오래 걸린다.
Major GC의 발생 횟수를 최소화 하기 위해, 메모리적 낭비를 줄이는 개발 습관이나 튜닝이 필요하다.
사용되는 메모리와 사용되지 않는 메모리를 식별하는 작업
Mark
단계에서 사용되지 않는다고 판단된 메모리를 해제하는 작업
Sweep
후에 분산되어 있는 객체들을 Heap
의 시작 주소부터 연속되어 나열되도록 모아 메모리가 할당된 부분과 그렇지 않은 부분으로 나누는 작업.
Mark
된 데이터를 새로운 메모리 영역으로 복사하는 작업. 살아 있는 객체를 복사하는 개념이기 때문에 Mark
과정과 동시에(연달아) 수행할 수 있다.
Minor GC의 경우 Mark-Copy를 사용하고, Major GC는 Mark-Sweep-Compact 형태로 가비지 컬렉션을 진행한다.
필자는 Minor GC에서는 왜 굳이 Mark-Copy를 사용하였는지 잘 모르겠다.
Mark 과정과 동시에 Copy과정이 수행 가능하다는 점에서 속도적인 이점과 데이터를 복사하는 개념이기 때문에 Sweep-Compact와 달리 다른 스레드에서의 참조가 깨지지 않아 용의하다는 장점 때문이지 않을까 싶다.
다음 버전인 Parallel GC에서 Young 영역에 대해 멀티쓰레드를 도입한 것을 보면 그렇지 않을까 하는 개인적인 의견일 뿐이다.
Young 영역은 1개의 Eden 영역과 2개의 Survivor 영역으로 구성되어 있다.
Eden 영역: 새로 생성된 객체가 할당되는 영역
Survivor 영역: 최소 1번의 GC 이상 살아남은 객체가 존재하는 영역
-> 두 개의 Survivor영역 중 하나는 항상 비워져 있다.
1. 객체가 새롭게 생성되면 Eden영역에 할당된다.
2. Eden영역이 꽉 차면 Minor GC가 발생한다.
Root Space(Stack, Native Method Stack, Method Area)로부터 접근 불가한(Unreachable) 메모리는 해제되고 살아남은 객체는 현재 사용되고 있는 Survivor 영역으로 옮겨지게 된다.
Survivor 영역으로 이동할 때마다 age-bit이 1씩 증가한다.
3. 1~2를 반복하다가 Survivor 영역이 가득 차게 되면 그 중 살아남은 객체를 다른 Survivor 영역으로 이동시킨다.
4. age-bit이 일정 수준(설정 가능) 이상으로 올라간 객체는 Old영역으로 이동시킨다.
Eden영역에 객체를 빠르게 할당하기 위해 마지막으로 할당된 객체의 주소를 캐싱해두는 방식을 사용하는데 이것을 bump the pointer라고 한다. 유효한 메모리를 탐색할 필요 없이 마지막 주소의 다음 주소에 바로 할당하도록 하여 속도를 높인 것이다. 메모리를 할당할 때 객체의 크기가 Eden영역에 들어갈 수 있는지만 확인하면 되므로 빠른 메모리 할당이 가능하다.
그러나 멀티 쓰레드 환경이라면 객체를 Eden영역에 할당할 때 동기화를 해주어야 한다. ( 동시에 같은 메모리 주소에 접근하여 할당할 경우 덮어 씌어지거나 문제가 발생할 수 있음. ) 이러한 문제를 해결하기 위해 추가로 TLABs라는 기술이 사용된다. Eden영역을 또 여러개로 나누어 각각의 쓰레드마다 객체를 할당하기 위한 주소를 부여하는 기술이다. 각 쓰레드는 자신이 갖는 주소에만 객체를 할당하면 되기 때문에 충돌 없이 Bump the poitner가 가능하다.
Yong영역에서 Root Space로부터의 참조 가능성만 고려하여 메모리를 해제할 경우, Old영역에 있는 객체가 Yong영역에 있는 객체를 참조하는 상황에서 문제가 발생할 수 있다. 그렇기 때문에 Old 영역을 확인하여 Yong영역의 객체를 참조하고 있는지까지 확인하는 작업이 필요하다. 그러나 Minor GC가 발생할 때마다 Old 영역의 객체들을 모두 순회하는 것은 효율적이지 않다.
Card Table을 이해하기 위해서는 어떻게 위와 같은 문제점을 해결할지에 대한 접근법부터 시작해야 한다. 제일 간단한 방법으로는 Old영역의 객체들 중에서 Yong영역의 객체를 참조하는 객체들을 기억하기 위해 따로 기록해 두는 것이다. 실제로 GC에서도 이와 비슷한 원리를 적용한다.
이것을 가능케 하려면 Old영역의 객체가 Yong영역의 객체를 참조하게 될 때마다 특정 코드가 실행되어야 하는데, 이러한 코드 조각을 Write Barrier라고 하는듯 하다. Write Barrier는 Old영역의 객체가 Yong영역을 참조한다는 사실을 기록한다.
실제 구현 방식에도 여러가지가 있을 수 있는데, 가장 단순하게 Yong영역의 객체를 참조하는 모든 Old영역 객체를 기록하는 방식이 있을 수 있다. 다만 메모리적으로 공간이 많이 필요할 수 있어 Card Table이라는 개념을 도입하였다. Old영역을 일정 범위로 조각하고 각 조각을 Card라고 한다. Card Table은 Card 범위 내에 있는 객체들 중에 Yong영역을 참조하는 객체가 있는지만 Bit나 Byte로 나타내는 것이다.
예를 들어 총 100바이트의 Old영역이 있고, 10바이트를 하나의 Card라고 한다면, 10bit만 있으면 모든 영역을 표현할 수 있다.
만약 1~10번째 bit중에서 2번째 비트를 1로 만들면, 10~20바이트 범위에 있는 객체들 중에 Yong영역을 참조하는 객체가 있다는 것이고, GC는 이것을 기반으로 해당 10~20바이트를 탐색하여 그 객체가 어떤 객체인지 찾아낸다.
Java의 GC에서 Card Table은 512byte당 1byte로 이루어지는 바이트 배열이다. 즉, 조각난 Old영역의 각 512byte가 하나의 Card이고, Card Table의 각 1byte가 512byte 범위의 상태를 나타내는 값이라고 볼 수 있을 것 같다.
위에서는 비트로 예시를 들었지만, Byte를 사용하고 있다. 정확한 이유는 모르겠다. Javascript의 GC관련 파일에서 Byte연산이 Bit연산 보다 빠르기 때문에 사용한다는 내용을 본 것 같긴 하지만, 내부적으로 어떤 원리로 작동하는지는 알 수 없었다.
( 위 내용은 개인적으로 찾아 보고 정리한 것이기 때문에 올바른 내용은 아닐 수 있다. 하지만 개념적으로는 얼추 맞기 때문에 대충 저런 역할을 한다고만 생각하면 될 것 같다. )
Major GC는 복잡한 것 없이 Old 영역이 모두 채워지면 Mark-Sweep-Compact 알고리즘을 이용해 GC가 일어난다.
Garbage Collection이 무엇인지 그리고 Serial GC 기준으로 Heap의 구조와 동작 원리까지 알아 보았다.
계속해서 GC는 더 효율적인 메모리 관리를 위해 업그레이드 되고 있고, 기본적인 구조만 알고 있다면 기능적으로 그리고 알고리즘적으로 차이만 있을뿐 크게 다르지는 않기 때문에 이해하기 수월할 것이다.
물론 추후에 엄청난 업그레이드가 있어서 구조가 크게 바뀌어 버린다면 다시 정리할 필요가 있을 것 같다..