제목: "Java Garbage Collection"
작성자: NAVER(이상민)
작성자 수정일: 2011년12월22일
링크: https://d2.naver.com/helloworld/1329
작성일: 2022년7월4일
자바 어플리케이션(스프링부트)을 개발하다보니, 특정 메서드에서 호출될때마다 Map 객체를 새롭게 만들어서 사용하는 부분이 생겨서 이 부분을 좀 더 효율적으로 만들고 싶어서, 여러가지 방안(쓰레드 로컬 등등)을 생각해보다가 책에서 간단하게 설명만 보았던 GC 동작 방식에 대해 알면 도움이 될 것 같아서 위 출처에서 글을 보고 작성하게 되었다.
GC에 대해서 알아보기 전에 알아야 할 용어가 있다. 바로 stop-the-world
이다. GC를 실행하기 위해 JVM이 어플리케이션 실행을 멈추는 것이다. stop-the-world
가 발생하면 GC를 실행하는 쓰레드를 제외한 나머지 쓰레드는 모두 작업을 멈춘다.
GC 작업을 완료한 이후에야 중단했던 작업을 다시 시작한다. 어떤 GC 알고리즘을 사용하더라도 stop-the-world
는 발생한다. 대개의 경우 GC 튜닝이란 stop-the-world
시간을 줄이는 것이다.
JAVA는 프로그램 코드에서 메모리를 명시적으로 지정하여 해제하지 않는다. 가끔 명시적으로 해제하려고 해당 객체를 null로 지정하거나 System.gc()
메서드를 호출하는 개발자가 있다. null로 지정하는 것은 큰 문제가 안되지만, System.gc()
메서드를 호출하는 것은 시스템의 성능에 매우 큰 영향을 끼치므로 System.gc()
는 절대로 사용하면 안된다.
자바에서는 개발자가 프로그램 코드로 메모리를 명시적으로 해제하지 않기 때문에 가비지 컬렉터가 더 이상 필요 없는 (쓰레기) 객체를 찾아 지우는 작업을 한다. 이 가비지 컬렉터는 두 가지 가설하에 만들어졌다.(사실 가설이라기보다는 가정 또는 전제 조건이라 표현하는 것이 맞다.)
이러한 가설을 weak generational htpothesis
라 한다. 이 가설의 장점을 최대한 살리기 위해서 HotSpot Vm에서는 크게 2개로 물리적 공간을 나누었다. 둘로 나눈 공간이 Young 영역과 Old 영역이다.
영역별 데이터 흐름을 그림으로 살펴보면 다음과 같다.
Permanent Generation
영역은 Method Area
라고도 한다. 객체나 억류된 문자열 정보를 저장하는 곳이며, Old 영역에서 살아남은 객체가 영원히 남아 있는 곳은 절대 아니다.그렇다면 "Older 영역에 있는 객체가 Young 영역의 객체를 참조하는 경우가 있을 때에는 어떻게 처리될까?"
이러한 경우를 처리하기 위해서 Old영역에는 512바이트의 덩어리(chunk)로 되어 있는 카드 테이블이 존재한다.
카드 테이블에는 Old 영역에 있는 객체가 Young 영역의 객체를 참조할 때마다 정보가 표시된다. Young 영역의 GC를 실행할 때에는 Old 영역에 있는 모든 객체의 참조를 확인하지 않고, 이 카드 테이블만 뒤져서 GC 대상인지 식별한다.
카드 테이블은 write barrier
를 사용하여 관리한다. write barrier
는 Minor GC를 빠르게 할 수 있도록 하는 장치이다. write barrier 때문에 약간의 오버헤드는 발생하지만 전반적인 GC 시간은 줄어들게 된다.
GC를 이해하기 위해서 객체가 제일 먼저 생성되는 Young 영역부터 알아보자. Young 영역은 3개의 영역으로 나뉜다.
Survivor 영역이 2개이기 때문에 총 3개의 영역으로 나뉘는 것이다. 각 영역의 처리 절차를 순서에 따라서 기술하면 다음과 같다.
이 절차를 확인해보면 알겠지만 Survivor 영역 중 하나는 반드시 비어 있는 상태로 남아있어야 한다. 만약 두 Survivor 영역에 모두 데이터가 존재하거나, 두 영역 모두 사용량이 0이라면 시스템이 정상적인 상황이 아니라고 생각하면 된다.
이렇게 Minor GC를 통해서 Old 영역까지 데이터가 쌓인 것을 간단히 나타내면 아래와 같다.
HotSpot VM에서는 보다 빠른 메모리 할당을 위해서 두가지 기술을 사용한다. 하나는 bump-the-pointer
라는 기술이며, 다른 하나는 TLABs(Thread Local Allocation Buffers
)라는 기술이다.
bump-the-pointer
는 Eden 영역에 할당된 마지막 객체를 추적한다. 마지막 객체는 Eden 영역의 맨 위 (Top)에 있다. 그리고 그 다음에 생성되는 객체가 있으면, 해당 객체의 크기가 Eden 영역에 넣기 적당한지만 확인한다. 만약 해당 객체의 크기가 적당하다고 판정되면 Eden 영역에 넣게 되고, 새로 생성된 객체가 맨 위에 있게 된다. 따라서 새로운 객체를 생성할 때 마지막에 추가된 객체만 점검하면 되므로 매우 빠르게 메모리 할당이 이루어 진다.그러나 멀티 스레드 환경을 고려하면 이야기가 달라진다. Thead-Safe 하기 위해서 만약 여러 스레드에서 사용하는 객체를 Eden 영역에 저장하려면 Lock 이 발생할 수 밖에 없고, Locak-Contention 때문에 성능이 매우 떨어지게 된다. HotSpot VM에서 이를 해결한 것이 TLABs이다.
TLABs
는 각각의 스레드가 각각의 몫에 해당하는 Eden영역의 작은 덩어리를 가질 수 있도록 하는 것이다. 각 쓰레드에는 자기가 갖고 있는 TLABs
에만 접근할 수 있기 때문에, bump-the-pointer
라는 기술을 사용하더라도 아무런 락 없이 메모리 할당이 가능하다.Eden 영역에 최초로 객체가 만들어지고, Survivor 영역을 통해서 Old 영역으로 오래 살아남은 객체가 이동한다는 사실을 꼭 기억해두자.
Old 영역은 기본적으로 데이터가 가득 차면 GC를 실행한다. GC 방식에 따라서 처리 절차가 달라지므로, 어떤 GC 방식이 있는지 살펴보면 이해가 쉬울 것이다. GC 방식은 JDK 7을 기준으로 5가지 방식이 있다.
이 중에서 운영 서버에서 절대 사용한면 안되는 방식이 Serial GC이다. Serial GC는 데스크톱의 CPU 코어가 하나만 있을 떄 사용하기 위해서 만든 방식이다. Serial GC를 사용하면 어플리케이션의 성능이 많이 떨어진다.
각 GC 방식에 대해 살펴보자.
Young 영역에서의 GC는 앞 절에서 설명한 방식을 사용한다. Old 영역의 GC는 mark-sweep-compact 이라는 알고리즘을 사용한다.
이 알고리즘의 첫단계는 Old 영역에 살아 있는 객체를 식별(mark)하는 것이다. 그 다음에는 힙의 앞 부분부터 확인하여 살아있는 것만 남긴다.(sweep)
마지막 단계에서는 각 객체들이 연속되게 쌓이도록 힙의 가장 앞 부분부터 채워서 객체가 존재하는 부분과 객체과 엎는 부분으로 나눈다(Compaction)
Serial GC는 적은 메모리와 CPU 코어 개수가 적을 때 적합한 방식이다.
Parallel GC는 Serial Gc와 기본적으로 알고르짐은 같다. 그러나 Serial GC를 처리하는 스레드가 하나인 것에 비해, Parallel GC를 처리하는 쓰레드는 여러개이다.
그렇기 때문에 보다 빠르게 객체를 처리할 수 있다. Parallel GC는 메모리가 충분하고 코어의 개수가 많을 때 유리하다. Parallel GC는 Throughput GC라고도 불린다.
다음 그림은 Serial GC와 Parallel GC의 쓰레드를 비교한 그림이다.
Parallel Old GC는 JDK 5 update 6부터 제공한 GC 방식이다. 앞서 설명한 Parallel GC와 비교하여 Old 영역의 GC 알고리즘만 다르다. 이 방식은 Mark-Summary-Compaction 단계를 거친다. Summary 단계는 앞서 GC를 수행한 영역에 대해서 별도로 살아 있는 객체를 식별한다는 점에서 Mark-Sweep-Compaction 알고리즘의 Sweep 단계와 다르며, 약간 더 복잡한 단계를 거친다.
다음 그림은 Serial GC와 CMS GC의 절차를 비교한 그림이다. 그림에서 보듯이 CMS GC는 지금까지 설명한 GC 방식보다 더 복잡하다.
초기 Initial Mark 단계에서는 클래스 로더에서 가장 가까운 객체 중 살아 있는 객체만 찾는 것으로 끝낸다. 따라서 멈추는 시간이 매우 짧다.
그리고 Concurrent Mark 단계에서 방금 살아있다고 확인한 객체에서 참조하고 있는 객체들을 따라가면서 확인한다.
마지막으로 Concurrent Sweep 단계에서는 쓰레드를 정리하는 작업을 실행한다. 이 작업도 다른 스레드가 실행되고 있는 상황에서 진행한다.
이러한 단계로 진행되는 GC 방식 이기 때문에 , Stop the world 시간이 매우 짧다. 모든 어플리케이션의 응답 속도가 매우 중요할 때 CMS GC를 사용하며, Low Latency GC라도고 불린다.
그런데 다음과 같은 단점이 있다
따라서, CMS GC를 사용할 때에는 신중히 검토한 후에 사용해야 한다. 그리고 조각난 메모리가 많아 Compaction 작업을 실행하면 다른 GC 방식의 stop-the-world 시간 보다 stop-the-world 시간이 더 길기 때문에 , Compaction 작업이 얼마나 자주, 오랫동안 실행되는지 확인해야 한다.
마지막으로 G1(Garbage First) GC에 대해서 알아보자. G1 GC를 이해하려면 지금까지의 Young 영역과 Old 영역에 대해서는 잊는 것이 좋다.
다음 그림에서 보다시피, G1 GC는 바둑판의 각 영역에 객체를 할당하고 GC를 실행한다. 그러다가, 해당 영역이 꽉 차면 다른 영역에서 객체를 할당하고 GC를 실행한다.
즉, 지금까지 설명한 Young의 세가지 영역에서 데이터가 Old 영역으로 이동하는 단계가 사라진 GC 방식이라고 이해하면 된다.
G1 GC는 장기적으로 말도 많고 탈도 많은 CMS GC를 대체하기 위해서 만들어 졌다.
G1 GC의 가장 큰 장점은 성능이다. 지금까지 설명한 어떤 GC 방식보다도 빠르다. 하지만, JDK 6에서는 G1 GC를 early access라고 부르며 그냥 시험삼아 사용할 수만 있도록 한다. 그리고 JDK 7에서 정식으로 G1 GC를 포함하여 제공한다.