Java 의 GC는 어떻게 동작하나?

June 04, 2018

GC (Garbage Collecton)

더이상 사용하지 않는 객체 등을 메모리에서 해제(삭제)하는 JVM의 작업

Java 프로세스가 동작하는 과정에서 GC는 불필요한 또는 더이상은 사용하지 않는 객체들을 메모리에서 제거함으로써, Java 프로세스가 한정된 메모리를 효율적으로 사용할 수 있게 해준다.

또한 JVM에서 GC의 스케줄링을 담당함으로서 Java 프로그래머들에게는 메모리를 관리해야하는 부담을 줄여주게된다. 즉, 일반적인 개발 작업간에는 메모리 할당/해제를 직접 프로그래밍하지 않아도 된다는 이야기다.

JVM의 GC에 대해서 알기 위해서는 우선 JVM 메모리 구조에 대해서 알아야 한다.

Alt JVMHeap

JVM Heap 구조


Java 메모리의 각 영역에서 GC가 발생하면, 사용하지 않는(참조가 존재하지 않는) 객체들은 메모리에서 제거된다.


Java 에서 GC를 도입이 가능했던 이유?

weak generational hypothesis 가설로 인해 GC 도입이 가능했다.

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


대부분의 객체는 금방 접근 불가능 상태(unreachable)가 된다.

객체를 생성하는 아래와 같은 코드를 생각해보자.

for (int i = 0; i < 10000; i++) {
  NewObject obj = new NewObject();  
  obj.doSomething();
}

10,000 건의 NewObject 객체는 Loop 내에서 생성되고, 사용되지만 Loop 밖에서는 더이상 사용할 일이 없어진다. 이런 객체들이 메모리를 계속 점유하고 있다면, 다른 코드를 실행하기 위한 메모리 자원은 지속적으로 줄어들기만 할 것이다.

GC는 이렇게 한번쓰이고 버려지는 객체들 즉, 접근 불가능 상태가 된 객체를 주기적으로 비워줌으로써 한정된 메모리를 효율적으로 사용할 수 있게 해준다.


오래된 객체에서 젊은 객체로의 참조는 아주 적게 존재한다.

Java 개발시 가장 많이 사용하는 객체는 아마도 POJO (Plain Old Java Object) 일 것이다.

보통 어떤 값이나 상태를 저장하기 위해 POJO 객체를 생성하고, 다른 메소드나 클래스에 전달하고, 다 사용한 객체는 더이상 사용하지 않는다. 경우에 따라 오래도록 살아남아 재활용 되는 케이스가 있긴하지만, 대부분의 경우는 아닐 것이다.

Model model = new Model("value");
doSomething(model);  

// 더이상 model 을 사용하지 않음



GC 가 일어나는 과정

GC는 사용하지 않는 객체를 메모리에서 제거하는 작업이다.


그럼 어떤 객체를 메모리에서 제거하고, 또 제거하지 말아야 하나?

현재 열심히 사용중인 객체를 메모리에서 제거해버린다면, 프로그램이 정상적으로 실행되지 않을 것이다.

때문에, GC를 위해서는 우선 메모리에 있는 객체가 현재 사용중인지 사용중이 아닌지를 구분할 수 있어야 한다.

위에서 살펴본 weak generational hypothesis 를 보면 ‘오래된 객체’라는 말이 나온다. 그럼 오래되었다는 기준은 무엇일까?

JVM에서는 이 오래됨 을 표현하기 위해 메모리를 여러 영역으로 나눈다.

Alt JVMHeap

JVM 메모리 영역(Heap)내에서 객체의 이동


처음 생성된 객체 new Model(); 는 Young Generation 영역의 일부인 Eden 영역에 위치하게된다. 그리고 Minor GC가 발생하게 되면, 사용하지 않는 다시말하면 다른 곳에서 참조되지 않는 객체는 메모리에서 제거된다.

Eden 영역에서 살아남은 객체는 Young Generation 영역의 또다른 일부인 Survivor 영역으로 이동하게된다. Survivor 영역은 Survivor1 영역과 Survivor2 영역으로 구성되어 있는데, Minor GC가 발생할 때마다 Survivor1 영역에서 Survivor2 영역으로 또는 Survivor2 영역에서 Survivor1 영역으로 객체가 이동하게되며, 이 과정에서 더이상 참조되지 않는 객체는 메모리에서 제거된다.

Minor GC가 발생하는 동안 Survivor1, Survivor2 영역을 오가며 살아남은 객체들은 최종적으로 Old Generation 영역으로 옮겨지며, Old Generation 영역에 있다가 미사용된다고 식별되는 객체들은 Full GC를 통해 메모리에서 제거된다.


Young Generation 영역에서 오래동안 살아남은 객체는 Old Generation 영역으로 옮겨지는데, 오래되었다는 기준은 무엇일까?

오래되었다고 하는 기준은 Young Generation 영역에서 Minor GC 가 발생하는 동안 얼마나 오래 살아남았는지로 판단한다. 각 객체는 Minor GC에서 살아남은 횟수를 기록하는 age bit 를 가지고 있으며, Minor GC가 발생할 때마다 age bit 값은 1씩 증가 하게되며, age bit 값이 MaxTenuringThreshold 라는 설정값을 초과하게 되는 경우 Old Generation 영역을 객체가 이동 되는 것이다. 또는 Age bit가 MaxTenuringThreshold 초과하기 전이라도 Survivor 영역의 메모리가 부족할 경우에는 미리 Old Generation 으로 객체가 옮겨질 수도 있다.

JVM 옵션 : -XX:MaxTenuringThreshold




GC 종류와 변화

JVM 버전이 올라감에 따라 여러가지 GC방식이 추가되고, 발전되어 왔다. 때문에 JVM 버전이 많이 올라간 만큼 다양한 GC 방식이 존재하며, 상황에 따라 필요한 GC 방식을 설정해서 사용할 수 있다.

그럼, 다양한 GC방식을 살펴보기로 하자.


Serial GC

Serial GC 적용을 위한 JVM 옵션 : -XX:+UseSerialGC

Serial 이란 단어의 의미인 ‘순차적인’ GC 방식이다.

Serial GC 그리고 다음에 나오는 Parallel GC 를 이해하기 위해서는 Mark-Sweep-Compaction 알고리즘을 알아두어야 할 필요가 있다.


Mark-Sweep-Compaction

Mark-Sweep-Compaction 이란 서로다른 다양한 GC에서 사용되는 알고리즘이다. 기본적인 GC 과정이라고 생각하면 좋을것이다.

GC가 사용되지 않는 객체를 메모리에서 제거하는 과정인만큼, GC 대상객체를 식별하고 제거하며 객체가 제거되어 파편화된 메모리 영역을 앞에서부터 채워나가는 작업을 수행하게 된다.

  • 사용되지 않는 객체를 식별하는 작업 (Mark)
  • 사용되지 않는 객체를 제거하는 작업 (Sweep)
  • 파편화된 메모리 영역을 앞에서부터 채워나가는 작업 (Compaction)

Compaction 작업의 경우 Windows의 디스크 조각 모음을 생각하면 좋을 것이다.

MarkSweepCompaction Mark-Sweep-Compaction 과정동안의 메모리 변화

Serial GC가 순차적으로 동작할 수 밖에 없는 이유는 GC를 처리하는 스레드가 하나이기 때문이다. 메모리나 CPU Core 리소스가 부족할 때 사용할 수 있을 것이다. Java가 처음 등장했던 90년대 후반의 PC들을 생각해보자.


Parallel GC

Parallel GC 적용을 위한 JVM 옵션 : -XX:+UseParallelGC, -XX:ParallelGCThreads (Minor GC 스레드 개수)

앞서 살펴본 Serial GC을 사용하던 시절보다 PC의 성능이 좋아졌다고 생각해보자. 메모리도 넉넉해졌고 CPU Core 도 좀 더 많아졌다. 이런 상황이라면 하나의 스레드로 동작했던 Serial GC를 멀티스레드로 실행하고 싶어질 것이다.

Parallel GC는 Minor GC를 처리하는 스레드를 여러개로 늘려 좀 더 빠른 동작이 가능하게한 방식이다.

SerialGC_vs_ParallelGC

Serial GC와 Parallel GC의 차이


그림을 보면 Serial GC는 GC 작업을 하는 스레드(GC Thread)가 하나이며, Parallel GC에서는 이 GC Thread가 여러개 존재한다.

이는 Parallel GC 에서의 GC 프로세스가 더 빠르게 동작할 수 있게 해주며 이러한 차이는 GC를 처리하는 동안 Java의 프로세스가 모두 멈춰버리는 Stop-The-World 현상이 나타나는 시간에도 영향을 주게된다.

즉, STW(Stop-The-World) 시간이 좀 더 적게 걸리는 Parallel GC에서의 Java 애플리케이션이 좀 더 매끄럽게 동작한다는 의미이다.


Parallel Old GC

Parallel Old GC 적용을 위한 JVM 옵션 : -XX:+UseParallelOldGC, -XX:ParallelGCThreads (Minor, Full GC 스레드 개수)

Parallel Old GC는 Parallel GC 를 조금더 업그레이드한 버전이다. 이름에서 알 수 있듯, Parallel GC에서 Old GC 알고리즘을 개선한 버전이다.

그럼 Old GC가 어떻게 개선되었는지 살펴보자.

우선 Parallel Old GC 는 Old GC 도 병렬로 수행될 수 있도록 하며, Old GC에 사용되는 스레드 수는 -XX:ParallelGCThreads 옵션을 통해 지정할 수 있다.

또한 Serial GC, Parallel GC에서 살펴보았던 알고리즘은 Mark-Sweep-Compaction 이었으며, Parallel Old GC 에서는 개선된 버전인 Mark-Summary-Compaction 알고리즘을 사용한다.

Summary 단계에서는 이미 GC가 수행된 영역에서 살아있는 객체를 식별하는 작업을 진행한다는 점이 Sweep과 다르다. Old GC 처리량을 늘려주기위한 작업이다.


CMS GC

CMS GC 적용을 위한 JVM 옵션 : -XX:+UseConcMarkSweepGC

앞서 살펴보았던 GC 보다 좀 더 개선된 방식이다. 개선이 된 만큼 성능은 좋아졌지만 GC의 과정은 좀 더 복잡해진 방식이다. CMS는 GC 과정에서 발생하는 STW(Stop-The-World) 시간을 최소화 하는데 초점을 맞춘 GC 방식이다.

다시 말하면 GC 대상을 최대한 자세히 파악한 후, 정리하는 시간(STW가 발생하는 시간)을 짧게 가져가겠다는 컨셉이다. 다만 GC 대상을 파악하는 과정이 복잡하한 여러단계로 수행되기 때문에 다른 GC 대비 CPU 사용량이 높다

CMS GC는 Initial Mark -> Concurrent Mark -> Remark -> Concurrent Sweep 과정을 거친다.

  • Initial Mark
    • GC 과정에서 살아남은 객체를 탐색하는 시작 객체(GC Root)에서 참조 Tree상 가까운 객체만 1차적으로 찾아가며 객체가 GC 대상(참조가 끊긴 객체)인지를 판단한다. 이 때는 STW 현상이 발생하게되지만, 탐색 깊이가 얕기 때문에 STW 발생 기간이 매우 짧다.
  • Concurrent Mark
    • STW 현상없이 진행되며, Initial Mark 단계에서 GC 대상으로 판별된 객체들이 참조하는 다른 객체들을 따라가며 GC 대상인지를 추가적으로 확인한다.
  • Remark
    • Concurrent Mark 단계의 결과를 검증한다. Concurrent Mark 단계에서 GC 대상으로 추가 확인되거나 참조가 제거되었는지 등등의 확인을 한다. 이 검증과정은 STW 를 유발하기 때문에 STW 지속시간을 최대한 줄이기 위해 멀티스레드로 검증 작업을 수행한다.
  • Concurrent Sweep
    • STW 없이 Remark 단계에서 검증 완료된 GC 객체들을 메모리에서 제거한다.


CMSGC

Initial Mark -> Concurrent Mark -> Remark -> Concurrent Sweep


CMS GC는 Compaction 작업을 필요한 경우에만 수행한다. 즉, 연속적인 메모리 할당이 어려울 정도로 메모리 단편화가 심한 경우에만 Compaction 과정을 수행하는 것이다.


G1 GC (G1: Garbage First)

G1 GC 적용을 위한 JVM 옵션 : -XX:+UseG1GC

하드웨어가 발전되면서 Java 애플리케이션에 사용할 수 있는 메모리의 크기도 점차 켜저갔다. 하지만 기존의 GC 알고리즘들로는 큰 메모리에서 좋은 성능(짧은 STW)을 내기 힘들었기 때문에 이에 초점을 둔 G1 GC가 등장하게 되었다.

즉, G1 GC는 큰 힙 메모리에서 짧은 GC 시간을 보장하는데 그 목적을 둔다.

G1 GC는 앞서 살펴본 GC와는 다른 방식으로 힙 메모리를 관리한다. 앞서 살펴보았던 Eden, Survivor, Old 영역이 존재하지만 고정된 크기로 고정된 위치에 존재하는 것이아니며, 전체 힙 메모리 영역을 Region 이라는 특정한 크기로 나눠서 각 Region의 상태에 따라 그 Region에 역할(Eden, Survivor, Old)이 동적으로 부여되는 상태이다.

JVM 힙은 2048개의 Region 으로 나뉠 수 있으며, 각 Region의 크기는 1MB ~ 32MB 사이로 지정될 수 있다. (-XX:G1HeapRegionSize 로 설정)

G1 GC가 설정된 JVM의 힙 메모리 영역의 스냅샷은 아마도 아래와 같을 것이다.

G1Heap

G1 GC가 적용된 JVM Heap 구조


G1 GC에서는 그동안 봐왔던 Heap 영역에서 보지 못한 Humongous, Available/Unused 이 존재하며 두 Region에 대한 역할은 아래와 같다.

  • Humongous : Region 크기의 50%를 초과하는 큰 객체를 저장하기 위한 공간이며, 이 Region 에서는 GC 동작이 최적으로 동작하지 않는다.

  • Available/Unused : 아직 사용되지 않은 Region을 의미한다.


G1 GC에서 Young GC 를 수행할 때는 STW(Stop-The-World) 현상이 발생하며, STW 시간을 최대한 줄이기 위해 멀티스레드로 GC를 수행한다. Young GC는 각 Region 중 GC대상 객체가 가장 많은 Region(Eden 또는 Survivor 역할) 에서 수행 되며, 이 Region 에서 살아남은 객체를 다른 Region(Survivor 역할) 으로 옮긴 후, 비워진 Region을 사용가능한 Region으로 돌리는 형태 로 동작한다.


G1 GC에서 Full GC 가 수행될 때는 Initial Mark -> Root Region Scan -> Concurrent Mark -> Remark -> Cleanup -> Copy 단계를 거치게된다.

  • Initial Mark
    • Old Region 에 존재하는 객체들이 참조하는 Survivor Region 을 찾는다. 이 과정에서는 STW 현상이 발생하게 된ㄷ.
  • Root Region Scan
    • Initial Mark 에서 찾은 Survivor Region에 대한 GC 대상 객체 스캔 작업을 진행한다.
  • Concurrent Mark
    • 전체 힙의 Region에 대해 스캔 작업을 진행하며, GC 대상 객체가 발견되지 않은 Region 은 이후 단계를 처리하는데 제외되도록 한다.
  • Remark
    • 애플리케이션을 멈추고(STW) 최종적으로 GC 대상에서 제외될 객체(살아남을 객체)를 식별해낸다.
  • Cleanup
    • 애플리케이션을 멈추고(STW) 살아있는 객체가 가장 적은 Region 에 대한 미사용 객체 제거 수행한다. 이후 STW를 끝내고, 앞선 GC 과정에서 완전히 비워진 Region 을 Freelist에 추가하여 재사용될 수 있게 한다.
  • Copy
    • GC 대상 Region이었지만 Cleanup 과정에서 완전히 비워지지 않은 Region의 살아남은 객체들을 새로운(Available/Unused) Region 에 복사하여 Compaction 작업을 수행한다.


Alt G1FullGC


JDK 7에서 G1 GC 가 정식으로 등장한 이후 많이 사용되고 있는 만큼 설정 옵션을 상세히 살펴보자

Option Default Description
-XX: G1HeapRegionSize   Region 크기. 1MB~32MB 범위에서 설정 가능. 최소 힙크기를 2048 개의 Region 으로 나눌 수 있도록 설정해야함
-XX:MaxGCPauseMillis 200 G1 GC가 유발하는 STW의 최대 시간. G1은 설정값을 최대한 맞추려고 노력할 뿐이며, 보장되는 값은 아니다.
-XX:DefaultMinNewGenPercent 5 Young 영역으로 사용할 힙 최소 크기 (전체 힙 크기대비 비율, %)
-XX:DefaultMaxNewGenPercent 60 Young 영역으로 사용할 힙 최대 크기 (전체 힙 크기대비 비율, %)
-XX:ParallelGCThreads   STW 상황에서 GC를 수행하는 스레드 개수. CPU core 수가 8개 이하인 경우 core 수와 동일하게 설정하는 것이 좋다.
-XX:ConcGCThreads   Concurrent Mark 를 수행하는 스레드 개수. ParallelGCThreads의 25% 로 설정하는 것이 좋다
-XX:InitiatingHeapOccupancyPercent 45 힙을 전체 크기 대비 특정 비율(%)만큼 사용하게될 경우 Mark 를 수행해야한다는 옵션
-XX:G1OldCSetRegionLiveThresholdPercent 65 Mixed GC 가 시작되는 Old Region 크기 비율

Mixed GC : Full GC 를 완료하는 시점에 Young/Old Region을 동시에 GC


참고