22/10/14 게시글 등록
22/10/15 내용 수정 및 추가
JVM(자바가상머신)이란? - Part 1, 소개
JVM(자바가상머신)이란? - Part 2, Execution Engine JVM(자바가상머신)이란? - Part 1 자바를 쓰는 개발자라면 누구나 들어봤을 JVM(Java Virtual Machine)을 알아보려고 한다. 자바 바이트코드가 JRE에서 동작을..
ssdragon.tistory.com
JVM(자바가상머신)이란? - Part 2, Execution Engine
JVM(자바가상머신)이란? - Part 1, 소개 자바를 쓰는 개발자라면 누구나 들어봤을 JVM(Java Virtual Machine)을 알아보려고 한다. 자바 바이트코드가 JRE에서 동작을 하는데, 이 JRE에서 가장 중요한 요소는
ssdragon.tistory.com
JVM(자바가상머신)이란? - Part 3, ClassLoader
JVM(자바가상머신)이란? - Part 1, 소개 자바를 쓰는 개발자라면 누구나 들어봤을 JVM(Java Virtual Machine)을 알아보려고 한다. 자바 바이트코드가 JRE에서 동작을 하는데, 이 JRE에서 가장 중요한 요소는
ssdragon.tistory.com
JVM(자바가상머신)이란? - Part4, Runtime Data Area
22/09/29 - 게시글 등록 22/10/13 - PermGen에 관한 Heap 영역 수정 및 추가 JVM(자바가상머신)이란? - Part 1, 소개 자바를 쓰는 개발자라면 누구나 들어봤을 JVM(Java Virtual Machine)을 알아보려고 한다. 자바..
ssdragon.tistory.com
Part1에서는 JVM이 무엇인지,
Part2에서는 Execution Engine(실행엔진),
Part3에서는 ClassLoader(클래스로더),
Part4에서는 Runtime Data Area (런타임 데이터 영역),
이번 Part5에서는 Garbage Collection(GC, 가비지 컬렉션)이라 부르는 것을 살펴보자.
Part4에서 JVM 메모리가 어떻게 구성됐는지 알게되었다.
JVM 안에서 모두 공유되는 Heap, 각각 메소드별로 할당되는 Stack 등 크게 5가지 영역이 있었다.
프로그램을 오래 실행할수록 새로운 데이터가 메모리에 추가된다면 언젠가 꽉 차게 되어 에러가 뜰 것이다.
이 문제점을 해결하는 것이 GC이다.
GC란?
Part2에서 간략하게 GC를 소개했는데 한 번 더 하자면
- 앞으로 사용되지 않는 객체의 메모리 = Garbage
- Garbage를 정해진 스케줄에 의해 정리해주는 것 = Garbage Collection
- 즉, Runtime Data Area의 Heap 영역에 생성된 객체들 중 더 이상 참조되지 않는 객체를 모아 제거함
- GC는 자동적으로 메모리 공간을 관리 및 최적화 해줌
GC의 대상
GC는 더 이상 사용되지 않는 데이터의 메모리를 해제시켜주는 장치이다.
JVM에서 자동적으로 실행되기에 개발자는 직접 메모리 관리를 하지않아도 된다.
GC의 주 대상은 Heap영역 내의 객체 중에서 참조되지 않는 데이터이다.
public class Main {
public static void main(String[] args) {
Person person = new Person("a", "곧 참조되지 않음");
person = new Person("b", "참조가 유지됨");
// GC 발생 가정 시점
}
}
public class Person {
String name;
String description;
Person(String name, String description) {
this.name = name;
this.description = description;
}
}
예시를 들면 위 코드에서 person은 a객체를 참조했지만 그 후 b객체를 참조하게 된다.
main 메소드가 종료되기 직전 GC가 이루어진다고 가정했을 때,
b객체는 참조가 유지되므로 a객체만 GC 대상이 된다.
public String makeQuery(String code) {
String queryPre = "select * from myomyo where a = '";
String queryPost = "' order by c";
return queryPre + code + queryPost;
}
또 다른 예시로는 makeQuery() 메소드를 호출하여 수행완료되면 queryPre 객체와 queryPost객체는 더 이상 필요없는 객체가 된다. 즉, Garbage가 된다. 이런 쓰레기 객체를 GC가 효율적으로 처리한다.
이렇게 참조 대상을 바꾸거나 메소드가 끝나서 Stack이 pop되면 참조되지 않는 객체가 생겨난다.
참조되고 있는지에 대한 개념을 reachability 라고 하며,
유효한 참조는 reachable, 참조되지 않으면 unreachable이라 한다.
GC는 unreachable 객체들을 Garbage로 인식한다.
Heap 영역 내부의 객체들은 Method Area, Stack, Native Stack에서 참조되면 rechable이다.
reachable로 인식되게 만드는 영역들을 root set이라고 한다.
또한 reachable이 참조하고 있는 다른 객체도 reachable이다.
root set으로 참조되지 않는 객체들은 unreachable이어서 GC의 대상이 된다.
Stop The World
- GC를 수행하기 위해 JVM이 멈추는 현상
- GC가 작동하는 동안 GC관련 Thread를 제외한 모든 Thread는 멈춘다.
- 일반적으로 튜닝이라는 것은 이 시간을 최소화하는 것
JVM은 GC를 통해 메모리를 관리하게 된다.
따라서 GC를 자주 실행하면 성능이 좋아질까?
GC를 자주 사용하면 GC관련 쓰레드를 제외한 모든 쓰레드는 일시정지된다.
이 Stop The World현상이 빈번하게 발생하게되므로 성능이 저하됨을 알 수 있다.
적절하게 GC가 실행되도록 하여 이 시간을 줄이는것이 중요하다.
GC가 가능한 이유?
Weak Generational Hypothesis(약한 세대 가설) 덕분에 가능하다.
이 가설은 다음과 같은 2가지 가정을 하고 있다.
- 대부분의 객체는 빠르게 unreachable(접근불가능) 상태가 된다
- 오래된 객체에서 젊은 객체로의 참조는 아주 적게 존재한다
(x축은 객체 수명, y축은 객체의 수)
이 가설의 경우 여러 관찰을 통해 높은 경향성을 가지는 것으로 증명된다.
이 관찰은 Oracle의 Java8 자료에서 볼 수 있다.
이 자료 및 그림에서 대부분 객체가 빠르게 소멸하는 것을 알 수 있다.
이러한 가설에 따라 Oracle의 HotSpot JVM 내부 메모리인 Heap 영역에서는 다음과 같이 3가지 Generation으로 나누었다.
Young Gen, Old Gen, Perm Gen이다.
이렇게 분리되고 개별적으로 GC를 사용할 수 있는 영역을 사용하면 GC의 성능을 향상시키는데 많은 도움이 되는 다양한 알고리즘을 사용할 수 있다.
- 대부분 객체는 생성되지마자 Garbage가 되는 것
- 따라서 매번 전체 검사하지 않고 일부만 검사하도록 generational한 구조를 고안함
- Young Generation
- 객체 대부분이 생성될 때 이곳으로 들어옴
- S0 또는 S1 둘 중 하나는 반드시 비어있어야 함 (두 영역은 우선순위 없음)
- 여기가 가득차면 Minor GC 발생
- Minor GC 발생하면 살아있는 객체들만 체크하고 나머지 제거
- 살아남은 객체들 중 오래 쓸 것같으면 Tenured Generation으로 옮김
- Tenured Generation(Old Generation)
- 여기가 가득차면 Major GC 발생
- Major GC는 Minor GC보다 오래걸림
- Permanent Generation
- Method Area라고도 함
- 클래스와 메소드의 메타데이터를 담는 곳
- Old Gen에서 넘어오는 곳이 아님
- 제한된 크기를 가지는 단점때문에 Java 8 부터 Metaspace 영역으로 대체
- 이 영역이 가득차면 Major GC(Full GC) 발생
GC에 대한 원리를 이 Heap 구조에 맞춰서 설명하자면
- Eden 영역에 새로운 객체들이 지정된다.
- Eden 영역에 데이터가 어느 정도 쌓이면 참조되지 않은 객체는 삭제되고, 참조되는 객체는 S0으로 옮겨진다.
- 그렇게 S0 영역이 차면, Eden 영역에 참조되는 객체와 S0 영역에서 참조되는 객체는 S1 영역으로 옮겨진다.
- 물론 참조되지 않는 객체는 삭제된다.
- 더 이상 Young 영역에 공간이 남지 않으면 참조되는 객체들은 Old 영역으로 옮겨진다.
여기까지 읽어보면 Young에서의 Minor GC와 Old에서의 Major GC라는 두 가지 GC를 알 수 있다.
이 두 가지 GC가 어떻게 작동하느냐에 따라서 GC 방식과 성능이 차이난다.
GC가 발생하거나 객체가 각 영역에서 다른 영역으로 이동할 때 STW가 발생하면서 어플리케이션 병목 현상이 일어나고 성능에 영향을 줄 수 있다. 그래서 HotSpot JVM에서는 쓰레드 로컬 할당 버퍼(TLABs : Thread-Local Allocation Bufferes)라는 것을 사용한다. 이를 통해 각 쓰레드별 메모리 버퍼를 사용하면 다른 쓰레드에 영향을 주지 않는 메모리 할당 작업이 가능해진다.
Algorithm
그렇다면 Garbage를 어떻게 식별해서 메모리에서 해제시킬까?
그럼 다양한 알고리즘을 살펴보자.
1. Reference Counting Algorithm
- Garbage 탐색에 초점을 맞춘 알고리즘
- 각 객체마다 Reference Count를 관리하며, 이 카운트가 0이 되면 GC 수행
- 카운트가 0이 되면 메모리에서 제거된다는 장점
- 순환 참조 구조에서 Reference Count가 0이 되지 않는 문제가 발생하여 Memory Leak 발생 가능
예시를 들면 다음과 같다.
Object a = new Integer(1);
Object b = new Integer(2);
a = b;
a와 b가 각각 Integer를 생성했을 때는 좌측 이미지처럼 카운트가 1씩 증가했고,
이후 b가 참조하는 것을 a도 참조하면서 자연스럽게 Integer 1은 카운트가 0이 되고 GC 대상이 된다.
단점의 예시는 다음과 같다.
a의 참조가 끊어져도 순환구조로 인해 카운트가 1로 살아있으므로 GC 대상이 되지 않아 memory leak가 발생한다.
2. Mark And Sweep Algorithm
Reference Counting Algorithm의 단점을 해결하기 위해 나온 알고리즘이다.
root set에서 시작하는 참조관계를 추적하여 객체들에 대해서 마크를 한다.
이 단계를 Mark Phase라고 한다.
이후 마크되지 않은 객체들을 추적하여 삭제하는데 이 단계를 Sweep Phase라 한다.
이러면 메모리가 Fragmentation(파편화)되는 단점이 있다.
메모리 파편화는 정렬되지 않은 조각으로 나뉘어져, 절대적 크기는 충분하지만 추가적으로 메모리 할당이 되기 힘든 상태이다.
거기다가 GC 중이라면 Mark 작업과 어플리케이션 Thread 충돌 방지를 위해 Heap의 사용이 제한된다.(일시중지)
이를 해결하기 위한 것이 Mark-Sweep-Compact 알고리즘와 Copy 알고리즘이다.
3. Mark-Sweep-Compact Algorithm
Mark And Sweep Algorithm처럼 참조되는 객체들에 대해서 마크하고, 참조되지 않으면 삭제한다.
그 후에 메모리를 정리하여 메모리 파편화를 해결한다.
단점은 모든 객체를 새 위치에 복사하고 해당 객체에 대한 모든 참조를 업데이트해야 하므로 일시 중지(Suspend) 시간이 증가하고 Overhead가 발생할 수 있다. 즉, STW 시간이 길어질 수 있다.
4. Mark And Copy Algorithm
이것 역시 메모리 파편화를 해결하기 위해 제시된 또 다른 알고리즘이다.
현재 GC가 사용하는 Generational Algorithm이 이 알고리즘을 발전시킨 형태이다.
Heap을 Active 영역과 InActive 영역으로 나눈다.
Active 영역에만 객체를 할당하고, 가득차면 GC를 수행한다.
GC를 수행하면 Suspend 상태가 되고 살아남은 객체들을 InActive 영역으로 복사한다.
복사하는 동안 프로그램이 Suspend 상태이므로 Stop-The-Copying라고도 한다.
복사가 끝나면 Active 영역에는 Garbage만 남고 InActive 영역에는 살아남은 객체만 남는다.
이후 Garbage를 제거하면 Active 영역은 Free Memory 상태가 된다.
즉, Active 영역과 InActive 영역이 바뀌면서 정리가 됐다고 볼 수 있다.
Active 영역과 InActive 영역은 논리적인 구분이다.
메모리 파편화를 해결하기위해 나온것처럼 복사할 때 연속된 메모리 공간에 쌓게된다.
단점은 Heap의 절반만 사용가능하는 비효율성과 Suspend 현상, 복사할 때 Overhead가 존재한다.
5. Generational Algorithm
Mark And Copy 알고리즘을 사용하면서 Active 영역과 InActive 영역을 오가면서 Copy 작업을 하는게 상당한 Overhead를 발생시킨다. 따라서 Weak Generational Hypothesis 가설을 토대로 Copy 알고리즘의 대안이 나오게 되었다.
객체는 Young Gen에 할당되고 GC가 수행될 때마다 살아남은 객체에 Age를 기록한다.
Age의 임계값의 기본값은 31이다.
이런 특정 임계값을 넘어서면 Old Gen으로 Copy하는 작업을 수행한다.
이를 Promotion이라고 하며 대부분 Young Gen에서 제거되기에 Copy 작업을 최소화할 수 있다.
또한 Old Gen으로 복사할 때 Compaction(압축) 작업이 이루어진다.
Old Gen가 가득 차서 Full GC가 발생하게 되면 STW가 발생한다.
이 알고리즘은 메모리 파편화, 메모리 활용, Copy 알고리즘의 Overhead 등의 여러 알고리즘의 단점을 상당 부분 극복한 알고리즘이다. 무엇보다 적절한 Generation으로 각각에 맞는 알고리즘을 적용시킬 수 있다.
Young Gen에는 Mark-Sweep 알고리즘을 적용하고, Promotion 과정에는 Copy 알고리즘과 흡사하게 진행시킬 수 있는 것처럼 이러한 장점으로 HotSpot JVM의 Heap도 이런 구조를 가지고 있다.
HotSpot JVM에서 제공하는 GC들을 보면 어떤 알고리즘으로 GC를 수행하는지 명시되어 있는데 이것을 알아보자.
GC 종류
다양한 알고리즘 조합이 많이 있지만 크게는 중요한 4가지 조합으로 요약된다.
- Young 및 Old 를 위한 Serial GC (직렬)
- Young 및 Old 를 위한 Parallel GC (병렬)
- Young 을 위한 Parallel New + Old 를 위한 Concurrent Mark and Sweep (CMS)
- Young 와 Old 를 포함하는 G1
1. Serial GC
- Young Gen에는 Mark And Copy 알고리즘 사용
- Old Gen에는 Mark-Sweep-Compact 알고리즘 사용
Young과 Old가 연속적으로 처리되며 하나의 CPU를 사용한다.
이 처리를 수행할 때 STW가 발생한다.
즉, Single Thread Collector 이므로 병렬화를 할 수 없고 여러 CPU 코어를 활용할 수 없다.
JVM에서 CPU 코어를 하나만 사용하기 때문에 단일 CPU가 있는 환경에서 실행되는 수백MB의 힙 크기 JVM에서만 권장된다.
대부분의 서버측 배포에서 드문 조합이다.
(Java 5,6 기본값 GC)
대기 시간이 많아도 크게 문제되지 않는 시스템에서 사용됨
예시를 들어보면 다음과 같다.
(From 영역과 To 영역은 Survivor0과 Survivor1 영역이다)
- 새로운 객체는 Eden으로 간다.
- Eden이 꽉차면 From과 To 영역 중에서 비어있는 영역으로 살아있는 객체가 이동한다. 이 때 Survivor 영역에 들어갈 수 없는 큰 객체는 Old로 바로 이동한다. From 영역에서 살아있는 객체도 To 영역으로 이동한다.
- To 영역이 꽉 찼을 경우에는 Eden 영역이나 From 영역에 남아있는 객체들은 Old 영역으로 이동한다.
- 이후에 Old 영역에 있는 객체들은 Mark-Sweep-Compact 알고리즘을 따른다.
2. Parallel GC (Throughput GC)
- Young Gen에는 Mark And Copy 알고리즘 사용
- Old Gen에는 Mark-Sweep-Compact 알고리즘 사용
이 방식의 목표는 다른 CPU가 대기 상태로 남아있는 것을 최소화하는 것이다.
Serial GC와 달리 두 GC를 병렬로 처리한다.
많은 CPU를 사용하기에 GC 부하를 줄이고 애플리케이션 처리량을 증가시킨다.
이 접근 방식을 사용하면 GC 시간을 상당히 줄일 수 있다.
이 Parallel GC는 처리량을 높이는 것이 주 목표인 경우 멀티 코어 시스템에 적합하다.
GC하는 동안 모든 코어가 Garbage를 병렬로 청소하므로 일시 중지 시간이 단축된다.
(Java 7,8 기본값 GC)
3. Concurrent Mark and Sweep (CMS)
- Young Gen에는 Paralle STW Mark And Copy 알고리즘 사용
- Old Gen에는 주로 Concurrent Mark-Sweep 알고리즘을 사용
이 방식은 Low-Latency Collector로도 알려져 있고, Heap 영역이 클 때 적합한 GC이다.
Young에 대한 GC는 Pallel GC와 동일하다.
Old 에서 GC하는동안 긴 일시 중지를 방지하도록 설계되었다. 즉, 일시 중지가 매우 짧다.
두 가지 방법으로 이를 달성하는데
1. Old Gen을 압축하지 않고 여유 목록(Free-Lists)을 사용하여 회수된 공간을 관리
2. 응용 프로그램과 동시에 Mark And Sweep 단계에서 대부분의 작업 수행
이런 단계를 수행하기 위해 애플리케이션 쓰레드를 명시적으로 중지하지 않는다.
그래도 여전히 응용 프로그램 쓰레드와 CPU 시간을 놓고 경쟁한다는 점에 유의해야 한다.
기본적으로 이 GC 알고리즘에서 사용하는 쓰레드 수는 컴퓨터 물리적 코어 수의 1/4와 같다.
즉, 어플리케이션 쓰레드와 GC 쓰레드가 동시에 실행되어 STW를 최소화하는 GC이다.
이 조합은 기본 목표가 대기 시간인 경우 멀티 코어 시스템에서 좋은 선택이다.
개별 GC 일시 중지 시간을 줄이면 최종 사용자가 애플리케이션을 인식하는 방식에 직접적인 영향을 미치므로 더 반응이 좋은 애플리케이션 느낌을 받을 수 있다.
하지만 Compaction을 수행하지않기에 메모리 파편화가 일어나는데 이로 인해 메모리 할당이 불가능해지면 Concurrent Mode Failure 경고가 발생하면서 Compaction 작업을 수행한다. 이 때는 다른 STW 시간보다 긴 시간이 소요되므로 서버 환경에서 위험할 수 있다.
이런 단점을 해결하고자 G1 GC가 나오면서 Java9부터 Deprecated 되었다.
4. Garbage First(G1) GC
- Young Gen에는 Snapshot-At-The-Beginning(SATB)
- Old Gen에는 Snapshot-At-The-Beginning(SATB)
대규모 Heap 사이즈와 멀티 프로세서 환경에서 사용되도록 만들어진 GC이다.
CMS GC와 다르게 메모리를 페이징 하듯이 논리적인 단위(Region)으로 Heap 전체 영역을 나눠서 관리한다. Region 단위로 메모리를 관리하여 CMS와 다르게 Compaction 단계를 진행하고 메모리 파편화 문제를 해결했다. 또한 STW 시간을 예측할 수 있다.
위 그림처럼 Region 영역에 객체를 할당하고 GC를 실행한다.
해당 영역이 꽉 차면 다른 영역에서 객체를 할당하고 GC를 실행한다.
그래서 지금까지 Young -> Old로 이동하는 단계가 아닌 GC 방식이라 이해하면 된다.
(Java 9부터 기본값 GC)
느낀점
"Java 기능과 안정성 때문에 버전 업그레이드를 해야 할까?"라는 생각을 했었다.
이번에 JVM을 학습하고 나니 버전이 올라갈수록 다양하고 구현된 GC를 보게되니 버전업을 할수록 성능이 좋아질 것 같다.
구현된 GC를 선택적으로 사용할 수도 있으니 지금껏 내가 모르는 무언가가 많이 있었다는걸 느꼈다.
GC도 자바 어플리케이션을 실행하는데에 있어서 중요한 부분인데 단순히 "자동으로 메모리 관리해주는 것"이라고만 생각했었다.
물론 맞는 말이지만 이로 인해 GC를 학습할 겨를이 없었다.
지금도 공식문서부터 다양한 블로그까지 찾아서 썼지만 무언가 허전한 느낌이 든다.
정리되지 않은 글로 인해 혼란이 가득해서 학교 도서관에 찾아보았지만 없어서 도서신청을 한 상태이다.
그래서 정말 이해가 안가는 부분은 교수님과 얘기하면서 토론하였다.
아직 학습할 것이 많은 나에게 JVM의 GC만 파는것이 맞는건가 싶기도 했지만 나쁜 지식은 없으니 내가 재밌는걸 해보자는 생각이다.
추후 JVM 도서가 온다면 다시 한번 정리하면서 더 자세히 적고싶다.
GC 튜닝은 서비스 기능을 완성하고 코드 리팩토링도 끝난 후 시간적 여유가 있고, 성능에 대한 문제가 있을 때 하지 않을까 생각했다.
그래서 GC 튜닝을 다른 기업들도 하고 있는지 궁금해서 찾아봤는데 찾아봤는데 네이버 D2 테크블로그에서 다음과 같이 설명했다.
결론부터 이야기하면 모든 Java 기반의 서비스에서 GC 튜닝을 진행할 필요는 없다.
하지만 메모리 크기를 지정하지 않고, Timeout 로그가 수도 없이 출력된다면 GC 튜닝을 하는것이 좋다.
그런데 한 가지 명심해야 하는 점이 있는데, GC 튜닝은 가장 마지막에 하는 작업이라는 것이다.
따라서 GC에 대한 경험은 많이 해보지 못하겠지만 한 번은 경험해보고 싶다.
오라클 공식문서
https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/toc.html
https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/sizing.html
https://www.oracle.com/webfolder/technetwork/tutorials/obe/java/gc01/index.html
https://www.oracle.com/java/technologies/hotspotfaq.html
Java 및 Spring 강의 및 자료 사이트
https://www.baeldung.com/java-memory-management-interview-questions
https://www.baeldung.com/jvm-garbage-collectors
https://www.baeldung.com/java-choosing-gc-algorithm
기업 테크 블로그
https://tecoble.techcourse.co.kr/post/2021-08-30-jvm-gc/
https://d2.naver.com/helloworld/1329
https://d2.naver.com/helloworld/329631
개인 블로그
https://johngrib.github.io/wiki/jvm-memory/#generation-구조-요약
https://johngrib.github.io/wiki/java-g1gc/
https://johngrib.github.io/wiki/jvm-memory/
https://mirinae312.github.io/develop/2018/06/04/jvm_gc.html
https://mangkyu.tistory.com/119
https://plumbr.io/handbook/garbage-collection-algorithms-implementations#g1
'프로그래밍 > Java' 카테고리의 다른 글
Java Stream(스트림)은 원본 데이터를 변경할 수 있다!? (0) | 2023.03.27 |
---|---|
자바에서 String을 조심해야하는 이유 (0) | 2022.10.22 |
JVM(자바가상머신)이란? - Part4, Runtime Data Area (0) | 2022.09.29 |
[Java] sort와 parallelSort 비교 (0) | 2022.09.18 |
Java - 값이 null,공백 등 Blank인지 확인하기 (0) | 2022.04.07 |