1. 가비지 컬렉션(Garbage Collection)이란?
가비지 컬렉션(Garbage Collection, 이하 GC)은 자바의 메모리 관리 방법 중의 하나로 JVM(자바 가상 머신)의 Heap 영역에서 동적으로 할당했던 메모리 중 필요 없게 된 메모리 객체(garbage)를 모아 주기적으로 제거하는 프로세스를 말한다.
C언어의 경우 free()메서드를 이용해 직접 메모리를 해제할 수 있지만 Java나 Kotlin은 JVM의 가비지 컬렉터가 불필요한 메모리를 알아서 정리해주기 때문에 개발자가 메모리를 직접 해제할 필요가 없다. 따라서 개발자는 메모리 관리, 메모리 누수 문제를 관리하지 않아도 되어 개발에만 집중할 수 있다는 장점이 있다.
하지만 GC에도 단점이 있는데 메모리 관리를 자동으로 해준다 해도 메모리가 언제 해제되는지 정확하게 알 수 없어 제어하기 힘들며, GC가 동작하는 동안에는 다른 동작을 멈추기 때문에(Stop-The-World: STW) 오버헤드가 발생되는 문제점이 있다.
STW (Stop The World)
GC를 수행하기 위해 JVM이 프로그램 실행을 멈추는 현상
GC가 작동하는 동안 GC 관련 Thread를 제외한 모든 Thread는 멈추게 되어 서비스 이용에 차질이 생길 수 있다.
따라서 이 시간을 최소화 시키는 것이 쟁점이다.
2. 가비지 컬렉션 대상은 뭘까?
그럼 GC는 어떤 Object를 garbage로 판단해 스스로 지우는걸까?
가비지 컬렉션은 특정 객체가 garbage인지 아닌지 판단할 때, 객체에 레퍼런스가 있다면 Reachable로 구분되고, 객체에 유효한 레퍼런스가 없다면 Unreachable로 구분한 후 수거한다.
1. Reachable : 객체가 참조되고 있는 상태
2. Unreachable : 객체가 참조되고 있지 않은 상태(GC의 대상)
JVM 메모리에서 객체들은 실질적으로 Heap영역에 생성되고 Method Area나 Stack Area에서는 Heap Area에 생성된 객체의 주소만 참조한다. 이렇게 생성된 Heap Area의 객체들이 메서드가 끝나는 등의 특정 이벤트들로 인하여 Heap Area 객체의 메모리 주소를 가지고 있는 참조 변수가 삭제되면 위 그림에서 빨간색 객체와 같이 Heap 영역 어디에서도 참조하고 있지 않은 객체(Unreachable)들이 발생하게 된다.
이러한 객체들을 주기적으로 가비지 컬렉터가 제거해주는 것이다.
3. 가비지 컬렉션 청소 방식
위에서 GC가 어떻게 Reachable과 Unreachable을 판단하는지 간단하게 살펴보았다.
그렇다면 GC는 Unreachable한 객체를 어떤 방식으로 청소하는걸까?
✅ Mark and Sweep
다양한 GC에서 사용되는 Mark-Sweep 방식은 객체를 찾아내는 내부 알고리즘이다.
GC가 동작하는 아주 기초적인 청소 과정이라고 생각하면 된다.
Mark 과정: GC Root에서 부터 시작하여 참조값을 따라가며 접근 가능한 객체들에 Mark 표시
Sweep 과정: Mark 되지 않은 객체(접근할 수 없는 객체)는 제거(Sweep) 대상이 되고, 해당 객체들을 Heap에서 제거
Compact 과정: Sweep 후 분산된 객체들을 Heap의 시작 주소로 모아 메모리가 할당된 부분과 그렇지 않은 부분으로 압축
(GC 종류에 따라 생략 가능)
GC Root
Mark and Sweep 방식은 루트로부터 해당 객체에 접근 가능 여부가 기준이 된다.
GC Root에서 시작해 이 Root가 참조하는 모든 오브젝트, 또 그 오브젝트들이 참조하는 다른 오브젝트들을 탐색해 내려가며 마크(Mark)한다.
GC Root가 될 수 있는 데이터들
- 실행 중인 스레드(Stack)의 로컬 변수 (Local Variable)
- Method Area의 정적 변수 (Static Variable)
- Native Method Stack의 JNI에 의해 생성된 객체들 (JNI Reference)
4. 가비지 컬렉션 동작 과정
JVM의 힙(Heap) 영역은 동적으로 레퍼런스 데이터가 저장되는 공간으로서, 가비지 컬렉션의 대상이 된다.
Heap 영역은 처음 설계될 때 다음 2가지 전제로 설계되었다.
1. 대부분의 객체는 금방 접근 불가능한 상태(Unreachable)가 된다.
2. 오래된 객체에서 새로운 객체로의 참조는 아주 적게 존재한다.
즉, 대부분의 객체는 일회성이라는 특성을 이용해 JVM 개발자들은 효율적인 메모리 관리를 위해 객체의 생존 기간에 따라 Young과 Old영역 두가지로 물리적인 Heap 영역을 나누었다.
위 그림에서 Old 영역이 Young 영역보다 크게 할당되는 이유는 Young 영역에 할당될 수명이 짧은 객체들은 큰 공간을 필요로 하지 않으며 큰 객체들은 Young 영역을 거치지 않고 바로 Old 영역에 할당되기 때문이다.
🧐 메모리를 여러 세대로 분류하는 이유가 무엇일까?
1980년대 초반에는 대부분의 GC 알고리즘이 단일 공간(또는 메모리 힙)에서 모든 객체를 관리하는 방식을 사용했으나 전체 메모리를 스캔하고 모든 객체를 조사하는 작업이기 때문에 객체가 많을수록 GC 작업이 오래 걸리는 문제가 있었다.
"Lisp"라는 프로그래밍 언어에서 메모리를 young / old generation으로 분류하였고 young generation에서는 더 빈번하게 GC를 수행했다. 대부분의 객체가 짧은 생존 주기를 갖고 있기 때문에, young generation에서의 GC는 빠르게 수행될 수 있었다.
이후 Java의 HotSpot VM도 메모리를 young / old generation으로 분할하고 각각에 대해 다른 GC 알고리즘을 적용한다.
🌱 Young 영역 (Young Generation)
Young 영역은 새롭게 생성된 객체가 할당(Allocation)되는 영역이다.
대부분의 객체가 금방 Unreachable 상태가 되기 때문에, 많은 객체가 Young 영역에 생성되었다가 사라진다.
Young 영역은 Eden 영역, 두 개의 Survivor 영역(S0, S1) 총 3개의 영역으로 나뉜다.
Young 영역에서 발생하는 가비지 컬렉션은 Minor GC라 부르며 Young 영역의 공간은 상대적으로 작기 때문에 메모리 상의 객체를 찾아 제거하는데 적은 시간이 걸린다.
Eden 영역
new를 통해 새로 생성된 객체가 위치한다.
정기적인 가비지 수집 후 살아남은 객체들은 Survivor 영역으로 보내진다.
Survivor 0 / Survivor 1
GC를 최소 1번 이상 살아남은 객체가 존재하는 영역이다.
Survivor0, 1 영역을 같이 쓰는 것이 아니라 효율적인 객체 복사 작업을 위해 둘 중 하나는 꼭 비워두어야 한다.
✅ Mark and Copy
- Eden 영역이 꽉 차면 Minor GC가 실행돼 Eden 영역과 Survivor 영역 객체들 중 접근 가능한 객체들에 Mark 표시
- Marking 된 객체(살아남은 객체)를 다른 Survivor 영역으로 이동하고 살아남은 객체들은 age 값이 1씩 증가
- 위 과정을 반복하며 객체가 일정 횟수 이상 살아남으면 Survivor 영역에서 Old 영역으로 승격
오라클의 HotSpot VM에서 보다 빠른 메모리 할당을 위해 GC에서 사용하는 두 가지 기술
1. Bump-the-Pointer
메모리 할당을 위해 사용되는 메모리 블록의 끝에 포인터를 유지하는 방식이다.
객체가 할당될 때마다 포인터를 이동시켜 다음 사용 가능한 메모리 주소로 이동시켜 메모리 할당을 빠르게 만든다.
따라서 새로운 객체 생성 시 마지막에 추가된 객체만 점검하면 되기 때문에 단순하고 빠른 방법이지만 메모리 해제가 순차적으로 일어나지 않고 임의의 순서로 이루어진다면 메모리 단편화와 같은 문제가 발생할 수 있다.
→ Eden 영역이 자주 확장되어야 할 경우 성능에 영향
- 객체를 메모리 상 연속된 공간에 할당
- Eden 영역에 할당된 마지막 객체 추적
- 마지막 객체는 Eden 영역의 맨 위인 top에 위치
- 그다음에 생성되는 객체가 있을 경우 해당 객체의 크기가 Eden 영역에 넣기 적당한지 확인
- 적당하면 Eden 영역에 넣음
- 충분한 공간이 없을 경우 메모리 상의 다른 위치로 Eden 영역 확장
기존의 객체들은 확장된 영역으로 이동, 이후 생성되는 객체들도 새로운 공간에 할당
Multi Thread 환경에서 Thread-safe 하기 위해서는 여러 스레드에서 사용하는 객체를 Eden 영역에 저장하려면 락(lock)이 발생할 수밖에 없고 이는 성능을 매우 떨어지게 한다.
→ 이를 해결한 기술이 TLABs
2. TLABs(Thread-Local Allocation Buffers)
스레드별로 각자 몫에 해당하는 Eden 영역의 작은 덩어리(TLAB)를 가짐
각 스레드에는 자기가 가진 TLAB에만 접근할 수 있으므로 bump-the-pointer 기술을 사용하더라도 Lock 없이 메모리 할당이 가능해짐
bump-the-pointer 방식은 일반적으로 작은 크기의 객체에 대해 사용되며, TLABs 방식은 이러한 작은 객체들을 효율적으로 할당하기 위해 Eden 영역 내에서 사용된다. 따라서 Eden 영역에서는 주로 TLABs 방식이 선호되어 사용된다.
Survivor 영역
- Copying Collector (Copying Garbage Collector)
- 두 개의 Survivor 영역(S0, S1)으로 번갈아가며 할당
- GC 시 S0 또는 S1 중 한 개의 영역은 항상 비어있는 상태이며 살아남은 객체들은 비어있는 영역으로 이동
- 이후 다음 GC 시 이동된 객체들이 다른 비어있는 영역으로 이동
🌱 Old 영역 (Old Generation)
Old 영역은 Young 영역에서 Reachable 상태를 유지하며 age 임계값이 차게되어 살아남은 객체가 복사되는 영역이다.
Young 영역보다 크게 할당되며, 영역의 크기가 큰 만큼 가비지는 적게 발생한다.
객체들이 Young 영역에서 계속 Promotion 되어 Old 영역의 메모리가 부족해지면 Major GC 혹은 Full GC가 발생한다.
Full GC는 Old 영역의 모든 객체를 조사하고, 더 이상 사용되지 않는 객체들을 식별하여 메모리에서 해제한다.
Full GC는 Old generation을 모두 포함하는 전체 힙에 대한 가비지 컬렉션으로서, 상대적으로 더 시간이 오래 걸릴 수 있지만 Old 영역에 있는 객체들은 일반적으로 더 오래 살아남는 객체들이기 때문에 가비지 컬렉터가 드물게 발생한다.
Major GC가 일어나면 Thread가 멈추고 Mark and Sweep 작업을 해 CPU에 부하를 주기 때문에 본문 처음에 언급했던 Stop-The-World 문제가 발생한다. 따라서 자바 개발자들은 끊임없이 가비지 컬렉션 알고리즘을 발전시켜왔다.
5. 가비지 컬렉션 알고리즘 종류
1️⃣ Serial GC
- 가장 단순한 방식의 GC로 싱글 스레드(스레드 1개)로 동작
- 싱글 스레드로 동작하여 느리고, 그만큼 Stop The World 시간이 다른 GC에 비해 길다.
- Mark & Sweep & Compact 알고리즘을 사용
- Old 영역에 살아있는 객체를 식별(Mark)
- Old 영역을 스캔하여 살아있는 것만 남김(Sweep)
- 각 객체들이 연속되게 쌓이도록 힙의 가장 앞부분부터 채움(Compact)
- 보통 실무에서 사용하는 경우는 없음 (디바이스 성능이 안 좋아서 CPU 코어가 1개인 경우에만 사용)
🧐 굳이 프로그램을 멈추고 GC를 실행하는 이유가 뭘까?
"Stop the world"를 수행하는 이유는 GC 작업 중에 실행 중인 코드와 메모리 상태를 조사해야 하고, 객체 식별 및 참조 업데이트를 수행해야 하는데 이를 안전하게 수행하기 위해 프로그램 실행을 일시적으로 멈추는 것이다.
- 일관성
- GC 작업 중에 객체 식별 및 참조 업데이트가 발생해 멀티스레드 환경에서 동기화가 필요할 수 있어 GC 작업이 실행 중인 코드에 영향을 미치지 않도록 보장
- 안전성
- GC 작업에서 실행 중인 코드와 메모리 상태를 변경하면 예기치 않은 동작이 발생할 수 있기 때문에 GC 작업 중 프로그램 실행을 일시적으로 멈추어 안전하게 메모리 조사, 정리 가능
2️⃣ Parallel GC
- Java 8의 default GC
- Serial GC와 기본적인 알고리즘은 같지만, Young 영역의 Minor GC를 멀티 스레드로 수행(Old 영역은 싱글 스레드)
- Young 영역의 GC를 멀티 스레드 방식을 사용하기 때문에, Serial GC에 비해 상대적으로 Stop The World가 짧음
3️⃣ Parallel Old GC (Parallel Compacting Collector)
- Parallel GC를 개선한 버전
- Young 영역 뿐 아니라 Old 영역도 멀티 스레드로 GC 수행
- 새로운 가비지 컬렉션 청소 방식인 Mark-Summary-Compact 방식을 이용
4️⃣ CMS(Concurrent Mark Sweep) GC
- Compact 과정이 없으며 stop-the-world 시간을 줄이기 위해 고안됨
- Reachable한 객체를 한번에 찾지 않고 나눠서 찾는 방식을 사용 (4 STEP으로 나눠짐)
- Initial Mark: GC Root가 참조하는 객체만 마킹 (STW O)
- Concurrent Mark: 참조하는 객체를 따라가며, 지속적으로 마킹. (STW X)
- Remark: concurrent mark 과정에서 변경된 사항이 없는지 다시 한번 마킹하며 확정하는 과정. (STW O)
- Concurrent Sweep: 접근할 수 없는 객체를 제거하는 과정 (STW X)
🧐 CMS GC에서는 Compact 단계가 없는 이유가 뭘까?
- 부분적으로 동작
- CMS GC는 GC 작업을 메인 애플리케이션의 실행과 동시에(Concurrent하게) 수행
- Compact 단계는 객체들을 이동시키는 작업으로, 이 작업은 애플리케이션의 실행을 중단시켜야 함
- 따라서 중단 시간을 최소화하기 위해 Compact 단계를 생략하고, 다른 단계를 병렬로 처리해 효율적인 가비지 컬렉션을 수행
- Fragmentation 방지
- Compact 단계를 수행하지 않는 대신, 가비지 객체를 해제하여 메모리 회수
- 이로 인해 메모리 단편화가 발생할 수 있지만, CMS GC는 메모리 단편화를 최소화하기 위한 여러 가지 기법과 옵션 제공
- ex) 사용 가능한 여유 공간을 유지하고 객체를 할당할 때 최선의 위치를 선택하는 등의 방법을 사용해 메모리 단편화 완화
- 메모리 단편화가 심해질 경우 Full GC를 통해서만 문제 해결 가능
5️⃣ G1(Garbage First) GC
- Java 9+ 의 default GC
- Heap을 Region이라는 일정한 부분으로 나눠 Eden, Survivor, Old 등 동적으로 역할을 부여해 메모리 관리
- 전체 Heap에 대해서 일일이 탐색하지 않고 메모리가 많이 차있는 Region을 부분적으로 탐색하여, 각각의 Region에만 GC 발생
- 현재 GC 중 STW 시간이 제일 짧음
G1GC 내부를 보면 새롭게 보이는 영역들이 보인다.
Humonogous
: Region 크기의 50%를 초과하는 큰 객체를 저장하기 위한 공간
Available / Unused
: 아직 사용되지 않은 Region
- Initial Mark: Old Region에 존재하는 객체들이 참조하는 Survivor Region을 찾음.(STW)
- Root Region Scan: 위에서 찾은 Survivor 객체들에 대한 스캔 작업 실시.
- Concurrent Mark: 전체 Heap의 스캔 작업을 실시하고, GC 대상 객체가 발견되지 않은 Region은 이후 단계 제외.
- Remark: 애플리케이션을 멈추고(STW) 최종적으로 GC 대상에서 제외할 객체 식별.
- Clean up: 애플리케이션을 멈추고(STW) 살아있는 객체가 가장 적은 Region에 대한 미사용 객체 제거.
- Copy: GC 대상의 Region이었지만, Clean up 과정에서 완전히 비워지지 않은 Region의 살아남은 객체들을 새로운 Region(Available / Unused) Region에 복사하여 Compaction 수행.
🧐 CMS GC에서는 효율적인 GC를 위해 Compaction 단계를 없앴는데 G1GC에서는 왜 다시 생겼지?
CMS GC는 중단 시간을 최소화하기 위해 Compaction 단계를 제거하고 병행(mark-sweep-compact 단계에서 수행하는 병행 수집)과 같은 기술을 사용하여 동시성을 확보했다.
G1GC는 CMS GC와는 다른 목표와 가치를 가지고 있는 GC 알고리즘이다. G1GC는 대규모 힙 및 대용량 메모리 요구사항을 가진 애플리케이션의 성능을 향상시키기 위해 설계되었다. G1GC는 힙을 영역(Regions)으로 나누고 모든 영역을 동시에 수집하는 병행 수집(concurrent collection) 방식을 채택한다.
G1GC에서 Compaction 단계를 다시 도입한 이유는 메모리 단편화를 최소화하기 위해서이다. G1GC는 더 이상 연속된 메모리 공간이 필요하지 않기 때문에 세그먼트(compaction units)로 나누어진 영역 단위로 객체를 이동시키는 것이 가능하다. 이를 통해 메모리 단편화 문제를 완화하면서도 중단 시간을 효과적으로 제어할 수 있다. Compaction 단계를 사용함으로써 G1GC는 메모리 단편화를 최소화하면서도 성능과 중단 시간을 균형 있게 유지하는 장점을 가지게 된다.
6️⃣ ZCG
- JDK 15 version Production Ready
- STW시간을 줄이기 위해서 Marking 시간에만 STW
- G1의 Region 처럼, ZGC는 ZPage라는 영역을 사옹하며, Region은 크기가 고정인데 비해 ZPage는 2mb 배수로 동적 운영
- Colored pointers, Load barriers 핵심 알고리즘
- G1GC와의 차이점: ZGC는 Pointer를 이용해 객체를 Marking하고 관리
- Mark Start: ZGC의 Root에서 가리키는 객체 Mark 표시 (STW O)
- Concurrent Mark/Remap: 객체의 참조를 탐색하면서 모든 객체에 Mark 표시
- Mark End: 새롭게 들어온 객체들에 대해 Mark 표시 (STW O)
- Concurrent Pereare for Relocate: 재배치하려는 영역을 찾아 Relocation Set에 배치
- Relocate Start: 모든 Root 참조의 재배치를 진행하고 업데이트 (STW O)
- Concurrent Relocate: 이후 Load Barriers 를 사용하여 모든 객체를 재배치 및 참조 수정
- 객체를 가리키는 변수의 포인터에서 64bit 을 활용해 Marking
- Finalizable: finalizer을 통해서만 참조되는 Object의 Garbage
- Remapped: 재배치 여부를 판단하는 Mark
- Marked 1 / 0: Live Object
- 따라서 ZGC는 반드시 64bit 운영체제에서만 사용가능
- ZGC는 bit를 바탕으로 G1GC와는 다르게 메모리를 재배치하는 과정에서 위의 bit 를 바탕으로 STW 없이 재배치
- Remap Mark와 Relocation Set을 확인하면서 참조와 Mark 업데이트
https://memostack.tistory.com/229
https://huisam.tistory.com/entry/jvmgc
https://goodgid.github.io/Java-Optimizing-Advanced-Garbage-Collection-G1-GC/
https://imasoftwareengineer.tistory.com/103
'Back-end' 카테고리의 다른 글
[JPA] JPA, Hibernate, Spring Data JPA에 대한 이런저런.. 정리 (0) | 2023.08.29 |
---|---|
[CI/CD] GitHub Actions를 이용한 빌드 및 배포 자동화 (0) | 2023.08.10 |
[Spring Boot] OAuth 2.0 를 이용한 소셜 로그인 구현 (0) | 2023.05.22 |
[Spring Boot] yml 파일에 작성한 정보로 어떻게 OAuth 설정을 할까? (0) | 2023.05.22 |
[Java] HashTable VS HashMap(Linked List / Red-Black Tree) (0) | 2023.05.04 |