GC(Garbage Collection)
이전에 자바의 JVM에 대해서 알아봤을 때 객체를 생성하게 되면 Heap 영역에 올라가게 된다고 알아봤다.
Heap 영역에 올라온 객체를 참조하는 변수나 필드가 없다면 의미 없는 개체가 되므로 JVM이 GC를 통해 해당 객체를 Heap 영역에서 제거시킨다고 했는데 여기서 GC가 뭔지 알아보자.
먼저 GC는 필요 없어진 메모리 객체(Garbage)를 모아(Collection) 주기적으로 제거하는 프로세스이다.
예전 학부생 시절에 공부했던 C언어는 메모리를 개발자가 직접 할당해 사용하고, 필요 없어진 데이터는 메모리를 해제하는 과정을 거치게 되는데 이러한 메모리를 해제하는 과정이 자바에서는 GC를 통해서 자동으로 해준다.
자바 언어 이외에도 여러 프로그래밍 언어에서 GC를 기본으로 내장되어 있다.
GC가 필요한 이유
앞서 얘기했던 C언어를 생각해 보면 객체에 메모리를 직접 할당해 주거나, 해제해 주는 작업은 꽤 번거로운 작업이다. 또한, 의도치 않게 사용하지 않은 객체의 메모리를 해제하지 않으면 메모리 자원을 지속적으로 점유하고 있기 때문에 그만큼 다른 코드를 실행하기 위한 자원이 부족해진다.
이렇게 꼭 필요하지만 번거로운 작업을 GC가 자동으로 처리해줌으로써 제한적인 메모리 자원을 보다 효율적으로 사용할 수 있게 된다.
하지만 이런 GC에도 단점이 있는데 생각해보면 내가 만든 객체를 GC가 언제 해제시키는지 정확하게 알 수 없어 제어하기 힘들다.
그리고 GC가 동작하는 동안에는 다른 동작을 멈추기 때문에 오버헤드가 발생한다.
- STW(Stop The World)
STW는 GC를 수행하기 위해 JVM이 프로그램 실행을 멈추는 현상을 의미한다.
GC가 동작하는 동안 GC 관련 스레드를 제외한 모든 애플리케이션 스레드는 멈추게 되어 서비스 이용에 차질이 생길 수 있다. 따라서 이러한 STW를 최소화 시키는 것이 쟁점이다.
또한, GC가 동작하게 되면서 오버헤드가 발생하기 때문에 GC를 너무 자주 실행되는 것은 소프트웨어 성능 하락의 문제가 되기도 한다.
애플리케이션의 사용성을 유지하면서 효율적이게 GC를 실행하는 최적화 작업이 필요한데 이러한 작업을 GC 튜닝이라고 한다.
GC가 메모리를 해제하는 대상
GC는 어떤 객체를 의미 없는 객체라 판단하여 메모리를 해제하는 것일까
GC는 특정 객체가 의미없는 객체 즉, garbage인지 아닌지 판단하기 위해서 도달능력(Reachability)이라는 개념을 적용한다.
객체를 다른 곳에서 참조되고 있으면 Reachable(객체가 참조되고 있는 상태), 객체를 참조하지 않으면 Unreachable(객체가 참조되지 않는 상태)로 구분하여 처리한다.
JVM 메모리에서는 객체들은 실질적으로 Heap 영역에서 생성되고 Method Area나 Stack Area에서 생성된 객체의 주소만 참조하는 형식으로 구성된다.
이렇게 생성된 Heap Area의 객체들이 메서드가 끝나는 등의 특정 이벤트들로 인하여 객체의 메모리 주소를 가지고 있는 참조 변수가 삭제되는 현상이 발생하게 되고 참조하고 있지 않은 객체(Unreachable)들이 발생하게 된다.
이러한 Unreachable 객체들은 Heap 메모리 공간에 남아 있으면서 자원을 차지하기 때문에 주기적으로 GC가 제거해 준다.
GC가 메모리를 해제하는 방법
앞서 GC는 Unreachable 객체들을 힙 영역에서 제거(메모리 해제)를 하는 것을 알아보았다.
그러면 어떤 방법을 통해서 GC가 객체들을 제거하는지 알아보자.
- Mark And Sweep
Mark And Sweep이란 다양한 GC에서 사용되는 객체를 솎아내는 내부 알고리즘이다.
GC가 garbage 객체를 식별(Mark)하고 제거(Sweep)하며 파편화된 메모리 영역을 앞에서부터 채워나가는 작업(Compaction)을 수행하게 된다.
이러한 Mark And Sweep 방식은 Root Space로부터 객체에 접근이 가능한지 확인하여 참조하지 않는 객체를 제거해 간다.
여기서 Root Space는 Heap 메모리 영역을 참조하는 Method Area, static 변수, native method stack이 되게 된다.
- Mark 과정
GC가 Heap 메모리 공간에 있는 객체들을 찾아 각각 어떤 객체를 참조하고 있는지 확인하여 Reachable 객체인지 혹은 Unreachable 객체인지 Marking 작업을 한다.
- Sweep 과정
참조하고 있지 않은 객체(Unreachable)들을 Heap 영역에서 제거한다.
이때 제거된 객체가 있던 빈 공간을 가리키는 포인터를 남겨두게 되는데 해당 포인터는 메모리 여유 공간으로 판단하여 새로운 객체를 할당할 수 있다.
- Compact 과정
Sweep 과정을 거친 후 분산된 객체들을 Heap의 시작 주소로 모아 메모리가 할당된 부분과 그렇지 않은 부분으로 압축한다.
여기까지 살펴본 Mark And Sweep 과정을 통해서 Root Space로부터 연결이 끊긴 순환 참조되는 객체들을 모두 지울 수 있다.
GC 동작 과정
JVM의 힙 영역은 동적으로 객체가 저장되는 공간으로 GC의 대상이 되는 공간이다. 만약 JVM의 힙 영역에 있는 모든 객체들을 앞서 살펴봤던 Mark And Sweep 과정을 거치게 된다면 객체가 많이 할당될수록 GC 시간이 더 오래 걸리게 된다.
객체가 저장되는 힙 영역은 처음 설계할 때 2가지를 전제로 설계되었다.
- 대부분의 객체는 금방 Unreachable 상태가 된다.
- 오래된 객체에서 새로운 객체로의 참조는 아주 적게 존재한다.
설계 내용만 봐도 객체는 대부분 일회성이고 메모리에 오랫동안 남아있는 경우가 드물다고 판단한다.
이러한 특성을 이용해 보다 효율적인 메모리 관리를 위해 객체의 생존 기간에 따라 물리적인 힙 영역을 나누게 되었고, Young과 Old 총 2가지 영역으로 설계하게 되었다.
- Young 영역
새롭게 생성된 객체가 할당되는 영역으로 대부분의 객체가 금방 Unreachable 상태가 되기 때문에 많은 객체가 Young 영역에 생성되었다가 사라진다.
Young 영역에 대한 GC를 Minor GC라고 부른다.
Young 영역은 다시 3가지 영역으로 나뉜다.
- Eden
new를 통해 생성된 객체가 위치하는 영역이다.
해당 공간에서 정기적인 GC 작업 후 살아남은 객체들은 Survivor 영역으로 보낸다.
- S0(Survivor 0) / S1(Survivor 1)
최소 1번의 GC 작업을 거치고 살아남은 객체가 존재하는 영역이다.
Survivor 영역에는 특별한 규칙이 있는데 Survivor 0 또는 Survivor 1 둘 중 하나에는 꼭 비어 있어야 하는 것이다.
- Old 영역
Young 영역에서 Reachable 상태를 유지하여 살아남은 객체가 복사되는 영역으로 Young 영역보다 크게 할당되며 영역의 크기가 큰 만큼 가비지는 적게 발생한다.
Old 영역에 대한 GC를 Major GC 또는 Full GC라고 부른다.
위의 그림과 같이 Young 영역이 Old 영역보다 적은 이유는 Young 영역의 수명이 짧은 객체들은 큰 공간을 필요로 하지 않기 때문이다. 큰 객체들은 Young 영역이 아니라 바로 Old 영역에 할당된다.
오라클에서 GC에 대한 공식 문서를 살펴보면 Young 영역과 Old 영역을 구분해서 처리하는 이유를 알 수 있다.
위의 사진에서 Y 축은 할당된 바이트 수를 보여주고, X 축은 시간 경과에 따라 할당된 바이트 수를 보여준다.
결과를 보고 알 수 있는 것은 시간이 지남에 따라 할당되는 바이트 수가 줄어든다는 것을 알 수 있다. 즉, 대부분의 객체는 초반에 생성되어 금방 삭제되는 짧은 수명을 가지고 있고, 오랜 기간 참조하는 객체는 거의 없다는 것을 알 수 있다.
Minor GC 과정
모든 객체는 처음에 Young 영역에 생성된다. Young 영역 Old 영역에 비해 상대적으로 메모리 크기가 작기 때문에 메모리 상의 객체를 찾아 제거하는데 적은 시간이 걸린다.
Young 영역에서 수행되는 Minor GC 과정을 살펴보자.
1. 처음 생성된 객체는 Eden 영역에 위치하게 된다.
2. Eden 영역이 꽉 차게 되면 Minor GC가 실행된다.
3. Mark 동작을 통해 Reachable 상태의 객체를 탐색한다.
4. Eden 영역에서 살아남은 객체는 비어있는 Survivor 영역으로 이동한다.
5. Eden 영역에서 Unreachable 상태의 객체는 메모리를 해제한다.
6. 살아남은 모든 객체들은 age 값을 1씩 증가시킨다.
여기서 age란 객체가 살아남은 횟수를 의미하는 값이며 Object Header에 기록된다.
age 값이 임계값에 다다르면 Old 영역으로 이동(Promotion) 여부를 결정한다.
HotSpot JVM의 경우 이 age의 기본 임계값으로 31이 설정되는데 그 이유는 객체 헤더에 age를 기록하는 부분이 6비트(-32 ~ 31)로 되어 있기 때문이다.
만약 두 Survivor 영역에 모두 데이터가 존재하거나, 모두 사용량이 0이라면 현재 시스템이 정상적인 상황이 아니라는 반증이 된다.
7. 다시 Eden 영역에 신규 객체들로 가득 차면 다시 Minor GC가 발생하고 Mark 과정을 거친다.
8. Marking 한 객체들을 비어있는 Survivor 1로 이동하고 Unreachable 상태의 객체들을 Sweep 한다.
9. 살아남은 모든 객체들은 age가 1씩 증가한다.
10. Eden 영역에서 Minor GC가 완료되면 Survivor 0 영역으로 가서 Minor GC 과정을 다시 거치게 된다.
따라서 S0 영역에 있는 객체들에 대해 Mark And Sweep을 진행한다.
11. 최종적으로 S0에서 살아남은 객체를 S1 영역으로 이동시키면서 age 값을 1 증가시킨다.
12. 위에서부터 살펴본 Minor GC 과정을 반복한다.
Major GC 과정
앞서 살펴봤던 Minor GC를 거치며 age의 임계값(HotSpot JVM기준 31)에 도달한 객체들은 Old 영역으로 넘어가게 된다. 이러한 과정을 Promotion(승격)이라고 한다.
Promotion 된 객체들이 많아져 Old 영역의 메모리가 부족해지면 Major GC가 실행된다.
Old 영역에 있는 모든 객체들을 검사하여 참조되지 않는 객체들을 Mark And Sweep을 통해서 삭제한다.
하지만 Old 영역은 메모리 공간이 크기 때문에 객체를 찾거나 삭제하는 작업 속도가 느리다. 이로 인해서 Major GC는 일반적으로 Minor GC보다 오래 걸리고, 10배 이상의 시간을 사용한다.
바로 여기서 STW(Stop-The-World) 문제가 발생한다.
Major GC가 일어나면 스레드가 멈추고 Mark And Sweep 작업을 해야 해서 CPU에 부하를 주기 때문이다.
이러한 문제를 해결하기 위해 여러 GC 알고리즘이 존재한다.
GC 알고리즘 종류
JVM이 GC를 통해서 메모리를 자동으로 관리해 주는 것은 개발하는 입장에서 상당히 편하다. 하지만 GC를 수행하기 위해서는 필연적으로 STW 문제가 발생하게 된다.
또한, 자바가 발전함에 따라 힙 사이즈가 커지면서 애플리케이션의 지연 현상이 두드러지게 되었고, 이를 최적화하기 위해 다양한 GC 알고리즘이 개발되었다.
GC 알고리즘은 모두 설정을 통해 자바에 적용할 수 있다. 따라서 현재 상황에 따라 필요한 GC 방식을 설정해서 사용한다.
- Serial GC
서버의 CPU 코어가 1개일 때 사용하기 위해 개발된 가장 단순한 GC
GC를 처리하는 스레드가 1개(싱글 스레드)이어서 가장 STW 시간이 길다.
Minor GC에는 Mark-Sweep을 사용하고, Major GC에는 Mark-Sweep-Compact를 사용한다.
보통 실무에서는 CPU 코어가 1개인 경우는 없기 때문에 사용하지 않는다.
java -XX:+UseSerialGC -jar Application.java
자바 프로그램을 실행할 때 위와 같은 명령어를 통해서 GC 옵션을 지정하여 사용할 수 있다.
- Parallel GC
자바 8 버전의 디폴트 GC로 Serial GC와 기본적인 알고리즘은 같지만 Young 영역의 Minor GC를 멀티 스레드로 수행한다.
한 가지 주의할 점으로는 Young 영역에서만 멀티 스레드로 동작하고, Old 영역의 Major GC는 단일 스레드로 동작한다.
멀티 스레드로 실행하기 때문에 Serial GC에 비해 STW 시간이 감소하는 이점이 있다.
아래와 같은 명령어를 통해서 GC를 설정할 수 있다.
java -XX:+UseParallelGC -jar Application.java
# -XX:ParallelGCThreads=N 해당 옵션을 통해서 사용할 스레드의 갯수를 지정할 수 있다.
만약 CPU 코어가 1개인 상태에서 ParallelGC를 설정한 경우 적용되지 않고, 기본 GC인 Serial GC를 사용하게 된다.
- Parallel Old GC(Parallel Compacting Collector)
Parallel GC를 개선한 버전으로 Young 영역뿐만 아니라 Old 영역에서도 멀티 스레드로 GC 수행한다.
Mark-Summary-Compact 방식을 사용하여 Old 영역에서도 멀티 스레드로 GC를 수행하고, 해당 Old 영역에 Sweep 후 남은 객체들 사이의 공간을 압축하여 줄인다.
아래와 같은 명령어를 통해 Parallel Old GC를 설정할 수 있다.
java -XX:+UseParallelOldGC -jar Application.java
# -XX:ParallelOldGCThreads=N 해당 옵션을 통해서 사용할 스레드의 갯수를 지정할 수 있다.
- CMS GC(Concurrent Mark Sweep)
CMS GC는 애플리케이션의 스레드와 GC 스레드가 동시에 실행되어 STW 시간을 최대한 줄이기 위해 고안되었다.
GC 대상을 파악하는 과정이 복잡한 여러 단계로 수행되기 때문에 다른 GC 대비 CPU 사용량이 높고, 메모리 파편화 문제를 가지고 있어 자바 9 버전부터 deprecated, 14 버전에 와서는 사용이 중지되었다.
java -XX:+UseConcMarkSweepGC -jar Application.java
- G1 GC(Garbage First)
CMS GC를 대체하기 위해 jdk 7 버전에서 최초로 출시된 GC로 자바 9 버전 이상부터 디폴트 GC로 지정되었다.
G1 GC는 기존의 힙 영역의 개념을 뒤엎고 Region이라는 논리적인 개념을 새로 도입한 GC이다.
전체 힙 영역을 Region이라는 영역으로 체스같이 분할하여 상황에 따라 Eden, Survivor, Old 등 역할을 고정이 아닌 동적으로 부여한다.
위 그림을 보면 Eden, Survivor, Old 외에 다른 Region이 있는 것을 알 수 있다.
- Humongous Resion: Resion의 크기가 50% 초과하는 객체를 저장하기 위한 공간이다. 해당 Resion은 Full GC에 영향을 받지 않아서 메모리 단편화가 발생할 수 있다.
- Available/Unused Resion: 아직 사용되지 않은 Resion을 의미한다.
기존의 GC와 차이점은 일일이 메모리를 탐색해 객체들을 제거하지 않는다. 대신 메모리가 많이 차있는 영역을 인식하는 기능을 통해 메모리가 많이 차있는 영역을 우선적으로 GC 한다.
또 다른 차이점으로는 살아남은 객체들을 Survivor 영역에 순차적으로 이동시키지 않는다. 대신 G1 GC는 더욱 효율적이라고 생각하는 위치로 객체를 재할당 시킨다.
예를 들어 Survivor 1 영역에 있는 객체가 Eden 영역으로 할당하는 것이 더 효율적이라고 판단될 경우 Eden 영역으로 이동시킨다.
이러한 G1 GC는 Garbage로 가득 찬 영역을 빠르게 회수하여 빈 공간을 확보하므로 결국 GC 빈도가 줄어드는 효과를 얻게 된다.
힙 메모리가 너무 작을 경우 사용을 권장하지 않고 4GB 이상의 힙 메모리, STW 시간이 0.5초 정도 필요한 상황에 사용한다.
아래의 명령어를 통해 GC를 설정한다.
java -XX:+UseG1GC -jar Application.java
- Shenandoah GC
Shenandoah GC는 자바 12 버전에 출시된 GC로 레드 햇에서 개발하였다.
강력한 Concurrency와 가벼운 GC 로직으로 힙 사이즈에 영향을 받지 않고 일정한 pause 시간 소요가 특징이다. 이를 통해기존 CMS가 가진 단편화, G1이 가진 pause의 이슈를 해결한 GC다.
아래의 명령어를 통해 GC 설정이 가능하다.
java -XX:+UseShenanodoahGC -jar Application.java
- ZGC(Z Garbage Collector)
ZGC는 자바 11 버전부터 도입된 GC로 대량의 메모리를 low-latency로 잘 처리하기 위해 디자인되었다.
G1의 Region처럼 ZPage라는 영역을 사용하며, G1의 Region은 크기가 고정인데 비해, ZPage는 2mb 배수로 동적으로 운영되는 것이 특징이다.
ZGC가 내세우는 최대 장점 중 하나는 힙 크기가 증가하더라도 STW의 시간이 절대 10ms을 넘지 않는다는 것이다.
아래의 명령어를 통해 GC 설정이 가능하다.
java -XX:+UnlockExperimentalVMOptions -XX:+UseZGC -jar Application.java
참고 자료
'자바' 카테고리의 다른 글
[Java] - Call By Value와 Call By Reference (2) | 2024.09.11 |
---|---|
[Java] - Mutable vs Immutable (0) | 2024.09.09 |
[Java] - JVM(Java Virtual Machine) (0) | 2023.12.21 |
[Java] - 스레드 (1) | 2023.11.29 |
[Java] - 입출력 스트림 (0) | 2023.11.29 |