JavaScript V8 엔진 내부에서 메모리가 어떻게 관리되고 사용되는지 알아보자. V8 엔진은 NodeJS, Deno, Electron과 같은 런타임 및 Chrome, Chromium, Brave와 같은 웹 브라우저에서 사용된다. V8 엔진은 C++로 작성되었기 때문에 C++ 어플리케이션을 이식할 수 있다.
먼저 V8에서 메모리 구조가 어떻게 구성되어 있는지 살펴보자. JavaScript가 싱글 스레드 언어인만큼, V8 역시 자바스크립트 context당 하나의 프로세스를 사용한다. 단, 서비스 워커를 사용하는 경우에만 워커의 개수만큼 프로세스를 증식한다. 실행중인 프로그램은 V8 프로세스에서 할당된 일정량의 메모리로 표현되고 이를 Resident set이라고 한다. Resident set은 아래와 같은 구조로 구성된다.
힙 메모리는 메모리 영역에서 가장 큰 블록이고 가비지 컬렉션이 발생하는 곳이다. 힙 메모리 전체에서 가비지 컬렉션이 동작하는 것은 아니고 Young generation과 Old generation영역에서만 실행된다. 힙 메모리는 더 세부적으로 나눌 수 있다.
스캐벤져
라는 이름의 마이너 GC를 통해 관리한다. New 영역의 크기는 --min_semi_space_size
(초기값)와 --max_semi_space_size
(최대값) V8엔진의 플래그 값을 사용해 조정할 수 있다.💡 2019년도 기준으로는 GC를 개발자가 임의로 실행시킬 수 없었으나, 최신 V8엔진에서는
node --expose-gc index.js
옵션을 통해global.gc()
메서드를 노출시킬 수 있다.
스택은 메모리 영역이고 V8마다 하나의 스택을 가진다. 스택은 메서드와 함수 프레임, 원시 값, 객체 포인터를 포함한 정적 데이터가 저장된다. V8의 Stack영역의 관리는 OS에 위임하여 관리한다.
https://speakerdeck.com/deepu105/v8-memory-usage-stack-and-heap
앞서 언급했듯이 스택 메모리는 V8이 아닌 OS가 관리한다. 반면에 Heap 영역은 OS가 관리하지 않는다. 더군다나 Heap 메모리는 가장 큰 메모리 영역을 갖고 있기 때문에, 프로그램이 커진 경우 ROM(run out of memory)문제가 발생할 위험 역시 커진다. 이러한 이유 때문에 가비지 컬렉션이 필요하게 된다.
힙 영역에서 포인터와 데이터를 구분하는 것은 가비지 컬렉션에서 중요한 부분이다. V8 엔진은 Tagged pointers를 사용해서, 단어의 마지막 비트를 통해 포인터와 데이터를 구분한다.
Mark-Sweep-Compact
알고리즘에서도 살펴 보았듯이, 전역객채의 Referencing chain에 해당하지 않는 값들이 garbage collection
의 대상이 된다. 하지만 전역 변수와 같이 global scope에 존재하는 레퍼런스 값들은 무조건 전역객체가 레퍼런싱하게 되어, 가비지 컬렌션의 대상에 포함되지 않는다. 이러한 동작 때문에 전역변수는 메모리 누수의 위험이 있다.
하나의 값을 여러 변수가 참조하는 경우, 하나의 레퍼런스 변수를 해제하더라도, 다른 레퍼런스 변수가 여전히 데이터를 참조하고 있기 때문에, 가비지 컬렉션의 대상이 되지 않는다. 이러한 이유 때문에, 다중참조에서
클로저를 통해 스택 영역에 담긴 지역변수는 힙 영역으로 이동된다. 따라서 클로저가 제거되기 전까지는 가비지 컬렉터의 대상이 되지 않는다.
callback을 참조하는 함수가 종료되지 않는 한, callback 함수에 존재하는 모든 참조 값들은 가비지 컬렉터의 대상이 되지 않는다.
heap
영역의 데이터를 stack
영역에서 사용한다.💡 Stack영역에서 데이터를 사용하면, 성능상의 이점도 존재한다.
heap
영역으로 이동되서 관리된다. 따라서 메모리 누수의 위험을 생긴다spread
연산자나, Object.assign()
메서드를 통해 Deep copy한다.브라우저에 내장된 메모리 프로파일러를 사용하면, 메모리 누수를 쉽게 확인할 수 있다. 메모리 누수는 생성만 되고 반환은 안되는 상태이기 때문에, 메모리 사용량이 상승곡선을 그리면 메모리 누수를 의심 해볼만 하다.