자바스크립트에서의 메모리 관리

dahyeon·2023년 1월 13일
0

자바스크립트

목록 보기
7/7

본 포스팅에서는 자바스크립트에서 메모리가 어떻게 할당되고 관리되는지 살펴볼 것이다.

시작하기에 앞서, 본 포스팅은 이 링크(번역본은 여기)에 있는 포스팅을 바탕으로 작성되었으며 추가 설명이 필요하다고 느껴진 부분은 참고 자료에 명시한 자료들을 통해 보충하였음을 알려드립니다.


메모리 할당 - Heap과 Stack

시작하기에 앞서, 자바스크립트에서는 heap과 stack에 각각 어떤 데이터들이 할당되는지 알아보자.

Stack: 정적 메모리 할당

Stack에는 정적 데이터들이 할당된다. 정적 데이터는 컴파일 타임에 엔진이 그 크기를 이미 알고 있는 데이터를 의미한다. 엔진은 프로그램을 실행하기 직전에 정적 데이터에 고정된 양의 메모리를 할당한다. 자바스크립트에서 정적 데이터에는 원시 값(strings, numbers, booleans, undefined, null)과 객체 또는 함수를 가리키는 참조(references)가 있다.

Heap: 동적 메모리 할당

Heap에는 객체와 함수들이 저장된다. 이러한 데이터의 크기는 런타임에 알 수 있으므로 엔진은 이러고정된 양의 메모리를 할당하지 않고 필요한 만큼 할당해준다.


가비지 콜렉션(Garbage collection)

Heap에는 동적 데이터들이 런타임 시 메모리를 할당받아 저장된다. 만약 사용이 완료된 데이터가 메모리에서 사라지지 않고 계속 남아있다면, 언젠가는 heap 공간이 부족해질 것이다. 따라서 더 이상 사용하지 않는 메모리 공간을 해제하는 작업이 필요하다. 자바스크립트 엔진의 가비지 콜렉터(garbage collector)에서 이 작업을 담당한다.

그런데 객체가 더 이상 사용되지 않을 것이란 걸 어떻게 알 수 있을까? 가장 많이 사용되는 두 알고리즘인 참조 횟수 계산 방식(reference-counting garbage collection)과, 마크 앤 스윕(mark and sweep) 알고리즘에 대해서 알아보자.

참조 횟수 계산 방식(Reference-counting garbage collection)

이 방식에서는 더 이상 참조되고 있지 않은 객체를 메모리에서 해제한다. 즉, 각 객체가 참조되는 횟수를 저장하고 이 횟수가 0이 되면 메모리에서 해제하는 방식으로 동작한다.

문제점

이 방식의 문제점은 순환 참조(cyclic references)가 발생하면 참조 횟수가 0으로 떨어지지 않아 메모리에서 해제되지 않는다는 것이다.

아래의 예시 코드를 보면,

let son = {
  name: 'John',
};

let dad = {
  name: 'Johnson',
}

son.dad = dad;
dad.son = son;

son = null;
dad = null;
  • sondad가 서로 참조하고 있다. 이 때 son, dad를 각각 null로 바꾼다 해도 순환 참조로 인한 참조 횟수가 존재하기 때문에 메모리에서 해제되지 못한다.

마크앤스윕(Mark-and-sweep) 알고리즘

이 알고리즘은 root 객체로 출발해서 도달 가능한 객체를 마킹하고, 마킹되지 않는 객체를 더 이상 사용하지 않는다고 간주한다. 브라우저에서 rootwindow 객체이며, NodeJS에서는 global 객체가 된다. 좀 더 자세히는 다음과 같은 과정으로 진행된다.

  • 마킹(Marking): root 객체로부터 출발해서 depth-first-search 방식으로 도달 가능한 모든 객체(reachable objects)를 탐색하고 마킹한다.
  • 스위핑(Sweeping): 힙을 탐색하면서 마킹되지 않은 객체, 즉 도달 불가능한 객체(non-reachable objects)의 메모리 주소를 기억하고, 추후 다른 객체를 저장할 때 사용한다.
  • 압축(Compacting): 스위핑 이후에 필요하다면 마킹된 객체들의 위치가 이동된다. 이는 메모리 파편화(fragmentation) 및 메모리 할당 성능을 개선하기 위한 과정이다.

심화편: V8 메모리 구조와 가비지 콜렉션

V8 엔진에서 heap은 좀 더 세분화되어 있고, 가비지 콜렉션 절차도 이에 따라 단계적으로 진행된다. V8에서의 메모리 구조와 가비지 콜렉션에 대해 좀 더 자세히 알아보자.

V8 메모리 구조

V8 엔진에서 실행되는 프로그램은 V8 프로세스의 메모리 공간 일부를 할당받는데 이를 Resident Set이라고 한다. 만약 service worker를 사용한다면 워커 당 한 개의 새로운 V8 프로세스가 생성된다.

Resident Set은 다음 그림과 같이 구성되어 있다.


각 공간이 어떻게 활용되는지 가비지 콜렉션 과정을 훑어보면서 알아보자.

  • 객체들은 먼저 New space에 할당된다. 새 객체를 위한 공간을 할당하려고 할 때마다 할당 포인터(allocation pointer)의 값이 증가한다.
  • 할당 포인터가 New space의 끝에 도달하면 마이너 GC가 발생한다.

마이너 GC(Minor GC)

  • New space는 to-spacefrom-space라는 두 개의 semi space로 나뉘어져 있다. 메모리 할당은 from-space에서 진행되고, from-space가 꽉 차면 마이너 GC가 발생한다.
  • 마이너 GC에서는 스택 포인터(GC roots)로부터 출발해서 도달 가능한 객체를 to-space로 옮긴다. 옮겨진 객체들에 의해 참조되는 다른 객체들도 모두 to-space로 옮기고, 해당 포인터의 값도 업데이트 된다. from-space의 모든 객체들이 탐색될 때까지 진행하며, 이 과정에서 to-space는 자동으로 압축(compact) 된다.
  • from-space에 있는 객체들을 해제한다.
  • to-spacefrom-space를 스왑한다. 객체들은 from-space에 저장되고, to-space는 비어있다.
  • 새로운 객체를 from-space의 메모리 공간에서 할당한다.

위 마이너 GC가 두 번 발생할 동안 살아남은 객체들은 old space로 이동한다.

위 그림에서 볼 수 있듯 old spacepointer spacedata space로 나뉜다.

  • Old 포인터 영역: 살아남은 객체들을 가지며, 이 객체들은 다른 객체를 참조한다.
  • Old 데이터 영역: 데이터만 가진 객체들(다른 객체를 참조하지 않는다)을 가진다. 문자열, 박싱(boxing)된 숫자, 실수형(double)로 언박싱(unboxing)된 배열은 마이너 GC가 두 번 발생하면서 "New 영역"에서 살아남아 이 영역으로 이동한다.

메이저 GC(Major GC)

메이저 GC는 V8에서 old space가 충분하지 않다고 판단되면 앞서 설명한 마크앤스윕(mark-and sweep) 알고리즘으로 수행한다.

이 때 어플리케이션의 실행이 멈추는 것을 극복하기 위해서 V8에서는 다음과 같은 기술을 사용한다.

  • 마킹(marking) 과정은 다중 헬퍼 스레드(helper threads)를 사용해서 진행되기 때문에 메인스레드에 영향을 주지 않는다.
  • 마킹이 끝나거나 메모리 제한에 도달했을 때 메인스레드를 이용해서 마킹의 마지막 단계(mark finalization)를 수행한다.
  • 스윕스레드(sweep threads)를 이용해서 스윕을 수행한다. 압축 작업도 병렬적으로 같이 진행된다. 이 과정에서 포인터의 값이 갱신된다.

참고 자료

JavaScript's Memory Management Explained
V8 엔진(자바스크립트, NodeJS, Deno, WebAssembly) 내부의 메모리 관리 시각화하기
🚀 Visualizing memory management in V8 Engine (JavaScript, NodeJS, Deno, WebAssembly)
Memory Management - JavaScript | MDN

profile
https://github.com/dahyeon405

0개의 댓글