Skip to content
Published on

[컴파일러] 10. 실행 시간 환경 - 스택, 힙, 가비지 컬렉션

Authors

실행 시간 환경 - 스택, 힙, 가비지 컬렉션

컴파일러는 코드를 생성할 때 프로그램이 실행 시간에 어떻게 메모리를 사용하는지 알아야 합니다. 이 글에서는 메모리 구성, 스택 할당, 힙 관리, 그리고 가비지 컬렉션을 다룹니다.


1. 메모리 구성(Storage Organization)

실행 중인 프로그램의 메모리는 일반적으로 다음과 같이 구성됩니다.

높은 주소
+------------------+
|      스택 (Stack) |  <- 함수 호출 정보, 지역 변수
|        |         |
|        v         |
|                  |
|        ^         |
|        |         |
|      힙 (Heap)   |  <- 동적 할당 데이터
+------------------+
| 정적 데이터       |  <- 전역 변수, 상수
+------------------+
| 코드 (Code)      |  <- 프로그램 명령어
+------------------+
낮은 주소

각 영역의 역할

  • 코드 영역: 컴파일된 기계어 명령어가 저장됩니다. 일반적으로 읽기 전용입니다.
  • 정적 데이터 영역: 전역 변수와 상수가 저장됩니다. 프로그램 시작 시 할당되어 종료 시까지 유지됩니다.
  • 스택 영역: 함수 호출 시 활성 레코드가 push되고, 반환 시 pop됩니다.
  • 힙 영역: malloc, new 등으로 동적 할당된 데이터가 저장됩니다.

2. 활성 레코드(Activation Record)

함수가 호출될 때마다 활성 레코드(activation record) 또는 **스택 프레임(stack frame)**이 생성됩니다.

활성 레코드의 구조

높은 주소
+---------------------------+
|  실제 매개변수 (arguments) |
+---------------------------+
|  반환값                    |
+---------------------------+
|  제어 링크 (control link)  |  <- 호출자의 프레임 포인터
+---------------------------+
|  접근 링크 (access link)   |  <- 정적 스코프 지원
+---------------------------+
|  저장된 레지스터            |
+---------------------------+
|  지역 변수                 |
+---------------------------+
|  임시 변수                 |
+---------------------------+
낮은 주소

각 필드의 역할

  1. 실제 매개변수: 호출자가 전달한 인자값입니다.
  2. 반환값: 함수의 결과를 저장하는 공간입니다. 레지스터를 사용하기도 합니다.
  3. 제어 링크(Control Link): 호출자의 활성 레코드를 가리키는 포인터입니다. 함수 반환 시 스택을 복원하는 데 사용합니다.
  4. 접근 링크(Access Link): 비지역 변수에 접근하기 위한 포인터입니다.
  5. 저장된 레지스터: 함수 진입 시 보존해야 할 레지스터 값입니다.
  6. 지역 변수: 함수 내에서 선언된 변수입니다.
  7. 임시 변수: 컴파일러가 생성한 임시 값입니다.

3. 호출 순서(Calling Sequence)

함수 호출과 반환 시 실행되는 코드를 호출 순서라고 합니다.

호출자(Caller)가 하는 일

1. 실제 매개변수를 평가하여 스택에 push
2. 반환 주소를 저장
3. 피호출자로 제어를 이동 (call 명령)

피호출자(Callee)가 하는 일

1. 저장해야 할 레지스터를 스택에 push
2. 이전 프레임 포인터(FP)를 저장 (제어 링크)
3. 새로운 프레임 포인터(FP)를 설정
4. 지역 변수를 위한 공간 할당 (SP 이동)

반환 시 피호출자가 하는 일

1. 반환값을 지정된 위치에 저장
2. SP를 FP로 복원
3. 저장된 레지스터를 복원
4. 이전 FP를 복원
5. 반환 주소로 점프

호출 과정 예시

void main() {             void foo(int x) {
    foo(10);                  int y = x + 1;
    // ...                    bar(y);
}                         }

                          void bar(int a) {
                              int b = a * 2;
                          }

스택 변화:

[1] main 호출 시        [2] foo(10) 호출 시      [3] bar(11) 호출 시

+-------+              +-------+              +-------+
| main  |              | main  |              | main  |
+-------+              +-------+              +-------+
                       | foo   |              | foo   |
                       | x=10  |              | x=10  |
                       | y=11  |              | y=11  |
                       +-------+              +-------+
                                              | bar   |
                                              | a=11  |
                                              | b=22  |
                                              +-------+

4. 비지역 변수 접근

중첩 함수(nested function)가 있는 언어에서, 바깥 함수의 변수에 접근해야 할 때가 있습니다.

접근 링크는 정적으로 둘러싸는(enclosing) 함수의 활성 레코드를 가리킵니다.

// Pascal-like 코드
procedure A;
    var x: integer;

    procedure B;
        var y: integer;

        procedure C;
        begin
            x := y + 1;  // A의 x와 B의 y에 접근
        end;

    begin
        C;
    end;

begin
    B;
end;
스택 (호출: A -> B -> C):

+-------+
| A     |  <- 접근 링크: 없음 (최외곽)
| x     |
+-------+
| B     |  <- 접근 링크: A의 레코드를 가리킴
| y     |
+-------+
| C     |  <- 접근 링크: B의 레코드를 가리킴
|       |
+-------+

C에서 x에 접근: 접근 링크를 2번 따라감 (C -> B -> A)
C에서 y에 접근: 접근 링크를 1번 따라감 (C -> B)

접근 링크를 따라가는 횟수는 중첩 깊이의 차이와 같습니다.

디스플레이(Display)

디스플레이는 접근 링크 체인을 따라가는 대신, 각 중첩 깊이의 현재 활성 레코드에 대한 포인터 배열을 유지합니다.

디스플레이 배열:
  display[1] = A의 활성 레코드 포인터
  display[2] = B의 활성 레코드 포인터
  display[3] = C의 활성 레코드 포인터

C에서 x에 접근: display[1]로 바로 접근 (O(1))
C에서 y에 접근: display[2]로 바로 접근 (O(1))

디스플레이의 장점은 비지역 변수 접근이 항상 상수 시간이라는 것입니다. 단, 함수 진입/탈출 시 디스플레이 갱신 비용이 있습니다.


5. 힙 관리(Heap Management)

힙은 동적으로 할당되고 해제되는 데이터를 저장합니다.

힙 할당이 필요한 경우

  • 객체의 크기가 컴파일 시간에 알 수 없는 경우
  • 객체의 수명이 함수 호출 범위를 넘는 경우
  • 데이터 구조(연결 리스트, 트리 등)의 동적 생성

메모리 할당 전략

1. 퍼스트 핏(First Fit):
   충분히 큰 첫 번째 빈 블록을 사용

   [사용중][  빈 블록  ][사용중][ 빈 블록 ]
                ^
            여기에 할당

2. 베스트 핏(Best Fit):
   요청 크기에 가장 가까운 빈 블록을 사용
   -> 외부 단편화 가능성이 높음

3. 버디 시스템(Buddy System):
   2의 거듭제곱 크기의 블록으로 분할/합병
   -> 내부 단편화 가능성이 있지만 합병이 빠름

단편화(Fragmentation)

외부 단편화 (External Fragmentation):
  빈 공간의 총합은 충분하지만, 연속 블록이 부족

  [사용][빈][사용][빈][사용][빈]
        ^^^       ^^^       ^^^
    각각 100B이지만 200B 요청 불가

내부 단편화 (Internal Fragmentation):
  할당된 블록 내에 사용되지 않는 공간

  요청: 100B, 할당: 128B -> 28B 낭비

6. 가비지 컬렉션(Garbage Collection) 기초

가비지 컬렉션(GC)은 더 이상 사용되지 않는 메모리를 자동으로 회수하는 기법입니다.

가비지란?

**루트 집합(root set)**에서 도달할 수 없는 객체를 가비지라고 합니다.

루트 집합: 스택 변수, 전역 변수, 레지스터

    [루트] --> [객체A] --> [객체B]
                 |
                 v
              [객체C]

              [객체D] --> [객체E]   <- 도달 불가 = 가비지!

7. 참조 카운팅(Reference Counting)

각 객체에 참조 카운트를 유지하여, 카운트가 0이 되면 회수합니다.

a = new Object();   // Object의 refcount = 1
b = a;              // Object의 refcount = 2
a = null;           // Object의 refcount = 1
b = null;           // Object의 refcount = 0 -> 회수!

참조 카운팅의 장단점

장점:
  - 즉시 회수: 참조가 0이 되면 바로 메모리 반환
  - 일시 정지 없음: GC 정지(stop-the-world) 없음
  - 구현이 간단

단점:
  - 순환 참조 처리 불가:
    [A] --> [B]
     ^       |
     +-------+
    A.refcount = 1, B.refcount = 1 (영원히 0이 안 됨)

  - 카운트 갱신 오버헤드: 대입마다 카운트 갱신
  - 캐시 효율 저하: 참조 갱신 시 원격 메모리 접근

8. 추적 기반 가비지 컬렉션

마크 앤 스윕(Mark-and-Sweep)

2단계로 동작합니다.

마크 단계 (Mark Phase):
  1. 루트 집합에서 시작
  2. 도달 가능한 모든 객체에 "마크" 표시
  3. DFS 또는 BFS로 객체 그래프 순회

스윕 단계 (Sweep Phase):
  1. 힙의 모든 객체를 순회
  2. 마크되지 않은 객체를 회수
  3. 마크된 객체의 마크를 제거
마크 전:
  [A*] -> [B*] -> [C*]
                    |
                    v
  [D ] -> [E ]    [F*]

  * = 루트에서 도달 가능

스윕 후:
  [A] -> [B] -> [C]
                  |
                  v
  [빈] -> [빈]  [F]

  D, E는 회수됨

마크 앤 스윕의 특징

장점:
  - 순환 참조도 정확히 회수
  - 오버헤드가 쓰레기의 양에 비례

단점:
  - Stop-the-World: GC 실행 중 프로그램 일시 정지
  - 단편화: 회수 후 메모리가 조각남

9. 복사 컬렉터(Copying Collector)

힙을 두 개의 반공간(semispace)으로 나누어 사용합니다.

[From 공간]              [To 공간]
+---------+              +---------+
| A  B  C |  복사 -->    | A  C    |
| (빈) D  |              | (빈)    |
+---------+              +---------+

살아있는 객체만 To 공간으로 복사
-> 단편화 없음, 연속 메모리

Cheney 알고리즘

BFS 방식으로 살아있는 객체를 복사합니다.

1. To 공간의 시작 위치에 scan과 free 포인터 설정
2. 루트에서 참조하는 객체를 To 공간으로 복사
3. scan 포인터가 가리키는 객체의 참조를 처리:
   - 참조하는 객체가 From에 있으면 To로 복사
   - 참조를 새 위치로 갱신
4. scan이 free를 만나면 완료

복사 컬렉터의 특징

장점:
  - 단편화 없음 (압축 효과)
  - 할당이 매우 빠름 (포인터 증가만)
  - 살아있는 객체에만 비용 발생

단점:
  - 메모리의 절반만 사용 가능
  - 살아있는 객체가 많으면 복사 비용이 큼
  - 포인터 갱신 필요

10. 세대별 가비지 컬렉션(Generational GC)

세대별 GC는 "대부분의 객체는 젊어서 죽는다(generational hypothesis)"는 관찰에 기반합니다.

세대 구분

+------------------+
|  Old Generation  |  (오래 살아남은 객체)
|  (Major GC 대상) |  <- GC 빈도 낮음
+------------------+
|                  |
| Young Generation |  (새로 생성된 객체)
|  (Minor GC 대상) |  <- GC 빈도 높음
|  +----+ +------+ |
|  |Eden| |Survivor||
|  +----+ +------+ |
+------------------+

동작 방식

1. 새 객체는 Young 세대에 할당
2. Young 세대가 가득 차면 Minor GC 실행:
   - 살아있는 객체를 Survivor 영역으로 복사
   - 여러 번 살아남은 객체는 Old 세대로 승격(promotion)
3. Old 세대가 가득 차면 Major GC 실행:
   - 전체 힙을 대상으로 GC (비용이 큼)

기억 집합(Remembered Set)

Old 세대에서 Young 세대를 참조하는 경우를 추적해야 합니다.

Old 세대의 객체 A가 Young 세대의 객체 B를 참조하면:
  -> 기억 집합에 A를 추가
  -> Minor GC 시 기억 집합의 객체도 루트로 취급

이를 통해 Minor GC가 Old 세대 전체를 스캔하지 않아도 됩니다.

실제 GC 구현 예시

Java HotSpot JVM:
  Young: Eden + Survivor0 + Survivor1 (복사 수집)
  Old: Mark-Compact 또는 CMS/G1/ZGC

Go:
  세대 없이 동시(concurrent) 마크 앤 스윕
  삼색 표시 (tri-color marking) 사용

Python:
  참조 카운팅 + 세대별 GC (순환 참조 처리용)

11. 현대 GC의 발전

동시 GC(Concurrent GC)

프로그램 실행과 GC를 동시에 수행합니다.

[프로그램 스레드]  -------실행-------실행-------실행------->
[GC 스레드]       --마크--|-스윕---|---------|-마크--|-->

-> Stop-the-World 시간을 최소화

증분 GC(Incremental GC)

GC 작업을 작은 단위로 나누어 프로그램과 번갈아 실행합니다.

[프로그램] [GC] [프로그램] [GC] [프로그램] [GC] [프로그램]
   10ms   2ms    10ms    2ms    10ms    2ms    10ms

-> 긴 일시 정지 대신 짧은 일시 정지 여러 번

현대 컬렉터

Java G1 (Garbage First):
  - 힙을 동일 크기의 리전(region)으로 분할
  - 가비지가 많은 리전을 우선 수집
  - 일시 정지 목표 시간 설정 가능

Java ZGC / Shenandoah:
  - 매우 짧은 일시 정지 (수 밀리초 이내)
  - 대용량 힙 (수 테라바이트) 지원
  - 동시 압축(concurrent compaction)

정리

개념핵심 내용
활성 레코드함수 호출 시 생성되는 스택 프레임
제어 링크호출자의 프레임을 가리키는 포인터
접근 링크정적 스코프의 바깥 함수를 가리키는 포인터
디스플레이각 중첩 깊이의 현재 활성 레코드 포인터 배열
힙 관리동적 할당/해제, 단편화 관리
참조 카운팅참조 수를 세어 0이면 회수, 순환 참조 문제
마크 앤 스윕도달 가능한 객체 표시 후 나머지 회수
복사 컬렉터살아있는 객체만 새 공간으로 복사
세대별 GC객체 수명에 따라 구분하여 효율적 수집

실행 시간 환경에 대한 이해는 효율적인 코드를 생성하는 데 필수적입니다. 이 시리즈에서 다룬 컴파일러의 각 단계(어휘 분석, 구문 분석, 의미 분석, 중간 코드 생성, 실행 시간 환경)는 현대 프로그래밍 언어와 도구의 기반이 되는 핵심 기술입니다.