Garbage Collection(가비지 컬렉션)
프로그램을 개발하다 보면 유효하지 않은 메모리인 가비지(Garbage)가 발생하게 된다. C언어를 이용하면 free()라는 함수를 통해 직접 메모리를 해제해주어야 하지만 Java나 JavaScript을 이용해 개발을 하다보면 개발자가 메모리를 직접 해제해주는 일은 없다. 그 이유는 JVM의 Garbage Collector가 프로그램이 동적으로 할당했던 메모리 영역 중 불필요한 메모리를 알아서 정리(해제)해주기 때문이다.
여기서 동적으로 할당했던 메모리 영역은 프로그램 런타임에 사용되는 Heap 메모리 영역을 뜻하고, 불필요한 영역은 어떤 변수도 가리키지 않게 된 영역을 의미한다.
장점
이렇게 GC를 도입하면 수동으로 메모리를 관리하던 것에서 비롯된 에러들을 방지할 수 있다.
- 개발자의 실수로 인한 메모리 누수 방지
- 해제된 메모리에 접근하는 오류 방지
- 해제된 메모리를 또 해제하는 이중 해제 방지
단점
- 어떠한 메모리 영역이 해제의 대상이 될지 검사하고 해제하는 일은 프로그램이 해야하는 일을 못하도록 방해하는 오버헤드
- GC의 메모리 해제 타이밍을 개발자가 정확하게 알기 어렵다.
이런 특성에 따라 실시간성이 매우 강조되는 프로그램의 경우 GC에게 메모리 관리를 맡기는 것이 알맞지 않을 수 있다.
GC 알고리즘
그렇다면 GC는 어떻게 해제할 동적 메모리 영역들을 알아서 판단할까?
GC를 구현하는 2가지 대표적인 알고리즘에는 Reference Counting과 Mark And Sweep이 있다.
Reference Counting
Root Space는 스택 변수, 전역 변수 등 Heap 영역 참조를 담은 변수라 생각하면 된다. 이 때, Reference Couting은 Heap 영역에 선성된 객체들이 각각 reference count라는 별도의 숫자를 가지고 있어 해당 객체에 접근할 수 있는 방법이 하나도 없다면 즉, reference count가 0에 다다르면 가비지 컬렉션의 대상이 된다. 하지만 reference counting에는 순환 참조 문제라는 한계점이 있다. 위 그림 빨간색 동그라미 부분의 객체들처럼 Root space에서의 Heap space 접근을 모두 끊었지만 객세들이 서로가 서로를 참조하고 있기 때문에 Reference count는 1 이상 유지될 것이다. 결국 사용하지 않는 메모리 영역이 해제되지 못하고, Memory Leak이 발생할 수 있다.
참고: Root Space
- Stack의 로컬 변수
- Method Area의 Static 변수
- Native Method Stack의 C/C++로 작성 JNI 참조
Mark And Sweep
Mark And Sweep 알고리즘은 Reference Counting의 순환 참조 문제를 해결할 수 있다. 루트에서부터 그래프 순회를 통해 연결된 객체들을 찾아내고 접근 가능하지 않다면 해제의 기준으로 삼는다. 왼쪽 그림처럼 마크를 통해 연결이 끊어진 객체들은 지우는 방식의 Sweep까지 하나의 과정을 거친다. 추가로 루트로부터 연결된 객체는 Reachable, 연결되지 않았다면 Unreachable이라 부른다.
그림에서는 Sweep 이후에 분산되어 있던 메모리가 잘 정리된 것을 확인할 수 있는데 이를 메모리 파편화를 막는 Compaction이라고 한다. 하지만, Mark And Sweep에서 Compaction은 필수과정은 아니다.
이렇게 Mark And Sweep 방식을 사용하면, 루트로부터 연결이 끊긴 순환 참조되는 객체들도 모두 지울 수 있다. 자바와 자바스크립트가 Mark And Sweep 방식으로 메모리 관리를 한다.
- Mark: 사용되는 메모리와 사용되지 않는 메모리를 식별하는 작업
- Sweep: Mark 단계에서 사용되지 않음으로 식별된 메모리를 해제하는 작
Mark And Sweep 특징
- 의도적으로 GC를 실행시켜야 한다.
- 어플리케이션 실행과 GC 실행이 병행된다.
객체의 reference count가 0이 되면 지워버리는 reference counting 방식과는 달리, Mark And Sweep은 의도적으로 GC를 실행시켜야 한다. 즉, 어느 순간에는 실행중인 어플리케이션이 GC에게 컴퓨터 리소스들을 내줘야 한다는 말이다. 따라서 어플리케이션의 사용성을 유지하면서 효율적이게 GC를 실행하는 것이 꽤나 어려운 최적화 작업이다.
JVM의 GC - Heap 영역
Mark And Sweep 특징에서 의도적으로 GC를 실행시켜야 한다고 했다. 이 때의 타이밍을 알기 위해서는 Heap 영역을 조금 더 들여다봐야 한다. JVM의 Heap은 크게 Young Generation과 Old Generation으로 두 영역으로 나눌 수 있다. Young Generation에서 발생하는 GC는 Minor GC, Old Generation에서 발생하는 GC는 Major GC라고 부른다.
Youn Generation은 다시 Eden / Survival 0 / Survival 영역으로 나눌 수 있다. Eden은 새롭게 생성된 객체들이 할당되는 영역이고, Survival 영역은 Minor GC로부터 살아남은 객체들이 존재하는 영역이다. Survival에는 Survival 0 혹은 Survival 1 둘 중 하나는 꼭 비어 있어야 한다는 특별한 규칙이 있다.
Heap 영역을 통해 GC 과정 살펴보기
새로운 객체들은 처음에 Eden 영역에 생성되고, 새로운 객체가 계속 생성되다 보면 Eden 영역이 꽉 차는 순간이 올 때 Minor GC가 일어난다. 앞에서 살펴본 Mark And Sweep이 진행된다. 루트로부터 Reachable이라 판단된 객체는 Survival 0으로 옮겨지고, 옮겨진 객체들의 숫자가 1로 바뀌는데 이를 age-bit를 뜻한다. minor gc에서 살아남을 때마다 1씩 증가한다.
이러한 방식이 계속 반복되면서 Reachable한 객체들이 살아남을 때마다 age-bit이 1씩 증가하는데 JVM GC에서는 일정 수준의 age-bit을 넘어가면 오래도록 참조될 객체라 판단하여 해당 객체를 Old Generation에 넘겨주는데 이 과정을 Promotion이라고 한다. Java 8에서는 Parallel GC 방식 사용 기준 age-bit가 15가 되면 Promotion을 진행한다. 시간이 아주 많이 지나 Old Generation도 다 채워지는 순간에는 Major GC가 발생하면서 Mark And Sweep 방식을 통해 필요없는 메모리를 비워준다. 이 Major GC는 Minor GC보다 오래 걸린다.
이렇게 Heap 영역을 굳이 Young Generation과 Old Generation으로 구분한 이유는 무엇일까? GC 설계자들이 어플리케이션을 분석해보니 대부분의 객체가 수명이 짧다는 것을 깨달았고, GC도 결국 비용인데 메모리의 특정 부분만을 탐색하며 해제하는 것이 더 효율적이라 Young Generation 안에서 최대한 처리하기 위해 물리적인 Heap 영역을 나누었다.
Heap 영역 정리
Young Generation
- 새롭게 생성된 객체가 할당(Allocation)되는 영역
- 대부분의 객체가 금방 unreachable 상태가 되기 때문에, 많은 객체가 Young Generation에 생성되었다가 사라진다.
- Young Generation에 대한 가비지 컬렉션을 Minor GC라 부른다.
Old Generation
- Young Generation에서 Reachable 상태를 유지하여 살아남은 객체가 복사되는 영역
- Young Geneartion 보다 크게 할당되며, 영역의 크기가 큰 만큼 가비지는 적게 발생한다.
- Old Generation에 대한 가비지 컬렉션을 Major GC라 부른다.
Garbage Collection GC 종류 정리
GC 종류 | Minor GC | Major GC |
대상 | Young Generation | Old Generation |
실행 시점 | Eden 영역이 꽉 찬 경우 | Old 영역이 꽉 찬 경우 |
실행 속도 | 빠르다 | 느리다 |
JVM의 Garbage Collection 종류
그러면 Mark And Sweep 두 번째 특징으로 어플리케이션 실행과 GC 실행이 병행된다고 했는데 JVM은 어떤 방식으로 어플리케이션 실행과 GC 실행을 병행할까?
Stop The World
GC가 어떤 방식으로 어플리케이션 실행과 병행되는지 살펴보기 전에 Stop The World 라는 개념을 알아야 한다.
Stop The World는 GC를 실행하기 위해 JVM이 어플리케이션 실행을 멈추는 것을 말한다. 즉, GC가 실행될 때는 GC를 실행하는 스레드를 제외한 모든 스레드들의 작업이 중단되고, GC가 완료되면 작업이 재개된다. 앞서 어플리케이션의 사용성을 유지하면서 효율적으로 GC를 실행하는 것이 어려운 최적화 작업이라고 했는데 이 Stop The World 시간을 최소화하는 것이 어려운 최적화 작업을 의미한 것이다.
Serial GC
Serial GC는 하나의 스레드로 GC를 실행하는 방식으로 Young Generation은 앞서 설명한 MarkAndSweep 알고리즘대로 수행되지만, Old Generation은 Mark Sweep Compact 알고리즘이 사용된다. 앞에서 설명한 것처럼 Sweep 시 Compact라는 작업이 추가되었다고 생각하면 좋다. 하나의 스레드로 GC를 실행시키다 보니 Stop The World 시간이 긴데 싱글 스레드 환경 및 Heap 메모리 영역이 매우 작을 때 사용하기 위해 나온 방식이다. 반대로 생각하면 CPU의 코어가 여러 개인 운영 서버에서 Serial GC를 사용하는 것은 반드시 피해야 한다.
Parallel GC
Paralle GC는 여러 개의 스레드로 가비지 컬렉션을 수행하는데 기본적인 처리 과정은 Serial GC와 동일하다. 하지만 Parallel GC는 여러 개의 스레드를 통해 Parallel하게 GC를 수행함으로써 앞서 살펴봤던 Serial GC보다 Stop The World 시간이 짧아진 것을 알 수 있다.
멀티코어 환경에서 어플리케이션 처리 속도를 향상시키기 위해 사용되어 GC의 오버헤드를 상당히 줄여주었고, Java 8에서 default GC 방식으로 쓰인다. 하지만 어플리케이션이 멈추는 것은 피할 수 없었고, 이러한 부분을 개선하기 위해 다른 방식이 등장하게 되었다.
CMS GC
CMS GC에서 CMS는 Concurrent Mark Sweep의 줄임말로 Parallel GC와 마찬가지로 여러 개의 스레드를 이용한다. 하지만 기존의 Serial GC나 Parallel GC와는 다르게 Mark And Sweep 알고리즘을 Concurrent하게 수행한다.
대부분의 가바지 컬렉션 작업을 어플리케이션 스레드와 동시에 수행해서 Stop The World 시간을 최소화시킨다. 하지만 메모리와 CPU를 많이 사용하고, Mark-And-Sweep 과정 이후 메모리 파편화를 해결하는 Compaction이 기본적으로 제공되지 않는다는 단점이 있다. 이러한 CMS GC는 Java9 버전부터 deprecated되었고, 결국 Java14에서는 사용이 중지되었다.
G1 GC
마지막 G1(Garbage First) GC는 CMS GC를 대체하기 위해 개발되었고, Java7부터 지원되기 시작했다.
G1 GC는 Heap 영역을 위에서 살펴본 방식과 조금 다르게 사용한다. G1 GC는 Eden 영역에 할당하고, Survival로 카피하는 등의 과정을 사용하지만 물리적으로 메모리 공간을 나누지 않는다. 대신 Region(지역)이라는 개념을 새로 도입하여 Heap을 균등하게 여러 개의 지역으로 나누고, 각 지역을 역할과 함께 논리적으로 구분(Eden 지역인지, Survival 지역인지, Old 지역인지) 객체를 할당한다.
G1 GC는 Heap을 동일한 크기의 Region으로 나누고, 가비지가 많은 Region에 대해 우선적으로 GC를 수행하는 것이 핵심이다. Java9 이상부터는 G1 GC를 기본 GC 실행방식으로 사용한다.
출처
[Java] Garbage Collection(가비지 컬렉션)의 개념 및 동작원리 (1/2)
[Java] 다양한 종류의 Garbage Collection(가바지 컬렉션) 알고리즘 (2/2)
'Java > Java' 카테고리의 다른 글
스레드 동기화를 위한 volatile, Atomic (0) | 2023.07.22 |
---|---|
Thread 클래스(with synchronized) (0) | 2023.07.22 |
Java의 소수점 계산 오류 및 해결 (0) | 2023.07.16 |
ArrayList와 LinkedList 차이 (0) | 2023.07.15 |
Java의 hashCode, equals와 hashCode 같이 써야하는 이유 (0) | 2023.07.10 |