오늘의 나보다 성장한 내일의 나를 위해…
Garbage Collector & JVM
면접에서 GC에 관해서 질문을 받았다.
Q: GC를 사용함으로써 성능 향상이 되는 경우는 어떤 경우일까요?
사실상 GC를 사용하는 것 자체가 성능에 해라고 생각해 없다고 답변을 했었는데 검색 및 질문을 해본 결과 찾은 답변은 아래와 같다.
어떤 공간에 메모리를 할당하게 되면 할당한 공간을 제외한 부분이 남게된다. 이것을 파편화라 한다.
GC(Garbage Collector)에 대해서 이야기 하기 전에 먼저 JVM의 구조를 아는 것이 중요하다.
그래서 GC를 설명하기 전에 JVM을 먼저 살펴보자.
JVM의 역할
- 자바 애플리케이션을 Class Loader를 통해 읽어 들여 자바 API와 함께 실행
- JVM은 Java와 OS 사이에서 중개자 역할을 수행
- JAVA가 OS에 독립적으로 실행 및 재사용을 가능하게 함
- 메모리 관리, GC을 수행
- 스택기반의 가상머신
- ARM 아키텍쳐 같은 하드웨어는 레지스터 기반으로 동작하는데 비해 JVM은 스택기반으로 동작한다.
JVM의 구조
- .class 파일 = 바이트 코드
- byte 코드 → JVM과 같은 가상 머신이 이해할 수 있는 코드 - binary 코드
- CPU가 이해할 수 있는 코드
Class Loader
바이트 코드를 읽어오며 메모리에 적절히 배치하는 역할
- 로딩: .class 파일을 읽어온다.
- 링크: 코드 내부의 래퍼런스를 연결
- 초기화: 클래스에 있는 static 값들을 초기화
Runtime Data Area(Memory)
JVM이 프로그램을 수행하기 위해 OS로부터 할당받는 메모리 영역이다.
[Method Area, Heap Area]
-
Method: 클래스 수준의 정보 저장
- 클래스 멤버 변수, 클래스 메소드 정보, Type(Class or interface)정보, Constant Pool, static, final 변수 등이 생성된다.
- Class의 메타 정보를 저장.(Field 이름, Field 타입, Class 이름 등등)
-
Heap: 객체(인스턴스) 수준의 정보 저장
- GC의 주요 대상
- 흔히 코드에서 ‘new’ 명령어를 통해 생성된 인스턴스 변수가 놓인다.
- 스택영역에 저장되는 로컬 변수, 매개변수와 달리 힙영역에서 보관되는 메모리는 메소드 호출이 끝나도 사라지지 않고 유지된다.
- (언제까지?) 주소를 읽어버려 가비지가 되어 GC에 의해 지워질 때까지 아니면 JVM이 종료될 때까지
[Stack Area, PC register, Native Method Stack]
쓰레드 단위로 하나씩 생성된다.
-
Stack: JVM이 종료의 참조 주소들을 저장
- JVM 시작 시 생성되고 프로그램 종료될 때까지 유지된다.
- 로컬변수와 매개변수의 특징은 선언된 블록 안에서만 유효한 변수들이고 이것이 스택영역에 저장되는 것이다.
클래스 Person p=new Person();
이라는 소스를 작성했다면
Person p는 스택 영역에 생성되고 new로 생성된 Person 클래스의 인스턴스는 힙 영역에 생성된다.
스택영역에 생성된 p의 값으로 힙 영역의 주소값을 가지고 있다.
→ 즉, 스택 영역에 생성된 p가 힙 영역에 생성된 객체를 가리키고(참조하고) 있는 것
-
PC register: 현재 쓰레드가 실행되는 부분의 주소와 명령을 저장하고 있는 영역
- 네이티브 메소드는 java가 아닌 low-level로 구현된 메소드
- Thread 마다 하나씩 존재하고 Native Pointer와 Return Address를 가지고 있음
- 즉, PC register는 Thread가 어떠한 명령을 실행하게 될지에 대한 부분을 기록 ex. Thread.currentThread()
- 네이티브 메소드는 java가 아닌 low-level로 구현된 메소드
Execution Engine
-
인터프리터
- 바이트 코드를 한줄 한줄 읽어서 네이티브 코드로 변환
-
JIT(Just In Time) 컴파일러
- 바이트 코드에서 반복되는 코드 부분은 JIT 컴파일러가 미리 네이티브 코드로 변환시켜 놓음
- 반복되는 코드가 읽힐 순서가 왔을 때, 인터프리터로 읽지 않고 바로 네이티브 코드를 바로 사용한다.
- 인터프리터 읽을 때의 속도 효율성을 JIT 컴파일러가 보완하는 형태
-
GC(Garbage Collector)
- 더 이상 참조되지 않는 객체를 모아서 메모리 정리를 한다.
Gargabe Collector
Java에서의 GC는 기본적으로 Mark And Sweep 방식으로 돌아간다.
Mark And Sweep 방식으로 GC를 실행할 경우 2가지 특징이 있다.
- 의도적으로 GC를 실행시켜야 한다.
- 애플리케이션 실행과 GC 실행이 병행된다.
가바지는 사용되지 않는 객체를 말한다.
JVM에서 Root set이 어딜까?
Mark And Sweep 알고리즘은 Root Space로부터 동적 메모리 영역의 객체 접근이 가능한지를 해제의 기준으로 둔다.
Root Space에서부터 해당 객체에 접근이 가능하다면 메모리에 유지시키고, 접근이 불가능하다면 메모리에서 지워버리는 방식인 것이다.
그렇다면 JVM의 Root Space는 어디일까?
Heap 영역 메모리에 대한 참조를 들고 있을 수 있는 영역일 것이다.
JVM Memory 영역 중에서는 다음과 같다.
- Stack의 로컬 변수
- Method Area의 Static 변수
- Native Method Stack의 JNI 참조
아래 그림을 참고하자.
Mark and Sweep
Root가 참조하는 모든 객체, 또 그 객체들이 참조하는 객체들을 탐색해 내려가며 할당받은 각 메모리 영역에 1비트씩 남겨서 사용 중을 표시(Mark) 한다. 이게 바로 GC의 첫 번째 단계인 Mark 단계이다.
Mark가 끝나면 GC는 힙 내부 전체를 돌면서 Mark 되지 않은 메모리들을 해제한다. 이 과정을 Sweep라고 부른다.
Mark and Sweep Algorithm의 단점은 GC를 수행하는 동안 Stop the World가 발생한다는 것이다.
- Stop The World: Stop The World는 가비지 컬렉션을 실행하기 위해 JVM이 애플리케이션의 실행을 멈추는 작업이다. GC가 실행될 때는 GC를 실행하는 쓰레드를 제외한 모든 쓰레드들의 작업이 중단되고 GC가 완료되면 작업이 재개된다. 당연히 모든 쓰레드들의 작업이 중단되면 애플리케이션이 멈추기 때문에, GC의 성능 개선을 위해 튜닝을 한다고 하면 보통 stop-the-world의 시간을 줄이는 작업을 하는 것이다.
힙(Heap) 영역의 구조와 가비지 컬렉션 프로세스
힙 영역은 Old, Eden, S0, S1 총 네 개 영역으로 나눌 수 있다.
인스턴스의 메모리 할당 측면에서 성능을 높이려고 용도를 구분해둔 것이다.
- Metaspace: 클래스 메타 데이터를 native 메모리에 저장하고 메모리가 부족할 경우 이를 자동으로 늘려주는 공간을 말한다.
힙은 Young, Generation, Old Generation으로 크게 두 개의 영역으로 나누어 지고, Young Generation은 또다시 Eden, Survivor Space 0, 1로 세분화 되어진다. S0, S1 으로 표시되는 영역이 Survivor Space 0, 1이다. 각 영역의 역할은 가비지 컬렉션 프로세스를 알면 알 수 있다.
(1) 새로운 객체는 Eden 영역에 할당된다. 두 개의 Survivor Space는 비워진 상태로 시작한다.
(2) Eden 영역이 가득 차면, MinorGC(Young Generation을 대상으로 하는 GC)가 발생한다.
(3) MinorGC가 발생하면, Reachable 객체들은 S0으로 옮겨진다. Unreachable 객체들은 Eden 영역이 클리어 될 때 함께 메모리에서 사라진다.
(4) 다음 MinorGC가 발생할 때, Eden 영역에는 3번과 같은 과정이 발생한다. Unreachable 객체들은 지워지고, Reachable 객체들은 Survivor Space로 이동한다. 기존에 S0에 있었던 Reachable 객체들은 S1으로 옮겨지는데, 이때, age 값이 증가되어 옮겨진다. 살아남은 모든 객체들이 S1 으로 모두 옮겨지면, S0와 Eden 은 클리어 된다.
(5) 다음 S0와 Eden 은 클리어가 발생하면, 4번 과정이 반복되는데 v이 가득 차 있었으므로 S1에서 살아남은 객체들은 S0으로 옮겨지면서 Eden과 S1은 클리어 된다. 이때도 age값이 증가되어 옮겨진다.
(6) Young Generation에서 계속해서 살아남으며 v하는 객체들은 age 값이 특정값 이상이 되면 Old Generation(Java 8까지는 Tenured Generation라 부름)으로 옮겨지는데 이 단계를 Promotion이라고 한다.
그렇기 때문에 MinorGC가 계속해서 반복된다면 Promotion 작업도 꾸준히 발생하게 된다.
(7) Promotion 작업이 계속해서 반복되면서 Old Generation이 가득 차게 되면 MajorGC(Old Generation을 대상으로 하는 GC)가 발생하게 된다.
Garbage Collection의 종류
Serial GC
- Mark-Sweep-Compact 알고리즘 사용
- 적은 메모리와 CPU 코어 갯수가 적을 때 적합
Old 영역에 살아있는 객체를 Mark하고 Heap의 앞부분부터 살아있는 객체를 Sweep한 뒤 힙의 앞부분부터 객체를 쌓는다.(Compact) 메모리와 CPU 코어수가 적을 때 좋다.
- mark 단계에서는 old 영역에서 살아있는 객체를 확인한다.
- sweep 단계에서는 heap 영역의 앞부분부터 확인하여 표시된 객체를 제거한다.
- compact 단계에서는 메모리 단편화를 방지하기 위해 힙의 앞부분부터 객체를 채워 넣는다.
Parallel GC
- Serial GC와 알고리즘은 같지만 GC를 처리하는 Thread가 여러 개이다.
- 메모리와 코어가 충분할 때 적합하다.
Serial GC와 기본적인 알고리즘은 같지만, GC를 처리하는 Thread의 개수가 여러 개다. 메모리와 CPU 코어 수가 많을수록 좋다. Java 8 버전에서 default로 사용되는 gc이다.
이는 Parallel GC에서의 GC 프로세스가 더 빠르게 동작할 수 있게 해주며 이러한 차이는 GC를 처리하는 동안 Java의 프로세스가 모두 멈춰버리는 Stop-The-World 현상이 나타나는 시간에도 영향을 주게된다. 즉, STW(Stop-The-World) 시간이 좀 더 적게 걸리는 Parallel GC에서의 Java 애플리케이션이 좀 더 매끄럽게 동작한다는 의미이다.
Paraelle Old GC
- Parallel GC에서 Old GC 알고리즘을 개선한 버전이다.
- Mark-Summary-Compact 알고리즘을 사용하며 좀 더 복잡하다.
Mark-Sweep-Compact 방식은 단일 쓰레드가 old 영역을 검사하는 방식이라면 mark-summary-compact 방식은 여러 쓰레드를 사용해서 old 영역을 탐색한다.
- mark 단계에서는 old 영역을 region 별로 나누고 region 별로 살아있는 객체를 식별한다.
- summary 단계에서는 region 별 통계정보로 살아있는 객체의 밀도가 높은 부분이 어디까지 인지 dense prefix를 정한다. 오랜 기간 참조된 객체는 앞으로 사용할 확률이 높다는 가정하에 dense prefix를 기준으로 compact 영역을 줄인다.
- compact 단계에서는 compact 영역을 destination과 source로 나누며 살아있는 개체는 destination으로 이동시키고 참조되지 않는 객체는 제거한다.