V8엔진의 Garbage Collector를 알아보자.
일단은 앞에 1,2장에서 정리한것처럼 다시 짧게 정리부터 해보자.
자, GC는 더이상 사용되지 않는 메모리를 자동으로 회수해 메모리 누수를 방지하는 메모리 관리 기능이다.
일반적으로 GC의 중요하고 필수적인 작업은 다음과 같다.
위 작업들이 순차적으로 진행되는게 자연스러워 보이기는 하다, 왜냐하면 살아있는지 죽어있는지 유무를 판단한 다음, 죽여서 메모리를 회수하고 남긴 공간을 새로운 변수가 선언될때 재사용할 수 있으니까 말이지.
하지만, 첫번째 과정을 생각해보면 살아있는지 혹은 죽어있는지를 판단하는 작업에 걸리는 시간이 길어지면, 변수를 할당하기까지의 시간도 매우 늘어나게되고 결과적으로는 자바스크립트의 런타임 하는 과정이 매우 느리게 진행된다.
따라서 서비스의 성능이 저하될 수 있다는 것이다!
V8에 구동하는 GC는 크게 2가지 종류로 나뉘는데, Magor GC와 Minor GC다.
Major GC는 크게 3가지 특징이 있다.
1. Marking
2. Sweeping
3. Compaction
image source
자바스크립트의 GC는 Reference 기반으로 동작하기 때문에 Heap에 대해서 알아봐야 한다. (정확히는 Refereence가 아니라 도달할 수 있는지 여부에 따라 동작한다.)
Marking은 GC의 가장 필수적인 내용인데, 바로 Object가 살아있는지 혹은 죽어있는지 판단하는 부분이고, 전체 힙에서 판단한다.
여기서 살아있다라는 의미는, roots부터 시작해서 해당 객체에 도달 할 수 있다라는걸 의미한다.
GC 루트 집합: 객체 포인터 집합으로, 전역 객체와 실행 스택이 포함되어 있음.
마킹이란 GC가 어떤 객체가 사용중인지, 즉 roots부터 javascript object pointer를 따라가면서 도달 가능한것인지 마킹하는걸 말하고, 도달할 수 있는 모든 객체를 찾을때까지 마킹한다.
즉 GC는 죽어있는 메모리를 자동회수가 아니라, roots부터 시작해서 도달할 수 없는 객체들의 메모리를 회수하는걸 의미한다.
roots부터 시작해 도달하지 못한 객체들(더 이상 사용하지 않는)이 남긴 메모리의 공백을 free-list 라는 데이터 구조에 memory address를 기록한다.
즉 Sweeping은 Marking 이후에 가능한 작업이다.
free-list에 왜 도달하지 못한 object를 추가하나?
GC 수행 후 새로운 변수를 선언할때 비어진 메모리를 찾는다.(할당할 수 있는 free memory) 이때 free-list에 있는 공간부터 먼저 탐색하고 해당 메모리를 사용한다.
그리고 이런 과정을 조금 더 빠르게 하기 위해서 free-list는 메모리 chunk 크기에 따라 구분한다.
즉 변수를 선언하거나 할당할때 메모리를 찾는다라는 의미는 free-list에 표시한 memory chunk를 본다는 의미다.
10 이라는 메모리 공간이 필요한대, Marking 하면서 죽어있는 메모리 공백의 틈이 1--3--5--7 이라고 하자.
이때 우리가 10이라는 메모리 공간이 필요한데, free-list에 메모리 청크 크기에 따라 구분되지 않으면 1--3--5--7을 찾게되고, 이는 속도 지연을 야기시킨다.
때문에 처음부터 10이라는 공백이 없다~라는 정보가 있으면 빠르게 확인할 수 있다.
Sweeping 과정 후 살아남은 객체들 사이에 수많은 틈이 생기게 된다.(프레그멘테이션) 그러기 때문에 살아있는 객체는 free-list의 공간을 활용해 압축되지 않은 다른 페이지에 복사한다.
여기서 GC의 약점이 나오는데, 살아남은 객체를 복사하는 과정은 비용이 들기 때문이다. (runtime 중 JS가 멈추겠지?)
그 다음 Minor GC를 알아보기 전에 Heap구조를 알아야 Minor GC가 왜 탄생했는지를 알 수 있다.
Heap은 Genaration이라는 영역으로 구분한다.
Young Generation, Old Generation이라고 불리는 영역들로 구분되어 있다.
V8엔진에서는 Young Generation이라고 불리고, Apple의 JavaScript Core에서는 Nursey라고 하는데, 이 둘은 동일한것을 가르키는 명칭이다.
여기서 또 Young Generation은 두가지 영역으로 부르는데, 각각 Nursery, Intermediate generaion이다.
사실 이 두 영역은 서로 switching도 되기 때문에, 일단은 2가지 영역으로 구분된다라고 일단 이해하자. 중요한건 switching이 가능하다는 것이다!!
객체는 처음에 Young Generaion의 Nusery라는 공간에 할당이 된다.
그리고. GC가 수행하고나서 살아남으면 Nursery에서 Intermidate로 옮겨지게 되고, 그리고 다시한번 GC로부터 살아남게 되면 Old Generation으로 이동한다.
Heap에는 Young Generation과 Old Generaion이 있다. (GC관점에서)
Young Generaion 관점에서는 2가지 영역이 존재하는데 각각 Nursery, Intermediate 라고 표현하지만 추후에 각각 Switching이 되기때문에 편의상으로 붙인 이름이라고 생각하자.
Nursery: 처음 객체가 할당되는 공간
Intermediate: 첫번째 GC에서 살아남은 객체가 이동할 공간
Old Generation: 두번째 GC에서 살아남은 객체가 이동할 공간
방금 말한 Major GC와 가장 중요한 핵심은 Generational Hypothesis다. (이름 어렵네...)
말 그대로 Generation 가설로, 모든 객체는 Young Generation에서 Intermediate 영역으로 가지 못한다 라고 한다!
즉 대부분의 객체는 할당 이후에 바로 죽기 때문에 Intermediate에 도달하지 못한다!
V8 엔진의 Genration 영역 구분도 이러한 가설을 기반하여 설계했다고 한다.
즉 앞에서 이야기한 GC는 압축 및 이동이므로 살아남은 객체만 복사하는데, 복사라는 작업은 매우 큰 비용이 들어서 좋지않다! 라고 생각할 수 있지만.
위 가설을 기반으로 생각하면 실제로 살아남은 객체는 극소수 이기에 할당하는 객체에 비해서 살아남은 객체만 복하나는 방식은 효율적이다.
Major GC는 전체 Heap에서 GC를 수행하지만, Minor GC는 Young Generation에 대해서 GC를 수행한다.
왜 Miner GC가 필요할까요~~~?
이때 필요한 개념이 Generation Hypothesis다!
즉 가설에 따르면 배부분의 객체는 할당 후 바로 free되기 때문에 Old Generation에 남는 객체가 거의 없기에, 전체 Heap을 콜렉팅 하는것은 매우 비효율적이다 라는것이다!
따라서 Young Generation에 특화된 GC가 필요하고 이래야 전체 GC가 동작하는 시간을 줄일 수 있다.
일단 위에서 Young Generation에는 Nusery와 Intermediate 2개의 공간이 존재한다고 했지만, 혼동을 막기위해 V8에서 제공하는 용어를 사용하자.
V8은 Young Generation에서 semi-space라는 개념을 사용하는데, Young Generation 공간의 50%를 항상 빈공간으로 두어야 한다.
왜냐하면 GC를 수행하고 살아남은 객체들이 옮겨갈 수 있는 공간을 마련하기 위해서다!
GC가 실행되는 동안 Young Generation에서 처음에 비워져 있는 영역을 To-Space라고 한다. (살아남은 객체가 옮겨갈 영역)
그리고 복사하는 영역을 From-Space라고 한다.(살아남은 객체를 옮겨올 영역, 즉 맨처음 객체들이 할당되는 부분)
즉, 처음 GC가 실행되는 동안 From-Space에서 살아남은 모든 객체들은 To-Space 공간으로 이동하는데, 그럼 From-Space에는 roots부터 시작해서 도달하지 못한 객체들, 즉 더이상 사용하지 않는 놈들만 남게되고, 해당 From-Space는 모두 지울 수 있는 공간이 된다. (deletable space!)
GC가 완료되면 To-Space로 이동한 객체는 To-Space의 메모리 주소값으로 포인터가 업데이트 된다.
그리고 기존의 From-Space와 To-Space 역할은 서로 스위칭하기 때문에
살아남은 객체가 이동해온 To-Space는 앞으로 새로운 객체가 할당될 From-Space가 되고,
모두 삭제해 깨끗해진 From-Space는 미래에 살아남은 객체가 옮겨갈 To-Space가 된다!
그럼 새로워진 From-Space는(기존의 To-Space) 새로운 객체를 할당받을 수 있고, 다시한번 GC가 수행되고나서 살아남은 객체는 New-To(기존의 From-Space)로 이동하게 된다.
그리고 기존에 한번 생존한 객체는 New-To가 아닌 Old Space(Old Generation)으로 이동한다!
그러면 왜 기존에 생성되어서 살아남은 객체는(즉 2번의 GC에서 살아남은) 샐운 객체처럼(1번의 GC에서 살아남은)처럼 New-To로 이동하지 않고 Old Space(Old Generation)으로 이동할까요?
From-To로 계속 스위칭하는 전략은 Young Generation 공간이 금방 부족해지기 때문이다! (금방 부족해지면 GC가 더 자주 돌겠지?)
그리고 Young Generationdms Old Generation보다 빈번하게 GC를 수행하다 보니, 한번이라도 생존한 객체는 새로운 객체보다 살아남을 가능성이 매우 높기 때문에 다른 공간으로 이동할 필요가 있다! (Generation Hypothesis)
GC들은 전통적으로 수행할때 프로그램이 지연된다.
자바스크립트는 단일 스레드로 동작하는데, GC가 동작하는 동안 JS는 메인 스레드에서 일시 중지 된다는 말이다.
이러한 일시 중지 되는 시간을 줄이는것이 GC의 큰 목표이고, V8에서는 Orinoco라는 프로젝트를 시작했다고 한다.
Orinico 프로젝트는 가장 최신의 Paralel, Incremental, Concurrent를 사용한다고 한다.
GC 수행을 여러 스레드에서 작업하므로 GC에 걸리는 시간을 줄이고 어플리케이션의 성능을 향상시킬 수 있다!
그리고 3가지 방식중 가장 간단하다.
하지만 여전히 메인쓰레드에서 GC가 동작할때 Heap에서 JS코드는 수행되지 않으므로 일시중지 시간만 최소화하는 방식이다.
말 그대로 증분작업으로, GC에서 수행하는 작업을 분할해 메인쓰레드에서 JS-GC-JS-GC-JS-GC 형식으로 수행한다.
이렇게 증분하는 방식은 Heap의 상태가 계속 변경되어서 Parallel보다 어려운 방식이다.
이 방식은 전통적은 GC에서 JS코드가 실행되지 않는 메인 쓰레드 지연시간을 해결하는 관점은 좋지만, 메인쓰레드의 소요시간을 줄이지는 않는다.
실제로 메인쓰레드 소요시간이 늘어난다고 하는데.
Heap의 상태가 빈번하게 변경되어 이전에 작업했던 GC의 과정을 다시 수행해야할 수 있는 문제점이 있기 때문이다.
하지만 웹분야에서 1초라는 시간도 GC 때문에 지연되면, 유저는 매우 나쁜 경험을 하게되므로 GC과정이 길어지더라도 메인 쓰레드에서 JS가 지연되는 시간을 줄이는 방식을 채택한 것이다.
말 그대로 JS코드와 GC가 동시에 수행할 수 있는 방법으로, 전통적인 GC에서 JS가 멈추는 문제를 해소하는 방식이다.
JS는 단일 쓰레드 이므로 메인 쓰레드에서 수행하며 GC는 백그라운드에서 작업을 수행한다.
하지만 이 방식은 아름답지만 생각보다 신경써야하는 어려운 기법이다.
왜냐하면 메인 쓰레드에서 객체를 참조하거나 할당할때 GC에서도 객체의 상태를 변경하고 있어서 충돌이 일어나는 현상이 생길수도 있기때문이다.
즉 GC와 JS코드간의 상호작용이 하나라도 어긋나면 JS에서 개발자의 의도와 맞지않게 다른 메모리 주소를 참조할 수 있는 오버헤드 현상이 일어난다.
Scavenging은 Parallel 방식을 사용해 각 쓰레드에 도달한 객체를 To-Space로 이동시킨다.
그리고 남겨진 From-Space에는 한 쓰레드만 GC를 수행하는것이 아니기때문에 빠르게 청소를 수행할 수 있다.
왜나하면 Parallel 방식을 수행하면 Generation 가설에 기반해 Old Generation보다 청소해야하는 양이 Young Ganeration이 더 많기 때문이다.
Major GC는 Concurrent Marking을 수행하는데, 즉 메인쓰레드에서 JS 코드를 수행하면서 동시에 헬퍼쓰레드에서 도달할 수 있는 객체인지 식별한다(Marking)
그리고 Concurrent Marking이 완료되면 메인 쓰레드에서 JS코드가 일시중지가 시작된다.
그리고 JS코드가 일시중지되고, 루트부터 모든 객체의 마킹된것을 확인한 다음 Parallel 방식으로 수행해 헬퍼쓰레드와 메인쓰레드가 함께 압축과 Pointer 업데이트를 진행한다.