[Java] 자바 최적화(6) - 가비지 컬렉션 기초
가비지 컬렉션의 원칙
Java는 가비지 컬렉션을 이용해 메모리를 회수하지만, JVM 스펙에는 구체적인 구현에 대한 어떠한 정보도 없다. 따라서 JVM 종류에 따라 다양한 구현체가 존재하고, 필요에 따라 탈착형으로 바꿀 수도 있다. 구현에 대한 제약이 없다보니 심지어 Epsilon GC같이 메모리를 회수하지 않는 가비지 컬렉터도 있다. 그러나 이러한 특별 케이스를 제외하면 메모리 회수에 대한 큰 원칙이 2가지 있다.
하나는 사용하지 않는 메모리는 결국 회수되어야 한다는 것이다. 언제 어떤 방식으로든 회수되지 않으면 낭비되는 메모리가 쌓이다가 결국 어플리케이션이 필요한 메모리를 할당받지 못하고 에러를 발생시킬 것이다. 따라서 가비지 컬렉터는 할당된 메모리들을 모두 관리하면서 사용중인지 검사하고, 더이상 사용하지 않는 메모리를 언젠가는 회수해야 한다.
또 하나는 절대로 살아있는 객체를 회수하면 안된다는 것이다. 가비지 컬렉션은 사용자가 직접 메모리를 관리하지 않으려고 만들어진 것이기 때문에, 가비지 컬렉션이 언제 일어날지 예측할 수 없다. 따라서 어떤 객체를 사용하려고 생성했는데 가비지 컬렉션이 일어나서 없어진다면 해야할 일을 못하게 된다. 이런 어처구니가 없는 상황이 벌어지지 않도록 각 객체들이 살아있는지를 정확하게 판별해야 한다.
약한 세대별 가설
가비지 컬렉션의 대상이 되는 객체들의 수명은 일정한 패턴을 따른다. 대부분의 객체들은 아주 짧은 시간동안만 사용되며, 그렇지 않은 객체들은 대부분 아주 오랫동안 사용된다. 따라서 객체의 수명은 쌍봉분포(bimodal distribution)를 따른다.
이렇게 객체를 이원적으로 분류할 수 있기 때문에 대부분의 가비지 컬렉터는 세대별로 영역을 구분하여 관리된다. 각 객체별로 가비지 컬렉션에서 살아남은 횟수를 카운트해서 세대를 구분하고 영역을 이동시킨다.
[이미지 출처] https://www.oracle.com/webfolder/technetwork/tutorials/obe/java/gc01/index.html
JVM 힙은 크게 Young, Old, PermGen으로 구분할 수 있는데, PermGen은 클래스로더에 의해 바이트코드가 로딩되는 곳으로 가비지 컬렉션의 대상이 아니며, Java 8부터 metaspace로 바뀌면서 Native 메모리로 넘어갔기 때문에 더이상 JVM 힙의 일부도 아니다. 가비지 컬렉션과 연관이 있는 영역은 Young과 Old 영역이다.
Young 영역은 Eden과 Survivor로 나누어진다. Eden 영역은 새로 생성된 객체들이 할당되는 공간이다. Survivor 영역은 Eden 영역에서 살아남은 객체들이 이동하는 공간이다. 위 그림에서 Survivor 영역은 2개의 영역으로 나뉘는데, 가비지 컬렉션이 일어날 때마다 두 공간을 왔다갔다하면서 이동한다. 따라서 항상 둘 중 한 영역은 비어있는 상태다. Eden 영역이 가득차서 새로운 객체를 할당하지 못하게 되면 Young 영역에 가비지 컬렉션이 일어나는데, 이것을 Young GC(Minor GC)라고 한다. Young GC에서는 Eden 영역의 살아남은 객체와 Survivor 영역에서 살아남은 객체들을 다른 Survivor 영역으로 이동시킨다. Survivor 영역에서 일정 횟수 이상 살아남은 객체들은 Old 영역으로 이동한다.
Old 영역은 오랜시간 살아남은 객체들이 존재하는 영역이다. 일반적인 상황에서는 Survivor 영역에서 일정 횟수 이상 살아남은 객체들이 이동하는 공간이다. 그러나 객체의 생성 속도가 매우 빠른 경우, Survivor 영역의 공간이 부족하면 Eden 영역에서 Survivor로 이동할 수 없는 경우가 발생할 수 있다. 이런 경우에는 Eden 영역에서 Tenured 영역으로 바로 이동할 수 있다. 이것을 조기 승격(Premature Promotion)이라고 한다.
Old 영역에서 일어나는 Old GC(Major GC)는 Young GC에 비해 시간이 오래걸린다. 약한 세대별 가설에 따르면 Young 영역의 대부분의 객체들은 살아남지 못한다. 반면, Old 영역은 오래 살아남은 객체들이 있기 때문에 GC가 일어나도 살아남는 객체의 비율이 높다. 따라서 Mark 과정에서 시간이 오래걸린다. 또한, 실제로 Old 영역은 Young 영역보다 몇 배로 크기 때문에 객체의 개수도 훨씬 많다. 따라서 Old GC는 Young GC보다 훨씬 시간이 오래걸린다.
위의 내용은 어디까지나 일반적인 가비지 컬렉션에 대한 것이고, 실제 구현체는 너무나 다양하다. Java 9부터 기본 가비지 컬렉터로 사용되는 G1GC는 위 그림처럼 연속된 공간으로 영역을 구분하지 않고, 미리 나누어진 영역에 메모리를 할당하고 세대를 구분한다. 좀 더 최신 가비지 컬렉터인 ZGC와 Shenandoah GC는 심지어 객체의 세대를 구분하지 않는다.
더 이상 세대별 가설이 적용되지 않는 것처럼 보일 수도 있지만, 새로운 가비지 컬렉터들을 이해하기 위해서는 초기 가비지 컬렉터들부터 기술이 발전해온 과정을 이해하는 것이 중요하다고 생각한다. 그리고 또 재미있는 것은, ZGC와 Shenandoah GC에서도 세대를 구분하여 성능을 개선하는 방법을 연구하고 있다는 점이다.
[Generational ZGC] https://openjdk.org/jeps/439
[Generational Shenandoah] https://openjdk.org/jeps/404
Mark and Sweep
가비지 컬렉션은 기본적으로 Mark and Sweep 알고리즘으로 수행된다. 직관적인 이름을 보면 알 수 있듯이 Mark 과정은 살아있는 객체들에 표시를 하는 것이고, Sweep 과정은 살아있지 않은 객체들을 순회하면서 메모리를 회수하는 과정이다. 따라서 가비지 컬렉터에게 필요한 것은 두 가지이다. 할당된 모든 객체를 추적할 수 있는 할당 리스트, 각 객체들이 살아있는 객체인지 판별할 수 있는 방법이 필요하다.
가비지 컬렉터는 살아있는 객체들을 판별하기 위해 DFS로 완전탐색하며 마킹한다. DFS 알고리즘에는 시작점이 필요한데, 그것이 바로 GC 루트이다. GC 루트는 객체들이 존재하는 힙 외부에서 내부를 참조하는 외부 포인터를 말한다. 힙 내부의 객체를 참조하는 포인터는 종류가 다양하다. 예를 들어, 스택에 존재하는 local 변수나 parameter, 메소드 영역에 존재하는 static 변수, JNI를 통한 Native 영역에서의 참조 등이 있다. 가비지 컬렉터에 대한 JVM 스펙이 존재하지 않다보니 GC 루트에 대한 명확한 정의는 없지만, 힙 외부로부터의 참조라고 생각하면 될 것 같다.
결론적으로, Mark and Sweep 과정은 아래와 같다.
- 할당 리스트의 모든 객체에 mark bit를 지운다.
- GC 루트에서 시작해 모든 살아있는 객체의 mark bit를 세팅한다.
- 할당 리스트를 순회하면서 mark bit가 세팅되지 않은 객체를 삭제하고 메모리를 회수한다.
그러나 이러한 Mark and Sweep 과정은 어플리케이션과 동시에 수행될 수 없다. 만약 Mark 과정이 진행중인 상황에 새로운 객체를 생성한다면, 새로 만들어진 객체가 mark bit가 세팅되지 않은 객체를 참조하게 될 수도 있다. 당연히 가비지 컬렉터는 mark bit가 세팅되지 않은 객체를 삭제할 것이고, 새로운 객체는 원하는 동작을 수행할 수 없는 상태가 된다.
따라서 Mark and Sweep은 어플리케이션의 동작을 완전히 멈춘 상태에서 수행된다. 이것을 STW(Stop the World)라고 한다. 우리의 목적은 어플리케이션이지, 가비지 컬렉션이 아니다. STW에 의해 어플리케이션이 멈추는 것은 성능을 저하시키는 요인이 된다. 결국, STW를 줄이는 것이 가비지 컬렉션 최적화의 궁극적인 목표이다. GC 튜닝도 마찬가지고, 새로운 가비지 컬렉션 알고리즘 연구에서도 마찬가지다.
STW 시간을 단축하기 위해서는 Mark and Sweep 과정의 일부를 어플리케이션과 동시에 수행할 수 있어야 한다. 이것을 위해 Mark and Sweep을 추상화한 것이 삼색 마킹(Tri-color marking) 알고리즘이다.
삼색 마킹
이름에서도 알 수 있듯이, 삼색 마킹 알고리즘에는 3가지 색깔을 사용해 객체를 구분한다.
- 흰색 - 아직 탐색하지 않은 객체
- 회색 - 탐색의 대상 객체
- 검은색 - 탐색이 끝나 살아있는 것으로 판별된 객체
삼색 마킹 알고리즘의 과정은 아래와 같다.
- GC 루트를 회색으로 표시한다.
- 할당 리스트의 모든 객체를 흰색으로 표시한다.
- 회색 객체들을 순회하면서 검은색으로 바꾸고, 흰색인 자식노드를 모두 회색으로 표시한다.
- 회색 객체가 없을 때까지 3번을 반복한다.
- 흰색 객체를 삭제하고 메모리를 회수한다.
얼핏 보기에는 Mark and Sweep에서 사용하는 DFS와 비슷해 보이지만, 객체의 상태가 2가지에서 3가지로 바뀌면서 어플리케이션과의 동시 실행이 가능해졌다. 새로 생성된 객체를 살아남은 객체와 사용하지 않는 객체 중 하나가 아닌, 탐색의 대상으로 표시하면 새로 생성된 객체가 참조하는 객체들의 탐색을 강제하여 살아있는 객체의 회수를 방지할 수 있게 되었기 때문이다.
CMS(Concurrent Mark and Sweep)
이렇게 어플리케이션과의 동시 실행이 가능한 가비지 컬렉터 중 하나가 CMS GC이다. CMS GC는 Old 영역을 정리하는 가비지 컬렉터로, STW로 중단되는 시간을 최소화하기 위해 Mark와 Sweep 과정의 일부를 어플리케이션과 동시에 실행한다. 구체적인 과정은 6가지 단계로 이루어진다.
- Initial Mark - STW가 일어나며, GC 루트의 객체들을 탐색한다.
- Concurrent Mark - 어플리케이션 실행과 동시에 진행되며, 회색 객체들을 탐색하며 마킹한다.
- Concurrent Preclean - Remark 시간을 줄이기 위해 Concurrent Mark 단계 중 생성된 객체들을 확인한다.
- Remark - STW가 일어나며, Concurrent Mark 단계에서 생성된 객체들을 마킹한다.
- Concurrent Sweep - 어플리케이션 실행과 동시에 사용하지 않는 객체들을 정리한다.
- Concurrent Reset - GC 과정에서 사용한 데이터를 리셋한다.
CMS GC는 STW로 인해 중단되는 시간을 최소화한다. 이것은 응답시간이 중요한 어플리케이션에서는 장점이 된다. 그러나 알고리즘에서 볼 수 있듯이 한 번의 GC가 일어날 때 STW가 두 번 발생하고, 전체 GC에 걸리는 시간도 길다. 또한, 기본적인 Mark and Sweep에 비해 알고리즘이 복잡하므로 CPU와 메모리를 추가적으로 사용하게 된다. 따라서 어플리케이션의 throughput은 감소하게 된다는 단점이 있다. 또한, CMS GC의 실행 중에 Old 영역이 가득차게 되면 ParallelOld GC로 자동 변경되는데, 이것을 CMF(Concurrent Mode Failure)라고 한다. 따라서 CMS GC에서는 객체 생성이 급격히 많아지게 되면 예상과 다르게 동작할 수 있다. CMS GC에서는 살아남은 객체들을 압축하는 과정이 없기 때문에 힙 단편화를 발생시키기도 한다.
이러한 단점들로 인해 CMS GC는 Java 9부터 deprecated 되고, Java 14부터는 완전히 제거되었다.
기타 개념들
카드 테이블
Young GC가 일어날 때 Young 영역의 객체들에 대한 참조를 탐색해야 하는데, 힙 외부로부터의 참조 뿐만 아니라 드물게 Old 영역에서의 참조가 존재할 수 있다. 이러한 경우를 대비하기 위한 자료구조가 카드 테이블이다. 카드 테이블은 Old 영역의 512바이트당 1바이트의 공간으로 만든다. 예를 들어, 어떤 메소드에서 static 변수를 초기화한다고 하자.
this.map = new HashMap<>();
JVM은 내부적으로 아래와 비슷한 코드를 실행한다.
CARD_TABLE[&this >> 9] = 1;
결론적으로, Young GC가 일어날 때는 GC root와 카드 테이블에서 Young 영역으로의 참조를 찾아 살아있는 객체를 찾는다는 뜻이다.
세이프 포인트
가비지 컬렉션에서 STW를 완전히 없애는 것은 불가능하다. 따라서 결국 GC를 위해서는 어플리케이션의 동작을 멈춰야 하는데, 안전하게 동작을 멈추는 지점을 세이프 포인트라고 한다. 세이프 포인트에 대한 정의도 JVM마다 다르기 때문에 딱 정의하기는 어렵지만, 바이트코드 2개를 실행할 때마다 체크하거나, JIT 컴파일러에 의해 컴파일된 경우 루프의 시작점 등에서 세이프 포인트를 체크한다. 만약 동작을 중단해야한다면 동작을 중단하고, 다른 스레드들이 중단될 때까지 기다린다. 모든 스레드가 중단되고 나면 비로소 GC가 일어날 수 있다.
TLAB(Thread Local Allocation Buffer)
새로운 객체는 Eden 영역에 할당된다. 만약 이 Eden 영역을 여러 스레드에서 공유한다면 어떻게 될까? 같은 위치에 두 스레드가 메모리 할당을 받아서 겹치게 되거나, lock을 걸어 객체 할당에 오랜 시간이 걸릴 수 있을 것이다. 이러한 문제를 해결하기 위해 Eden 영역을 나눠 각 스레드마다 객체를 할당할 수 있는 공간을 배분한다. 이것을 TLAB라고 하며, 각 스레드별로 할당된 공간의 크기는 가변적으로 변할 수 있다.
SATB(Snapshot at the Beginning)
삼색 마킹을 이용해 어플리케이션과 가비지 컬렉션을 동시에 실행하는 경우, 맨 처음 GC 루트 객체들에 마킹을 하는 것을 SATB라고 한다. 특정 시점의 스냅샷을 남기듯이 GC 사이클이 시작할 때 이미 존재하는 객체들과 이후에 새로 생성되는 객체들을 구분하기 위한 것이다. 스냅샷을 남기기 위해서는 STW를 통해 어플리케이션 실행을 멈춘 후, GC 루트를 탐색해야 한다. 만약 스냅샷을 찍는 시점에 객체가 살아있다면, 이후에 객체를 더이상 참조하지 않더라도 GC에 의해 메모리가 회수되지 않는다.
Parallel vs Concurrent vs Incremental
가비지 컬렉션의 특성을 말할 때 많이 사용되는 단어가 parallel, concurrent, incremental이다. Parallel은 가비지 컬렉션을 수행하는 스레드가 여러 개라는 의미이고, Concurrent는 어플리케이션이 실행되는 중에 가비지 컬렉션이 동시에 일어난다는 뜻이다. Incremental은 가비지 컬렉션 과정이 모두 끝나지 않은 상태에서 멈췄다가, 나중에 다시 실행할 수 있다는 뜻이다.
3줄 요약
- 대부분의 가비지 컬렉터는 세대별로 객체를 관리한다
- 가비지 컬렉션의 기본은 Mark and Sweep 알고리즘이다
- Mark and Sweep을 어플리케이션 실행과 동시에 하기 위해서 삼색 마킹 알고리즘을 사용할 수 있다