5 minute read

가비지 컬렉션

가비지 컬렉션은 애플리케이션이 할당한 메모리 공간을 주기적으로 검사해 더이상 사용되지 않는 메모리를 해제하는 기능을 말한다. 여기서 말하는 더이상 사용되지 않는 메모리란, 어떤 식별자도 참조하지 않는 메모리 공간을 말한다. 대표적으로 이 기능을 가진 언어(혹은 엔진)는 자바, C#, 자바스크립트 등이 있다.

let example = 10;
example = 20;

console.log(example) // 20
// example에 실제 저장이 된 것은 10이라는 상수의 주소값
// example을 호출하면 주소값을 통해 20을 찾아간다. 
// 이를 참조 자료형이라 부른다.

위 예제에서 변수 example에 처음 할당된 값은 10이지만 이후 20이란 값을 재할당했다. 이제 10은 어떤 변수도 값으로 갖고 있지 않으므로 불필요한 값이 되었고, 이런 불필요한 값은 가비지 콜렌션에 의해 메모리에서 자동으로 해제된다. 단, 메모리에서 언제 해제될 지는 예측할 수 없다.

가비지 컬렉션에는 대표적인 두 가지 방법이 있다.

  1. 트레이싱:

    객체에 in-use flag를 두고, 사이클마다 메모리 관리자가 모든 객체를 추적해서 사용중인지 아닌지를 표시(mark), 그 후 표시되지 않은 객체를 삭제(sweep)하는 단계를 통해 메모리를 해제한다.

  2. 레퍼런스 카운팅

    한 객체를 참조하는 변수의 수를 추적하는 방법이다.

    • 변수의 레퍼런스가 복사될 때마다 : 레퍼런스 카운트++
    • 객체를 참조하고 있던 변수의 값이 바뀌거나, 변수 스코프를 벗어나면 : 레퍼런스 카운트–
    • if 레퍼런스 카운트 === 0 (아무도 그 객체에 대한 레퍼런스를 가지고 있지 않을 때) : 그 객체와 관련한 메모리는 비울 수 있다


Q : 크롬 브라우저 및 node.js의 v8 엔진은, 어떻게 가비지 컬렉션을 하고 있나요?

크롬 V8은 2개의 가비지 컬렉터가 있는데 하나는 Minor GC라 하며 Young Space라 불리는 메모리 공간이 부족할 때 활동하는 가비지 컬렉터이며 따라서 상대적으로 빈번히 GC를 실행한다. 하나는 Major GC이며 가끔씩 Young Space 뿐만 아니라 Heap Space 전체를 통째로 가비지 컬렉팅할 때 동작한다.


V8 메모리 구조

V8 메모리 구조

힙 메모리

V8 엔진은 힙 메모리에 객체나 동적 데이터를 저장한다. 힙 메모리는 메모리 영역에서 가장 큰 블록이면서 가비지 컬렉션(GC)이 발생하는 곳이다. 힙 메모리 전체에서 가비지 컬렉션이 실행되는 것은 아니다. Young과 Old 영역에서만 실행된다. 힙 메모리는 다음과 같이 더 세부적으로 나눌 수 있다.

  • New 영역: New 영역 또는 “Young 제너레이션”은 새로 만들어진 모든 객체를 저장하고 이 객체들은 짧은 생명 주기를 가진다. 이 영역은 크기가 작고 JVM에서 S0와 S1과 같은 2개의 세미(semi) 영역을 가진다. 이 영역은 이후에 살펴볼 스캐벤져(Scavenger, 마이너 GC)가 관리한다. New 영역의 크기는 --min_semi_space_size(초기값)와 --max_semi_space_size(최대값) V8 엔진의 플래그 값을 사용해 조정할 수 있다.
  • Old 영역: Old 영역 또는 “Old 제너레이션”은 마이너 GC가 두 번 발생할 동안 “New 영역”에서 살아남은 객체들이 이동하는 영역이다. 이 영역은 이후에 살펴볼 메이저 GC(Mark-Sweep 및 Mark-Compact)가 관리한다. Old 영역의 크기는 V8 엔진의 플래그 값 --initial_old_space_size(초기값)와 max_old_space_size(최대값)을 사용해 조정할 수 있다. 이 영역은 다시 2개의 영역으로 나누어진다.
    • Old 포인터 영역: 살아남은 객체들을 가지며, 이 객체들은 다른 객체를 참조한다.
    • Old 데이터 영역: 데이터만 가진 객체들(다른 객체를 참조하지 않는다)을 가진다. 문자열, 박싱(boxing)된 숫자, 실수형(double)로 언박싱(unboxing)된 배열은 마이너 GC가 두 번 발생하면서 “New 영역”에서 살아남아 이 영역으로 이동한다.
  • 라지 오브젝트 영역: 다른 영역의 제한된 크기보다 큰 객체들이 살고 있는 영역이다. 각 객체는 자체 mmap 메모리 영역을 갖는다. 라지 오브젝트들은 가비지 컬렉터로 이동하지 않는다.
  • 코드 영역: 실시간(JIT) 컴파일러가 컴파일된 코드들을 저장하는 곳이다. 유일하게 실행 가능한 메모리가 있는 영역이다. (코드들은 “라지 오브젝트 영역”에 할당될 수도 있고 실행도 가능하다)
  • 셀 영역, 속성 셀 영역, 맵 영역: 이 영역들은 각각 Cells, PropertyCells, Maps을 포함한다. 각 영역은 모두 같은 크기의 객체들을 포함하며, 어떤 종류의 객체를 참조하는지에 대한 제약이 있어서 수집을 단순하게 만든다.

각 영역은 페이지들로 구성되어 있다. 페이지는 운영 체제에서 mmap(또는 Windows에서 MapViewOfFile)로 할당된 연속된 메모리 청크를 의미한다. 각 페이지 크기는 라지 오브젝트 영역을 제외하고 1MB를 차지한다.

스택

스택은 메모리 영역이고 V8 프로세스마다 하나의 스택을 가진다. 스택은 메서드와 함수 프레임, 원시 값, 객체 포인터를 포함한 정적 데이터가 저장되는 곳이다. 스택 메모리의 크기 제한은 --stack_size V8 플래그 값을 사용해 설정할 수 있다.


마이너 GC (Scavenger)

아래 슬라이드에서 화살표를 따라가 보면서 마이너 GC 과정을 살펴보자.

V8 마이너 GC에 대한 슬라이드

  1. 시작할 때 To 영역에 객체가 이미 있다고 가정해보자(01~06 블록은 사용된 메모리로 표시됨).
  2. 새 객체(07 블록)를 생성한다.
  3. V8은 To 영역에서 필요한 메모리를 가져오려고 시도하지만, 객체들을 모두 수용할 수 없기 때문에 V8은 마이너 GC를 발생시킨다.
  4. 마이너 GC는 객체들을 To 영역에서 From 영역으로 이동시킨다. 이제 모든 객체는 From 영역에 있고 To 영역은 비워진다.
  5. 마이너 GC는 스택 포인터(GC 루트)부터 From 영역까지 객체 그래프를 재귀적으로 순회하면서 메모리를 사용한 객체들을 찾는다. 이 객체들은 To 영역의 페이지로 이동된다. 이 객체들을 참조하는 객체들은 To 영역의 페이지로 이동되고 포인터들은 갱신된다. From 영역의 모든 객체들을 찾을 때까지 이 과정이 반복된다. 마지막 객체까지 찾으면 To 영역은 자동으로 압축되어 조각화를 줄인다.
  6. 이제 From 영역에 남아있는 객체는 가비지이므로 마이너 GC는 From 영역을 비운다.
  7. 새 객체는 To 영역 메모리에 할당된다.
  8. 어느 정도 시간이 지나 “To 영역”에 더 많은 객체가 생겼다고 가정해보자(07~09 블록은 사용된 메모리로 표시됨).
  9. 애플리케이션이 새 객체(10 블록)을 생성한다.
  10. V8은 To 영역에서 필요한 메모리를 가져오려고 시도하지만, 객체들을 모두 수용할 수 없기 때문에 V8은 두 번째 마이너 GC를 발생시킨다.
  11. 위 과정은 반복되고 두 번째 마이너 GC에서 생존한 객체들은 “Old 영역”으로 이동한다. 첫 번째 마이너 GC에서 생존한 객체들은 “To 영역”으로 이동하고 남아있는 객체들은 “From 영역”에서 제거된다.
  12. 새 객체는 “To 영역”에 할당된다.


메이저 GC (Mark-Sweep-Compact)

메이저 GC는 Old 제너레이션 영역을 작고 깨끗하게 유지시킨다. 메이저 CG는 V8에서 Old 영역의 메모리가 충분하지 않다고 판단될 때 발생한다. Old 영역은 동적으로 계산된 크기에 기반하며, 마이너 GC 주기에서 채워진다. 스캐벤저 알고리즘은 작은 데이터 크기에는 적합하지만 Old 영역과 같이 큰 힙 메모리에는 적합하지 않다. 메모리 오버헤드가 있기 때문에 메이저 GC는 Mark-Sweep-Compact 알고리즘을 사용하여 처리된다. 메이저 GC는 Tri-color(흰색-회색-검은색) 마킹 시스템을 사용한다. 따라서 메이저 GC는 세 단계의 프로세스를 거치며, 세 번째 단계는 조각화 휴리스틱(fragmentation heuristic)에 따라 실행된다.

img

  • 마킹(Marking): 두 알고리즘의 공통적인 첫 번째 단계로, 가비지 컬렉터가 어떤 객체가 사용중인지 식별한다. 사용중이거나 GC 루트(스택 포인터)에 재귀적으로 도달할 수 있는 객체들은 활성 상태로 표시된다. 마킹은 기술적으로 힙 메모리를 방향 그래프(directed graph)로 간주해 깊이 우선 탐색(depth first search)를 수행한다.
  • 스위핑(Sweeping): 가비지 컬렉터가 힙 메모리를 순회하면서 활성 상태로 표시되지 않은 객체들의 메모리 주소를 기록한다. 이 공간은 이제 사용 가능한 목록(free-list)에서 사용 가능하다고 표시되며 다른 객체들을 저장하는 데 사용될 수 있다.
  • 압축(Compacting): 스위핑이 일어난 다음, 필요하다면 모든 활성 상태의 객체들이 함께 이동될 것이다. 압축 단계는 조각화를 줄이고 새 객체들에 대한 메모리 할당 성능을 증가시킨다.

또한 메이저 GC는 GC를 수행하는 동안 애플리케이션 실행을 멈추므로 stop-the-world GC라고도 한다.

메이저 GC 과정을 살펴보자.

  1. 많은 마이너 GC 주기를 거치고 Old 영역이 거의 다 찼으며 V8이 “메이저 GC”를 발생시킨다고 가정해보자.
  2. 메이저 GC는 스택 포인터에서 시작해 재귀적으로 객체 그래프를 순회하면서, Old 영역 내 메모리를 사용한 객체와 남아있는 객체를 가비지로 표시한다.
  3. 동시 마킹이 완료되거나 메모리 제한에 도달하면 GC는 메인 스레드를 사용하여 마킹의 마지막 단계를 수행한다. 이 때 일시 정지 시간이 발생한다.
  4. 메이저 GC는 동시 스위프 스레드를 사용해 모든 참조 없는 객체들의 메모리를 사용 가능한 상태로 표시한다. 또한 조각화를 피하기 위해 관련 메모리 블록을 동일한 페이지로 이동하도록 병렬 압축 작업도 발생한다. 포인터들은 이 세 단계를 통해 갱신된다.


참고문서

Visualizing memory management in V8 Engine (JavaScript, NodeJS, Deno, WebAssembly)

Memory terminology


캐시

많은 시간이나 연산이 필요한 일에 대해 결과를 저장해두는 것 일시적인(temporarily) 특징이 있는 데이터를 저장하기 위한 목적으로 존재하는, 고속의 데이터 저장공간 -> 캐싱을 사용하면 이전에 검색하거나 계산한 데이터를 효율적으로 재사용

캐시의 데이터는 일반적으로 RAM(Random Access Memory)과 같이 빠르게 액세스할 수 있는 하드웨어에 저장됨

장점

  • 애플리케이션 성능 개선

  • 데이터베이스 비용 절감

  • 백엔드 부하 감소

  • 예측 가능한 성능

  • 데이터베이스 핫스팟 제거

  • 읽기 처리량 (IOPS) 증가

    • 읽기 처리량: IOPS; Input/output operations per second. HDD, SSD 등의 컴퓨터 저장 장치의 성능 측정 단위

ex) 클라이언트: HTTP 캐시 헤더, 브라우저 네트워크: DNS 서버, HTTP 캐시 헤더, CDN, 리버스 프록시 서버 및 데이터베이스: 키-값 데이터 스토어(e.g. Redis), 로컬 캐시(인-메모리, 디스크)

아마존-캐시설명페이지