이번 글에서는 Node JS와 같은 런타임과 Chrome과 같은 웹 브라우저에서 사용되는 V8 엔진의 메모리 관리를 살펴보겠다.
V8엔진은 Google이 개발한 오픈소스로 자바스크립트 엔진으로 사용되며 C++로 개발되었다. 이러한 엔진을 통해 자바스크립트와 같은 고급언어를 컴퓨터가 이해할 수 있는 기계어로 변환하여 실행시킬 수 있는 것이다.
프로그램을 변환하는 방식에는 인터프리터 방식과 컴파일 방식 두가지로 나눌 수 있다.
인터프리터 방식: 코드를 한 줄 한 줄 해석하면서 기계어로 번역하는 방식으로 코드를 실행하기 전 컴파일 단계가 없기때문에 실행 속도가 빠르다는 장점이 있지만 오류나 버그를 지나칠 수 있다는 단점이 있다.
컴파일 방식: 코드를 입력받으면 파일 전체를 읽은 뒤, 이를 컴파일하여 기계어로 변환한다. 그리고 이 기계어는 CPU로 입력되어 코드가 실행된다.
컴파일러는 작업을 단순화 시키는 장점이 있고, 컴파일 단계가 있어서 예상치 못한 오류나 버그를 발견할 수 있다. 하지만 파일 전체를 읽어 기계어로 변환하기 때문에 느리다는 단점이 있다.
V8엔진은 인터프리터 방식을 사용하는데 이는 코드가 길어질수록 속도가 느려지는 단점을 극복하기 위해 JIT(JUst In Time)컴파일러를 같이 사용하여 자바스크립트 코드를 기계어로 컴파일한다.
참고: https://velog.io/@remon/V8-%EC%97%94%EC%A7%84%EC%9D%B4-%EB%8C%80%EC%B2%B4-%EB%AD%90%EC%95%BC
JavaScript는 싱글스레드 이므로 JavaScript 컨텍스트당 단일 프로세스를 사용하므로 서비스 작업자를 사용하는 경우 작업자 당 새로운 V8 프로세스가 생성된다. 실행중인 프로그램은 할당된 메모리 표시로 Resident Set라고 한다.
힙 메모리는 객체 또는 동적 데이터를 저장한다. 메모리 영역 중 가장 큰 블록으로 가비지 컬렉션(GC)가 일어나는 곳이다. 가비지 컬렉션은 New Space 또는 Old Space에서만 발생한다.
각 영역은 mmap(Windows의 경우 MapViewOfFile)시스템 콜을 통해 운영체제로부터 할당받은 페이지로 구성되어 있으며, 각 페이지의 크기는 Large object space 영역을 제외하고 1MB이다.
지금까지는 메모리 구조를 파악했고, 이제부터 프로그램이 실행될 때 메모리가 어떻게 할당되는 살펴보자.
class Employee {
constructor(name, salary, sales) {
this.name = name;
this.salary = salary;
this.sales = sales;
}
}
const BONUS_PERCENTAGE = 10;
function getBonusPercentage(salary) {
const percentage = (salary * BONUS_PERCENTAGE) / 100;
return percentage;
}
function findEmployeeBonus(salary, noOfSales) {
const bonusPercentage = getBonusPercentage(salary);
const bonus = bonusPercentage * noOfSales;
return bonus;
}
let john = new Employee("John", 5000, 5);
john.bonus = findEmployeeBonus(john.salary, john.sales);
console.log(john.bonus);
가장 먼저 스택의 "글로벌 프레임(Global frame)"이라는 곳에 전역 범위(Global Scope)가 저장된다.
정리해보면 먼저 스택에 글로벌 프레임에 전역 범위에 존재하는 객체의 포인터와 정적 데이터 값들이 저장되고, 객체의 포인터가 가리키는 힙 메모리 주소에 해당 객체가 저장된다.
클래스의 인스턴스가 생성되면, 힙 메모리 영역에 해당 객체가 만들어지고, 함수가 실행되면 스택에는 함수 프레임과 함수에 필요한 인수, 반환 값, 지역 변수가 해당 블록 내에 저장된다.
실행이 끝난 객체는 스택에서 제거되는데 이로 인해 힙에서 더 이상 포인터를 가지지 않는 객체는 그냥 두게 되면 메모리가 부족해지고, 프로그램 속도가 느려진다. 이러한 객체는 가비지 컬렉션을 수행하게 된다.
가비지 컬렉션을 수행할 때 힙에 존재하는 데이터와 포인터(레퍼런스)를 구분하는 것이 중요하기 때문에 V8은 tagged pointer를 이용해서 이 둘을 구분한다. Tagged pointer는 각 워드의 끝에 하나의 비트를 할당하여 해당 데이터가 포인터인지 데이터인지를 나타냅니다.
V8 엔진은 가비지 컬렉션으로 메모리 누수가 발생하지 않도록 힙 메모리를 관리한다. 스택에서 더 이상 참조되지 않는 객체를 사용하는 메모리를 해제하여 새로운 객체 생성을 위한 공간으로 만든다.
V8의 가비지 컬렉터는 V8 프로세스에서 재사용할 수 있도록 사용되지 않은 메모리를 회수하는 역할을 한다.
V8 엔진이 사용하는 가비지 컬렉터는 generational GC의 일종으로, 객체의 나이를 기준으로 힙 영역을 여러 하위 영역으로 세분화하여 가비지 컬렉션을 수행한다. V8 엔진이 수행하는 가비지 컬렉션에는 크게 두 단계가 존재한다.
Scavenger라고도 하는 Minor GC는 New space 영역에 존재하는 어린(주로 1MB ~ 8MB의 크기)객체를 가비지 컬렉트한다.
New space 영역에선 “할당 포인터”를 사용하여 새로운 객체를 위한 메모리 영역을 할당하는데, 객체가 새로 할당될 때마다 포인터 값이 증가하다가 더 이상 증가할 수 있는 포인터가 없어질 때 Minor GC가 수행된다. Minor GC는 Cheney 알고리즘을 사용하는데, 꽤 자주 수행되며 별도의 헬퍼 스레드를 이요하며 실행 속도또한 굉장히 빠르다.
New space 여역은 to-space와 from-space로 나뉘게 된다. 항상 Old space에 할당되는 실행 가능한 코드와 같은 객체를 제외한 대부분의 할당은 from-space에서 이루어 지는데 from-space가 꽉 차게 되면 Minor GC가 실행된다.
그리고 Minor GC는 from-space와 to-space를 교환하여 모든 객체는 from-space에 존재하고, to-space 영역은 비어있는 상태가 되어 새로운 객체는 from-space 메모리에 할당받게 된다.
두번의 Minor GC가 수행한 뒤 살아남은 모든 객체는 old space로 옮겨진다.
Minor GC를 수행한뒤 from-space와 to-space를 맞바꾸고 이러한 과정을 반복한다.
또한, write barrier라는 것을 사용하여 Old space에서 New space를 참조하는 레퍼런스를 기록한다. 이를 통해 Minor GC를 수행할 때마다 Old space 영역을 살펴볼 필요 없이, 현재 사용되고 있는 객체가 무엇인지 빠르게 파악할 수 있다.
참고: https://www.memorymanagement.org/glossary/w.html#term-write-barrier
Major GC는 Old space 영역을 담당하는 Minor GC에 의해 객체들이 New space에서 Old space로 옮길 때 Old space에 공간이 부족한 경우 실행된다.
Old space와 같이 크기가 큰 영역에 Minor GC를 적용하기에는 메모리 오버헤드가 발생할 수 있어 Major GC는 Mark-compact 알고리즘을 사용한다.
이러한 가비지 컬렉터들의 중요한 성능 지표 중 하나가 "GC를 수행하면서 얼마 동안 메인 스레드를 블로킹하는가?"인데 전통적인 블로킹 방식의 GC의 경우, 메인 스레드를 오랜 시간 블로킹하여 페이지가 버벅거리는 등 UX가 저하되는 문제가 있다.
현재 사용되고 있는 V8 엔진의 가비지 컬렉터를 Orinoco라고 하는데, Orinoco는 병렬적, 점진적, 등시적으로 GC를 수행하여 최대한 메인 스레드를 블로킹하지 않는 방식을 사용한다.
병렬적 방식은 메인 스레드와 헬퍼 스레드가 거의 똑같은 양의 작업을 도시에 수행하는 방법으로, 여전히 블로킹 방식이긴 하지만 사용하는 헬퍼 스레드의 개수만큼 블로킹 되는 시간을 절감할 수 있다.
세 방식 중 가장 쉬운 방법이며, 여러 헬퍼 스레드에서 GC를 수행하기 때문에 동시에 같은 객체에 접근하지 못하도록 동기화 작업을 할 필요는 있다.
점진적 방식은 메인 스레드가 다른 작업들과 번갈아 가면서 GC를 수행하는 방식이다.
GC 수행 -> 스크립트 수행 -> GC 수행 -> 스크립트 수행 ...과 같이 진행되는데, 스크립트와 번갈아 실행됨에 따라 힙의 상태가 변경되어 이전 작업이 무용지물이 될 수 있어 앞서 벙렬적 방식보다 어려운 방식이다.
GC가 메인 스레드에서 실행되는 총 시간은 변함 없으나 스크립트와 번갈아 실행하면서 스레드가 한 번에 블로킹 되는 시간을 줄일 수 있어 정상적으로 화면을 렌더링하거나 유저와 상호작용할 수 있게 된다.
동시적 방식은 벙렬적 방식과 비슷한데 차이점은 동시적 방식에선 GC가 헬퍼 스레드에서만 수행된다는 점이다.
GC와 스크립트가 동시에 실행될 수 있기 때문에 GC 도중에 힙의 상태가 바뀔 수 있어 세 방식 중 가장 어려운 방식이다. 또한 벙렬적 방식과 같이 여러 헬퍼 스레드가 같은 객체에 접근할 수 있으므로 동기화 처리 또한 필요하다.
동기화 처리로 인한 오버헤드가 졵해지만 메인 스레드를 블로킹하지 않고 GC를 처리할 수 있다는 장점이 있다.
Minor GC는 병렬적 방식을 사용하여 new space에 대한 GC를 수행할 때 여러 헬퍼 스레드를 사용해서 작업ㄷ을 분할한다.
Major GC의 경우 힙의 최대 크기에 다다르면 marking 작업을 헬퍼 스레드에서 동시적으로 시작한다. 헬퍼 스레드테서 마킹 작업을 수행하는 와중에 메인 스레드에서 실행 중인 스크립트에서 객체에 대한 새로운 참조를 생성하는 경우, write barrier를 사용하여 새로운 참조를 기록한다.
마킹 작업이 끝나면 메인 스레드에서 빠르게 마킹 작업을 마무리하느넫, 이 과정에서 루트부터 다시 탐색하여 살아있는 객체가 제대로 마킹되었는지 체크한다. 이후 헬퍼 스레드와 함께 병렬 방식으로 압축 작업을 진행하는 이와 동시에 헬퍼 스레드에서 동시적으로 sweeping 작업을 수행한다.