본문 바로가기
Old Posts/Java

[Java] 자바 가비지 컬렉션 기초(Garbage Collection Basic)

by A6K 2021. 8. 10.

자바 개발자라면 '가비지 컬렉션(Garbage Collection)'에 대해서 고민하게 되는 순간이 온다. 토이 프로젝트나 작은 프로젝트에서는 가비지 컬렉션에 대한 관심이 적다. 가비지 컬렉터의 선택이 성능에 미치는 영향보다는 어떤 알고리즘을 사용했는지, 어떤 라이브러리를 사용하는지, 어떤 설정으로 구동하는지가 더 중요하다. 하지만 일정 규모 이상의 프로젝트를 진행하다보면 결국 가비지 컬렉터의 선택까지 고민하게 된다.

실력있는 자바 개발자라면 가비지 컬렉션의 동작에 대한 이해를 하고 자신이 구현하는 애플리케이션에 맞는 적당한 가비지 컬렉터를 선택, 설정들을 튜닝할 줄 알아야한다.


가비지 컬렉션(Garbage Collection)

C언어나 C++ 같은 언어에서 메모리 관리는 프로그래머의 책임이다. 필요한 메모리 공간을 라이브러리를 통해 운영체제로부터 할당받아 사용하다가 다 쓰면 다시 해제해서 운영체제로 반환해야한다. 개발자가 꼼꼼히 메모리 관리를 하지 않는다면 운영체제로부터 메모리 공간을 할당받기만하고 반환하지 않아 프로세스가 점점 커지다가 운영체제에 의해 시그널을 맞고 죽게 된다. 메모리를 해제하지 않아서 생기는 이런 버그를 '메모리 릭(Memory Leak)'이라고 한다.

자바는 메모리 관리라는 막중한 책임에서 프로그래머를 자유롭게해줬다. 자바 프로그래머는 직접 메모리 공간의 할당과 반환을 수행하는 대신 JVM을 통해 메모리를 할당받는다. 더 이상 사용되지 않는 메모리 공간은 JVM이 알아서 회수한 다음 해제해준다. JVM의 이런 메모리 해제 동작을 '가비지 컬렉션(Garbage Collection)'이라고 한다.


가비지(Garbage)

가비지 컬렉션은 더 이상 사용되지 않는 메모리 공간을 JVM이 알아서 회수해주는 동작을 의미한다고 했다. 더 이상 사용되지 않는 메모리 공간이 바로 '가비지(Garbage)'라고 할 수 있다. 그렇다면 사용되지 않는 메모리 공간이란 어떤 것을 의미할까?

다음 자바 코드를 보자.

Person person = new Person("Dave");
person.sayHello();

person = new Person("Eric");
person.sayHello();

이 코드를 수행하면서 두 개의 객체가 생성된다. "Dave"라는 이름의 Person 객체와 "Eric"이라는 이름의 Person 객체가 실행되는 도중에 생성된다. 생성된 객체는 person 변수에 의해서 참조된다.

그림으로 그려보면,

JAVA 프로그램에서의 Garbage

"Dave" 객체가 생성된 이후 person 변수에 의해 참조된다. 이 때는 가비지가 없다. 이 후, "Eric" 객체가 생성되고 person 변수가 새로 생성된 "Eric" 객체를 참조한다. "Dave" 객체를 참조하던 person 변수가 "Eric" 객체를 참조하면서 "Dave" 객체를 참조하는 변수가 사라졌다. "Dave" 객체는 어떠한 경로로도 참조되지 않기 때문에 "Unreachable" 상태라고하며, 이 객체는 가비지로 판단되어 회수당하게 된다. 가비지 컬렉션을 수행하는 '가비지 컬렉터(Garbage Collector)'는 스택 변수로부터 참조 체인을 통해 도달할 수 없는(Unreachable) 객체들을 가비지로 판단하고, 이 객체들의 메모리 공간을 회수한다.


Stop-the-world

가비지 컬렉터가 가비지들을 회수하는 동작이 별도의 스레드에서 조용히 수행되면 큰 문제는 없다. 하지만 가비지 컬렉터는 종종 가비지들의 메모리 공간을 회수하기 위해 자바 애플리케이션의 실행을 멈춘다. 이런 동작을 'Stop-the-world'라고 한다. 마치 카페에서 화장실 청소를 할 때, 잠깐 입구에 팻말을 걸어두고 출입을 막은 후에 청소를 하는 것과 같다. 화장실 청소 중에는 화장실을 이용할 수 없듯이 'Stop-the-world' 상태에서는 애플리케이션 로직의 실행이 진행되지 않는다.

가비지 컬렉터가 유발하는 'Stop-the-world' 시간은 종종 수 초에 이를 정도가 되기도한다. 이는 애플리케이션의 종류에 따라 용인되기도하고 그렇지 않기도하다. 예를들어 새벽에 실행되는 배치 작업에서 몇 초정도는 허용할 수 있다. 두 세시간 동안 빠르게 수행되면 될뿐, 몇 초간 실행이 멈추는 것은 큰 문제가 되지 않는다. 반면 리그오브레전드에서 랭킹 게임을 하고 있는데 갑자기 가비지 컬렉션을 한다고 몇 초간 동작이 멈춰버리면 매우 곤란하다. 따라서 'Stop-the-world' 시간을 어느 정도로 허용할 것인지는 실행할 애플리케이션의 특성에 따라 다르다.

대부분 JVM에서 GC 튜닝이라고하면 이 Stop-the-world 시간을 줄이는 것을 의미한다.


Weak Generational Hypothesis

JVM의 가비지 컬렉터는 'Weak Generational Hypothesis'를 전제로 설계되었다. 'Weak Generational Hypothesis'는 다음과 같다.

  • 대부분의 객체들은 생성된 이후 짧은 시간안에 Unreachable 상태가 된다.
  • 생성된지 오래된 객체에서 방금 생성된 객체로의 참조는 아주 적다.

여기서 우선 고려해봐야 할 점은 대부분의 객체들이 생성된 이후 짧은 시간안에 Unreachable 상태가 된다는 점이다. 대부분 객체들은 메소드 안에서 생성되어 사용되다가 메소드의 실행이 종료되면 사용되지 않는다. 즉, 객체를 참조할 stack 변수들이 메소드의 종료와 함께 사라지기 때문에 바로바로 Unreachable 상태가 된다. 반면 한번 살아남은 객체들은 시간이 길게 지나도 오래동안 살아남을 가능성이 있다.

HotSpot JVM의 Heap 메모리 레이아웃

가비지 컬렉션이 일어날 때마다 JVM의 힙 영역 전체를 스캔하는 동작은 매우 비효율적이다. 따라서 오라클의 HotSpot JVM 등의 가상머신은 힙 메모리 영역을 'Young Generation' 영역과 'Old Generation' 영역으로 구분해서 사용한다. Young Generation은 다시 'Eden' 영역과 두 군데의 Survivor 영역(S0, S1)으로 나누어 관리한다.

'Weak Generational Hypothesis'에 의하면 대부분의 객체는 Young Generation 영역에서 생성되었다가 사라지기를 반복한다. Young Generation에서 객체가 회수되는 작업을 Minor GC라고 한다. Minor GC는 상대적으로 빈번하게 발생하지만 짧게 끝난다. 가비지 컬렉션의 동작이 Old Generation까지 스캔해서 가비지를 찾지 않도록해서 더욱 짧은 Stop-the-world 시간안에 메모리를 회수할 수 있도록 했다.

Young Generation 영역에서 몇 번의 Minor GC를 겪으며 살아남은 객체는 Old Generation 영역으로 옮겨져, Minor GC 대상에서 제외되도록 한다. Young Generation 영역에서 Old Generation 영역으로 객체가 이동하는 것을 'Promotion'이라고 한다.

Young Generation 영역에서 프로모션되어 Old Generation 영역으로 넘어간 객체들이 많아질 수록 Old Generation 영역의 메모리 공간도 점점 가득차게 된다. 결국 Old Generation 영역에서도 가비지 컬렉션이 수행되어야한다. Old Generation 영역에서 수행되는 가비지 컬렉션을 Major GC라고 한다. Major GC는 Minor GC에 비해 발생하는 빈도가 작지만 좀 더 긴 Stop-the-world 시간을 발생시킨다.

Old Generation 영역에서 Young Generation 영역을 참조

Old Generation으로 프로모션된 객체가 Young Generation에 있는 객체를 참조할 수도 있다. 이 경우 Young Generation에 있는 객체만 스캔해서는 정확한 가비지 여부를 판단할 수 없다. 결국 Old Generation 영역에 있는 객체에 대한 스캔도 필요로하게 되는데, 여기에서 Weak Generational Hypothesis의 두번째 조건이 사용된다.

Old Generation에 있는 객체가 Young Generation에 있는 객체를 참조할 경우 그 참조 여부를 512바이트 Chunk로 되어 있는 '카드 테이블(Card Table)'에 기록해놓는다. Young Generation 영역에 Minor GC를 수행할 때, Old Generation 객체 전체를 스캔하는 대신 카드 테이블에 있는 참조 정보들만 확인해서 Young Generation으로의 참조가 있는 Old Generation 영역만 스캔해서 가비지 여부를 판별한다. Old Generation에서 Young Generation으로의 참조가 발생하면, Write Barrier를 이용해 Card Table에서 해당 영역에 참조가 있다고 표시해둔다.

Old Generation 영역에서 Young Generation 영역으로의 참조는 적다고 가정했기 때문에 참조들을 별도의 카드 테이블에 기록하는 행위가 부담스러워질일은 없다.(라고 가정한다)


객체들의 Promotion 과정

앞서 JVM 힙 메모리 레이아웃을 말하면서 Young Generation 영역에서 Old Generation 영역으로 객체가 이동하는 행위를 'Promotion(프로모션)'이라고 했다. 좀 더 자세하게 객체가 생성되고 프로모션되는 과정을 살펴보자.

우선 새로 생성된 객체들은 Eden 영역에 생성된다.

객체의 생성이 계속되면 Eden 영역이 가득찬다.

그러면 Minor GC가 발생해서 Eden 영역에 있는 가비지들의 메모리 공간을 회수한다. 위 그림에서 빨간색으로 마킹된 객체들이 가비지고, 그들의 공간을 가비지 컬렉터에 의해서 회수된다.

Eden에서 살아남은 객체들은 두 개의 Survivor 영역(S0, S1) 중 하나로 옮겨진다. S0를 사용한다고 하겠다. 옮겨진 객체에는 age 값이 매겨진다.

몇 번의 minor GC가 발생하면서 살아남은 Eden의 객체들이 S0 영역으로 채워지다보면, 결국 S0 영역도 가득찬다. 이 때, S0 영역에 있는 객체에 대해서도 가비지 여부를 판단해서 회수할 수 있으면 회수한다.

S0 영역에 있는 객체들 중에 살아남은 객체는 또 다른 Survivor 영역인 S1으로 옮겨진다. 그러면서 Age 값이 1 증가한다. S0 영역은 비워져있는 채로 놔둔다.

결국 계속 살아남은 객체들은 S0과 S1을 오가면서 age 값이 올라가게 된다. (나이를 먹는다) 

그러다가 일정 수준 이상으로 age 값이 올라가면 객체를 Old Generation 영역으로 옮긴다. 객체가 Promotion 된 것이다.


몇 가지 옵션들

JVM 옵션 설명
-Xms, -Xmx  힙 메모리의 최소, 최대 사이즈
-XX:NewSize Young Generation 영역의 초기 사이즈
-XX:MaxNewSize Young Generation 영역의 최대 사이즈
-XX:NewRatio Old Generation의 크기, Young Generation에 비해 몇 배의 크기로 지정할 것인지
-XX:SurvivorRatio Young Generation이 Survivor 영역의 몇 배 크기를 가질 것인지 지정

Garbage Collectors

지금까지는 "가비지 컬렉션을 수행한다.", "가비지 컬렉터가 메모리를 회수한다" 정도로만 설명했다. 하지만 가비지 컬렉터의 종류는 여러개가 있다. 정확한 가비지 컬렉터의 알고리즘은 좀 더 찾아봐야하겠지만 대충 어떤 내용인지는 살짝 엿보고 갈 수 있다.

가비지 컬렉터는 'Mark-Sweep-Compact' 단계를 거치면서 메모리를 정리한다. (컴팩션(Compaction)은 수행하는 가비지 컬렉터도 있고, 안하는 컬렉터도 있다)

Mark 단계는 메모리 영역을 스캔하면서 어떤 객체들을 살려둘지 식별하는 단계다. Sweep 단계는 죽어있는(Unreachable) 객체들의 메모리를 회수하고 살아있는 객체들만 메모리를 점유하고 있도록하는 단계다. 마지막으로 Compact 단계는 살아남은 객체들을 마치 윈도우의 디스크 조각모음처럼 한쪽으로 모아서 '메모리 단편화'를 발생시키지 않고, 새로운 객체를 생성할 때 적당한 공간을 찾는 오버헤드를 제거하기 위한 단계다.

Serial GC

Serial GC

-XX:+UseSerialGC 옵션으로 사용할 수 있다.

Serial GC는 하나의 GC 스레드가 메모리의 관리를 수행한다. Stop-the-world 상태로 만들고, 하나의 GC 스레드가 mark, sweep, compact 단계를 수행한다. 당연히 처리 속도가 느리기 때문에 Stop-the-world 시간이 길어진다. (일반적으로 Serial GC를 프로덕션에 절대 사용하지 말라고 강조한다.)

Parallel GC

Parallel GC

-XX:+UseParallelGC 옵션으로 사용할 수 있다.

기본적으로 Serial GC와 같은 알고리즘으로 동작한다. 하지만 Serial GC가 하나의 스레드에서 수행되는 반면, Parallel GC는 여러 스레드에서 동시에 처리된다. mark, sweep, compact 동작을 여러 스레드에서 처리하기 때문에 Stop-the-world 시간이 줄어든다.

Parallel GC는 메모리가 충분하고 코어의 개수가 많을 때 유리하다. GC 스레드는 기본적으로 cpu 개수만큼 할당된다. 만약 다른 값으로 설정하고 싶으면 -XX:ParallelGCThread=${N} 로 설정할 수 있다. -XX:MaxGCPauseMillis=<N> 으로 최대 지연시간을 줄일 수 있다.

Parallel Old GC

XX:+UseParallelOldGC 옵션으로 사용할 수 있다.

Parallel GC 알고리즘과 비교해서 Old 영역에 대한 GC는 Mark-Summary-Compaction 단계를 통해 GC를 수행한다는 점이 다르다. Summary 단계는 앞서 GC를 수행한 영역에 대해서 별도로 살아있는 객체를 식별한다는 점에서 Mark-Sweep-Compaction 알고리즘의 Sweep 단계와 차별화된다.

CMS (Concurrent Mark Sweep) Collector

XX:+UseConcMarkSweepGC 옵션으로 사용할 수 있다.

지금까지 봤던 Mark-sweep-compaction 동작은 Stop-the-world 시간 동안 가비지컬렉션이 진행되었다. Parallel GC를 통해 이 시간을 줄일수는 있지만 궁극적으로는 GC가 끝나야 Stop-the-world 시간이 종료된다. 'CMS Collector'는 GC 단계 중간 중간 Stop-the-world를 해제해서 전체 GC 동안의 stop-the-world 시간을 줄이는 방식을 사용한다.

우선 Initial Mark 단계에서 클래스 로더에 가장 가까운 객체중 살아있는 객체를 찾는 것으로 끝난다. 이후 Stop-the-World를 종료해서 사용자 애플리케이션이 실행할 수 있도록 한다. (Initial Mark 단계는 Minor GC에 Piggyback 된다)

이후 Concurrent Mark 단계를 별도의 스레드에서 진행한다. 이 단계에서는 Initial Mark 단계에서 살아있다고 확인한 객체에서 참조하고 있는 객체들을 따라가면서 살아있음을 확인한다. 이 단계는 Stop-the-world를 걸지 않고, 별도의 스레드에서 진행된다.

Remark 단계에서는 Concurrent Mark 단계에서 새로 추가되거나 참조가 끊긴 객체를 확인한다. 이 때, 다시 Stop-the-world 가 걸린다.

마지막으로 Concurrent Sweep 단계에서는 가비지를 정리해서 메모리를 회수하는 작업이 진행된다. 이 작업은 Stop-the-world가 아닌 상태에서 진행된다.

GC 중간중간 Stop-the-world를 풀기 때문에 전체적인 Stop-the-world 시간이 매우 짧다. 중간중간 사용자 애플리케이션이 실행되기 때문에 응답성도 높다. 애플리케이션의 응답속도가 매우 중요한 경우 CMS GC를 사용한다.

다만 CMS Collector는 Compaction을 하지 않는다. 때문에 Old Generation에 대한 메모리 단편화문제가 발생할 수 있다. 또 한, 다른 GC 방식보다 메모리와 CPU를 더 많이 사용한다. 만약 메모리 단편화를 해소하기 위해 Compaction을 명시적으로 수행한다면, Compaction 수행동안은 Stop-the-world를 걸어야하기 때문에 Compaction 정도에 따라 Stop-the-world가 얼마나 증가하는지 유의해야한다.

CMS 컬렉터는 Young Generation에 대한 GC 수행시 Parallel GC와 같은 알고리즘을 사용한다. 이 때, XX:ParallelCMSThreads=<N> 옵션으로 스레드 개수를 설정할 수 있다.


G1 Garbage Collector

XX:+UseG1GC 옵션으로 사용가능하다.

G1은 Garbage First라는 의미로 Java 7부터 사용가능하다. G1 컬렉터는 큰 메모리와 여러 프로세서가 탑재된 머신에서 효율적으로 동작한다. G1 컬렉터는 장기적으로 CMS 컬렉터를 대체하기 위해서 만들어졌다.

G1은 JVM의 힙 메모리를 조금 다른 레이아웃으로 사용한다.

G1 GC Heap Allocation

메모리를 동일한 사이즈의 여러 리전으로 구분한다. 이 리전들은 기존 가비지컬렉터에서 사용했단 Eden, Survivor, Old 등으로 사용된다. 여기에 빈 공간인 Available/Unused 리전과 리전 크기의 50%를 초과하는 객체를 저장하기 위한 Humonguous 리전이 있다.

마찬가지로 Eden 영역으로 사용되는 메모리 용량이 일정수준 이상으로 올라가면 Minor GC가 발생한다. Minor GC가 발생하면서 Survivor 영역으로 살아남은 객체들이 복사되고, Survivor 영역을 오가면서 age값이 증가된 객체는 일정 수준 이상의 경우 Old 리전으로 프로모션된다.

Eden에서 Survivor 리전으로 객체가 복사되는 Minor GC 과정에서는 stop-the-world가 발생할 수 있다. 대신 멀티 스레드에서 동작해 stop-the-world 시간을 줄일 수 있다.

Full GC

G1 GC에서 Full GC가 수행될 때는 Initial Mark → Root Region Scan → Concurrent Mark → Remark → Cleanup → Copy 단계를 거친다.

Old 리전에 존재하는 객체들이 참조하는 Survivor 리전을 찾는다. 이 과정에서 STW가 발생한다.

Root Region Scan : Initial Mark 단계에서 찾은 Survivor 리전에 대한 GC 대상 객체 스캔 작업을 진행한다.

Concurrent Mark : 전체 힙의 리전에 대해 스캔 작업을 진행하며, GC 대상 객체가 발견되지 않은 리전은 이후 단계를 처리하는데 제외되도록 한다.

Remark : STW를 걸고 최종적으로 GC에서 살아남을 객체를 식별해낸다.

Cleaup : STW를 걸고 살아있는 객체가 가장 적은 즉, 가비지가 가장 많은 리전에 대해 미사용 객체를 제거한다. 이후 STW를 끝내고 앞선 GC에서 완전히 비워진 리전을 FreeList에 추가하여 재사용될 수 있도록 한다.

Copy : GC 대상 리전이었지만 Cealup 과정에서 완전히 비워지지 않은 리전들의 살아남은 객체들을 새로운 리전에 복사하면서 컴팩션(Compaction) 작업을 수행한다.

G1 GC에 대한 내용만으로 포스트 하나를 채울 수 있을 정도다. 자세한 내용과 설정들은 추후 기회가 되면 포스트로 정리를 하겠다.

Reference

댓글