저는 프로젝트를 진행하면서 과자를 먹곤 하는데요. 과자를 먹다 나온 쓰레기들을 버리러 갈 때 팀원들의 자리에 있는 쓰레기도 함께 모아 가져가며 가비지 콜렉터라는 농담을 했습니다. 그러다가 문득 JavaScript의 Garbage Collection(가비지 콜렉션 이하, GC)은 어떤 방식으로 작동하는지 궁금했습니다. 이전에는 알아보았을 땐 BFS와 같은 탐색을 진행하면서 참조할 수 없는 메모리를 해제한다고 얼렁뚱땅 넘어갔었는데 FE article에서 소개한 Garbage Collection in V8를 읽고 내용을 정리하고 요약해 보았습니다.

V8 엔진

V8엔진은 구글에서 개발한 오픈 소스 자바스크립트 엔진입니다. 웹 브라우저에서 자바스크립트 코드를 실행하는 데 사용되며, 높은 성능을 가지고 있습니다. 또한 Node.js와 같은 환경에서도 사용되어 서버 사이드 자바스크립트 코드를 실행할 수 있게 합니다. 현재 구글 크롬 브라우저와 안드로이드 브라우저에 탑재되어 있습니다.

 

V8 엔진의 Garbage Collection의 프로세스는 JavaScript 객체에만 국한되지 않습니다. 이미 제거된 HTML요소와 사용하지 않은 CSS 스타일 시트, 이미 완성된 애니메이션 등 수집할 수 있는 모든 항목에 대한 GC 프로세스를 수행합니다.

객체 추적

객체에 대한 참조가 더 이상 없으면 해당 객체는 "죽은" 것으로 간주됩니다. 따라서 Garbage Collection의 핵심은 "죽은" 객체를 삭제하여 메모리를 확보하는 것입니다. 반면 "살아 있는" 객체는 루트에서 참조를 통해 접근 가능한 객체입니다.

단순화된 추적 체계 출처: Garbage Collection in V8


모든 객체에 대한 참조는 GlobalGCInfoTable이라는 별도의 전역 테이블에 저장됩니다. GC의 과정에서 루트(root)에서 시작하여 객체들을 반복적으로 순회(루트는 여러 개가 될 수있으며 모든 활성 루트에 대해 순회합니다.)하면서 "죽은" 객체를 찾아 메모리에서 해제하기 위함이죠. 위의 그림에서 검은 점은 참조할 수 있는 객체로 "살아 있는" 것으로, 흰색 점은 "죽은" 객체를 뜻합니다.

 

C++ 객체와는 달리 JavaScript 객체는 V8 엔진에 의해 완전히 제어되며 전용 V8 힙에 상주합니다. 각 JavaScript 객체는 Iterate, IterateFast, IterateBody, IterateBodyFast 메서드가 있는 HeadObject 클래스를 확장합니다. 힙 자체에는 이 힙의 객체를 통해 추적을 시작하는 Visit 메서드가 있습니다.

Generations

"죽은" 개체를 찾아서 청소하는 절차는 상당히 느릴 수 있습니다. C++의 경우 작업을 별도의 스레드로 위임하고 요청에 따라 수행할 수 있습니다. 그러나 단일 스레드인 JavaScript의 경우 정기적으로 전체 객체를 추적한다면 지연이 발생하고 성능이 저하될 수 있습니다. 시스템의 과부하를 방지하고 사용자가 눈치 채지 못하는 GC 프로세스를 수행하기 위해 V8에서는 프로세스를 여러 부분으로 나누어 각 부분을 별도로 작업하기로 결정했습니다.

 

대부분의 경우 생겨난지 얼마되지 않은 어린 객체가 생긴지 오래된 객체보다 더 빨리 죽을 가능성이 높다는 가설이 있습니다. 그래서 V8에서는 세대(Generation)이라는 개념을 도입했습니다. 힙은 작은 어린 세대(최대 16Mb, Young Generation)들로 나뉘며, 여기에는 모든 새로 할당된 객체들이 배치됩니다. 오래된 세대(Old generation)는 오래된 객체들을 위한 것이며, 최대 1.4Gb 크기를 가질 수 있습니다. 두 세대 모두 각각 1Mb의 페이지로 구성됩니다. 600Kb보다 큰 객체들은 별도의 페이지에 배치되며, 오래된 세대의 일부분으로 간주되죠. 오래된 세대는 단순히 오래된 객체들만이 아니라 실행 가능한 코드 객체들을 저장하는 '코드 공간’과, 객체의 구조 정보를 담고 있는‘맵 공간도 포함합니다.

Young Generation

젊은 세대에서의 Semi-space 객체 할당 메모리에서 객체를 제거하는 과정을 이해하기 위해, 먼저 이 객체들이 메모리에 어떻게 저장되는지를 파악해야 합니다. 이미 언급했듯이, 자바스크립트는 단일 스레드에서 작동합니다. 동기화가 필요하지 않으며, 각 자바스크립트 컨텍스트는 자신만의 개인 힙을 가집니다. 이후의 모든 알고리즘은 이러한 사실을 기반으로 구축됩니다. 그래서 젊은 세대는 활성과 비활성의 두 Semi-space로 나뉩니다. 모든 새로운 객체는 처음에 "bump-pointer" 방식을 사용하여 현재 활성된 Semi-space에 배치됩니다.

bump-pointer 출처: Garbage Collection in V8


bump-pointer 방식은 힙에서 현재 사용 가능한 영역을 가리키는 포인터를 항상 유지하는 것입니다. 새로운 객체를 만들 때 이 포인터는 객체의 시작 부분이 됩니다. 객체의 끝은 크기를 계산하여 결정됩니다. 결과적으로 객체의 끝을 결정한 후 포인터는 다음 사용 가능한 주소로 이동합니다.

 

Semi-space이 채워지면 Scavenger 메커니즘이 활성화됩니다. 이 메커니즘의 역할은 살아있는 객체들을 순회하고 그것들을 새로운 비활성 반공간으로 옮기는 것입니다. 이 작업은 마이너 가비지 컬렉션이라고 불립니다. 이후 두 Semi-space는 위치를 바꿉니다. 현재 활성 Semi-space는 비활성이 되고 비활성이었던 Semi-space는 비워지고 활성 공간이 됩니다. 따라서 두 번째 반복부터는 비활성 Semi-space에 아직 활성 객체에 대한 참조가 있을 수 있습니다. 다음 마이너 가비지 콜렉션 실행 중에 객체가 이미 두 번째 Semi-space으로 이동한 경우 그것들은 오래된 것으로 간주되어 오래된 세대로 옮겨지며 죽은 객체들은 삭제됩니다.

Scavenger 매커니즘 출처: Garbage Collection in V8

소규모 가비지 컬렉션의 수행 시간은 젊은 세대 내의 활성 객체 수에 따라 달라집니다. 모든 객체가 죽은 경우, 프로세스는 1밀리초 미만이 걸릴 것입니다. 그러나 모든 또는 대부분의 객체가 살아 있는 경우, 훨씬 더 오랜 시간이 걸릴 것입니다.

Old generation

오래된 세대 오래된 세대 역시 객체 할당을 위해 버프 포인터 방식을 사용하며, 포인터들은 별도의 요약 테이블에 저장됩니다. 이제부터 오래된 세대의 살아있는 객체들의 크기가 경험적으로 계산된 한계를 초과할 때 '메이저 가비지 컬렉션’이라는 다른 과정에 의해 정리됩니다. 지연 시간과 메모리 소비를 줄이기 위해 오래된 세대는 Mark-Sweep-Compactor 방식을 사용합니다. 큰 웹 페이지의 경우 전체 힙을 마킹하는 데 최대 100ms까지 걸릴 수 있습니다. 이를 방지하기 위해 V8은 짧은 단위로 객체를 마킹하여 각 단계가 5ms를 초과하지 않도록 합니다. 이 스키마 자체는 삼색 마킹 스키마(tricolor marking scheme)라고 불립니다. 여기서는 프로세스의 일반적인 본질을 개괄적으로 설명하겠습니다.

 

각 객체는 세 가지 상태 중 하나에 있으며, 색상으로 표시됩니다. 실제로 객체의 상태는 2비트 필드이며, 다음과 같습니다.

 

00 - 흰색 - 모든 새 객체의 초기 상태입니다. GC에 의해 아직 감지되지 않았음을 의미합니다.
01 - 회색 - GC가 도달하면 개체는 이 상태로 전환됩니다. 이러한 개체는 추적을 위한 목록에 넣습니다.
11 - 검정색 - 최종 상태로, GC가 개체의 모든 하위 노드를 방문한 후이며 추적 목록에서 제거될 수 있습니다.

 

이 방식은 메인 스레드를 오랫동안 차단하지 않고 마킹 작업을 부분적으로 큐에 넣을 수 있게 합니다. 추적할 객체 목록이 비어 있으면 프로세스가 완료됩니다.

 

그러나 여기에는 한 가지 문제가 있습니다. 마킹 단계 사이에 자바스크립트 코드가 실행되어 새로운 객체를 추가하거나 오래된 객체를 제거할 수 있습니다. 이로 인해 이미 마킹된 객체의 상태가 무의미해집니다. 이 문제를 해결하기 위해, 엔진은 객체 트리의 모든 변경 사항을 컬렉터에게 알려야 합니다. 이는 write barrier라 불리는 과정을 통해 수행됩니다.

 

write barrier는 기존 객체에 새 객체를 추가할 때마다 부모 객체가 이미 마킹되었는지 확인하는 barrier 함수가 트리거합니다. 자식 객체가 아직 연결된 것이 없으면 부모는 추적 목록으로 다시 반환되고 객체 자체는 즉시 회색 상태가 됩니다.

 

전체 객체 그래프가 마킹된 후 GC에 의해 도달한 객체는 검정색 상태로 표시되고 나머지는 흰색으로 유지됩니다. 마이너 가비지 컬렉션과 마찬가지로, 살아있는 객체들은 새로운 Semi-space이나 새로운 오래된 세대 페이지(old generation page)로 이동합니다. 메이저 가비지 컬렉션의 지속 시간은 선형적이며 오래된 세대에 있는 살아있는 객체의 수에 따라 달라집니다. 점진적 가비지 컬렉션 알고리즘을 사용하여, V8은 메이저 가비지 컬렉션 실행 시간을 6ms 이내로 유지하려고 합니다.

마무리

여기까지가 V8엔진의 가비지 콜렉션의 간단한 정리입니다. 이후의 내용을 더 알고 싶다면 제가 참고 한 페이지를 확인해보세요. 더 자세한 내용을 볼 수 있으며 Page fragmentation, Idle Tasks Scheduling등을 포함해 여기에 설명하지 못한 내용들이 더 있습니다.

참고 자료

Garbage Collection in V8
위키 백과, V8 (자바스크립트 엔진)

+ Recent posts