들어가며

지난 포스팅에서는 JVM의 전체적인 메모리 구조(Runtime Data Area)에 대해 공부한 내용을 정리했다. JVM이 OS로부터 메모리를 할당받고, 이를 용도에 따라 Method, Heap, Stack 등으로 나누어 관리한다는 것을 알 수 있었다.

오늘은 그중에서도 객체가 저장되는 가장 중요한 공간인 힙(Heap) 영역과, 이 공간을 자동으로 관리해 주는 가비지 컬렉션(Garbage Collection) 에 대해 학습하고자 한다.

C, C++ 같은 언어에서는 개발자가 직접 메모리를 할당하고 해제해야 했지만 자바는 GC가 있기에 개발자가 비즈니스 로직에 조금이라도 더 집중할 수 있을 것이라 생각한다. 서버의 성능 문제나 메모리 누수 등의 이슈를 해결하기 위해서는 이 메커니즘을 반드시 이해해야 한다. 이번 포스팅을 통해 힙 영역의 구조와 GC의 동작 원리를 정리해보겠다.



힙(Heap) 영역이란?

먼저 가비지 컬렉션에 대해 알기 전에, 힙 영역에 대해 한 번 더 알아보겠다.

힙 영역은 JVM이 관리하는 메모리 중 가장 핵심적인 영역으로, new 키워드로 생성된 모든 객체와 배열이 저장되는 공간이다.

특징
- 모든 스레드가 공유하는 영역이다. (동기화 문제 발생 가능)
- 애플리케이션 실행 중 런타임에 동적으로 할당된다.
- 메모리가 부족하면 OOM이 발생한다.
- GC의 주요 대상이 되는 영역이다.
- 개발자가 명시적으로 메모리를 해제할 수 없으며, GC에 의해 관리된다.

JVM 스택 영역의 변수들은 힙 영역에 생성된 객체의 주소값(Reference)을 가지고 있으며, 만약 이 참조가 끊어지면 힙에 있는 객체는 ‘쓰레기(Garbage)’ 취급을 받게 된다.


GC의 기본 원리: Reachability

GC는 힙 영역에서 사용하지 않는 객체를 찾아내어 메모리를 회수하는 작업이다.
그렇다면 JVM은 어떤 객체를 ‘사용하지 않는 것’으로 판단할 것인가?
바로 Reachability(도달 가능성) 이다.

  • Reachable: 유효한 참조가 있는 객체 (Stack, Method Area 등에서 참조 중인 경우)

  • Unreachable: 유효한 참조가 없는 객체 (GC의 대상)


이때 “유효한 참조"의 기준이 되는 시작점을 GC Root(Root Set) 라고 한다. 주로 다음과 같은 것들이 Root가 된다.

  1. Stack 영역의 로컬 변수/파라미터 (현재 실행 중인 메서드 내의 변수)

  2. Method 영역의 Static 변수

  3. JNI(Native Method)에서 참조하는 객체

GC는 이 Root Set에서 시작해 참조 사슬(Reference Chain)을 따라가며 연결된 객체는 살려두고(Mark), 연결되지 않은 객체는 메모리에서 제거한다.


GC 알고리즘의 기초: Mark and Sweep

GC가 실제로 동작하는 기본적인 알고리즘은 Mark and Sweep이다.

Mark 는 Root Space로부터 그래프 순회를 통해 연결된 객체들을 찾아내어 마킹한다. (살아있는 객체 식별)
Sweep 은 마킹되지 않은 객체들(Unreachable)을 힙 영역에서 제거한다.

이 과정에서 필연적으로 Stop-the-World(STW) 라는 현상이 발생한다. 청소를 하려면 잠깐 하던 일을 멈춰야 하듯 GC를 실행하기 위해 JVM이 애플리케이션의 모든 스레드를 멈추는 것이다.

이 STW 시간이 길어지면 사용자는 “렉이 걸렸다"고 느끼거나, 서비스 타임아웃이 발생해 장애로 이어질 수 있다. 따라서 GC 튜닝의 핵심은 이 STW 시간을 줄이는 것이라 볼 수 있다.


힙 영역의 구조와 Weak Generational Hypothesis

“모든 객체를 다 검사해서 지우면 너무 비효율적이지 않을까?” 라고 생각 할 수 있다. 힙 영역 전체를 매번 스캔하는 것은 비용이 크기 때문이다. 그래서 JVM 설계자들은 약한 세대 가설(Weak Generational Hypothesis) 을 바탕으로 힙 영역을 효율적으로 나누었다.

약한 세대 가설의 두 가지 전제가 있다.

  • 대부분의 객체는 금방 접근 불가능 상태(Unreachable)가 된다.
  • 오래된 객체에서 젊은 객체로의 참조는 아주 적게 존재한다.

즉, “대부분의 객체는 생겨나자마자 금방 죽는다” 는 특성을 이용하여 힙 영역을 Young GenerationOld Generation으로 물리적으로 나누었다.

Young Generation

새롭게 생성된 객체가 할당되는 곳이다. 대부분의 객체가 금방 사라지므로, 여기서 일어나는 GC를 Minor GC라고 부른다. Young 영역은 다시 3곳으로 나뉜다.

  • Eden: 객체가 최초로 생성되는 공간이다.

  • Survivor 0 / 1: Eden에서 살아남은 객체가 이동하는 곳 (둘 중 하나는 반드시 비어 있어야 함)


Minor GC와 Promotion 과정

  1. 할당: 객체가 생성되면 Eden 영역에 할당된다.

  2. Minor GC: Eden이 꽉 차면 Minor GC가 발생한다. 살아남은 객체는 사용 중인 Survivor 영역으로 이동한다.

  3. Age 증가: Survivor 영역에서 살아남을 때마다 객체의 age bit가 1씩 증가한다.

  4. Promotion(승격): age가 특정 임계값(MaxTenuringThreshold, 기본 15)을 넘으면 “이 객체는 오래 쓸 것 같다"고 판단하여 Old 영역으로 이동시킨다.


Old Generation

Young 영역에서 오랫동안 살아남은 객체들이 이동하는 곳이다. Young 영역보다 크게 할당되며, 여기서 발생하는 GC를 Major GC (혹은 Full GC) 라고 부른다.

  • Young 영역보다 크기가 크기 때문에 GC 시간이 더 오래 걸린다.

  • ‘Stop-the-World’ 문제의 주범이 바로 이 Major GC이다.


다양한 GC 알고리즘

자바 버전이 올라가면서 GC 알고리즘도 발전해 왔다.

  • Serial GC: CPU 코어가 1개일 때 사용. (실무 사용 X)

  • Parallel GC: Minor GC를 멀티 스레드로 처리. (Java 8 기본)

  • G1 GC (Garbage First): 힙을 거대한 물리적 구획이 아닌 Region이라는 작은 논리적 단위로 나누어 관리한다. 전체를 뒤지지 않고 가비지가 많은 영역을 우선적으로 청소하여 STW를 획기적으로 줄임. (Java 9+ 기본)

  • ZGC: 대용량 메모리를 처리하기 위해 설계된 Low Latency GC


마치며

이번 포스팅의 핵심은 객체의 생명 주기에 따라 힙 영역을 Young/Old로 나누어 관리함으로써 성능을 최적화한다” 는 점이다. 주니어 개발자로서 당장 GC 튜닝을 할 일은 없겠지만, 내가 짠 코드가 메모리 상에서 어떻게 흘러가는지 이해하고 코드를 작성하는 것은 매우 중요하다고 생각한다.

가령, 불필요한 객체 생성을 줄이고, 적절한 스코프 안에서 변수를 사용하는 습관이 결국 효율적인 GC 처리를 돕는 길일 것이다. 나중에는 자바 버전에 따른 GC 알고리즘의 변화(G1 GC 등)에 대해서도 포스팅 하게 될 것 같다.

참고
Oracle Docs
Naver D2