V8의 메모리 구조
•
JavaScript는 단일 스레드기 때문에 V8 하나의 JavaScript 실행 컨텍스트를 위해 싱글 프로세스를 사용한다.
•
V8 프로세스에서 실행중인 프로그램은 위 그림에서 보듯 Resident set라고 부르는 할당된 메모리 형태로 표현된다.
•
먼저 Heap과 Stack으로 구분할 수 있다.
Heap Memory
•
객체나 동적 데이터를 저장하는 곳으로, 가장 큰 블록이면서 GC가 발생한다. 단, 모든 영역에서 발생하는 것은 아니고 New, Old Space에서만 발생한다.
•
Heap Memory는 그림에서 보여지듯 또 다양한 영역으로 구분된다.
•
New Space
◦
New Space 또는 Young Generation이라고 불리는 이 영역은 대부분 짧은 생명주기를 가지는 객체들이 저장되는 곳이다.
◦
이 공간은 JVM의 S0 & S1과 유사한 두 개의 semi-space를 가지는데, 이 공간들은 Scavenger(또는 Minor GC)에 의해 관리된다.
•
Old Space
◦
Old Space 또는 Old Generation이라고 불리는 이 영역은, 앞서 말한 New Space에서 2회 이상 발생한 Minor GC 사이클에서 살아남은 객체들이 저장되는 곳이다.
◦
이 공간은 Major GC(Mark-Sweep과 Mark-Compact)에 의해 관리된다. 또한 Old pointer space와 Old data space로 나뉜다.
▪
Old Pointer Space
•
다른 객체에 대한 포인터를 가지고 있는 객체들이 저장되는 공간
▪
Old Data Space
•
다른 객체를 가리키지 않고 데이터만을 갖는 객체들이 저장되는 공간
•
Large Object Space
◦
이 공간은 다른 공간들에 적재되기에 사이즈가 초과되는 객체들이 저장되며 각 객체들은 고유한 메모리 영역을 가지게 되며 절대 GC에 의해 이동되지 않는다.
•
Code-Space
◦
이 공간은 JIT 컴파일러가 컴파일한 코드 블록들이 저장되는 곳, 이 곳은 유일한 실행 가능한 메모리 영역이다.
•
Cell Space, Property Cell Space and Map Space
◦
각각의 목적에 맞는 객체들을 저장하고 있다고 생각하고 넘어가면 될 듯 하다.
Stack
•
Stack 메모리 영역의 경우, V8 프로세스 당 하나만 존재한다. 이는 메소드나 원시 값, 객체에 대한 포인터같은 정적인 값들이 저장된다.
•
JVM의 스레드 스택같다.
V8 메모리 사용 (Stack vs Heap)
•
메모리를 어떻게 조직화할 것에 대해서는 충분히 이해했으니 이제 프로그램이 동작 중에 어떻게 메모리에 저장되고 사용되는지 알아보자
•
다음과 같은 JavaScript 코드가 있다고 가정하자.
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);
JavaScript
복사
◦
이를 말로 풀어서 설명해보면
◦
먼저 함수, 클래스와 같은 키워드에 대해 전역 범위의 프레임이 먼저 스택에 저장된다.
▪
위의 경우 function, class로 정의된 Employee, findEmplyeeBonus 등이 해당한다.
◦
이에 대한 실행 내용들은 Heap Memory의 Code-Space에 저장되고 이에 대한 참조를 위해 포인터 변수가 스택에 함께 저장될 것이다.
▪
const BONUS_PERCENTAGE = 10;에서 const 키워드를 통해 정의된 BONUS_PERCENTAGE라는 변수가 전역 프레임에 저장된다.
◦
let john = new Employee(”John”, 5000, 5);에서 new Employee 생성자를 호출하며 해당 생성자를 Stack 메모리의 최상단에 적재한다.
▪
이는 Heap 메모리에 새로운 객체를 생성하고 이 객체의 프로퍼티를 각각 입력받은 “John”, 5000, 5로 초기화하고 해당 함수의 역할이 끝났으니 제거된다.
▪
물론 Heap 메모리에 해당 객체는 생성되어 있는 상태를 유지한다. 그리고 해당 객체에 대한 포인터는 전역 프레임인 스택에 저장된다.
◦
john.bonus = findEmployeeBonus(john.salary, john.sales);가 실행되는데, 앞서와 같이 findEmployeeBonus라는 함수가 스택 메모리에 적재되고 해당 함수 범위의 지역변수들이 함께 적재된다.
▪
즉 salary, bonusPercentage, bonus, noOfSales 등이 저장된다.
◦
이후, 해당 함수의 첫번째 줄인 const bonusPercentage = getBonusPercentage(salary);를 실행하므로 해당 줄에서 함수인 getBonusPercentage(salary)를 스택의 최상단에 적재한다.
▪
물론 해당 함수의 범위 변수인 percentage, salary, BONUS_PERCENTAGE도 함께 적재한다. 다만 BONUS_PERCENTAGE는 전역 프레임에서 적재되었으므로 무시한다.
▪
이후 해당 함수의 내용을 수행하고 return 문에 의해 percentage를 반환한다.
◦
이 값은 bonusPercentage(stack 영역에 저장된 함수의 지역 변수)에 저장되고 이후 과정을 처리한다.
◦
이렇게 반환된 값은 스택 영역의 전역 프레임에 저장되어 있는 john 포인터 변수를 참조하여 힙메모리 객체를 변경하고(bonus 프로퍼티에 값 저장) 종료된다.
•
여기서 알 수 있는 점은 다음과 같다.
◦
전역 범위는 전역 프레임으로 Stack 영역에서 유지된다.
◦
모든 함수 호출은 프레임 블록으로써 Stack 영역의 최상단에 추가된다.
◦
매개변수와 리턴 값을 포함한 모든 지역 변수들은 함수의 프레임 블록과 함께 Stack 영역에 저장된다.
◦
int나 string 같은 원시 자료형은 Stack에 바로 저장된다. 이것은 전역 범위에서도 적용된다.
◦
Employee나 Function 같은 객체 자료형은 Heap에 생성되며 Stack 영역의 Stack Pointer를 사용해 참조된다. 자바스크립트에서 함수는 그저 객체이기 때문이다. 이는 전역 범위에서도 적용된다.
◦
현재 함수에서 호출한 함수는 스택에서 해당 함수의 위에 추가된다.
◦
하나의 메인 프로세스가 종료되면 Heap에 있는 객체들은 Stack의 특정 영역에 더 이상 포인터를 가지지 않게된다.
◦
다른 객체 내에서 객체 참조를 명시적으로 복사하지 않는 한, 모든 객체 참조는 참조 포인터를 사용하여 수행된다.
V8 메모리 관리 (Garbage Collection)
•
Stack 영역의 경우, 자동으로 운영체제에서 메모리를 관리를 해준다.
•
Heap 영역의 경우, 운영체제에서 관리를 해주지 않는데, 큰 메모리 영역을 차지하고 있으면서 동적 데이터를 저장하고 있다.
◦
이는 Out Of Memory 에러가 발생할 위험을 지수적으로 높여준다. 또한 시간이 지나며 데이터들이 조각 조각 떨어져 존재하여 성능이 느려지기도 한다.
•
V8 엔진은 가비지 컬렉션을 사용해 Heap 영역을 관리한다. 간단하게 말하면, 가비지 컬렉션은 참조되지 않는 객체들이 사용하는 메모리를 비워서 새로운 객체를 생성하기 위한 공간을 만드는 역할을 한다.
•
여기서 참조 되지 않는 객체는 Stack 영역으로부터 직접, 간접적으로 참조되지 않는 객체를 말한다.
•
V8 엔진의 가비지 컬렉터의 역할은 V8 프로세스에서 재사용하기 위해 사용되지 않은 메모리를 회수하는 것이다.
마이너 GC (Scavenger)
•
마이너 GC는 New Generation 영역을 작고 깨긋하게 유지시킨다. 새로 생성되는 객체들은 New 영역에 할당되고 크기가 매우 작다.
•
더 이상 New Generation 영역에 객체를 저장할 공간이 없을 때, 마이너 GC가 발생한다. 이 과정을 스캐벤저(Scavenger)라고 하며, Cheney의 알고리즘을 사용해 구현되었다.
•
스캐벤저는 매우 자주 발생하고 병렬 헬퍼 스레드를 사용하며 굉장히 빠르다.
◦
과정
▪
New 영역은 크기가 같은 2개의 세미 영역으로 나뉜다. 이를 각각 To, From 영역이라고 한다.
▪
대부분의 신규 할당은 To 영역에서 만들어진다. To 영역에 더 이상 할당할 공간이 없으면 마이너 GC가 발생한다.
▪
1세대
1.
To 영역에 신규 객체를 생성한다.
2.
V8은 To 영역에서 필요한 메모리를 가져오려고 시도하지만, 공간이 부족하기 때문에 V8은 마이너 GC를 발생시킨다.
3.
마이너 GC는 객체들을 To 영역에서 From 영역으로 이동시킨다. 이제 모든 객체는 From 영역에 있고 To 영역은 비워진다.
4.
마이너 GC는 Stack Pointer부터 From 영역까지 객체 그래프를 재귀적으로 순회하면서 참조되는 객체들을 찾는다. 이 객체들은 To 영역의 페이지로 이동되고 포인터들은 갱신된다.
From 영역의 모든 객체들을 찾을 때까지 이 과정이 반복된다. 마지막 객체까지 찾으면 To 영역은 압축되어 조각화를 줄인다.
5.
이제 From 영역에 남아있는 객체들은 참조되지 않는 객체들이므로 From 영역을 비운다.
6.
새 객체는 To 영역 메모리에 할당된다.
▪
2세대
1.
어느 정도 시간이 지나 To 영역에 더 이상 공간이 없을 때, To 영역에 대한 새 객체 할당 요청이 발생한다.
2.
V8은 To 영역에서 필요한 메모리를 가져오려고 시도하지만 공간이 부족하기 때문에 V8은 두 번째 마이너 GC를 발생시킨다.
3.
위 과정이 반복되고 두 번의 마이너 GC에서 생존한 객체들은 Old 영역으로 이동한다. 한 번의 마이너 GC에서 생존한 객체들은 To 영역으로 이동하고 남아있는 객체들은 From 영역에서 제거된다.
4.
새 객체는 To 영역에 할당된다.
메이저 GC
•
메이저 GC는 Old Generation 영역을 관리한다. 메이저 GC는 V8에서 Old 영역의 메모리가 충분하지 않다고 판단될 때 발생한다.
•
Old 영역은 동적으로 계산된 크기에 기반하며 마이너 GC 주기에 따라 객체가 할당된다.
•
스캐벤저 알고리즘은 작은 데이터 크기에는 적합하지만 Old 영역과 같이 큰 힙 메모리에서는 부적합하다.
•
메모리 오버헤드가 있기 때문에 메이저 GC는 Mark-Sweep-Compact 알고리즘을 사용하여 처리된다.
•
메이저 GC는 흰색 - 회색 - 검은색 마킹 시스템을 사용한다. 따라서 메이저 GC는 세 단계의 프로세스로 진행되며 세 번째 단계는 조각화 휴리스틱에 따라 실행된다.
•
마킹
◦
두 알고리즘의 공통적인 첫 번째 단계로, 가비지 컬렉터가 어떤 객체가 참조되어지고 있는 중인지 식별한다.
◦
Stack Pointer에서 재귀적으로 도달할 수 있는 객체들은 모두 활성 상태로 표시된다. 마킹은 기술적으로 힙 메모리를 방향이 존재하는 그래프로 간주해 DFS를 수행한다.
•
스위핑
◦
가비지 컬렉터가 힙 메모리를 순회하면서 활성 상태가 아닌 객체들의 메모리 주소를 기록한다. 이 공간은 이제 사용가능한 목록에서 사용가능하다고 표시되며 다른 객체들이 저장될 수 있다.
•
압축
◦
스위핑이 일어난 다음, 필요하다면 모든 활성 상태의 객체들이 함께 이동된다. 압축 단계는 조각화를 줄이고 새 객체들에 대한 메모리 할당 성능을 증가시킨다.
•
메이저 GC는 실행중에 애플리케이션이 멈추므로 stop-the-world GC라고도 한다. 이를 피하기 위해서 V8에서는 다음과 같은 기술을 사용한다.
◦
증분 GC
▪
하나의 대규모 GC 대신 다중의 증분 단계로 수행된다.
◦
동시 마킹
▪
여러 보조 스레드를 사용하여 메인 스레드에 영향을 미치지 않고 동시에 마킹을 수행한다.
◦
동시 스위핑/압축
▪
스위핑 및 압축 작업을 메인 스레드에 영향을 주지 않고 보조 스레드에서 동시에 수행한다.
◦
지연 스위핑
▪
지연 스위핑은 페이지의 가비지 삭제를 메모리가 필요할 때까지 지연시킨다.
•
이러한 기술을 사용한 메이저 GC 과정은 다음과 같이 진행된다.
1.
많은 마이너 GC 사이클이 지난 후 Old 영역이 가득 찬 상태로 가정한다.
2.
메이저 GC는 메인 스레드에 영향을 주지 않고 여러 동시 보조 스레드를 사용하여 Stack Pointer부터 시작하여 Old 영역에서 객체 그래프를 재귀적으로 탐색한다.
3.
동시 마킹이 완료되거나 메모리 한계에 도달하면, GC는 메인 스레드를 사용하여 마킹 최종화 단계를 수행한다. 이로 인해 10 ms보다 작은 시간 멈춤이 발생한다.
4.
메이저 GC는 이제 모든 가비지 객체의 메모리를 자유로 표시하고, 동시 스위핑 스레드를 사용하여 관련된 메모리 블록을 동일한 페이지로 이동시켜 단편화를 방지, 이러한 단계 중에 포인터도 반영된다.
참고
node.js 개발자라면 꼭 한번은 읽어봤으면 좋겠다 ㄹㅇ루..