모든 기록
Garbage Collection · 2026.09.06

2주차 - Unity가 쓰는 보수적 GC, 포인터인지 정수인지 어떻게 구분할까

타입 정보가 없는 C/C++ 환경에서 BDWGC가 스택과 힙을 워드 단위로 훑어 포인터로 의심되는 값을 마킹하는 방식과, 그로 인해 생기는 오탐 문제를 정리.

2주차 - Unity가 쓰는 보수적 GC, 포인터인지 정수인지 어떻게 구분할까 대표 이미지

보수적 GC란

C나 C++을 설명할 때 언급되지만, 실제로는 Unity에서 쓰는 보수적(conservative) Garbage Collector 방식이다.

보수적이라는 말은?

일반 GC는 컴파일러가 "이 스택 슬롯은 포인터고 저건 정수다"라는 타입 정보를 넘겨준다. 하지만 C에는 그런 정보가 없다. 그래서 BDWGC는 스택, 레지스터, 전역 데이터 영역, 힙 객체 내부를 워드 단위로 훑으면서 힙 범위 안의 유효한 주소처럼 보이는 값은 모두 포인터로 간주한다.

유효한 주소와 포인터?

힙은 원래 새로 생긴 객체 등을 담는 곳이다. 여기서 포인터는 해당 객체 안에 들어 있는 정보다.

struct Node {
    int          value;   // 4바이트
    struct Node *next;    // 8바이트
};

GC_MALLOC(sizeof(struct Node))로 만든 객체는 힙에서 16바이트짜리 덩어리다.

이 내부 16바이트는 실제로 아래와 같이 구성돼 있다.

주소 0x600100 에 있는 Node 객체 (16바이트)
┌────────────────────────┬────────────────────────┐
│ 00 00 00 2A  (+패딩)   │ 00 00 00 00 00 60 01 80│
└────────────────────────┴────────────────────────┘
   value = 42                next = 0x600180

즉 포인터는 주소를 담고 있는 값(워드)이고, 그 값은 객체 안에 앉아 있다.

그러면 이제 객체가 노드가 되고 포인터가 간선이 된다.

[스택]                    [힙]
 head ─────────────────▶ Node@600100
                          ├ value: 42       ← 숫자, 무시
                          └ next: 0x600180 ─┐
                                            ▼
                                         Node@600180
                                          ├ value: 7
                                          └ next: 0x000000 (NULL)
  • 스택/레지스터/전역 영역을 워드 단위로 훑는다. 여긴 힙이 아니라 루트다. 여기서 0x600100이라는 값을 발견한다.
  • "힙 범위 안이고 객체 시작점이네" → Node@600100 마크.
  • 마크한 객체 내부의 16바이트를 다시 워드 단위로 훑는다. 42는 힙 범위 밖이라 무시하고, 0x600180은 유효한 객체 주소다.
  • Node@600180 마크.

여기서 힙은 스캔 대상이며 동시에 가리켜지는 대상이다.

여기서 문제는

  • 스택 슬롯에 남은 옛 포인터, interior pointer, 그리고 그 하나가 하위 그래프 전체를 붙잡는 상황이다.
  • 또한 0x6001000x700000으로 이동시킨다면 해당 객체를 가리키고 있던 모든 포인터를 옮겨야 한다.
    • 여기서 대칭이 깨진다.
struct Weird {
    long        n;   // 값: 0x600100 (진짜 정수)
    struct Foo *p;   // 값: 0x600100 (진짜 포인터)
};

이면 마킹 시 이 두 값을 모두 포인터 취급한다. 그러면 두 값이 모두 바뀌게 되고 데이터가 조용히 손상된다.

엥 근데 그냥 애초에 저 객체를 보면 되지 않나?

할 수 있다. 하지만 이건 일반 GC들이 하는 것이고, 여기서 소개하는 GC는 이 작업을 해내지 못한다.

그렇기 때문에

BDWGC는 의심 가는 모든 힙 주소를 포인터로 친다. 그렇기 때문에 가비지임에도 못 버리는 경우가 많다.

이어 읽으면 좋은 기록

2주차 - Java의 GC는 왜 세대로 나뉘어 있을까

Eden/S0/S1/Old로 나뉜 Java의 세대별 힙 구조와 Minor/Full GC의 동작, identity hash code로 객체 동일성을 보증하는 방식, TLAB 덕분에 new()가 malloc()보다 빠른 이유까지.