JVM이란?
JVM은 Java를 공부했었던 사람이라면 뭔지 모를 수가 없는 존재일 것이다. JVM은 Java Virtual Machine으로 한국어로는 자바 가상 머신(기계)이다. 대부분 JVM을 들어 본 사람이라면 Java를 컴파일을 위해 사용하는 정도, 또 가비지 컬렉터(Garbage Collector)를 돌려주는 정도로만 알고 있을 것이다(나도 그렇다). 오늘 바로 이 JVM을 완전히 깊게는 아니고 조금 더 딥하게 파헤쳐 보자.
정확히 JVM이 하는 일이 뭐야?
JVM이 하는 일은 놀랍게도 정답이 벌써 나왔다. 바로 Java Application을 컴퓨터에서 실행할 수 있도록, 즉 컴파일할 때 사용한다. JVM이 여타 다른 C 언어나 C++, Python과 다르게 좀 특이한 점은 온갖 해석 방법을 다 사용한다. 일단 코딩으로 작성한 프로그램을 컴퓨터에 실행시키려면 고급 언어로 작성 된 파일들을 기계어 코드로 변환, 혹은 해석해서 컴퓨터에서 실행한다. 이때 고급 언어를 기계어 코드로 한 번에 변환해서 컴퓨터로 실행하는 방법을 "컴파일"이라 한다. 컴파일 방법을 사용한 언어는 C, C++ 등이 있다. 이와 반대로 고급 언어를 기계어 코드로 한 번에 변환 하는 것이 아니라 실행하면서 장인과 같이 한땀 한땀 번역하는 방법을 "인터프리터"라고 한다. 인터프리터 방식을 채택한 언어는 Python, Javascript가 있다. 왜 굳이 이 두 방법을 모두 설명한 이유가 있다. JVM은 욕심쟁이라 이 두 방법을 모두 사용하는 "하이브리드" 방식이기 때문이다.
하이브리드 방식은 고급 언어로 작성 된 코드를 중간 코드로 변환한 후 이 중간 코드를 인터프리터 방식으로 컴퓨터에서 프로그램을 실행시킨다. 중간 코드는 컴퓨터에서 직접적으로 실행 될 수 없는 코드지만 컴퓨터 하드웨어에 독립적인 코드이다. 바로 이 중간 코드 덕분에 자바의 강점 중 하나인 "이식성"이 두드러지는 것이다.
벌써 너무 힘들지만 조금 더 딥하게 들어가보자.(딥 어제 배운 영단어 아님)
JVM Architecture
사람은 겉모습만 보고 판단하면 안 된다. JVM도 그렇다. 이제 JVM의 내면을 좀 알아봐 줄 차례이다. JVM은 크게 세 가지 영역으로 구성된다.
- 클래스 로더(Class Loader)
- JVM Memory(Runtime Data Area)
- 실행 엔진(Execution Engine)
Class Loader
클래스 로더는 소스 파일을 컴파일 하면 '*.java' 파일이 바이트 코드로 변환되면서 '*.class' 파일이 생성된다. 이 클래스 파일들을 주 메모리에 적재하는 일을 하는데, 한 번에 모든 클래스 파일을 올리는 것이 아니라 실행되는 시점에 필요한 클래스 파일들을 메모리에 로드한다. 클래스 로더에는 세 가지 유형이 있고, 각자 담당하는 클래스 파일들이 있다.
- BootStrap Class Loader
- 루트 클래스 로더이며 Extension ClassLoader의 상위 클래스이다.
- 표준 Java 패키지(java.lang, java.util, java.io 등)를 로드한다.
- 경로 : $JAVA_HOME/jre/lib
- Extension Class Loader
- BootStrap ClassLoader의 하위 클래스이자 System ClassLoader의 상위 클래스이다.
- 표준 Java 라이브러리의 확장 클래스를 로드한다.
- 경로 : $JAVA_HOME/jre/lib/ext
- System Class Loader(Application Class Loader)
- 최하위 클래스 로더이다.
- 클래스 경로에 있는 파일을 로드한다.
- 경로 : 기본적으로 클래스 경로는 애플리케이션의 현재 디렉토리로 설정된다. (수정할 수도 있긴 있음)
JVM에서는 이 세 가지 클래스 로더들로 클래스를 어떻게 핸들링 할까? 클래스 로딩 프로세스에는 로딩, 링크, 초기화 세 단계가 존재한다.
Loading
로딩 작업은 특정 이름을 가진 클래스 혹은 인터페이스의 바이트 코드를 가져와 클래스 또는 인터페이스를 생성하는 작업이다.
JVM에서는 ClassLoader에 클래스를 어떤 방식으로 로드할까? JVM은 대충대충 돌아가지 않는다. 다 나름의 원칙이 있다.
그림에서 본 것과 같이 JVM에서 클래스를 발견하면 해당 클래스가 이미 로드 되었는지 확인한다. 만약 클래스가 메소드 영역에 로드 외어 있으면 JVM이 실행하고, 없을 경우에는 세 개의 ClassLoader를 탐색한다. 이때 항상 Application ClassLoader > Extension ClassLoader > Bootstrap Class Loader 순서로 요청을 위임한다. Bootstrap Class Loader의 해당 클래스 경로에서 검색한 후 클래스가 사용 가능하면 로드 되고, 그렇지 않으면 반대로 Extenstion Class Loader에게 위임한다. Extension Class Loader 역시 본인의 클래스 경로에서 클래스를 검색한 후 클래스가 사용 가능하면 로드되고, 그렇지 않으면 Application Class Loader에게 위임한다. 마지막으로 Application Class Loader에서 클래스 검색 후 클래스가 사용 가능하면 로드되고, 그렇지 않으면 ClassNotFoundException이나 NoClassDefFoundError가 발생한다.
구구절절을 정리하자면 클래스 로더는 클래스 또는 자원을 찾기 위해 본인에게 없으면 상위 클래스 로더에게 위임한다. Application Class Loader > Extenstion Class Loader > Bootstrap Class Loader 순으로 위임하며 Bootstrap, Extenstion Class Loader에서 클래스 로드에 실패한 경우에만 Application Loader가 클래스를 로드하려고 시도한다.
Linking
클래스가 메모리에 로드된 후 이 단계를 거친다. 클래스 또는 인터페이스를 연결하는 작업이며 3단계의 링킹 과정을 거친다.
- Verify
- '*.class' 파일의 구조적이나 문법적으로 문제가 없는지 확인한다. 에러 발생 시 VerifyException이 발생한다.
- Prepatation
- JVM은 클래스의 정적 필드에 메모리를 할당하고 기본 값으로 초기화한다.
- 예를 들어, private static final boolean enabled = true;라는 코드가 있을 때 boolean 타입의 기본 값인 false를 enabled에 대입한다.
- Resolution
- 선택적으로 진행되는 과정이다.
- 심볼릭 메모리 레퍼런스를 메소드 영역에 있는 실제 힙 메모리 영역의 인스턴스에 대한 레퍼런스로 대체한다.
- Initialization
- Prepatation에서 메모리에 할당한 정적 필드의 값들을 할당한다.
- enabled의 원래 대입 값인 true를 대입한다.
- 클래스 또는 인터페이스의 초기화 메서드가 실행이 된다.
Runtime Data Area
런타임 데이터 영역에는 Method Area, Heap, Java Stack, PC Registers, Native Method Stacks 다섯 가지 구성 요소가 있다.
Method Area
Runtime Constant Pool, 필드 및 메서드와 같은 모든 클래스 수준의 데이터와 생성자 등이 저장된다. 만일 Method Area에 가용한 메모리가 충분하지 않을 경우 JVM은 OutOfMemoryError를 내뱉는다. 이 영역은 JVM 시작 시 생셩되며 JVM 당 하나씩만 존재한다. 그렇기 때문에 JVM의 모든 Thread들이 Method Area를 공유한다.
Heap
모든 객체와 객체와 관련된 인스턴스 변수가 저장된다. 모든 클래스의 인스턴스 및 배열 등을 메모리에 할당한다. Heap 또한 JVM 당 하나만 존재하기 때문에 여러 Thread에서 동일한 메모리를 공유하기 때문에 동시성 문제가 발생할 수 있다. Garbage Collector가 관리하는 공간이다.
Java Stacks
JVM에 새로운 Thread가 생성될 때마다 따로 할당되는 영역이다. 메서드와 관련된 지역 변수와 매개변수, 메서드 호출 및 부분적인 결과가 이 영역에 저장된다.
모든 메서드 호출에 대해 Stack Frame이 생성되며 메서드 호출이 종료되면 해당 Stack Frame은 소멸된다.
- Stack Frame
- Local Variable
- 각 프레임에 로컬 변수를 저장하는 변수 배열이 존재한다.
- 모든 지역 변수와 해당 값이 여기에 저장된다.
- 배열의 길이는 컴파일 시점에 결정된다.
- Operand Stack
- 각 프레임에 연산과 관련된 후입선출(LIFO) 스택이 존재한다.
- 중간 작업을 수행하기 위한 런타입 작업 공간 역할을 한다.
- 이 스택의 최대 깊이는 컴파일 시점에 결정된다.
- Runtime Constant Pool Reference
- Runtime Constant Reference는 Constant Pool 참조를 위한 공간이다
- Local Variable
이 스택의 사이즈는 고정되어 있어 프로그램 실행 중에 JVM 스택에서 메모리가 부족하다면 StackOverFlowError가 발생한다.
PC Register
Thread가 시작될 때 생성되며, 현재 수행중인 JVM 명령어 주소를 저장하는 공간이다. 다른 함수의 호출 등으로 인해 기존에 실행되던 함수의 명령어 주소를 저장해 놓고 호출한 다른 함수가 종료됐을 때 저장했던 JVM의 명령어 주소로 돌아가 그 다음 명령들을 수행한다.
Native Method Stacks
바이트 코드가 아닌 컴퓨터가 실제로 실행할 수 있는 기계어로 작성된 프로그램을 실행시킨다. 자바 이외의 언어(C, C++ 등)로 작성된 네이티브 코드를 실행하는 영역이다.
Execution Engine
Class Loader가 메모리에 적재한 클래스(바이트 코드)들을 기계어로 변경하여 명령어 단위로 실행한다.
이때 인터프리터 방식과 JIT 컴파일러를 이용하는 방식이 있다.
interpreter
인터프리터는 바이트코드 명령어를 한 줄씩 읽으면서 바로바로 번역하기 때문에 비교적 실행 속도가 느린 편이다. 그리고 동일한 메서드가 여러 번 호출 될 때마다 매번 재해석이 필요하다.
JIT Compiler
JIT 컴파일러는 인터프리터의 단점을 극복했다. Execution Engine은 인터프리터를 사용해 바이트 코드를 실행한다. 그러다가 반복되는 코드를 발견하면 JIT 컴파일러를 사용한다.
JIT 컴파일러는 전체 바이트 코드를 컴파일하고 원시 기계 코드로 변경한다. 그러다 반복되는 코드가 나타났을 때 네이티브로 컴파일된 코드를 실행하는 방식으로 성능을 높였다.
Garbage Collector (GC)
Heap 영역에 생성된 객체들 중에서 참조되지 않은 객체들을 탐색 후 제거한다.
- 참조