모든 Java 애플리케이션은 JVM 환경에서 작동된다. 본 포스트에서는 JVM 아키텍처와 구성 요소, 그 중에서도 특히 Garbage Collection(이하 GC)와 관련된 Heap area에 대해 자세히 알아보고, GC의 기본적인 원리에 대해서도 정리해보고자 한다.
JVM 아키텍처
JVM은 Java Virtual Machine의 줄임말이며, Java Compiler에 의해 컴파일되어 생겨난 Bytecode(.class 파일)를 OS에 맞게 해석하고 실행하는 역할을 수행한다. 즉 "Java에서 프로그램을 실행한다"는 것은, 컴파일 과정을 통하여 생성된 class 파일을 JVM으로 로딩하고 Bytecode를 해석(interpret)하는 과정을 거쳐 메모리 등의 리소스를 할당하고 관리하며 정보를 처리하는 일련의 작업들을 일컫는다. 이 때 JVM은 Thread 관리 및 Garbage Collection과 같은 메모리 정리 작업도 수행하게 된다.
Java 애플리케이션의 수행 과정
위 그림은 기본적인 Java 프로그램의 수행 과정을 나타낸다. JVM상에서 Class Loader를 통해 class 파일들(+ 인터페이스, 라이브러리)이 로딩되고, 로딩된 class 파일들(+ 메소드들에 포함된 모든 인스트럭션 정보)이 Execution Engine(실행 엔진)을 통해 해석된다. 해석된 프로그램은 Runtime Data Area에 배치되어 실질적인 수행이 이뤄진다.
(Execution Engine에서의 Bytecode 해석 작업으로 인해, Java는 C언어와 같은 네이티브 언어에 비해 속도가 상당히 느리다는 단점이 있었으나 Sun에서 이를 개선하기 위해 JIT(Just In Time) 컴파일러를 만들었다. JIT 컴파일러는 프로그램의 성능에 영향을 주는 지점에 대해서 지속적으로 분석한다. 분석된 지점은 부하를 최소화하고 높은 성능을 내기 위한 최적화의 대상이 된다.)
Runtime Data Area의 구성 요소
그 중 Runtime Data Area에 대해 자세히 살펴보면, 대략 5가지 구성요소로 이뤄져있음을 알 수 있다.
- Method Area: 클래스, 변수, 메소드, static 변수, 상수 정보(Runtime Constant Pool) 등이 저장되는 영역이다.
(모든 thread가 공유한다. JVM이 시작될 때 생성된다.) - Heap Area: new 명령어로 생성된 인스턴스와 Array 객체가 저장되는 구역이다. GC가 발생하는 부분이다. 거꾸로 말하면, Heap Area가 아닌 나머지 영역은 GC의 대상이 아니다.
(모든 thread가 공유한다. JVM이 시작될 때 생성된다.) - Stack Area: thread가 시작할 때 생성된다. 메소드 내에서 사용되는 값들(매개변수, 지역변수, 리턴값 등)이 저장되는 구역으로, 메소드가 호출될 때 LIFO(혹은 FILO)로 'stack frame'이 하나씩 생성되고, 메소드 실행이 완료되면 스택에서 지워진다.
(각 thread별로 하나씩 생성된다.) - PC Register: Java의 thread들은 각자의 PC register를 가진다. 이 곳에는 현재 수행 중인 JVM의 인스트럭션의 주소값이 보관된다. CPU의 register와 역할이 비슷하다.
(각 thread별로 하나씩 생성된다.) - Native Method Stack: Java 이외의 다른 언어의 메소드 호출을 위해 할당되는 구역으로, 언어에 맞게 스택이 형성된다.
(각 thread별로 하나씩 생성된다.)
보다 단순하게는, "Heap 메모리 / Non-heap 메모리"와 같이 구분할 수도 있다. Heap 메모리에서 기억해야할 것은 '공유(shared) 메모리'라는 점이다. 즉 모든 thread에서 공유하는 데이터들이 저장되는 메모리다.
Heap Area
Java Heap은 단지 인스턴스와 Array 객체, 2가지 종류를 저장하는 공간일 뿐이다. 그런데 Runtime Data Area의 구성요소 중 Heap Area를 중점적으로 다루고자 하는 이유는 Garbage Collection 때문이다. JVM은 Heap에 메모리를 할당하는 인스트럭션(Bytecode로 new, newarray, anewarray, multianewarray)만 존재하며, 메모리 해제를 위한 어떤 Java 코드나 Bytecode도 존재하지 않는다. 결국 Java Heap의 메모리 해제는 오직 GC를 통해서만 수행된다.
Java Heap의 구조는 다음과 같다.
(JVM spec을 구현하는 vendor에 따라 JVM 구현체는 HotSpot JVM, IBM JVM 등이 존재한다. HotSpot JVM은 Sun, Oracle, Windows, Linux, MacOS에 탑재되었으며, IBM JVM은 IBM AIX에 탑재되어 있다. 본 포스트에서는 가장 일반적인 JVM으로 사용되는 HotSpot JVM을 기준으로 설명한다).
Young Generation은 Eden(이든) 영역과 Survivor 영역으로 구성된다.
Eden 영역은 객체가 Heap에 최초로 할당되는 장소이다(에덴 동산에서 따온 이름인 것 같다). 그러다가 Eden 영역이 꽉 차면, 이 영역에 있던 객체들은 당연하게도 어디론가 옮겨지거나 삭제되어야 할 것이다. 이 때 각 객체의 참조 여부를 검사하여 참조가 존재하는 'Live Object'이면 Survivor 영역으로 넘기고, 참조가 끊어진 Garbage(unreachable) 객체이면 그냥 Eden에 남겨 놓는다. 모든 Live Object가 Survivor 영역으로 넘어가면 Eden 영역을 모두 청소(Scavenge)한다.
Survivor 영역은 말 그대로, Eden 영역에서 GC가 일어난 후에도 살아남은 객체들이 잠시 동안 머무르는 곳이다. Survivor 영역은 2개로 구성되는데(Survivor 1, Survivor 2), Live Object를 대피시킬 때는 둘 중 하나의 Survivor 영역만 사용해야 한다(= 둘 중 하나의 영역은 반드시 비어 있어야 한다).
Young Generation에서 일어나는 모든 GC(Eden이 꽉찼을 때 || Survivor가 꽉찼을 때)를 Minor GC라고 부른다.
Old Generation은 Young Generation에서 오래 살아남아 성숙된 객체가 이동되는 곳이다. '성숙된 객체'란, 애플리케이션에서 특정 횟수 이상 참조되어 기준 Age를 초과한 객체를 말한다. 즉 Old Generation 영역은 새로 Heap에 할당되는 객체가 들어오는 곳이 아니고, 비교적 오랫동안 참조가 되어 이용되고 있고 앞으로도 계속 사용될 확률이 높은 객체들을 저장하는 영역이다. 이 영역에서 일어나는 GC를 Major GC(혹은 Full GC)라고 부른다.
추가로 Perm 영역이 존재하는데, 이 곳은 보통 class의 Meta 정보나 메소드의 Meta 정보, static 변수와 상수 정보들이 저장되며 흔히 '메타데이터 저장 영역'이라고도 한다. Java 8부터는 Native Memory 영역의 일부로 이동되었으며 'Metaspace' 영역으로 변경되었다(기존 Perm 영역의 static 객체들은 Heap 영역으로 옮겨져 GC의 대상이 최대한 될 수 있도록 하였다).
<엑셈 책 21p 그림 1-4>
Garbage Collection
GC는 보통 메모리의 압박이 있을 때, 메모리가 필요할 때 수행된다. 달리 말하면 GC는 새로운 객체의 할당을 위해 한정된 Heap 공간을 재활용하려는 목적으로 수행되는 것이다. GC의 역할을 정리하면,
- 메모리 할당
- 사용 중인 메모리 인식
- 사용하지 않는 메모리 인식
GC는 Garbage Collector라는 데몬 스레드에 의해 수행되는 것이다. GC 덕분에 Java 개발자는 C++과 달리 별도의 메모리 관리를 할 필요가 없다.
또 하나 알아야 할 용어는 'stop-the-world'이다. stop-the-world란, GC를 실행하기 위해 JVM이 애플리케이션 실행을 멈추는 것이다. stop-the-world가 발생하면 GC를 실행하는 쓰레드를 제외한 나머지 쓰레드는 모두 작업을 멈춘다. GC 작업을 완료한 이후에야 중단했던 작업을 다시 시작한다. 어떤 GC 알고리즘을 사용하더라도 stop-the-world는 발생한다. 대개의 경우 GC 튜닝이란 stop-the-world 시간을 줄이는 것이 근본적인 목표다.
(Minor GC든 Major GC든 stop-the-world는 발생한다. Minor GC라고 stop-the-world가 발생하지 않는 것이 아니다.)
위에서도 잠시 언급한 내용인데, GC는 크게 2가지 타입으로 나뉜다. Young 영역에서 발생하는 GC는 Minor GC, Old 영역이나 Perm 영역에서 발생하는 GC는 Major GC(Full GC)라고 부른다. 이 2가지 GC가 어떻게 상호작용하느냐에 따라 여러가지 GC 방식이 존재하며 성능에도 영향을 준다. GC가 발생하거나, 객체가 영역 사이를 이동할 때 애플리케이션의 병목이 발생하면서 성능에 영향을 주게 된다.
강제로(명시적으로) GC를 시키는 방법도 있긴 하다. System.gc()나 Runtime.getRuntime().gc()를 쓰면 되는데, 절대로 사용하면 안된다. GC를 수행하는 동안에는 다른 애플리케이션의 성능에 영향을 미치므로, 만약 실제 운영 중인 시스템에 System.gc() 같은 코드가 있으면 실제 시스템의 응답 속도에 엄청난 영향을 끼치게 될 것이다. GC는 개발자가 컨트롤 할 영역이 아니다. GC를 강제하지 말고, JVM이 Heap size에 기반해서 필요한 경우 GC를 수행하도록 하고, 개발자는 실행 중인 애플리케이션의 특성에 맞는 GC 튜닝을 고려해야 한다.
GC 방식에는 크게 4~5가지 정도가 존재하며, 각 방식은 WAS나 Java 애플리케이션 수행 시 옵션을 지정하여 선택할 수 있다.
- Serial Collector
- Parallel Collector
- Parallel Compacting Collector
- Concurrent Mask-Sweep(CMS) Collector
- Garbage First Collector (G1 Collector)
참고 자료
- JVM Performance Optimizing 및 성능분석 사례 (류길현, 오명훈, 한승민 저 / EXEM)
- 자바 성능 튜닝 이야기 (이상민 저 / 인사이트)
- Java Garbage Collection — NAVER D2
'Java' 카테고리의 다른 글
Java에서 String은 왜 불변일까? (2) | 2020.07.21 |
---|---|
String Constant Pool이란? | Java String Pool (3) | 2020.07.20 |
JVM, JRE, 그리고 JDK의 개념 (0) | 2019.02.04 |
POJO 이해하기 (0) | 2018.12.12 |
댓글