ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Javascript의 런타임 꼬꼬무 1편 (Heap과 GC)
    CS 2025. 5. 12. 20:40

    이 글은 해당 주제에 대해 기초적인 개념에서 더 나아가 한 단계 더 깊이 있는 이해를 시도 해보고자 작성되었습니다.

     

    잘 아시다시피 JavaScript는 기본적으로 싱글 스레드 언어입니다.

    한 번에 하나의 작업만을 순차적으로 처리하는 실행 모델입니다.

     

    브라우저에서 AJAX, fetch, setTimeout 등의 비동기 작업은 브라우저의 Web API 영역에서 처리하며,

    이 작업들은 메인 스레드(자바스크립트 엔진)와는 별도의 브라우저 내부 스레드에서 실행됩니다.

     

    이 과정에서 JavaScript Runtime과 관련된 보다 상세한 내용과 궁금증에 대한 해결을 작성합니다.

     

     

    Heap - 세분화와 V8 엔진 구조, 그리고 가비지 컬렉션(Garbage Collection)

    1) V8 엔진 메모리 구조와 메모리 사용

    V8엔진은 오픈 소스 JavaScript 엔진 중 하나이며,

    웹어셈블리로써 웹페이지에서 고성능 애플리케이션을 사용 가능하게 합니다.

    V8엔진(Chrome, Node.js 등)에서 메모리는 크게 스택과 힙으로 구성됩니다.

    출처: https://deepu.tech/memory-management-in-v8

    - New space: 짧은 생명 주기(short- lived)를 가지는 새로 생성된 객체가 저장되는 공간.

    - Old space: New Space에서 두 번의 Minor GC가 발생하는 동안 GC되지 않고 살아남은 객체들이 이동하는 공간.

    - Old pointer space: 살아남은 객체 중 다른 객체를 참조하는 객체가 저장되는 공간.

    - Old data space: 살아남은 객체 중 데이터만 가지는 객체가 저장되는 공간.

    - Large object space: 다른 영역의 크기보다 큰 객체들이 저장되는 공간. mmap 영역을 가지며 GC되지 않음.

    - Code-space: JIT 컴파일러에 의해 컴파일된 코드가 저장되는 공간. 실행 가능한 유일한 메모리가 유일하게 존재.

    - Cell space, property cell space, map space: 각각 Cells, PropertyCells, Maps가 존재하는 공간.

     

    각 영역은 mmap 시스템 콜을 통해 운영체제로부터 할당받은 페이지로 구성되어 있으며,

    각 페이지의 크기는 Large object space를 제외하고 1MB 입니다.

     

    짧은 생명 주기(short- lived)
    짧은 시간 동안(2번의 Minor GC 안에 삭제)만 존재하고, 곧바로 필요 없어지는 객체.
    function getUserInfo(name) {
      const user = {
        name: name,
        createdAt: new Date(),
      };
      return user;
    }

     

    여기서 user는 함수 실행 중에만 존재하며, 함수가 끝나면 더이상 사용되지 않음.
    하지만 V8 엔진은 어떤 명확한 기준으로 이를 판단하는가? (후술)

    Large object space
    V8 엔진에서는 객체가 일정 크기 이상이면 New/Old space 대신 Large Object Space에 직접 할당합니다.
    이때 기준이 되는 크기는 대략 256KB 수준입니다.
    (이 임계값(Threshold)은 V8 버전에 따라 조금 다를 수 있습니다)

    해당 공간은 복사형 GC의 대상이 아니며, 메모리 매핑 방식으로 별도로 관리됩니다.

    복사형 GC
    메모리 영역을 두 개로 나누고, 살아있는 객체만 한쪽에서 다른 한 쪽으로 복사하면서 메모리를 정리하는 방식.
    (New Space 역시 이 방식을 사용: To-space와 From-space라고 칭함)

    간편하고 효율적이며 메모리 정리가 깔끔하지만, 복사 비용이 존재합니다(큰 객체일수록 비효율적).

    mmap 기반 메모리 매핑
    mmap은 OS 수준에서 파일이나 메모리 블록을 직접 가상 메모리에 매핑하여 접근하는 방식입니다.
    큰 객체는 GC의 복사 대상이 되지 않도록 mmap을 이용해 Heap 외부에 있는 별도 메모리 공간에 직접 할당합니다.
    GC 시 복사가 아닌 free() 호출로 해제합니다.

    복사 비용이 없고 효율적으로 메모리를 사용하지만, 메모리 단편화(Fragmentation) 위험이 존재합니다.
    (*Fragmentation = 여유 공간이 많아 보여도, 필요한 만큼 연속된 공간이 없어서 쓸 수 없는 상태)
    JIT 컴파일러
    JavaScript는 인터프리터 언어입니다(한 줄씩 읽어서 그 때에 해석).
    하지만 속도를 빠르게 하기 위해 JIT 컴파일러가 등장 했습니다.

    JIT 컴파일러에 의해 컴파일 되는 코드는 Hot Code 입니다.
    (*Hot Code = 반복해서 자주 실행되는 코드 또는 시간이 오래 걸리는 함수)
    V8은 실행 중 코드 사용 패턴을 수집하여 정량적인 임계 값에 도달하면 해당 코드를 JIT 컴파일 대상으로 삼습니다.
    호출 횟수, 루프 반복 횟수, 타입 안정성 등을 판단하여 V8이 체계적으로 판단합니다.
    function heavyLoop() {
      for (let i = 0; i < 10000; i++) {
        Math.sqrt(i);
      }
    }
    
    function add(a, b) { return a + b }​

     

    JIT 컴파일된 코드가 나중에 예외적인 타입이나 분기 흐름을 만나면 비최적화(Unoptimized) 버전으로 롤백되며
    이를 디옵트(Deoptimization)이라고 말합니다.
    Cells: 일반적으로 상수, 슬롯, 내부 상태 등을 저장하는 데 사용되는 작은 단위의 저장소입니다.
    PropertyCells: 전역변수나 globalThis에 있는 속성의 상태를 저장하고 추적하는 객체.
    Maps: JavaScript 객체의 형태와 구조적 정의를 담고 있는 메타 정보.

     

    2) V8 엔진의 가비지 컬렉션

    현재 사용 가능한 힙 영역보다 더 많은 양의 메모리를 할당받으려고 하면 out of memory 에러가 발생하게 됩니다.

    또한 힙 영역이 제대로 관리되지 않으면 memory leak이 발생할 수 있습니다.

     

    V8 엔진이 사용하는 가비지 컬렉터는 generational GC의 일종으로, 앞서 메모리 구조 섹션에서 살펴본 것처럼

    객체의 나이를 기준으로 힙 영역을 여러 하위 영역으로 세분화하여 가비지 컬렉션을 수행합니다.

    generational GC
    생긴지 얼마 안된 객체일수록 GC에 의해 수거될 가능성이 높다는 경험적 이론.

    1. Most allocated objects are not referenced (considered live) for long, that is, they die young.
        대부분의 객체는 금방 unreachable 상태(GC의 대상)가 된다.
    2. Few references from older to younger objects exist.
        오래된 객체에서 젊은 객체로의 참조는 거의 존재하지 않는다.

     

    V8 엔진이 수행하는 가비지 컬렉션에는 크게 두 단계가 존재합니다.

    Minor GC (Scavenger)
    New space 영역에 존재하는 어린(주로 1MB ~ 8MB의 크기)객체를 가비지 컬렉트합니다.

    New space 영역에선 “할당 포인터”를 사용하여 새로운 객체를 위한 메모리 영역을 할당하는데,
    객체가 새로 할당될 때마다 포인터 값이 증가하다가 영역의 끝에 다다르면 Minor GC가 수행됩니다.
    Minor GC는 Cheney 알고리즘을 사용하는데, 꽤 자주 수행되며 실행 속도가 굉장히 빠릅니다.

    Major GC (Full Mark-Compact)
    Minor GC에 의해 객체를 New space로 Old space로 옮길 때, Old space의 여유 공간이 부족한 경우 실행됩니다.
    Major GC는 크기가 크기 때문에 더 적합한 Mark-Compact 알고리즘을 사용합니다.
    Cheney Algorithm
    1. 두 개의 힙 영역: From-space, To-space이 준비
    2. 할당은 모두 From-space
    3. GC가 시작되면:
        3-1. 루트 객체들 추적 시작
        3-2. 살아있는 객체를 To-space에 복사.
        3-3. 복사된 객체가 참조하는 다른 객체들도 재귀적으로 복사.
    4. GC가 끝나면 역할을 swap해서 To-space -> From-space로 변경.

    Mark-Sweep Algorithm
    런타임의 시작점인 Global Root에서 뻗어나오는 모든 메모리는 자신이 참조되는 Count를 저장할 수 있습니다.
    따라서 해당 값을 기점으로 사용되지 않는 메모리를 제거하면 될 것 같지만, 순환참조 문제를 해결할 수 없습니다.
    (루트에서 닿을 수 없지만, Count가 0이 아닌 메모리 묶음이 생기는 순환 구조)
    Mark-Sweep 알고리즘은 이를 해결하기 위해 사용되는 가장 대표적인 GC 알고리즘입니다.
    (구체적인 단계는 Mark-Compact Algorithm의 일부로서 동일합니다.)

    Mark-(Sweep)-Compact Algorithm
    1. Mark 단계: 루트 객체부터 시작하여 살아있는 객체를 표시
    2. Sweep 단계: 마킹되지 않은(unreachable한) 객체를 heap에서 제거
    3. Compact 단계
        2-1. 살아있는 객체들을 힙의 앞쪽으로 밀어붙임
        2-2. 이동 후 남는 공간을 재사용 가능하게 만듦(메모리 단편화(=Fragmentation)를 최소화)

    Fragmentation
    Mark-Sweep의 사용에서, 참조되지 않는 객체를 제거해도 살아남은 객체들은 힙 메모리 여러 곳에 존재하게 됩니다.
    이로 인해 Heap에는 '빈 공간'이 생기며 연속적이지 않아, 큰 객체를 위한 연속된 메모리 블록 할당이 어려워집니다.

    Old Generation은 GC가 자주 일어나지 않지만, 한번 발생할 때 처리해야 할 데이터가 많아집니다.
    메모리 오버헤드와 일시 정지 시간이 증가할 수 있지만, 단편화 해소와 안정적 메모리 할당이 더 유리하게 작용합니다.

     

    (Minor GC에서 Mark-Sweep 알고리즘으로 설명해주시는데, V8이 아닌 JVM 기준이기 때문입니다.)

     

    마지막으로 mdn web docs를 기준으로 JavaScript에서 실제로 GC가 어떻게 일어나는지 확인해보겠습니다.

    // 1
    let x = {
      a: {
        b: 2,
      },
    }; 
    
    // 2
    let y = x;
    
    // 3
    x = 1;
    
    // 4
    let z = y.a;
    
    // 5
    y = "mozilla";
    
    // 6
    z = null;

     

    단계 1)

    변수 x -> { a: { b: 2 } }

    프로퍼티 a -> { b: 2 }

     

    단계 2)

    변수 x, y -> { a: { b: 2 } }

    프로퍼티 a -> { b: 2 }

     

    단계 3)

    변수 x -> 1

    변수 y -> { a: { b: 2 } }

     

    단계 4)

    변수 z -> { b: 2 }

    변수 y -> { a: { b: 2 } }

    프로퍼티 a -> { b: 2 }

     

    단계 5)

    변수 y -> "mozilla"

    변수 z -> { b: 2 }

     

    최초로 객체 { a: { b: 2 } }가 더 이상 어떠한 변수에도 참조되지 않으므로 GC 대상입니다.

    { b: 2 }는 GC의 대상이 아닙니다.

    객체는 별도의 메모리 블록에 저장되고, 프로퍼티는 해당 블록에 대한 포인터일 뿐이기 때문입니다.

     

    단계 6)

    변수 z -> null

    { b: 2 }도 더 이상 참조되지 않으므로 GC 대상입니다.

     

Designed by Tistory.