Home [Java] Garbage Collector
Post
Cancel

[Java] Garbage Collector

JVM의 Heap 메모리 관리

우리는 자바를 사용하면서 객체를 만들고, 따로 메모리 해제하는 작업을 하지 않습니다. C언어에서는 메모리 누수를 막기위해 heap을 사용하고, 반드시 free를 통해 해제해주어야 하는데요. 자바는 그런 과정을 개발자가 따로 해주지 않습니다.

1
2
3
4
5
int* ptr = (int*)malloc(sizeof(int) * 10);
// ...ptr 사용

// 이후 수동으로 메모리 할당 해제
free(ptr);

이렇게 c언어는 직접 heap메모리를 사용한 뒤에 해제해주어야 합니다.

1
2
int[] arr = new int[10];
// ...arr 사용

java는 개발자가 따로 메모리 해제를 하지 않습니다. 여기서 주의해야할 부분이 있습니다. 위의 코드가 실행되면 arr변수는 heap이 아니라 stack영역에 할당됩니다. new int[10]에 의해 만들어진 객체가 heap영역에 할당되는 겁니다.

Stack-Heap-Memory

그래서 결국 stack영역에 arr이 생기고, heap영역에 int[10]객체가 생기게 됩니다. arr 변수는 int[10]객체를 참조하는 형태가 되겠죠.

아무튼 이렇게 자동으로 메모리 회수가 가능한 이유는 Java 코드(bytecode)는 JVM에서 실행되고, JVM이 heap 메모리를 관리해주기 때문입니다. heap영역에 우리는 마음껏 객체를 생성해두고, 그 객체를 사용하지 않으면, 적절한 타이밍에 GC(Garbage Collector)가 대신 메모리를 반납해줍니다.

객체를 사용하지 않자마자 바로 제거되는 것이 아닙니다. GC가 수행되는 타이밍이 있는데 아래에서 설명합니다. 물론 System.gc()를 사용해서 직접 gc가 동작하게 할 수는 있지만, 시스템 성능에 큰 영향을 주니 사용하지 마세요.

GC는 heap을 대상으로만 동작합니다. Stack영역은 메서드 영역 내에서만 데이터를 보존하므로 메모리 해제 시점을 정확하게 파악할 수 있습니다. Method영역도 비슷한 이유로 결국 GC가 필요없습니다.

GC의 수거 대상

그럼 구체적으로 어떤 객체가 GC의 수거 대상이 될까요? 위에서 잠깐 언급했듯이 간단하게 요약하면 ‘사용하지 않는’ 객체가 그 대상이 됩니다.

Root set

Root set은 객체를 참조하는 시작점을 의미합니다. 이 시작점은 지역변수, 파라미터, 정적변수 등이 될 수 있습니다. 객체에서 객체로도 참조가 존재할 수는 있지만, 시작점이 될 수는 없습니다. 왜냐하면 A에서 B로의 참조가 있다고 하더라도, A는 결국 다른 곳에서 참조가 되어야 하기때문입니다.

Root-Set

이 그림에서처럼 결국 root set이 될 수 있는 경우는 3가지중 하나입니다.

  1. 스텍 영역에서의 참조. (지역변수, 파라미터)
  2. 메서드 영역에서의 참조. (정적변수)
  3. 네이티브 스텍 영역에서의 참조. (JNI에서 생성된 객체에 대한 참조)

위에서 언급한 ‘사용하지 않는’의 기준은 Root set으로부터 시작해서 그 객체를 참조하는 reference가 없는 객체를 의미합니다. 모든 Root set에서 시작해서 참조를 따라가봐도 도달할 수 없는 객체는 GC의 수거 대상이 됩니다.

GC 동작 과정

GC 알고리즘은 여러 종류가 있습니다. 이 모든 GC 알고리즘이 공통적으로 아래와 같은 과정을 거칩니다.

Stop-the-world

GC를 수행하기에 앞서 Stop-the-world가 발생됩니다. Stop-the-world는 JVM이 프로그램의 모든 쓰레드(GC수행 쓰레드 제외)를 잠시 멈추는 작업입니다. 그리고 GC가 끝나고나면 다시 멈췄던 작업을 시작합니다.

Mark

Marking

GC의 수거 대상이 될 객체들을 탐색합니다. 위의 사진에서 금색으로 마킹된 부분과 같이 회수할 객체들을 미리 탐색합니다.

Sweep

Sweeping

그리고 객체들을 실제로 제거합니다. 이렇게 제거하고나면 빈 공간이 남게되는데, 이를 제거해야 메모리 공간을 효율적으로 사용하고 성능을 높힐 수 있습니다.

Compress-Void

이렇게 삭제하고 남은 메모리 공간까지 제거해줍니다.

Heap 영역을 나누는 것의 필요성

잘 생각해보면 대부분의 자바 객체는 잠깐 쓰고 버리는 경우가 많습니다. 아래는 간단한 예시입니다.

1
2
3
4
5
void parse(String text) {
    Parser p = new Parser();
    p.parse(text);
    // 이제 parser는 안쓰게 된다.
}

Parser는 parse함수 내에서만 잠깐 사용하고 버려지죠. DTO같은 객체들도 비슷한 예시입니다.

Object-Generation

이렇게 대체적으로 경험적 분석에 따라, 객체의 수명이 대체로 짧은 경우가 많습니다.

그런데 생각해보면 GC는 사용하지 않는 객체를 정리한다고 했습니다. 이 과정에서 JVM이 사용중인 ‘모든’ 객체를 매번 탐색하는건 매우 비효율적이겠죠?

그래서 GC를 좀 더 효율적으로 동작시키기 위해서 heap영역을 나누어 관리합니다.

GC 프로세스

Heap-Structure

그래서 Heap영역은 크게 2개의 영역으로 나눌 수 있습니다. GC는 이 2개 영역별 독립적으로 수행됩니다.

Young Generation

나이가 비교적 적은 객체들이 저장되어있는 영역입니다. 이 영역에서는 Minor GC가 수행됩니다. Minor GC가 동작하는 과정은 아래와 같습니다.

  1. 객체를 새로 생성하게 되면 Eden영역에 할당됩니다.
  2. Eden 영역이 꽉 차면 이때 Eden영역에 참조되지 않는 객체들은 모두 삭제 됩니다.
  3. 2번 과정에서 살아남은 객체들은 S0나 S1으로 이동합니다.
  4. 만약 Survivor 영역도 꽉 찼다면, GC를 수행하고 살아남은 객체들을 다른 Survivor영역으로 이동시킵니다. 이 과정은 메모리 단편화를 해결하기 위해 수행합니다.
  5. 앞의 과정을 계속 반복하면서 오래 살아남은 객체(특정 age이상)들을 old 영역으로 이동시킵니다.

Minor GC는 비교적 빠른 속도로 수행됩니다.

Old Generation

나이가 비교적 많은 객체들이 저장되어있는 영역입니다. 이 영역에서는 Major GC가 수행됩니다.

  • Major GC는 시간이 오래 걸리는 작업입니다. 이 GC가 최대한 발생하지 않도록 주의해야합니다.
  • Major GC를 수행하는 여러 알고리즘이 있습니다. (Serial GC, Parallel GC, CMS, G1 GC… 등) 이 중에서 G1 알고리즘은 독특하게도 Young, Old Generation 영역이 아닌 Region 개념을 사용해서 메모리를 관리하고 정리합니다.

결론

Java는 편리하게도 GC를 이용해 메모리 관리를 대신 해줍니다. 하지만 마냥 편하다고 쓰기만 할 것이 아니라, 간략하게나마 원리를 파악하는게 중요하다고 생각합니다.

이전에 했던 프로젝트에서 계속 원인 모를 프레임드랍이 생겼었습니다. 결국 어렵게 발견해낸 원인은 GC에 있었습니다. 전반적으로 코드가 계속해서 old generation에 객체가 쌓이도록 설계가 되어있었고, 이는 Major GC를 계속 수행하게 해서 프로그램이 중간에 끊겼던 것이었죠.

GC에 대한 지식이 있었다면 애초에 그런 방식으로 설계를 하지 않았을 겁니다. 이런 사소한 기본기 차이가 결국 좋은 프로그래머를 만드는 것 같습니다.

참고

  1. https://d2.naver.com/helloworld/329631
  2. https://www.oracle.com/webfolder/technetwork/tutorials/obe/java/gc01/index.html
This post is licensed under CC BY 4.0 by the author.