JVM은 Java 어플리케이션을 클래스 로더(Class Loader)를 통해 읽고, Java API와 함께 실행하는 역할을 한다.
Java는 물리적인 Machine과 별개인 가상 Machine을 기반으로 동작하도록 설계되었다. 따라서 Java 바이트코드를 실행하고자 하드웨어에 JVM을 동작시킴으로서 Java 실행코드를 변경하지 않고도 모든 종류의 하드웨어에서 동작이 가능하도록 한 것이다.
Java 프로그램의 실행 과정
1. 프로그램이 실행되면 JVM은 OS로부터 프로그램이 필요로 하는 메모리를 할당받는다. JVM은 할당받은 메모리를 용도에 따라 여러 영역으로 나누어 관리한다.
2. Java 컴파일러(javac)가 자바 소스코드(.java)를 읽어 Java 바이트 코드(.class)로 변환시킨다.
3. Class Loader를 통해 class파일들을 JVM으로 로딩한다.
4. 로딩된 class파일들은 Execution engine을 통해 해석된다.
5. 해석된 바이트 코드는 Runtime Data Areas에 배치되어 실질적인 수행이 이루어진다.
Java 바이트 코드 JAM이 이해할 수 있는 언어로 변환된 Java 소스 코드
Class Loader JVM 내로 클래스(.class 파일)를 로드하고, 링크를 통해 배치하는 작업을 수행하는 모듈이다.
Execution Engine 클래스를 실행시키는 역할을 수행한다. Class Loader가 JVM의 Runtime Data Area에 바이트 코드를 배치시키면, 이는 Execution Engine에 의해 실행된다.
Runtime Data Area 프로그램을 수행하기 위해 OS에서 할당받은 메모리 공간.
Interpreter Execution Engine은 Java 바이트 코드를 명령어 단위로 읽어서 실행한다. 하지만 이 방식은 한 줄 씩 수행하기 때문에 느린 Interpreter 언어의 단점을 그대로 가지고 있기 때문에 JIT Compiler를 도입했다.
JIT(Just In Time) Interpreter 방식으로 실행되다가 적절한 시점에 바이트 코드 전체를 컴파일하여 네이티브 코드로 변경하여 해당 시점 이후로는 InterPreting 하지 않고, 네이티브 코드로 실행하는 방식이다. 네이티브 코드는 캐시에 보관하기 때문에 한번 컴파일되면 빠르게 수행이 가능하지만, JIT 컴파일러가 컴파일하는 시간은 바이트 코드를 Interpreting하는 시간보다 훨씬 오래걸리기 때문에 JVM은 해당 메서드가 얼마나 자주 수행되는지 내부적으로 확인하고, 일정 정도를 넘을 때 컴파일을 수행한다.
GC (Garbage Collector)
프로그램을 개발하다 보면 유효하지 않은 메모리인 가비지(Garbege)가 발생한다. C언어를 이용할 경우, 이러한 메모리를 개발자가 직접 해제해주어야 하지만, Java에서는 이를 GC가 알아서 해준다.
Java에서는 개발자가 코드로 메모리를 명시적으로 해제하지 않기 때문에 GC가 더이상 필요없는 객체를 찾아 지우는 역할을 한다.
GC는 2가지 전제조건(가설)을 가지고 있다.
1. 대부분의 객체는 금방 접근 불가능 상태(unreachable)가 된다.
2. 오래된 객체에서 젊은 객체로의 참조는 아주 적게 존재한다.
이러한 가설을 'Weak Generational Hypothesis'라고 한다. 이 가설의 장점을 최대한 살리기 위해서 HopSpot VM에서는 크게 Young 영역과 Old 영역 2개로 물리적 공간을 나누었다.
1. Young 영역 (Young Generation 영역) : 새롭게 생성한 객체의 대부분이 위치하는 영역. 대부분의 객체가 금방 접근 불가능 상태가 되기 때문에 많은 객체가 Young 영역에 생성되었다가 사라진다. Young 영역에서 객체가 사라질 때 Minor GC가 발생한다고 한다.
2. Old 영역 (Old Generation 영역) : 접근 불가능 상태가 되지 않아 Young 영역에서 살아남은 객체가 Old 영역으로 복사된다. 대부분 Young 영역보다 크게 할당하며, 크기가 큰 만큼 Young 영역보다 GC가 적게 발생한다. Old 영역에서 객체가 사라질 때 Major GC 혹은 Full GC가 발생한다고 한다.
3. Permanent Generation 영역 (Method 영역) : 객체나 억류(intern)된 문자열 정보를 저장하는 곳이다. Old 영역에서 살아남은 객체가 영원히 남아있는 곳은 아니고, 해당 영역에서도 GC가 발생할 수 있다. 이는 Major GC 횟수에 포함된다.
GC의 동작 방식
세부적인 동작 방식은 영역과 적용 알고리즘에 따라 다르지만, 공통적으로 따르는 2단계는 아래와 같다.
1. stop-the-world
2. Mark and Sweep
stop-the-world
GC를 실행하기 위해 JVM이 어플리케이션 실행을 멈추는 것으로, stop-the-world가 발생하면 GC를 실행하는 쓰레드를 제외한 나머지 쓰레드들은 모두 작업을 멈춘다. stop-the-world의 시간을 줄이는 것을 GC 튜닝이라 한다.
Mark and Sweep
stop-the-world 이후, GC가 스택의 모든 변수 또는 접근 가능한 Reachable 객체를 스캔한다. 사용되지 않는 메모리를 식별하는 과정을 Mark, 제거하는 과정을 Sweep이라고 한다.
Minor GC
Young 영역은 Eden 영역과 Survivor 영역으로 나뉜다.
Eden 영역 : 새로 생성된 객체가 할당되는 영역
Survivor 영역 : 최소 1번 이상의 GC 이후 살아남은 객체가 존재하는 영역
동작원리
1. 인스턴스가 계속 생성되어 Eden 영역이 포화된다.
2. stop-the-world 후 Mark and Sweep이 실행된다.
3. 살아남은 객체가 첫번째 Survivor 영역으로 이동된다.
4. 첫번째 Survivor 영역이 포화된다.
5. Mark and Sweep으로 살아남은 객체가 두번째 Survivor 영역으로 이동된다.
6. 위 과정을 반복하다가 일정 횟수(age) 이상 살아남은 객체들이 Old 영역으로 이동된다. (Promotion)
Survivor 영역에서 객체가 살아남은 횟수를 age라고 하며, 이는 객체의 Header에 기록된다.
Major GC
Young 영역에서 Promotion으로 넘어온 인스턴스들에 의해 Old 영역의 메모리가 부족해지면 Major GC가 실행된다.
Old 영역은 Young 영역에 비해 크기가 크기 때문에 Major GC는 Minor GC에 비해 10배 이상의 시간이 소모될 수 있다.
서비스마다 생성하는 객체의 크기, 생존 주기 등의 상황이 모두 다르기 때문에 해당 서비스에 가장 적합한 쓰레드 개수, 인스턴스 개수, GC 옵션은 지속적인 GC 튜닝과 모니터링을 통해 찾아야 한다.