본문 바로가기

자바

[Java] JVM의 이해(3) - Garbage Collection

GC, Garbage Collection

JVM은 운영체제로부터 메모리를 활동받아 동작한다. 그렇다면 JVM은 메모리를 어떻게 관리할까?

 

C 언어를 배웠을 때를 생각하면 변수에 메모리를 동적할당(malloc)하고 해제(free)하는 과정들이 있었지만 자바에서는 그렇지 않았다. 제네릭을 이용해 변수를 선언했을 때 동적으로 크기가 변화하기도 했다. 프로그램이 끝날 때 까지 할당된 메모리가 해제되지 않는다면 분명히 저장 가능한 메모리는 바닥날 것이고 OOME(Out Of Memory Error)가 발생하고 만다. 실제로도 자바가 발전하면서 애플리케이션 지연(Suspend) 현상이 두드러짐에 따라 GC가 개발되었다.

 

GC는 필요하지 않은 메모리를 해제하여 사용할 수 있는 메모리를 확보해나간다.

 

필요한 메모리 영역, 필요하지 않은 메모리 영역. 어떻게 구분할까?

그 기준은 참조하느냐, 참조하지 않느냐가 될 것이다.

 

1. 참조 대상을 바꾸는 경우
2. 메소드가 종료되어 스택에서 빠지는 경우

 

위와 같은 예시에서 참조하지 않는 객체가 발생한다. 이 때 참조되는 경우를 reachable, 참조되지 않는 경우를 unreachable 이라고 한다.

 

unreachable한 메모리 영역을 GC가 인식하고 삭제하는 것이다. 그러기 위해서 필요한 것이 Root Set 이다.

아래와 같이 Root Set 을 타고 가서 참조하지 않는 영역은 Unreachable Object 즉, Garbage로 인식한다.

💡 GC는 사실 파이썬에도 존재한다.

 

그렇다면 GC를 ‘무조건’ 자주 하면 좋은 것일까? 그렇지는 않다. 왜냐하면 GC가 Garbage를 수집하는 동안에는 다른 동작을 할 수 없다. 즉, 프로그램의 동작이 GC 동작 기간 동안 멈추게 된다. 이로써 발생하는 오버헤드를 STW(Stop The World)라고 한다.

GC 알고리즘의 핵심은 이 STW를 어떻게 최소화할 수 있느냐에 초점이 맞춰져 있다.

메모리 영역의 분할

앞선 포스팅 내용들을 떠올려보자.

Runtime Data Area에 주로 데이터들이 자리하는데 각 영역의 특징들을 생각해보자.

Method Area, Heap, Java Stack, PC Register, Native Method Stacks

이 중에서 GC의 주 관리대상은 무엇이 될 것인가.

각 영역들의 특징을 생각해봤을 때 Heap 영역이 가장 유력하다.

왜?

객체의 인스턴스 들을 담고 있으니까.

많은 개발자들이 이 영역의 메모리를 어떻게 효율적으로 사용할 것인지 많이 고민을 했다. 그 결과 Heap 영역은 분할했다.

크게 세가지로 나뉜다.

 

Young Generation, Old Generation, Permanent

이렇게 영역을 나눈데에는 아래와 같은 이유가 있다.

  1. 수명이 짧은 객체에게 큰 공간은 불필요하다.
  2. 오래된 영역에서 최신 영역으로의 참조 방향은 적게 존재한다
  3. 대부분의 객체는 일회성으로 메모리에 오랫동안 남아있는 경우는 드물다.

그래서 수명이 짧은 객체는 주로 Young 영역에 배치하고 상대적으로 긴 객체는 Old 영역, 사라지지 않는 데이터는 Permanent 영역에 저장된다.

 

💡 Permanent

  • 로드된 클래스의 정보 등 변하지 않을 것이라고 어느 정도 보증되는 데이터가 저장
  • Java 8 버전부터 Native Memory의 Metaspace 영역으로 대체
  • Native Memory: Heap 영역의 바깥인 Off-Heap 공간

Minor GC, Major GC

컴퓨터 세상에서도 데이터의 나이가 존재한다.

지구가 태양을 한바퀴 돌아 제자리로 오는 주기를 1년으로 친다면 JVM 내부에서는 GC 알고리즘이 한번 동작하고 난 이후를 기준으로 한다. 즉 GC 알고리즘이 한번 동작하고 난 다음 1살 먹는 셈이다.

데이터는 처음 힙 영역에 들어오고나서 대략 아래와 같이 이동한다.

  1. Eden (이든) → 신생아실
    • 힙 영역에 갓 들어온 데이터가 배치
    • new 를 통해 새로 생성된 인스턴스가 위치
  2. Survivor 0/1
    • 최소 1번의 GC 이상 살아남은 객체가 존재하는 영역
    • 데이터의 나이가 1살 이상
    • Survivor0 과 Survivor1 둘 중 하나는 반드시 비어있어야 한다.
    • Survivor0 에서 GC 이후 살아남은 데이터는 Survivor1 로, Survivor1 에서 GC 이후 살아남은 데이터는 Survivor0 으로 번갈아가며 이동한다.
  3. Old (Tenured)
    • Survivor 영역에서 나이가 임계점에 다다른 데이터가 위치
    • Major GC: 이 곳에서 발생하는 GC

 

💡 Survivor 영역은 왜 0과 1로 총 2개가 있을까.

이 내용은 사실 하드웨어와 관련이 있다. Disk Management 와 관련한 내용이다.

아래 그림은 전에 OS 공부하면서 정리한 필기본 일부다.

  • logical block
    • 디스크 외부에서 보는 디스크의 단위 정보 저장 공간
    • 주소를 가진 1차원 배열 형태
    • 정보를 전송하는 최소 단위
  • Sector
    • Logical Block이 물리적인 디스크에 매핑된 위치
    • Sector 0은 최외곽 실린더의 첫번째 트랙, 첫번째 섹터
    • 디스크 내부에서 관리하는 단위

위 그림에서 알 수 있듯이 logical 블럭은 실제로 디스크에서 데이터가 저장된 위치를 가리키고 있는 형태이다.

만약 데이터를 지운다는 것은 저 매핑된 스택의 내용을 지우는 것과 다름없다. 즉, 실제 디스크 섹터에는 데이터가 남아있지만 logical block에서 섹터를 가리키는 포인터가 없는 것이다.

이 위치에 새로운 데이터가 입력된다고 하면 기존에 존재하는 데이터 위로 새로운 데이터를 덮어쓰는 방식이 되는 것이다. 만약 디스크의 해당 영역을 완전히 지우고 새로운 데이터를 넣는다고 하면, 각 영역을 일일이 Null을 넣어줘야 한다.

이 작업이 오래 걸리다보니 빠른 포맷, logical block에서 sector 를 가리키는 포인터만 지우고 추후 덮어씌우도록 업데이트 하는 게 일반적이다. (디지털 포렌식이 가능한 이유가 이런 이유)

일반적인 GC 알고리즘을 실행하고 나면 데이터 영역은 파편화가 되어 있다.

위의 자료에서 파란색 영역이 실제로 GC 에서 살아남은 데이터 영역이라고 하자. 불필요한 데이터 영역(주황색)을 지우고 나면 데이터는 파편화되어 분산되어 있다. 이후 빠른 탐색을 위해 관련 있는 데이터를 순차적으로 나열하는 작업이 진행된다.

Survivor 영역에서는 이렇게 살아남은 데이터를 새로운 영역으로 옮겨 차례대로 배열한다. 그리고 기존에 데이터가 위치했던 Survivor 영역은 지워버리기 때문에 둘 중 하나는 결국 비워져 있을 수밖에 없는 구조가 된다.

 

💡 Major GC, Minor GC

GC 가 발생하는 시점에 따라 Major GC, Minor GC 로 분류된다.

위에서 언급했다시피 Old 영역에서 이뤄지는 GC는 Major GC이고 Young 영역에서 이뤄지는 GC는 Minor GC 이다. 영역이 가득 차서 더이상 새로운 객체를 생성할 수 없을 때 GC가 발생한다.

Minor GC(Full GC)

JVM의 Young 영역에서 일어나는 GC로 Mark and Sweep 알고리즘이 가장 대표적이다. Root Set 부터 출발하여 참조되는 객체에 마크 표시를 하고(Mark Phase), 표시되지 않은 객체는 추적하여 삭제한다(Sweep Phase).

그 결과 앞에서 언급했던 메모리 단편화(Fragmentation)가 일어나게 된다.

 

💡 단편화 Fragmentation : 정렬되지 않은 조각으로 나뉘어져 절대적인 크기는 충분하나 추가적인 메모리 할당이 어려운 상태

 

이러한 문제를 해결하기 위해 남은 메모리는 연속적으로 정리하게 되는 과정이 일어난다. 이 과정까지 이뤄진다면 Mark and Compact 알고리즘이라 부른다.

 

 

Minor GC는 Young 영역에서 이뤄지는 만큼 발생 빈도가 잦은 편이다. 용량이 작은 객체는 금방 사라진다는 명제를 기반으로 GC 힙 영역과 GC 알고리즘은 설계된다.

그렇기 때문에 Young Generation 이 갖는 메모리 용량은 작은 편이고, 그 안에서 Eden, Survivor0/1로 나누어 관리하는 것에도 규칙이 있다.

 

💡 Eden 영역에서 GC에 살아남은 메모리는 Survivor 구역으로, 임계에 다다르기 전까지 Survivor0/1 사이로 데이터가 이동하며 관리된다.

Major GC

Old 영역의 메모리가 부족할 때 이뤄지는 Major GC는 긴 STW동안 발생한다. Old Generation이 가지고 있는 영역은 Young Generation이 가진 영역의 10배에 달하기 때문이다. 가지고 있는 메모리 용량이 크기 때문에 시간 소요가 큰 법이다. 

GC 소요시간이 Major GC가 더 크다고 해서 'Major' 라는 말이 붙었다.

Card Table

이렇듯 Major GC 와 Minor GC 가 각각 동작한다는 것을 알았다. 그리고 그것의 기반이 되는 것인 Set Root 까지도. 힙의 메모리 영역을 구체적으로 알게 되었으면 한번은 짚고 넘어가야 하는 부분이 있다. 바로 Card Table 개념이다.

앞에서 언급했던 Root Set을 기억하는가. Root Set 에서 참조 관계를 추적하고 참조되는 메모리 객체는 Marking 처리한다. 이 방식은 Young 영역에 국한된다. 그렇다면 Old 영역에서 Young 영역 일부를 참조하고 있다면 어떻게 처리할까. Root Set에서 찾지 못하고 해당 메모리 영역을 지워버리고 나면 추후에 Old 영역에서 곤욕을 치를 것이다. 이를 방지하기 위해 있는 것이 바로 Card Table 이다.

Old 영역의 일부는 Card Table로 구성되어 있는데 Young 영역의 객체를 참조하는 Old 영역 객체가 있다면 시작 주소에 카드를 Dirty 상태로 기록한다. 이후 참조가 해제되면 해당 표시도 사라지도록 해서 참조 관계를 쉽게 파악할 수 있다.