CS/Java (CS)

[Java] JVM과 Garbage Collection 동작 원리

ummchicken 2023. 1. 29. 16:44

이번 포스팅은 JVM 동작 원리와 GC 동작원리에 관한 내용이다.

 

 

JVM과 GC 동작원리 역시 근본 중의 근본인 내용이지만, 이제야 정리한다.

 

 

먼저 들어가기 전...

✔️ JVM, JRE, JDK 정리

 

  • JVM (Java Virtual Machine) 
    • 자바 프로그램이 어느 기기, 어느 운영체제에서도 실행될 수 있게 만들어준다.
    • 자바 프로그램의 메모리를 효율적으로 관리 & 최적화 해준다.

 

  • JRE (Java Runtime Environment)
    • JVM이 원활하게 잘 작동될 수 있도록 환경을 맞춰주는 역할을 한다. (JRE에 클래스 로더도 포함됨)

 

  • JDK (Java Development Kit)
    • JDK 에는 JRE에 없는 자바 컴파일러를 포함하고 있다.
    • 자바로 개발을 하고 싶다면, 설치해야 한다.

 

 

 


 

✔️ JVM (자바 가상 머신, Java Virtual Machine)

자바 프로그램이 어느 기기나 운영체제 상에서도 실행될 수 있도록 하는 것
  • 프로그램이 메모리를 관리하고 최적화 하는 것
  • JVM은 코드를 실행하고, 해당 코드에 대해 런타임 환경을 제공하는 프로그램에 대한 사양이다.
  • Java와 OS 사이에서 중개자 역할을 수행하여, OS에 구애받지 않고 재사용 가능하게 해준다.

 

 

✔️ JVM에서 메모리 관리

JVM 실행에서 가장 일반적인 상호작용은 힙과 스택 메모리 사용을 확인하는 것이다.

 

 

✔️ JVM 동작 원리 (실행 과정)

1. 프로그램이 실행되면, JVM은 OS로부터 이 프로그램이 필요로 하는 메모리를 할당받는다.
JVM은 이 메모리를 용도에 따라 여러 영역으로 나누어 관리한다.

2. 자바 컴파일러(JAVAC)가 자바 소스코드를 읽고, 자바 바이트코드(.class)로 변환시킨다.
(※ 자바 컴파일러 : 자바 소스코드(.java)를 바이트 코드(.class)로 변환시켜 줌)

3. 변경된 class 파일들을 클래스 로더를 통해 JVM 메모리 영역으로 로딩한다.
(※ 클래스 로더 : JVM은 런타임시 처음으로 클래스를 참조할 때,
해당 클래스를 로드하고 메모리 영역에 배치시킨다.
이 동적 로드를 담당하는 부분이다.)
(※ JVM 메모리 : Runtime Data Areas)

4. 로딩된 class 파일들은 Execution engine을 통해 해석된다.

5. 해석된 바이트 코드는 메모리 영역에 배치되어 실질적인 수행을 한다.
이런 실행과정 속 JVM은 필요에 따라 스레드 동기화나 가비지 컬렉션 같은 메모리 작업을 수행한다.
※ Runtime Data Areas
- JVM이 운영체제 위에서 실행되면서 할당받는 메모리 영역이다.

- 총 5가지 영역으로 나누어짐 :
메서드 영역, 힙, PC 레지스터, JVM 스택, 네이티브 메서드 스택
- 메서드 영역 : JVM이 시작될 때 생성되고, JVM이 읽은 각각의 클래스와 인터페이스에 대한 런타임 상수 풀, 필드 및 메서도 코드, 정적 변수, 메서드의 바이트 코드 등을 보관

- : 런타임에 동적으로 할당되는 데이터가 저장되는 영역. 객체나 배열 생성이 여기에 해당된다.
(힙에 할당된 데이터들은 가비지 컬렉터의 대상이 된다. JVM 성능 이슈에서 가장 많이 언급되는 공간이다.)

- PC 레지스터 : 스레드가 어떤 명령어로 실행되어야 할지 기록하는 부분.
(JVM의 명령의 주소를 가짐)

- JVM 스택 : 지역변수, 매개변수, 메서드 정보, 임시 데이터 등을 저장

- 네이티브 메서드 스택 : 실제 실행할 수 있는 기계어로 작성된 프로그램을 실행시키는 영역
(자바 외의 언어인 C나 C++ 같은 것들을 수행하기 위한 영역)

 

 

 


 

✔️ Garbage Collection 들어가기 전...

기본적으로 JVM의 메모리는 총 5가지 영역(class, stack, heap, method, PC)으로 나뉘는데, 
GC는 힙메모리만 다룬다.

 

 

 

JVM의 Heap

jdk 1.7 이전 Heap

✔️ 힙 영역에는 크게 3가지가 있다.
- Young Generation
- Old Area
- Permanent Area

✔️ Young 영역은 다시 
- Eden : 새로 생성한 대부분의 객체가 위치하는 곳
- Sevivor 1 : Eden 영역에서 GC가 한번 발생한 후 살아남은 객체들이 존재하는 곳
- Sevivor 2 : Sevivor 1과 같음
로 나뉜다.

✔️ Young 영역은 프로그램 내부에 새롭게 생긴 데이터가 저장되는 부분이고, 
이 데이터가 계속해서 사용되면 Old 영역으로 가게된다.
즉, 데이터의 목적과 수명에 따라 저장영역이 다르다.

✔️ Old 영역은 Young Generation에 대한 GC가 반복되는 과정 속에 살아남은 객체가 살아남는 곳이다.
특정 회수 이상 참조되어 Old 영역으로 가기 위한 Age를 달성하였을 때 이동하게 된다.

✔️ Permanent 영역은 클래스의 정보를 저장하는 영역이다.
(Class / Method의 Meta 정보, static 변수 / 상수들이 저장되는 곳)

더 자세한 설명 : https://jithub.tistory.com/296

 

 

 

JVM 1.8 이후

jdk 1.7 이전 버전의 Perm 영역 → Metaspace로 바뀜

 

어떠한 부분들이 바뀌었을까?

구분 Perm MetaSpace
저장 정보 클래스 meta / 메소드 meta / static 변수, 상수 클래스 meta / 메소드 meta
관리 포인트 Heap 영역 튜닝 + Perm 영역 별도 Native 영역 동적 조정
GC Full GC Full GC
메모리 측면 -XX: PermSize / -XX: MaxPermSize -XX: MetaSpaceSize / -XX: MaxMetaspaceSize
💡 가장 중요한 핵심은 Perm영역이 Heap이 아니라 Native 영역으로 바뀌었다는 것이다.

※ Native 특징
- Native 영역은 JVM에 의해서 크기가 강제되지 않고, 
프로세스가 이용할 수 있는 메모리 자원을 최대로 활용할 수 있다.

 

 

 

GC에 대한 이해는 훌륭한 Java 개발자가 되기 위한 필수 조건이라고 한다.

 

 

아무튼 그래서 GC는 어떻게 동작하는 걸까?

 

 


 

✔️ Garbage Collection 과정

 

먼저 들어가기 전, 알아야 할 용어가 있다.

 

Stop-the-world란?

GC를 실행하기 위해 JVM이 애플리케이션 실행을 멈추는 것이다.
stop-the-world가 발생하면 GC를 제외한 나머지 쓰레드는 모두 작업을 멈춘다.

GC 작업을 완료한 이후에야 중단했던 작업을 다시 시작한다.

어떤 알고리즘을 사용하더라도 stop-the-world는 발생한다.

→ 대개 우리가 알고있는 GC 튜닝이란, 이 stop-the-world 시간을 줄이는 것이다.

 

 

 

🚨 Java는 프로그램 코드에서 메모리를 명시적으로 지정하여 해제하지 않는다.

System.gc() 메서드를 호출하는 것은 시스템의 성능에 매우 큰 영향을 끼친다.

그러므로 System.gc() 메서드는 절대로 사용하면 안 된다.

 

 

 

Java에서는 개발자가 프로그램 코드로 메모리를 명시적으로 해제하지 않기 때문에 

가비지 컬렉터(Garbage Collector)가 더 이상 필요없는 쓰레기 객체를 찾아 지우는 작업을 한다.

 

이 가비지 컬렉터는 두 가지 가설 하에 만들어졌다.

  1. 대부분의 객체는 금방 접근 불가능 상태(unreachable)가 된다.
  2. 오래된 객체에서 젊은 객체로의 참조는 아주 적게 존재한다.

→ 이러한 가설을  'weak generational hypothesis'라 한다.

이 가설의 장점을 최대한 살리기 위해 크게 2개로 물리적 공간을 나누었다.

둘로 나눈 공간이 Young 영역과 Old 영역이다.

 

Heap 영역은 크게 2가지로 구성되어 있다.

  • Young 영역(Young Generation 영역) : 새롭게 생성한 객체의 대부분이 여기에 위치한다. 대부분의 객체가 금방 접근 불가능상태가 되기 때문에, 매우 많은 객체가 Young 영역에 생성되었다가 사라진다. 이 영역에서 객체가 사라질 때 Minor GC 상태가 발생한다고 말한다.
  • Old 영역(Old Generation 영역) : 접근 불가능 상태로 되지 않아 Young 영역에서 살아남은 객체가 여기로 복사된다. 대부분 Young 영역보다 크게 할당하며, 크기가 큰 만큼 Young 영역에서보다 GC는 적게 발생한다. 이 영역에서 객체가 사라질 때 Major GC(혹은 Full GC)가 발생한다고 한다.

 

 

영역별 데이터 흐름을 그림으로 살펴보자.

GC 영역 및 데이터 흐름도

위 그림의 Permanent Generation(이하 Perm 영역)은 Method Area라고도 한다.

이 영역에서 GC가 발생할 수도 있는데, 여기서 GC가 발생해도 Major GC의 횟수에 포함된다.

 

 

 

그럼 Old 영역에 있는 객체가 Young 영역의 객체를 참조하는 경우가 있을 때에는 어떻게 처리될까?

이러한 경우를 처리하기 위해서

Old 영역에는 512바이트의 덩어리(chunk)로 되어 있는 카드 테이블(card table)이 존재한다.

카트 테이블 구조

카드 테이블에는 Old 영역에 있는 객체가 Young 영역의 객체를 참조할 때마다 정보가 표시된다.

Young 영역의 GC를 실행할 때에는 Old 영역에 있는 모든 객체의 참조를 확인하지 않고,
이 카드 테이블만 뒤져서 GC 대상인지 식별한다.

 

 

 

 

✔️ Young 영역의 구성

 

GC를 이해하기 위해서 객체가 제일 먼저 생성되는 Young 영역부터 알아보자.

 

Young 영역은 3개의 영역으로 나뉜다.

  • Eden 영역
  • Survivor 영역 2개

 

 

다음은 각 영역의 처리 절차 순서이다.

 

Minor GC를 통해 Old 영역까지 데이터가 쌓인 것.

GC 전과 후의 비교

1. 새로 생성한 대부분의 객체는 Eden 영역에 위치한다.

2. Eden 영역에서 GC가 한 번 발생한 후 살아남은 객체는 Survivor 영역 중 하나로 이동된다.

3. Eden 영역에서 GC가 발생하면, 이미 살아남은 객체가 존재하는 Survivor 영역으로 객체가 계속 쌓인다.

4. 하나의 Survivor 영역이 가득 차게 되면, 그 중에서 살아남은 객체를 다른 Survivor 영역으로 이동한다.
그리고 가득찬 Survivor 영역은 아무 데이터도 없는 상태로 된다.

5. 이 과정을 반복하다가 계속해서 살아남아 있는 객체를 Old 영역으로 이동하게 된다.

→ 🚨 Survivor 영역 중 하나는 반드시 비어 있는 상태로 남아있어야 한다.

만약 두 Survivor 영역에 모두 데이터가 존재하거나, 두 영역 모두 사용량이 0이라면 정상적인 상황이 아니다.

 

 

 

💡 요약
Eden 영역에 최초로 객체가 만들어지고, 
Survivor 영역을 통해 Old 영역으로 오래 살아남은 객체가 이동한다.

 

 

 

 

✔️ Old 영역에 대한 GC

Young 영역보다 크게 할당되지만, GC는 적게 발생. (Full GC)

 

Old 영역은 기본적으로 데이터가 가득 차면 GC를 실행한다.GC 방식에 따라서 처리 절차가 달라진다. 

 

 

Full GC는 stop-the-world 시간이 길 수 밖에 없다.

기본적으로 메모리가 크고, 처리해야 될 양이 많기 때문이다.

이 Old 영역에 대한 GC를 다르게 하기 위해 많은 알고리즘들이 존재한다.

 

 

다음 GC 방식은 JDK 7을 기준으로 5가지 방식이 있다.

  1. Serial GC
  2. Parallel GC
  3. Parallel Old GC (Parallel Compactiong GC)
  4. Concurrent Mark & Sweep GC (이하 CMS)
  5. G1(Garbage First) GC

→ 🚨 이 중에서 운영 서버에서 절대 사용하면 안 되는 방식이 Serial GC이다.

Serial GC는 데스크톱의 CPU 코어가 하나만 있을 때 사용하기 위해서 만든 방식이다.

Serial GC를 사용하면 애플리케이션의 성능이 많이 떨어진다.

 

 

 

그럼 각 GC 방식에 대해 살펴보자.

 


 

Young 영역에서의 GC는 앞 절에서 설명한 방식을 사용한다.

 

✔️ Serial GC (-XX:+UseSerialGC)

Serial GC는 Mark-Sweep-Compaction을 사용한다.
  1. Mark 단계에서는 Old 영역에서 살아있는 객체를 확인한다.
  2. Sweep 단계에서는 Heap의 앞부분부터 확인하여 표시된 객체를 제거한다.
  3. Compaction 단계에서는 메모리 단편화를 방지하기 위해 Heap의 앞부분부터 객체를 채워 넣는다.

→ 이 알고리즘이 Mark-Sweep-Compaction이다.

 

Serial GC는 적은 메모리와 CPU 코어 개수가 적을 때 적합한 방식이다. (싱글 쓰레드)

 

 

 

✔️Parallel GC (-XX:+UseParallelGC)

Serial GC의 멀티쓰레드 버전.

Java 8 버전에서 default로 사용되는 GC이다.
  • Serial GC보다 빠르게 객체를 처리할 수 있음
  • 메모리가 충분하고 코어의 개수가 많을 때 유리하다
  • Throughput GC 라고도 부름

 

 

다음 그림은 Serial GC와 Parallel GC의 스레드를 비교한 것이다.

Serial GC와 Parallel GC의 차이

 

 

 

✔️ Parallel Old GC(-XX:+UseParallelOldGC)

Parallel GC와 비교하여 Old 영역이 처리되는 방식이 다르다.

Parallel Old GC는 Mark-Summary-Compaction 방식을 사용한다.

 

💡 Mark-Sweep-Compaction 방식은 단일 스레드가 old 영역을 검사하는 방식이라면,

Mark-Summary-Compaction 방식은 여러 스레드를 사용해서 Old 영역을 탐색한다.

 

  1. Mark 단계에서는 Old 영역을 region별로 나누고, region별로 살아있는 객체를 식별한다.
  2. Summary 단계에서는 region별 통계정보로 살아있는 객체의 밀도가 높은 부분이 어디까지인지 dense prefix를 정한다. 오랜 기간 참조된 객체는 앞으로 사용할 확률이 높다는 가정하에 dense prefix를 기준으로 compact 영역을 줄인다.
  3. compact 단계에서는 compact 영역을 destionatio과 source로 나누며, 살아있는 객체는 destination으로 이동시키고 참조되지 않은 객체는 제거한다.

 

앞서 설명한 Parallel GC와 비교하여 Old 영역의 GC 알고리즘만 다르다.

 

Summay 단계는 앞서 GC를 수행한 영역에 대해서

별도로 살아있는 객체를 식별한다는 점에서 Mark-Summary-Compaction 알고리즘의 Sweep 단계와 다르며, 

약간 더 복잡한 단계를 거친다.

 

 

 

✔️ CMS GC (-XX:+UseConcMarkSweepGC)

다른 GC와는 다르게 Compaction을 진행하지 않는다.

 

Serial GC와 CMS GC

 

  • Initail Mark : 클래스 로더에서 가장 가까운 객체 중 살아있는 객체만 찾는다. (STW 발생)
  • Concurrent Mark : 위에서 살아있다고 확인한 객체에서 참조되고 있는 객체를 확인한다. (STW 없음)
  • Remark : 위 단계에서 새로 추가되거나 참조가 끊긴 객체를 확인한다. (STW 발생)
  • Concurrent Sweep : 쓰레기를 정리한다. (STW 없음)

 

 

 

 

마지막으론 가장 많이 쓰이고 있는 G1GC에 대한 내용이다.

 

✔️ G1 GC

 

G1 GC를 이해하려면 지금까지의 Young 영역과 Old 영역에 대해서는 잊는 것이 좋다.

 

  • 현재 GC 중 STW의 시간이 제일 짧다.
  • CMS GC를 개선하여 만든 GC로 region(리전) 형식의 구조를 갖는다.

 

G1GC는 JDK 11부터 공식적인 GC 알고리즘으로 적용되었고, 하드웨어가 점점 발전하면서 대용량 메모리에 적합한 솔루션을 제공하기 위해 나타났다.

 

 

지금까지 설명한 Young의 세 가지 영역에서 데이터가 Old 영역으로 이동하는 단계가 사라진 GC 방식이라고 이해하면 된다.G1GC는 CMS GC를 대체하기 위해 만들어졌다.

 

 

🚨 위 그림들은 직전에 보여줬던 전통적인 Heap Area와 전혀 다른 모습이지만, 동일한 Heap Area이다.

G1GC는 Heap Area를 Young, Old 영역을 명확하게 구분하던 전통적인 GC들과 다르게 
물리적으로 구분하지 않는다.

개념적으로는 확실히 존재하지만, Heap Area를 일정 크기의 region으로 구분하여 논리적으로 구분하고 있다.

즉, HotSpot VM(Virtual Machine)의 개념은 그대로 가져와 사용한다.
이전에 사용하던 GC들과 마찬가지로 특수한 경우(크기가 너무 큰 경우)를 제외하고는
최초 객체가 생성이 되면 Eden에 할당하고, 
이후 Survivor로의 이동과 소멸, 그리고 Old Region으로의 이동의 생명주기를 가져간다.

 

  • 앞에 나왔던 GC들과 다르게 Eden, Survivor, Old 영역이 고정된 크기가 아니며, 전체 힙 메모리 영역을 Region이라는 특정한 크기로 나눈다.
  • Region의 상태에 따라 그 Region의 역할(Eden, Survivor, Old)가 동적으로 변동한다.
  • Region은 기본적으로  (전체 힙메모리 / 2048)로 지정된다.

 

 

 


 

✔️ ZGC (Z Garbage Collectors)란?

JDK 11부터 실험적으로 도입되었다.

조금 더 큰 메모리(8MB ~ 16TB)에서 효율적으로 Garbage Collect하기 위한 알고리즘이다.

 

ZGC는 아래의 목표를 중족하기 위해 설계된 확장 가능하고 낮은 지연율(low latency)을 가진 GC이다.

  • 각각의 STW 시간이 최대 10ms를 초과하지 않음
  • Heap의 크기가 증가하더라도, 정지 시간이 증가하지 않음
  • 8MB ~ 16TB에 이르는 다양한 범위의 Heap 처리 가능

 

 

JVM으로 구동되는 애플리케이션의 경우, GC가 동작할 때 Stop-The-World로 인해 성능에 큰 영향을 미쳐왔다.

이러한 정지 시간을 줄이거나, 없앰으로써 애플리케이션의 성능 향상에 기여한다.

 

 

 

✔️ ZGC 동작

 

ZGC는 메모리를 ZPages라고 불리는 영역으로 나눈다.ZPage는 동적 사이즈(G1 GC와 다름)로 2MB의 배수가 동적으로 생성 및 삭제될 수 있다.

 

사이즈별 Heap 영역

  • Small(2MB)
  • Medium(32MB)
  • Large(N * 2MB)

 

 

💡 중요 포인트

  • ZGC Heap은 위와 같은 다양한 사이즈의 영역이 여러 개 발생할 수 있다.
  • ZGC가 compaction된 후, ZPage는 ZPageCache라고 불리는 캐시에 삽입된다.
  • 캐시 안의 Zpage는 새로운 Heap 할당을 위해 재사용할 준비를 한다.
  • 메모리를 커밋과 커밋하지 않는 작업은 매우 비싼 작업이므로 캐시의 성능에 중요한 영향을 끼친다.

 

 

 


 

✔️ G1GC 튜닝 포인트

 

동작원리는 이제 알겠고... 도대체 뭘 바꿔야 할까?

 

우선, 성능테스트 + 로그 옵션을 켜야 한다.

 

 

💡 튜닝의 목적

GC에 걸리는 시간을 최소화하는 것.

 

 

 

 

 


출처