JVM의 동작 방식 (자바 코드 실행 과정)
1. 자바로 개발된 프로그램을 실행하면 JVM은 OS로부터 메모리를 할당받는다. (JVM은 이 메모리를 용도에 따라 여러 영역으로 나누어 관리한다.)
2. 컴파일(Compile): 작성한 자바 소스코드(.java)를 자바 컴파일러를 사용하여 컴파일한다. 컴파일은 소스 코드를 기계어가 아닌 중간 단계의 바이트 코드(Bytecode)로 변환하는 과정이다. 컴파일된 바이트 코드는 .class 확장자를 가진 파일에 저장된다.
3. 클래스 로딩: 자바 가상 머신(Java Virtual Machine, JVM)은 프로그램을 실행하기 전에 컴파일된 바이트 코드를 클래스로 로딩한다. 클래스 로더(Class Loader)는 필요한 클래스들을 찾아서 메모리(JVM Runtime Data Area)에 적재하고, 실행에 필요한 클래스들의 정보를 JVM에 제공한다.
4. 바이트 코드 해석: JVM은 메모리(Runtime Data Area)에 로드된 클래스의 바이트 코드(.class)를 Execution Engine을 통해 해석하여 실행한다. 바이트 코드는 JVM이 이해할 수 있는 기계어에 가까운 형태로, JVM은 이를 실행하여 프로그램을 동작시킨다.
5. 실행: JVM은 바이트 코드를 한 줄씩 실행한다. 프로그램은 main 메서드가 있는 클래스에서 시작되며, main 메서드는 프로그램의 진입점 역할을 한다. 실행 중에 필요한 데이터나 객체들은 메모리에 생성되며, JVM은 이들을 관리한다.
(ex. Garbage Collector)
자바 코드가 실행되는 과정을 살펴보면 C/C++과는 다르다는 것일 알 수 있다. C/C++은 운영체제 별로 컴파일러가 존재해서 해당 운영체제가 인식할 수 있는 기계어로 변환된다. 반면에 자바는 자바 컴파일러 하나만 존재하며, 기계어가 아닌 중간 단계의 바이트 코드로 변환된다. 또한 운영체제가 아닌 JVM에 의해 실행된다.
그럼 JVM은 무엇이고 왜 사용될까?
💡 바이트 코드란?
바이트 코드란 JVM에서 작동하도록 만든 이진 코드이다. 즉, JVM이 이해할 수 있는 언어로 변환된 코드이며 명령어의 크기가 1 바이트라서 자바 바이트 코드라고 불리고 자바 코드를 배포하는 가장 작은 단위이다. 확장자는 .class이다.
JVM이란?
자바 가상 머신 JVM(Java Virtual Machine)은 자바 프로그램 실행환경을 만들어 주는 소프트웨어다. 자바 코드를 컴파일하여 .class 바이트 코드로 만들면 이 코드가 자바 가상 머신 환경에서 실행된다. JVM은 자바 실행 환경 JRE(Java Runtime Environment)에 포함되어 있으며 현재 사용하는 컴퓨터의 운영체제에 맞는 자바 실행환경 (JRE)가 설치되어 있다면 자바 가상 머신이 설치되어 있다는 뜻이다.
Java는 어떠한 플랫폼에 영향을 받지 않는다.
JVM을 사용함으로써 얻는 가장 큰 이점이 무엇일까? JVM을 사용하면 하나의 바이트 코드(.class)로 모든 플랫폼에서 동작하도록 할 수 있다. 참고로, JVM 자체는 플랫폼에 의존하기 때문에 윈도우용 JVM, 리눅스용 JVM이 따로 존재한다.
.class 파일은 바이트 코드라고 하는데 사람이 쓰는 자바 코드에서 컴퓨터가 읽는 기계어로의 중간 단계라고 생각하시면 된다.
예를 들어 C언어로 작성된 Test.c가 있다. 이 Test.c를 윈도우 컴파일러를 사용해서 컴파일하면 Test.exe가 만들어진다. 윈도우 컴파일러로 컴파일되었기에 Test.exe는 윈도우에서만 실행되는 실행 파일이다. 리눅스 운영체제에서는 실행할 수 없다. 즉 C / C++에서는 컴파일 플랫폼과 타겟 플랫폼이 다를 경우에는 프로그램이 동작하지 않는다. 만약 이 Test.exe 파일을 리눅스 운영체제에서 실행하려면 리눅스 환경을 타겟으로 크로스 컴파일을 해서 리눅스 운영체제에 맞는 실행 파일을 새로 만들어야 한다.
Java의 경우에는 Java언어로 작성된 Test.java는 컴파일하면 Test.class 파일이 생성된다. 그리고 이렇게 생성된 바이트 코드는 각자의 플랫폼에 설치되어 있는 자바 가상 머신(JVM)이 운영체제에 맞는 실행 파일로 바꿔준다. 즉 Java에서는 C언어와는 달리 JVM을 사용하기 때문에 각자의 플랫폼에 맞게끔 컴파일을 따로따로 해줘야 할 필요가 없다. 하나의 바이트 코드로 JVM이 설치되어 있는 모든 플랫폼에서 동작이 가능하다는 이야기다.
정리하자면, JVM은 실 운영체제를 대신해서 컴파일된 바이트 코드를 실행하는 가상의 운영체제 역할을 한다.
※ Java는 플랫폼에 종속적이지 않지만 JVM은 플랫폼에 종속적이다.
이렇게 Java는 컴파일된 바이트코드로 어떤 JVM에서도 동작시킬 수 있기 때문에 플랫폼에 의존적이지 않다. 하지만 반대로 자바 가상 머신(JVM)은 플랫폼에 의존적이다. 즉 리눅스의 JVM과 윈도우의 JVM은 서로 다르다. 자바로 작성된 모든 프로그램은 자바 가상 머신에서만 실행될 수 있으므로, 자바 프로그램을 실행하기 위해서는 반드시 자바 가상 머신이 설치되어 있어야 한다. 따라서 오라클은 대부분의 주요 운영체제뿐만 아니라 웹 브라우저, 스마트 폰, 가전기기 등에서도 자바 가상 머신을 손쉽게 설치할 수 있도록 지원하고 있다.
JVM 명세
JVM 명세 (The Java® Virtual Machine Specification)를 따르기만 한다면 누구나 JVM을 개발하여 제공할 수 있다. 대표적으로 오라클의 핫스팟 JVM, IBM JVM 이외에도 다양한 JVM이 존재한다. JVM의 명세를 살펴보면 다음과 같은 말이 나온다.
To implement the Java Virtual Machine correctly, you need only be able to read the class file format and correctly perform the operations specified therein. Implementation details that are not part of the Java Virtual Machine's specification would unnecessarily constrain the creativity of implementors.
JVM의 실행시키기 위해서는 클래스 파일을 읽어서 지정된 작업을 올바르게 수행하기만 하면 된다. 명령 실행에 대한 구체적인 사항은 구현자의 창의력을 저해시킬 수 있기 때문에 JVM에 명시하지 않겠다.
For example, the memory layout of run-time data areas, the garbage-collection algorithm used, and any internal optimization of the Java Virtual Machine instructions (for example, translating them into machine code) are left to the discretion of the implementor.
예를 들어 런타임 영역에 대한 메모리 배치, 가바지 컬렉션의 알고리즘, 바이트 코드를 기계어로 변환하는 방법들은 구현자의 재량으로 남겨두겠다.
JVM 명세는 모든 JVM이 필수적으로 지켜야하는 사항에 대해서만 명시하고 있으며 구체적인 구현 방법은 JVM마다 다르다.
JVM의 특징
- 스택 기반의 가상 머신
- 대표적인 컴퓨터 아키텍처인 인텔 x86 아키텍처나 ARM 아키텍처와 같은 하드웨어가 레지스터 기반으로 동작하는 데 비해 JVM은 스택 기반으로 동작한다.
- 심볼릭 레퍼런스
- 기본 자료형(primitive data type)을 제외한 모든 타입(클래스와 인터페이스)을 명시적인 메모리 주소 기반의 레퍼런스가 아니라 심볼릭 레퍼런스를 통해 참조한다.
- 가비지 컬렉션(garbage collection)
- 클래스 인스턴스는 사용자 코드에 의해 명시적으로 생성되고 가비지 컬렉션에 의해 자동으로 파괴된다.
- 기본 자료형을 명확하게 정의하여 플랫폼 독립성 보장
- C/C++ 등의 전통적인 언어는 플랫폼에 따라 int 형의 크기가 변한다. JVM은 기본 자료형을 명확하게 정의하여 호환성을 유지하고 플랫폼 독립성을 보장한다.
- 네트워크 바이트 오더(network byte order)
- 자바 클래스 파일은 네트워크 바이트 오더를 사용한다. 인텔 x86 아키텍처가 사용하는 리틀 엔디안이나, RISC 계열 아키텍처가 주로 사용하는 빅 엔디안 사이에서 플랫폼 독립성을 유지하려면 고정된 바이트 오더를 유지해야 하므로 네트워크 전송 시에 사용하는 바이트 오더인 네트워크 바이트 오더를 사용한다. 네트워크 바이트 오더는 빅 엔디안이다.
바이트 코드를 읽는 방식
JVM은 바이트코드를 명령어 단위로 읽어서 해석하는데, Interpreter 방식과 JIT 컴파일 방식 두 가지 방식을 혼합하여 사용한다. 먼저 Interpreter 방식은 바이트코드를 한 줄씩 해석, 실행하는 방식이다. 초기 방식으로, 속도가 느리다는 단점이 있다.
이렇게 느린 속도를 보완하기 위해 나온 것이 JIT(Just In Time) 컴파일 방식이다. 바이트코드를 JIT 컴파일러를 이용해 프로그램을 실제 실행하는 시점(바이트코드를 실행하는 시점)에 각 OS에 맞는 Native Code로 변환하여 실행 속도를 개선하였다. 하지만, 바이트코드를 Native Code로 변환하는 데에도 비용이 소요되므로, JVM은 모든 코드를 JIT 컴파일러 방식으로 실행하지 않고, 인터프리터 방식을 사용하다가 일정 기준이 넘어가면 JIT 컴파일 방식으로 명령어를 실행한다.
JIT (Just In Time) 컴파일러란?
기존의 자바는 인터프리터 방식으로 명령어를 하나씩 실행하게끔 이루어져 있어 실행 속도가 느렸다. 하지만 하드웨어가 발전하면서 자바 컴파일러도 JIT 컴파일러 방식으로 개선되어 속도적인 측면에서 상당한 개선을 이루었다. JVM은 JIT(Just In Time) 컴파일러라고 한다. 또한, JIT 컴파일러는 같은 코드를 매번 해석하지 않고, 실행할 때 컴파일을 하면서 해당 코드를 캐싱해버린다. 이후에는 바뀐 부분만 컴파일하고, 나머지는 캐싱된 코드를 사용한다. 이렇게 JIT 컴파일러는 운영체제에 맞게 바이트 실행 코드로 한 번에 변환하여 실행하기 때문에 이전의 자바 해석기(Java interpreter) 방식보다 성능이 10배 ~ 20배 정도 더 좋다.
JVM에 대해 더 자세히 알고 싶다면 아래 링크를 참고.
참고