자바스크립트 V8 엔진 메모리 관리

김지수·2023년 6월 19일
0

Computer Science

목록 보기
4/4

이번 글에서는 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

V8메모리 구조


JavaScript는 싱글스레드 이므로 JavaScript 컨텍스트당 단일 프로세스를 사용하므로 서비스 작업자를 사용하는 경우 작업자 당 새로운 V8 프로세스가 생성된다. 실행중인 프로그램은 할당된 메모리 표시로 Resident Set라고 한다.

힙 메모리

힙 메모리는 객체 또는 동적 데이터를 저장한다. 메모리 영역 중 가장 큰 블록으로 가비지 컬렉션(GC)가 일어나는 곳이다. 가비지 컬렉션은 New Space 또는 Old Space에서만 발생한다.

  • New Space: "Young Generation"이라고도 하는 new space는 짧은 생명 주기를 가지는 새로운 객체가 저장되는 곳이다. 두개의 semi-space가 있으며 이 공간은 Scavenger(Minor GC)에 의해 관리된다.
  • Old Space: "Old Generation"이라고도 불리는 new space는 두개의 Minor GC가 수행된 후 New Space에서 살아남은 객체가 이동되는 곳이다. 이 공간은 Major GC(Mark Sweep & Mark-Compact)에 의해 관리된다.
    • Old Pointer Space: 살아남은 객체 중에서 다른 객체를 참조하는 객체가 저장되는 영역
    • Old Data Space: 살아남은 객체 중에서 데이터만 가지는 객체를 저장하는 역역
  • Large object space: 다른 공간의 제한된 크기보다 큰 객체가 저장되는 영역으로 각 객체는 mmap(메모리맵)영역을 가진다. 큰 객체들은 가비지 컬렉트되지 않는다.
  • Code-Space: JIT(Just In Time) 컴파일러가 컴파일된 코드 블록을 저장하는 곳으로 실행 가능한 메모리가 존재하는 유일한 공간이다. “코드”들은 Large object space에 저장될 수도 있고, 실행 가능하다.
  • Cell space, property cell space, map space: 각 Cells, PropertyCells, Maps가 존재하는 영역으로 각 공간에는 동일한 크기의 객체가 저장되며, 어떤 객체를 참조하는지에 대한 제약이 있어 수집이 간단하다.

각 영역은 mmap(Windows의 경우 MapViewOfFile)시스템 콜을 통해 운영체제로부터 할당받은 페이지로 구성되어 있으며, 각 페이지의 크기는 Large object space 영역을 제외하고 1MB이다.

V8 메모리 사용량 (Stack VS Heap)

지금까지는 메모리 구조를 파악했고, 이제부터 프로그램이 실행될 때 메모리가 어떻게 할당되는 살펴보자.

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)가 저장된다.

  • getBonusPercentage, findEmployeeBonus, Employee객체의 포인터가 저장되고, 포인터가 가리키는 주소인 힙 메모리 영역에 각 객체가 저장된다.
  • 그리고 BONUS_PERCENTAGE는 정적 데이터이므로 스택에 "10"이라는 값으로 저장된다. String, Number와 같은 원시 타입은 스택에 직접 저장된다.
  • "john"이 선언된 코드 실행순서가 되면 글로벌 프레임이 john의 포인터가 저장되고, 힙메모리 영역에 객체 데이터가 저장된다.(name, salary, sales, bonus)
  • 호출된 함수(findEmployeeBonus)는 스택 맨 위에 함수 프레임으로 푸시된다. 함수 프레임에는 인수 및 반환 값을 포함한 모든 지역 변수가 블록내에 저장된다.
  • 함수가 값을 반환하면 해당 함수 프레임이 스택에서 제거된다.
  • 스택에서 제거된 함수프레임으로 인해서 힙에 저장되어 있는 객체는 더이상 스택에서 포인터를 가지지 않고, 고아가 된다.

정리해보면 먼저 스택에 글로벌 프레임에 전역 범위에 존재하는 객체의 포인터와 정적 데이터 값들이 저장되고, 객체의 포인터가 가리키는 힙 메모리 주소에 해당 객체가 저장된다.
클래스의 인스턴스가 생성되면, 힙 메모리 영역에 해당 객체가 만들어지고, 함수가 실행되면 스택에는 함수 프레임과 함수에 필요한 인수, 반환 값, 지역 변수가 해당 블록 내에 저장된다.
실행이 끝난 객체는 스택에서 제거되는데 이로 인해 힙에서 더 이상 포인터를 가지지 않는 객체는 그냥 두게 되면 메모리가 부족해지고, 프로그램 속도가 느려진다. 이러한 객체는 가비지 컬렉션을 수행하게 된다.

가비지 컬렉션을 수행할 때 힙에 존재하는 데이터와 포인터(레퍼런스)를 구분하는 것이 중요하기 때문에 V8은 tagged pointer를 이용해서 이 둘을 구분한다. Tagged pointer는 각 워드의 끝에 하나의 비트를 할당하여 해당 데이터가 포인터인지 데이터인지를 나타냅니다.

V8 메모리 관리: 가비지 컬렉션

V8 엔진은 가비지 컬렉션으로 메모리 누수가 발생하지 않도록 힙 메모리를 관리한다. 스택에서 더 이상 참조되지 않는 객체를 사용하는 메모리를 해제하여 새로운 객체 생성을 위한 공간으로 만든다.

V8의 가비지 컬렉터는 V8 프로세스에서 재사용할 수 있도록 사용되지 않은 메모리를 회수하는 역할을 한다.
V8 엔진이 사용하는 가비지 컬렉터는 generational GC의 일종으로, 객체의 나이를 기준으로 힙 영역을 여러 하위 영역으로 세분화하여 가비지 컬렉션을 수행한다. V8 엔진이 수행하는 가비지 컬렉션에는 크게 두 단계가 존재한다.

Minor GC(Scavenger)

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가 실행된다.

  • from-space의 할당 포인터가 6번까지 있을 때 새로운 객체(7번)를 생성하려고 하면 더 이상의 공간이 없어 Minor GC를 실행한다.
  • Minor GC는 스택 포인터(GC 루트)에서 시작하여 from-space에서 객체 그래프를 재귀적으로 탐색해가면서 현재 사용 중인 객체들을 찾아낸 뒤 to-space 영역으로 이동시킨다. 이동된 객체가 참조하는 모든 개체도 to-space
    영역으로 이동되고 해당 포인터가 업데이트된다.
  • 이 작업이 끝나면 to-space가 자동으로 압축되어 메모리 단편화(fragmentation)를 줄인다.
  • to-space로 옮겨지지 못하고 from-space에 남겨진 객체들은 "가비지"로취급되어 가비지 컬렉트된다.

  • 그리고 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(Full Mark-Compact)

Major GC는 Old space 영역을 담당하는 Minor GC에 의해 객체들이 New space에서 Old space로 옮길 때 Old space에 공간이 부족한 경우 실행된다.

Old space와 같이 크기가 큰 영역에 Minor GC를 적용하기에는 메모리 오버헤드가 발생할 수 있어 Major GC는 Mark-compact 알고리즘을 사용한다.

  • Marking: 첫번째 단계로 현재 사용되는 개체를 파악한다. 객체가 "살아있음"을 판단하는 근거로 GC 루트(스택 포인터)에서 시작하여 해당 객체에 도달할 수 있는지 확인한다. (DFS 수행)
  • Sweeping: Marking 단계에서 표시되지 않은 객체가 사용하던 메모리 공간은 free-list에 저장된다. free-list는 탐색하기 쉽도록 크기순으로 세분화되며 이후 메모리를 할당하고자 할 때 free-list에서 적절한 크기의 메모리 공간을 찾아 할당하게 된다.
  • Compacting: Sweeping 단계 후 필요한 경우 메모리 단편화를 해결하기 위해 메모리 압축 작업을 진행한다. 살아남은 객체를 현재 압축 진행하지 않는 다른 메모리 페이지에 복사하는 방식으로 진행하는데 만약 살아남은 객체가 많다면 객체를 복사하는 오버헤드가 커질 수 있다. 따라서 단편화가 심하지 않은 페이지는 Seeping 단계까지만 수행하고 단편화가 많이 진행된 페이지만 압축을 진행한다.

Orinoco

이러한 가비지 컬렉터들의 중요한 성능 지표 중 하나가 "GC를 수행하면서 얼마 동안 메인 스레드를 블로킹하는가?"인데 전통적인 블로킹 방식의 GC의 경우, 메인 스레드를 오랜 시간 블로킹하여 페이지가 버벅거리는 등 UX가 저하되는 문제가 있다.

현재 사용되고 있는 V8 엔진의 가비지 컬렉터를 Orinoco라고 하는데, Orinoco는 병렬적, 점진적, 등시적으로 GC를 수행하여 최대한 메인 스레드를 블로킹하지 않는 방식을 사용한다.

병렬적 방식

병렬적 방식은 메인 스레드와 헬퍼 스레드가 거의 똑같은 양의 작업을 도시에 수행하는 방법으로, 여전히 블로킹 방식이긴 하지만 사용하는 헬퍼 스레드의 개수만큼 블로킹 되는 시간을 절감할 수 있다.
세 방식 중 가장 쉬운 방법이며, 여러 헬퍼 스레드에서 GC를 수행하기 때문에 동시에 같은 객체에 접근하지 못하도록 동기화 작업을 할 필요는 있다.

점진적 방식

점진적 방식은 메인 스레드가 다른 작업들과 번갈아 가면서 GC를 수행하는 방식이다.
GC 수행 -> 스크립트 수행 -> GC 수행 -> 스크립트 수행 ...과 같이 진행되는데, 스크립트와 번갈아 실행됨에 따라 힙의 상태가 변경되어 이전 작업이 무용지물이 될 수 있어 앞서 벙렬적 방식보다 어려운 방식이다.

GC가 메인 스레드에서 실행되는 총 시간은 변함 없으나 스크립트와 번갈아 실행하면서 스레드가 한 번에 블로킹 되는 시간을 줄일 수 있어 정상적으로 화면을 렌더링하거나 유저와 상호작용할 수 있게 된다.

동시적 방식

동시적 방식은 벙렬적 방식과 비슷한데 차이점은 동시적 방식에선 GC가 헬퍼 스레드에서만 수행된다는 점이다.
GC와 스크립트가 동시에 실행될 수 있기 때문에 GC 도중에 힙의 상태가 바뀔 수 있어 세 방식 중 가장 어려운 방식이다. 또한 벙렬적 방식과 같이 여러 헬퍼 스레드가 같은 객체에 접근할 수 있으므로 동기화 처리 또한 필요하다.
동기화 처리로 인한 오버헤드가 졵해지만 메인 스레드를 블로킹하지 않고 GC를 처리할 수 있다는 장점이 있다.

V8에서 사용하는 방식

Minor GC

Minor GC는 병렬적 방식을 사용하여 new space에 대한 GC를 수행할 때 여러 헬퍼 스레드를 사용해서 작업ㄷ을 분할한다.

Major GC

Major GC의 경우 힙의 최대 크기에 다다르면 marking 작업을 헬퍼 스레드에서 동시적으로 시작한다. 헬퍼 스레드테서 마킹 작업을 수행하는 와중에 메인 스레드에서 실행 중인 스크립트에서 객체에 대한 새로운 참조를 생성하는 경우, write barrier를 사용하여 새로운 참조를 기록한다.
마킹 작업이 끝나면 메인 스레드에서 빠르게 마킹 작업을 마무리하느넫, 이 과정에서 루트부터 다시 탐색하여 살아있는 객체가 제대로 마킹되었는지 체크한다. 이후 헬퍼 스레드와 함께 병렬 방식으로 압축 작업을 진행하는 이와 동시에 헬퍼 스레드에서 동시적으로 sweeping 작업을 수행한다.

profile
백엔드 노드 개발자

0개의 댓글