가비지 컬렉션(Garbage Collection)
최근 친구가 전화로 자바에서 생긴 메모리 이슈 관련 도움을 구한 적이 있었다. 친구랑 같이 원인을 찾다 보니 계속 새로운 객체를 생성하여 참조된 객체를 Garbage Collection이 제거하지 않아 생기는 문제일 수 있음을 알게 되었다. 이런 것을 방지하기 위해 싱글톤 패턴으로 코드를 작성하고 Spring에 와서는 Bean을 생성하고 DI를 적용하여 자동으로 싱글톤 패턴이 적용되었다. 그래서 항상 하던 대로 코드를 작성하다 보니 Garbage Collection을 신경 쓰지도 않았고 어떻게 동작하는지도 몰라서 궁금하여 찾아보고 정리하게 되었다. 특히 나도 나중에 저런 메모리 이슈가 생길 수 있고 지금 공부하는 내용이 도움이 될 것 같다고 생각된다.
Garbage Collection이란?
개발을 하다 보면 유효하지 않은 메모리가 발생하게 된다. C언어의 경우 free()라는 함수를 사용해서 직접 메모리를 해제해 주었지만 Java의 경우 직접 메모리를 해제한 경우는 대부분 없을 것이다. 이는 JVM의 Garbage Collector가 불필요한 메모리를 자동으로 정리해 주기 때문이다.
Java에서 메모리를 명시적으로 해제하려면 객체를 null로 만들거나 System.gc() 메소드를 호출하는 방법이 있다. 객체를 null로 만드는 것은 큰 문제가 안되지만 System.gc()를 호출하면 시스템 성능에 큰 악영향을 미치므로 절대 사용하면 안된다. 만약 null로 만들어서 해당 객체가 더이상 참조되지 않는다면 Garbage가 되는 것이고 Java에서는 이런 메모리 누수를 방지하기 위해 Garbage Collector가 주기적으로 검사하여 Garbage를 제거해준다.
Minor GC와 Major GC란?
JVM의 Heap영역은 아래 2가지를 전제로 설계되었다.
- 대부분의 객체는 금방 접근 불가능한 상태(Unreachable)가 된다.
- 오래된 객체에서 새로운 객체로의 참조는 아주 적게 존재한다.
즉 객체는 대부분 일회성이며 메모리에 오래 남아있는 경우는 거의 없다는 것이다. 이런 장점을 살리기 위해 객체 생존 기간에 따라 물리적인 Heap영역을 크게 2개로 나누었고 이 공간이 Young 영역, Old 영역이다.
- Young 영역(Young Generation)
새롭게 생성된 객체가 할당(Allocation)되는 영역으로 대부분의 객체가 금방 접근 불가능한(Unreachable) 상태가 되기 때문에 많은 객체가 이 영역에 생성되었다가 사라진다. Young 영역에 대한 GC를 Minor GC라고 부른다. - Old 영역(Old Generation)
Young영역에서 접근 불가능 상태로 되지 않아 살아남은 객체가 이 Old 영역으로 복사된다. 대부분 Young영역보다 크게 할당하며 크기가 큰 만큼 Young영역 보다는 GC가 적게 발생한다. Old영역에 대한 GC를 Major GC 또는 Full GC라고 부른다.
Old영역의 크기가 Young 영역보다 크게 할당되는 이유는 Young 영역의 객체들은 수명이 짧아 큰 공간을 필요로 하지 않으며 큰 객체들은 바로 Old영역에 할당되기 때문이다. 이 영역들의 데이터 흐름은 아래 그림과 같다.
이 그림의 Permanent Generation 영역은 Method Area라고도 한다. 객체나 intern된 문자열 정보를 저장하는 곳이다. Old영역에서 살아남은 객체가 영원히 남이 있는 곳은 아니며 여기서 발생하는 GC는 Major GC에 포함된다.
JVM은 오래된 객체에서 새로운 객체로의 참조는 아주 적게 존재한다. 즉 참조를 할 수도 있는 것이다. 그래서 Old영역의 객체가 Young 영역의 객체를 참조하는 경우를 처리하기 위해 Old영역에 512byte의 덩어리(Chunk)로 되어있는 카드 테이블(Card Table)이 존재한다.
이 카드 테이블에는 Old영역의 객체가 Young 영역의 객체를 참조할 때 마다 그에 대한 정보가 표시된다. 이런 기능이 있는 이유는 Young 영역에서 GC가 실행될 때 모든 Old영역에 존재하는 객체를 검사하여 참조되지 않는 Young 영역의 객체를 식별하는 것이 비효율적인 과정이기 때문이다. 카드 테이블이 있기에 Young 영역에서 GC가 진행될 때 이 카드 테이블만 찾아보고 GC로 처리할 객체인지 식별할 수 있도록 하고 있다.
GC(Garbage Collection)의 과정
GC를 실행하기 위해서는 사전적으로 JVM이 애플리케이션 실행을 멈춘다. 이를 stop-the-world라고 한다. stop-the-world가 발생하면 GC를 실행하는 스레드(Thread)를 제외한 나머지 스레드는 모두 작업을 멈춘다. 이후 GC작업이 완료되면 중단했던 애플리케이션 실행을 다시 시작한다. GC 알고리즘도 종류가 다양한데 어떤 알고리즘을 사용하더라도 stop-the-world는 발생한다고 한다. 대부분의 GC 튜닝도 이 stop-the-world 시간을 줄이는 것이다.
stop-the-world 이후 Mark and Sweep을 수행한다. 여기서 Mark는 사용되는 메모리와 사용되지 않는 메모리를 식별하는 작업이고 Sweep은 Mark 단계에서 사용되지 않는 것으로 식별된 메모리를 해제하는 작업이다. stop-the-world로 모든 작업의 실행이 멈추면 GC는 스택의 모든 변수나 접근 가능한(Reachable)객체를 스캔하면서 각각 어떤 객체를 참조하고 있는지를 탐색한다. 그리고 사용되고 있는 메모리를 식별하는데 이게 Mark이고 Mark되지 않은 객체들을 메모리에서 제거하는 과정이 Sweep이다.
Minor GC의 동작 방식
Minor GC는 Young 영역에서 일어나고 Young 영역은 1개의 Eden 영역과 2개의 Survivor 영역으로 총 3개의 영역으로 나뉜다.
- Eden 영역 : 새로 생성된 객체가 할당(Allocation)되는 영역이다.
- Survivor 영역 : 최소 1번의 GC 이후 살아남은 객체가 존재하는 영역이다.
객체가 새로 생성되면 Young영역 중 Eden 영역에 할당된다. 이 Eden영역이 가득 차면 GC가 발생하게 되고 사용되지 않는 메모리는 해제되며 사용중인 객체는 Survivor 영역으로 옮겨진다. Survivor 영역은 총 2개지만 반드시 1개의 영역에만 데이터가 존재해야 한다. 각 영역의 절차를 순서대로 정리하면 다음과 같다.
- 새로 생성한 객체는 대부분 Eden 영역에 할당된다.
- Eden영역이 가득 차서 Minor GC가 발생한다.
- 이때 Eden영역에서 사용되지 않는 객체의 메모리는 해제되고 살아남은 객체는 2개의 Survivor중 1개의 Survivor 영역으로 이동된다.
- 1,2번의 과정이 반복되면 Eden영역에서 발생한 Minor GC에서 살아남은 객체가 존재하는 Survivor영역으로 객체가 계속 쌓인다.
- Survivor영역이 가득 차게 되면 Survivor영역의 살아남은 객체를 다른 Survivor영역으로 이동시킨다. 이때 하나의 Survivor 영역은 반드시 빈 공간이 된다.
- 이러한 과정을 반복하여 계속해서 살아남은 객체는 Old 영역으로 이동(Promotion)된다.
객체의 생존 횟수를 카운트하기 위해 Minor GC로부터 살아남은 횟수를 의미하는 age를 Object Header에 기록한다. 그리고 Minor GC가 발생할 때 이 객체의 age를 보고 Promotion 여부를 결정한다. 그리고 위의 4번에서 Survivor영역 하나는 반드시 빈 상태가 되는데 이 말은 Survivor 영역 중 하나는 반드시 사용되고 있어야 한다. 만약 두 영역 모두에 데이터가 존재하거나 둘 다 사용되지 않는 상태라면 시스템에 문제가 있다는 것을 알 수 있다.
이 과정들을 그림으로 나타내면 아래와 같다.
Hotspot JVM에서는 더 빠르게 메모리 할당을 하기 위해 bump-the-pointer라는 기술과 TLABs(Thread-Local Allocation Buffers)라는 기술을 사용한다.
bump-the-pointer는 Eden 영역에 마지막으로 할당된 객체의 주소를 캐싱해 두는 것이다. 마지막 객체는 Eden영역의 맨 위에 있고 그 다음에 생성되는 객체가 있다면 해당 객체의 크기만 Eden영역에 넣기 적절한지 확인한다. 적절하다고 판단되면 Eden영역에 넣고 새로 생성된 객체가 맨 위에 있을 것이다. 이때 새로운 객체를 위해 사용 가능한 메모리를 탐색할 필요 없이 캐싱해둔 주소(마지막 주소)의 다음 주소를 사용하여 속도를 높이고 있다.
이 방법이 싱글 스레드를 사용할 땐 괜찮지만 멀티 스레드를 사용할 땐 문제가 생긴다. 여러 스레드에서 사용하는 객체를 Eden영역에 저장하려면 락(lock)을 걸어서 동기화를 해주어야 한다. 이때 lock-contention때문에 성능상 문제가 생긴다. 이를 해결하기 위해 TLABs라는 기술을 도입하게 되었다. TLABs란 각각의 스레드마다 Eden영역에 객체를 할당하기 위한 주소를 부여하는 것으로 스레드는 자기가 가지고 있는 TLABs에만 접근 가능하기 때문에 bump-the-pointer기술을 사용해도 아무런 lock 없이 메모리 할당이 가능하다.
Major GC의 동작 방식
Young영역에서 오래 살아 남은 객체는 Old 영역으로 Promotion된다. 이 과정이 반복됨에 따라 Old영역이 가득차게 되면 Major GC가 발생한다. Young영역의 크기는 일반적으로 Old 영역의 크기보다 작기 때문에 GC가 보통 0.5~1초 사이에 끝나서 Minor GC는 애플리케이션에 크게 영향을 미치지 않는다. 하지만 Old영역의 경우 Young영역보다 크고 Young영역을 참조할 수도 있기 때문에 일반적으로 Minor GC보다 Major GC의 소요 시간이 더 길며 보통 10배 이상의 시간이 더 걸린다.
Garbage Collection 알고리즘
Old 영역은 방식에 따라 처리 절차가 달라지니 어떤 GC 알고리즘이 있는지 살펴보자.
이 알고리즘들은 JVM이 GC로 메모리를 관리해줄 때 stop-the-world 때문에 생기는 지연 시간을 줄이기 위한 다양한 알고리즘이다.
Serial GC
Young 영역에서 Serial GC는 위에서 언급한 Mark and Sweep대로 수행된다.
Old 영역의 경우 Mark Sweep Compact라는 알고리즘이 사용된다. 기존 작업에서 Compact라는 작업이 추가된 것이다. Compact는 Heap영역을 정리하기 위한 단계로, 우선 Old 영역의 살아있는 객체를 식별(Mark)하고 Sweep하여 살아있는 것만 남긴다. 이후 Heap영역의 앞 부분부터 채워서 객체가 존재하는 부분과 존재하지 않는 부분으로 나누는 것이다(Compact).
이 Serial GC는 CPU 코어가 하나일 때 사용하기 위해 개발된 것으로 모든 GC를 처리하기 위해 아래 그림과 같이 하나의 스레드만 사용한다. 그렇기 때문에 일반적으로 CPU코어가 여러개인 운영하는 서버에서 절대 사용하면 안될 방식이다. 만약 이걸 사용하면 서비스 성능이 많이 떨어질 것이다.
java -XX:+UseSerialGC
Parallel GC
Throughput GC라고도 부르며 Serial GC와 기본적인 처리 과정은 동일하다. 하지만 아래 그림과 같이 여러 개의 스레드를 사용하여 병렬적으로 GC를 수행함으로써 GC에서 발생하는 오버헤드를 많이 줄여주는 차이점이 있다. 기존의 Serial GC가 하나의 스레드로 처리하던 것을 여러 스레드로 처리하기 떄문에 더욱 빠르게 객체를 처리할 수 있을 것이다. 이 Parallel GC는 멀티 프로세서나 멀티 스레드 머신에서 큰 규모의 데이터를 처리하는 애플리케이션을 위해 만들어졌다.
java -XX:+UseParallerGC
//사용할 스레드 개수 설정
-XX:ParallelGCThreads=<N>
// 최대 지연 시간 설정
-XX:MaxGCPauseMillis=<N>
Parallel Old GC
JDK 5 update 6부터 제공한 방식으로 Parallel GC와는 Old 영역의 GC 알고리즘만 다른 차이가 있다. Parallel Old GC는 Mark Sweep Compact가 아닌 Mark Summary Compaction이 사용된다. Summary 단계에서 앞서 GC를 수행한 영역에 대해서 별도로 살아있는 객체를 식별한다는 점에서 조금 다르며 약간 더 복잡하다.
java -XX:+UseConcMarkSweepGC
CMS(Concurrent Mark Sweep) GC
Paraller GC와 마찬가지로 여러 개의 스레드를 사용한다. 하지만 기존 방식과는 다르게 Mark Sweep 알고리즘을 Concurrent하게 수행한다.
- Initial Mark 단계에서는 살아있는 객체를 찾는 단계로 단순히 객체만 찾고 끝낸다. 이때 stop-the-world가 발생하는데 단순히 살아있는 객체만 찾고 끝내기 때문에 애플리케이션이 멈추는 시간이 매우 짧다.
- Concurrent Mark 단계에서는 방금 찾은 살아있는 객체에서 참조하고 있는 객체들을 따라가며 확인한다. 이 단계의 특징은 다른 스레드들이 실행중인 상태로 동시에 실행된다는 점이다.
- Remark 단계에서는 Concurrent Mark 단계에서 새로 추가되거나 참조가 끊긴 객체를 확인한다. 이때도 stop-the-world가 발생하며 마찬가지로 멈추는 시간이 매우 짧다.
- 마지막 Concurrent Sweep에서는 쓰레기를 정리하는 작업을 진행하며 이 작업도 다른 스레드가 실행되고 있는 상황에서 진행된다.
CMS GC는 stop-the-world가 짧은 만큼 애플리케이션의 지연 시간을 최소화 하기 위해 고안되었으며 모든 애플리케이션의 응답 속도가 중요할 때 사용한다.
CMS GC는 단점도 존재하는데 다른 알고리즘보다 CPU를 더 많이 사용하며 Compaction 단계를 수행하지 않는 단점이 있다. 그래서 시스템이 오래 운영되면 조각난 메모리들이 많아 Compaction 단계가 수행되면 오히려 stop-the-world 시간이 길어지는 문제가 발생할 수 있기 때문에 Compaction 작업이 얼마나 자주 수행되고 오랫동안 수행되는지 확인해줘야 한다.
CMS GC는 java 9부터 deprecated 됐고 java14에서는 사용 중지가 되었다.
G1(Garbage First) GC
장기적으로 서비스를 운영할 때 CMS GC에서 생기는 문제를 대체하기 위해 개발되었으며 java 7부터 지원했다. 기존 GC들은 Heap 영역을 Young 영역과 Old 영역으로 나누어서 사용했지만 G1 GC는 아래의 그림과 같이 Heap을 동일하게 나눈 바둑판처럼 생긴 각 영역에 객체를 할당하고 GC를 실행한다. 그러다가 각 영역이 가득 차면 다른 영역에서 객체를 할당하고 GC를 실행한다. 기존의 Young → Old로 이동하는 단계가 사라진 GC 방식이다.
G1 GC는 기존의 Eden, Survivor, Old의 역할 + Available/Unused 와 Humonogous 라는 2가지 역할이 추가되었다. Available/Unused는 사용되지 않는 영역을 의미하며 Humonogous는 영역 크기의 50%를 초과하는 객체를 저장하는 영역을 의미한다.
이 GC의 핵심은 Heap영역을 동일한 크기로 나누고 Garbage가 많은 영역을 우선적으로하여 GC를 수행하는 것이다. 얘도 Minor GC, Major GC로 나누어져서 수행된다.
1. Minor GC
한 영역에 객체를 할당하다가 가득 차면 다른 지역에 개체를 할당하고 Minor GC가 수행된다. G1 GC는 각 영역을 추적하고 있어서 Garbage가 가장 많은 영역을 찾아서 Mark and Sweep을 수행한다. Garbage가 가장 많은 영역이 우선되어 Garbage First라고 한다. Eden 지역에서 GC가 실행되면 Mark과정이 실행된 후 Sweep 과정이 실행된다. 이후 살아남은 객체들은 다른 영역으로 이동시킨다. 이때 복제되는 영역이 Available/Unused 라면 해당 영역은 Survivor 영역이 되고 Eden은 비었기 때문에 Available/Unused 영역으로 바뀐다.
2. Major GC
Full GC라고도 하며 시스템 운영 중 객체가 너무 많아져 빠르게 메모리를 회수할 수 없을 때 Major GC가 실행된다. 기존의 알고리즘은 Heap영역에서 실행되었기 때문에 실행 시간이 오래 걸렸다. 하지만 G1 GC는 각 영역을 추적하고 있기 때문에 어느 영역의 Garbage가 많은지 알고 있고 GC를 실행할 영역을 조합하여 해당 영역에서만 GC를 실행한다. 이 작업은 CMS처럼 Concurrent하게 실행되어 애플리케이션의 지연도 최소화 할 수 있다.
java -XX:+UseG1GC
G1 GC는 위와 같이 수행되기 때문에 다른 GC보다 훨씬 빠르고 큰 메모리 공간에서 멀티 프로세스 기반으로 운영되는 서비스를 위해 고안되었다. 다른 GC보다 훨씬 빠르기 때문에 Java9부터 기본 Garbage Collector로 사용되었다.
이런 GC들은 우리가 외우거나 개발을 할 필요는 없으며 각각 어떤 GC인지는 알고 이해만 하면 된다. 이후 필요할 때 필요한 GC 방식을 적용시키기만 하면 된다고 한다.