JVM 메모리구조
JVM(Java Virtual Machine)은 Java로 개발한 응용 프로그램을 컴파일하여 만들어지는 바이트코드를 실행시키기 위한 가상머신입니다. JRE(Java Runtime Environment)에 포함되어 있으며, Java 컴파일러가 프론트엔드를 담당한다면 Java 가상 머신은 코드 최적화와 백엔드를 담당합니다. Java 소스 코드는 javac 컴파일러를 거쳐 바이트코드로 변환되며, 이 바이트코드는 JRE에 들어있는 java classloader에 의해 JVM으로 적재되고 JVM은 적재된 바이트코드를 JIT 컴파일러를 사용한 방식으로 실행합니다.
JVM은 플랫폼 독립적인 특성을 가지며, JVM이 실행 가능한 환경이라면 어디서든 Java 프로그램이 실행될 수 있도록 합니다. 하지만 특정 운영체제의 특수한 기능을 호출하거나 하드웨어를 제어하는 등의 일은 JVM으로 할 수 없으며, 이러한 일을 수행하기 위해선 JNI 같은 Native 코드를 호출하기 위한 인터페이스를 거쳐야 합니다.
좀 더 간단하게 정리하면 JVM은 Java 응용 프로그램이 실행될 때 시스템으로부터 필요한 메모리를 할당받고 이 메모리를 용도에 따라 여러 영역으로 나누어 관리하는 기능을 수행합니다.
※ JNI (Java Native Interface)
JVM 위에서 실행되고 있는 자바코드가 네이티브 응용 프로그램(하드웨어와 운영 체제 플랫폼에 종속된 프로그램들) 그리고 C, C++ 그리고 어샘블리 같은 다른 언어들로 작성된 라이브러리들을 호출하거나 반대로 호출되는 것을 가능하게 하는 프로그래밍 프레임워크
※ 샌드박스 (Sandbox)
미국에서 어린아이를 보호하기 위해 모래통(Sandbox)에서만 놀도록 하는데서 유래한 보안 모델. 외부 접근 및 영향을 차단하여 제한된 영역 내에서만 프로그램을 동작시키며, 샌드박스 내에서 어떤 파일이나 프로세스가 안전하지 못하다고 판명되면 외부로의 접근을 차단하여 시스템에 피해를 입히는 것을 방지함.
JVM의 이름이 Java 가상 머신이다 보니 Java에 종속된 게 아닌가 생각할 수도 있지만 꼭 그렇지만은 않습니다. Java 가상머신이라고 해서 Java 바이트 코드만 인식하는 것은 아니며, Java가 아닌 다른 언어들을 가지고도 이 바이트코드를 생성할 수 있기 때문입니다. JVM은 코틀린 코드를 컴파일해도 읽을 수도 있고 스칼라 코드를 컴파일해도 읽을 수 있습니다. 단, 기본이 Java를 위해서 만들어졌기 때문에 Java 소스 코드로 컴파일된 바이트코드는 직관적으로 연결되지만, 코틀린이나 스칼라의 경우 Java와의 호환성을 고려하긴 했어도 상대적으로는 비직관적으로 연결됩니다.
마지막으로 현재 JVM은 .NET Framework와 함께 가상머신 언어 시장을 양분하고 있습니다.
JVM 동작 원리
다음은 Java SE7 기반 JVM의 아키텍처 개요도입니다. 아래 개요도를 기준으로 JVM 동작 원리에 대해 알아보겠습니다.
Java로 작성한 코드는 아래와 같은 수행 과정을 통해 실행됩니다. 클래스 로더(Class Loader)가 컴파일된 자바 바이트 코드를 런타임 데이터 영역(Runtime Data Areas)에 로드하고, 실행 엔진(Execution Engine)이 자바 바이트코드를 실행하는 방식입니다. 이에 대해 각 영역별로 좀 더 자세하게 알아보겠습니다.
클래스 로더 (Class Loader)
자바는 동적 로딩을 하는 특징을 갖고 있습니다. 동적 로딩은 컴파일타임이 아니라 런타임에 클래스를 처음으로 참조할 때 해당 클래스를 로드하고 링크하는 특징이 있습니다. 즉, 실행시 모든 클래스를 로딩하지 않고 필요한 시점에 로딩합니다. 이 동적 로드를 담당하는 부분이 JVM의 클래스 로더입니다.
실행 엔진 (Execution Engine)
클래스 로더를 통해 JVM 내의 런타임 데이터 영역에 배치된 바이트코드는 실행 엔진에 의해 실행됩니다. 실행 엔진은 자바 바이트코드를 명령어 단위로 읽어서 실행합니다. 이것은 CPU가 기계 명령어를 하나씩 실행하는 것과 비슷한 방식입니다.
컴파일된 Java 바이트 코드는 기계가 바로 수행할 수 있는 언어라기보다는 사람이 보기 편한 형태로 기술된 것입니다. 따라서 실행 엔진은 이와 같은 바이트코드를 실제로 JVM 내부에서 기계가 실행할 수 있는 형태로 변경하며, 그 방식에는 다음과 같이 두 가지가 있습니다.
인터프리터
바이트코드 명령어를 하나씩 읽어서 해석하고 실행하는데, 바이트코드의 해석은 빠르지만 인터프리팅 결과의 실행은 느리다는 단점을 가지고 있음. 일반적으로 바이트코드는 기본적으로 인터프리터 방식으로 동작함.
JIT(Just-In-Time) 컴파일러
인터프리터의 단점을 보완하기 위해 도입되었음. 인터프리터 방식으로 실행하다가 적절한 시점에 바이트코드 전체를 컴파일하여 네이티브 코드로 변경하고, 이후에는 해당 메서드를 더 이상 인터프리팅하지 않고 네이티브 코드로 직접 실행하는 방식. 네이티브 코드는 캐시에 보관되기 때문에 한 번 컴파일된 코드는 계속 빠르게 수행할 수 있음.
※ JIT 컴파일러가 컴파일하는 과정은 바이트코드를 하나씩 인터프리팅하는 것보다 훨씬 오래 걸리므로, 한 번만 실행되는 코드라면 컴파일하지 않고 인터프리팅하는 것이 훨씬 유리함. 따라서 JIT 컴파일러를 사용하는 JVM들은 내부적으로 해당 메서드가 얼마나 자주 수행되는지 체크하고, 일정 정도를 넘을 때에만 컴파일을 수행하도록 되어있음.
런타임 데이터 영역 (Runtime Data Areas)
런타임 데이터 영역은 JVM이 운영체제 위에서 실행되면서 할당받는 메모리 영역입니다. 런타임 데이터 영역은 6개의 영역으로 나눌 수 있습니다. 이 중 PC 레지스터(PC Register), JVM 스택(JVM Stack), 네이티브 메서드 스택(Native Method Stack)은 스레드마다 하나씩 생성되며 힙(Heap), 메서드 영역(Method Area), 런타임 상수 풀(Runtime Constant Pool)은 모든 스레드가 공유해서 사용합니다.
다음은 런타임 데이터 영역의 구조입니다.
PC 레지스터 (PC Register)
각 스레드마다 하나씩 존재하며 스레드가 시작될 때 생성됩니다. PC 레지스터는 현재 수행 중인 JVM 명령의 주소를 갖습니다.
JVM 스택 (JVM Stack)
마찬가지로 각 스레드마다 하나씩 존재하며 스레드가 시작될 때 생성됩니다. 스택 프레임(Stack Frame)이라는 구조체를 저장하는 스택으로, JVM은 스택 프레임을 추가하고(push) 제거하는(pop) 동작을 JVM 스택을 대상으로 수행합니다. 예외 발생 시 printStackTrace() 등의 메서드로 출력해주는 Stack Trace의 각 라인은 하나의 스택 프레임을 표현합니다.
※ 스택 프레임
JVM 내에서 메서드가 수행될 때마다 하나의 스택 프레임이 생성되어 해당 스레드의 JVM 스택에 추가되고 메서드가 종료되면 스택 프레임이 제거됨. 각 스택 프레임은 지역 변수 배열(Local Variable Array), 피연산자 스택(Operand Stack), 현재 실행 중인 메서드가 속한 클래스의 런타임 상수 풀에 대한 레퍼런스를 갖고 있음. 지역 변수 배열, 피연산자 스택의 크기는 컴파일 시에 결정되기 때문에 스택 프레임의 크기도 메서드에 따라 크기가 고정됨.
※ 지역 변수 배열
0부터 시작하는 인덱스를 가진 배열.
index 0 : 메서드가 속한 클래스 인스턴스의 this 레퍼런스
index 1 이후 : 메서드에 전달된 파라미터들이 저장되며, 메서드 파라미터 이후에는 메서드의 지역 변수들이 저장됨.
※ 피연산자 스택
메서드의 실제 작업 공간. 각 메서드는 피연산자 스택과 지역 변수 배열 사이에서 데이터를 교환하고, 다른 메서드 호출 결과에 대해 추가(push), 제거(pop) 과정을 반복함. 피연산자 스택 공간이 얼마나 필요한지는 컴파일할 때 결정할 수 있으므로, 피연산자 스택의 크기도 컴파일 시에 결정됨.
JVM 스택은 다른 참고 문헌에서 확인한 결과 호출 스택(Call Stack) 또는 실행 스택(Execution Stack) 이라고도 하며, 메서드의 작업에 필요한 메모리 공간을 제공합니다. JVM 스택에서 가장 상위에 위치한 메서드가 현재 실행 중인 메서드이며 바로 아래에 있는 메서드가 해당 메서드를 호출한 메서드입니다. 이를 통해 메서드 간의 호출 관계와 현재 실행중인 메서드를 파악할 수 있습니다.
네이티브 메서드 스택 (Native Mathod Stack)
자바 외의 언어로 작성된 네이티브 코드를 위한 스택입니다. 즉, JNI(Java Native Interface)를 통해 호출하는 C/C++ 등의 코드를 수행하기 위한 스택으로, 언어에 맞게 C 스택이나 C++ 스택이 생성됩니다.
메서드 영역 (Method Area)
모든 스레드가 공유하는 영역으로 JVM이 시작될 때 생성됩니다. JVM이 읽어 들인 각각의 클래스와 인터페이스에 대한 런타임 상수 풀, 필드와 메서드 정보, Static 변수, 메서드의 바이트코드 등을 보관합니다. 즉, 클래스에 정의된 모든 데이터와 클래스 변수가 생성됩니다.
힙 (Heap)
인스턴스 또는 객체를 저장하는 공간으로 가비지 컬렉션의 대상 영역입니다. 즉, 프로그램 실행 중 생성되는 모든 인스턴스와 인스턴스 변수가 생성되는 영역입니다. 또한 JVM 성능 등의 이슈에서 가장 많이 언급되는 공간입니다.
런타임 상수 풀 (Runtime Constant Pool)
메서드 영역에 포함되는 영역이며, JVM 동작에서 가장 핵심적인 역할을 수행하는 곳입니다. 각 클래스와 인터페이스의 상수뿐만 아니라, 메서드와 필드에 대한 모든 레퍼런스까지 담고 있는 테이블입니다. 즉, 어떤 메서드나 필드를 참조할 때 JVM은 런타임 상수 풀을 통해 해당 메서드나 필드의 실제 메모리상 주소를 찾아서 참조합니다.
마지막으로 간단한 예제를 통해 메서드가 동작 순서에 따라 JVM 스택의 push/pop 진행 과정을 살펴보겠습니다.
public class CallStackTest { public static void main(String[] args) { firstMethod(); } static void firstMethod() { secondMethod(); } static void secondMethod() { System.out.println("secondMethod()"); } }
위의 코드를 실행하면 다음과 같은 순서로 JVM 스택이 동작하게 됩니다. ①~⑨ 단계가 모두 수행되면 스택은 완전히 비워지고 프로그램은 종료됩니다.
① |
② |
③ |
④ |
⑤ |
⑥ |
⑦ |
⑧ |
⑨ |
|
|
|
|
|
|
|
|
|
println() |
||||||||
secondMethod() |
secondMethod() |
secondMethod() |
||||||
firstMethod() |
firstMethod() |
firstMethod() |
firstMethod() |
firstMethod() |
||||
main() |
main() |
main() |
main() |
main() |
main() |
main() |
이를 통해 스택에서 메서드 간의 호출 관계와 현재 수행중인 메서드를 확인할 수 있습니다.
이상으로 Java의 JVM 메모리구조에 대해서 알아봤습니다.
※ 참고 문헌
남궁성, 『Java의 정석 3rd Edition』, 도우출판(2016), p261. chapter 06 객체지향 프로그래밍 I
- namu.wiki, jvm, https://namu.wiki/w/Java%20Virtual%20Machine
- d2.naver.com, jvm, https://d2.naver.com/helloworld/1329