이전 포스팅 : Java의 JVM 메모리 구조
이전 포스팅에서 작성한 GC 동작 메커니즘에 대해 좀더 자세히 서술하고자 한다.
우선, GC는 Heap 영역에서 이루어진다. Heap은 Eden, S0, S1으로 이루어진 New-gen 영역과 Old-gen영역, Permanent 영역으로 구성되어 있다. 메모리 할당과 해제를 직접 수행해야 하는 C언어와 달리,
Java에서는 참조되지 않는 객체를 GC가 자동으로 제거해준다. 그 기본적인 동작 방식은 다음과 같다.
GC는 메모리 영역 내에서 프로그램이 참조하고 있는 객체와 참조하고 있지 않은 객체를 구분해 낸다. 이것을 Marking이라 한다. 위의 그림에서는 파란색으로 표시된 블록이 참조되고 있는 객체이고 주황색으로 표시된 블록이 참조되지 않고 있는 객체이다. 모든 영역이 스캔되므로 시간이 많이 소요되는 절차이다.
Marking 과정에서 주황색 블록으로 표시되었던 프로그램이 참조하지 않고 있는 객체들을 모두 제거한다. 이때 memory allocator는 포인터를 두어 빈 공간이 어디에 존재하는지 가르키게 한다.
참조되고 있지 않는 객체들을 제거한 뒤 메모리 재정렬 과정이 진행된다. 이 재정렬 과정이 필요한 이유는 다음과 같다. Deletion with Compacting 과정을 거치면 각각의 빈 공간을 가르키고 있던 포인터들은 빈 공간의 시작점을 가르키는 포인터 하나로 합쳐지게 된다.
Normal Deletion 과정이 일어나서 (a)의 회색 부분으로 표시된 것과 같은 빈 메모리 공간이 생겼다고 가정한다. 이때, 7 만큼의 공간을 차지하는 객체를 할당하고자 할 때 (a)에서는 할당이 불가하다. 7만큼의 공간을 차지하는 객체는 5만큼의 빈공간에 들어갈 수도 없고 3만큼의 빈공간에 들어갈 수도 없으며 1만큼의 빈공간에 들어갈 수도 없기 때문이다.
하지만 (b)와 같이 Deletion with Compacting 과정을 거치면 5와 3, 1만큼의 빈 공간이 모두 합쳐져 9만큼의 빈공간이 생기게 된다. 따라서 (b)에서는 7만큼의 공간을 차지하는 객체를 할당할 수 있다.
또한 (b)와 같이 빈 공간이 합쳐져 있지 않을 경우에는 어느 곳에 할당을 해야 하는지 찾는 과정에서 시간이 소요된다. (a)를 예로 들자면, 5라는 공간에 7을 넣을 수 있는지와 3이라는 공간에 7을 넣을 수 있는지, 1이라는 공간이 7을 넣을 수 있는지를 모두 체크해 보아야 하는 것이다. 객체를 할당하는 데 시간이 소요되어 전체적인 프로그램에도 지연이 발생할 수 있다.
이와 같은 이유들로 Compacting 과정이 필요함을 알 수 있다.
GC의 기본 동작에 대해 살펴보았다. 이제 이 기본 동작들을 반복하며 GC가 각 Heap 메모리 영역에서 어떻게 동작하는지에 대해 살펴보고자 한다.
가장 먼저 객체를 생성했을 때 그 객체가 할당되는 영역은 Eden 영역이다. 위의 그림과 달리 프로그램을 맨 처음 실행 할 때에는 Survivor 영역이 모두 비워져 있고 Eden 영역에만 객체가 할당된다. 위의 그림은 이미 GC가 수행되어 자주 참조되는 객체를 Survivor 영역에 할당한 경우에 해당한다.
Eden 영역에 더 이상 객체를 할당할 공간이 없을 때 Minor GC가 수행된다.
빈번히 참조되는 객체들은 survivor0 영역에 할당되고 참조되지 않는 객체들은 삭제되어 Eden 영역이 빈 공간이 된다. 각 객체들은 Minor GC로부터 살아남은 횟수를 기록하는 age bit를 갖고 있는데 Minor GC가 발생할 때마다 살아남은 객체들에 한하여 age bit를 1씩 증가시킨다. 위의 그림에서도 survivor0 영역으로 옮겨진 객체들의 age bit가 1씩 증가한 것을 알 수 있다.
survivor 영역으로 가지 않고 바로 Old-gen 영역으로 이동하는 경우도 존재하는데 이는 객체의 크기가 survivor 영역의 크기보다 큰 경우에 해당한다.
그 다음 발생하는 GC는 Eden 영역과 survivor0 영역 모두에서 marking 과정이 발생한다. 자주 참조되는 객체들은 survivor1 영역으로 이동한다. 이 과정에서 age bit가 1씩 증가한다. 자주 참조되는 객체들을 survivor1 영역으로 옮겨두었으므로 GC는 Eden 영역과 survivor0 영역의 객체들을 모두 삭제하여 빈 공간으로 만든다.
그 다음 발생하는 GC는 Eden 영역과 survivor1 영역 모두에서 marking 과정이 발생한다. 자주 참조되는 객체들은 survivor0 영역으로 이동하고 이 과정에서 age bit가 1씩 증가한다. 자주 참조되는 객체들을 survivor0 영역으로 옮겼으므로 GC는 Eden 영역과 survivor1 영역의 객체들을 모두 삭제한다.
이렇듯, survivor0와 survivor1 둘 중 한 곳은 항상 비어있는 상태로 유지가 된다. 또한 survivor0와 survivor1에 0과 1이 붙었다고 하여 우선순위가 존재하는 것은 아니다. 단순히 두 개의 survivor 영역을 구분하려는 용도로 사용된 것이다.
Minor GC를 반복하며 age bit의 숫자가 특정 age threshold(이 그림에서는 8을 임계값으로 잡고 있다)를 넘어설 경우 해당 객체는 New-gen 영역에서 Old-gen 영역으로 승격된다(Promoted). 해당 임계값은 아래의 JVM 옵션으로 설정 변경이 가능하다. 기본값은 15이다.
-XX:MaxTenuringThreshold=(임계값)
Minor GC가 계속 반복되며 Old-gen 영역마저 full 상태가 된다면 Major GC가 발생한다.
일련의 과정들을 통해 Minor GC와 Major GC가 무엇인지 확인할 수 있었다. 하지만 GC 중에서는 Full GC도 존재한다. Full GC는 Major GC와 비교했을 때 어떤 차이가 있는가?
[참조] https://www.oracle.com/webfolder/technetwork/tutorials/obe/java/gc01/index.html
[참조] https://1-7171771.tistory.com/140
[참조] https://plumbr.io/blog/garbage-collection/minor-gc-vs-major-gc-vs-full-gc