[Java] JVM 란?

🔎 JDK, JRE, JVM

JDK, JRE, JVM

JDK
Java Development Kit (자바 개발 키트)
자바 개발 환경으로 자바 어플리케이션을 개발하기 위해 필요한 도구를 제공한다. 자바 언어를 바이트 코드로 컴파일 해주는 자바 컴파일러(javac), 자바 클래스 파일을 해석해주는 역 어셈블리어(javap) 등이 있다. 자세한 내용은 Tools and Commands Reference 에서 확인이 가능하다.

JRE
Java Runtime Environment(자바 런타임 환경)
자바 실행 환경으로 JVM, 자바 클래스 라이브러리, 기타 자바 어플리케이션 실행에 필요한 파일들을 포함한다.

JVM
Java Virture Machine(자바 가상 머신)
Java는 운영체제에 종속적이지 않다는 특징을 가지고 있다. 운영체제에 종속받지 않고 실행되기 위해선 운영체제에서 Java를 실행시킬 무언가가 필요하다. 그게 바로 JVM이다.

컴파일 과정

Java 소스코드, 즉 원시코드(.java)는 CPU가 인식을 하지 못하므로 기계어로 컴파일을 해줘야한다. Java는 JVM이라는 가상머신을 거쳐서 운영체제에 도달하기 때문에 운영체제가 인식할 수 있는 기계어로 바로 컴파일 되는게 아니라 자바 컴파일러를 통해 JVM이 인식할 수 있는 Java bytecode(.class)로 변환된다.

여기서 자바 컴파일러는 JDK를 설치하면 bin에 존재하는 javac.exe를 말한다. (즉, JDK에 자바 컴파일러가 포함되어 있다는 소리)
javac 명령어를 통해 .java.class로 컴파일할 수 있다.

변환된 bytecode는 기계어가 아니기 때문에 운영체제에서 바로 실행되지 않는다.
이 때, JVM이 바이트코드를 기계어로 변환해서 CPU가 실행 가능하게 만들어준다.

바이트코드란? 가상 머신(VM)이 이해할 수 있는 중간 코드로 컴파일한 것을 말한다.
자바 바이트 코드(Java bytecode)는 JVM이 이해할 수 있는 언어로 변환된 자바 소스코드를 의미한다. 자바 컴파일러에 의해 변환된 코드의 명령어 크기가 1바이트라서 자바 바이트 코드라고 불리고 있다.

바이너리 코드란?
바이너리 코드 또는 이진 코드라고 한다. 컴퓨터가 인식할 수 있는 0과 1로 구성된 이진코드를 의미한다.

🩻 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 명세에 포함되지 않은 구현 세부 사항은 구현자들의 창의력을 불필요하게 제약할 수 있다.

JVM 명세에서 모든 JVM이 필수적으로 지켜야하는 사항에 대해서만 명시하고 있으며 구체적인 구현 방법은 다르다고 한다. 따라서 구조 또한 JVM마다 다르다.

JVM 명세를 바탕으로 JVM의 Runtime Data Area에 대해 알아보자.

🧠 Runtime Data Area

Runtime Data Area

Runtime Data Area는 JVM이 자바 프로그램을 실행하기 위해 운영체제로부터 할당받은 메모리 공간이다. 런타임 데이터 영역은 모든 스레드들이 공유하는 영역(Heap, Method)과 스레드 별 할당되는 영역(PC Register, Stack, Native Method Stack)으로 구분된다.

JVM을 시작하면 Heap 영역과 Method 영역이 생성되며 해당 영역들은 모든 스레드들이 공유한다. 각 스레드가 시작될 때마다 스레드마다 PC Register, Stack, Native Method Stack이 생성되며 스레드가 종료될 때 사라진다.
마지막으로 모든 스레드들이 실행되고 종료되면 JVM이 종료되면서 Heap 영역과 Method 영역도 사라진다.

🧭 PC Register

현재 실행중인 명령어의 주소를 저장한다.
멀티태스킹 환경에서 프로세스가 컨텍스트 스위칭을 통해 CPU를 다시 할당받을 때, PC Register에 저장된 값을 통해 이전에 실행하던 명령어의 다음 명령어부터 실행을 재개할 수 있다.

📚 Stack

각 스레드는 독립적인 Stack 영역을 갖는다. Stack 영역에는 Frame이라는 자료구조가 저장된다. Stack은 LIFO(Last In First Out) 방식으로 동작하며, 각 프레임은 함수의 실행 결과와 지역 변수를 저장한다.

Frame

  • Frame은 함수 호출 시 생성되고, 함수가 종료되면 사라지는 데이터 구조이다.
  • 프레임은 함수의 지역 변수, 연산 중간 결과를 위한 Operand Stack 그리고 Runtime Constant Pool에 대한 참조값을 지닌다.
  • 클래스 파일의 함수나 변수에 접근할 때는, Runtime Constant Pool에 저장된 Symbolic Reference를 통해 접근이 이루어진다.
  • 프로그램이 실행될 때, 이러한 Symbolic Reference는 동적 할당(프로그램 실행 시점에 메모리를 할당)을 통해 실제 메모리 주소로 변환된다.
  • Symbolic Reference를 통한 late-binding은 객체 지향의 핵심이다. (다형성)
Operand Stack
연산의 중간 결과나 함수 호출 시 전달되는 인자들을 임시로 저장하는 공간으로, 스택 자료구조를 사용한다. 함수 내에서 연산이 수행될 때, 이 스택을 통해 값이 저장되고 사용된다.
Symbolic Reference와 late-binding
Symbolic Reference는 실제 메모리 주소 대신 이름(심볼)으로 객체나 함수를 참조하는 방식을 의미한다. late-binding은 실행 시점에 메서드 호출이 결정되는 것을 의미한다. 이는 객체 지향 프로그램에서 다형성을 지원하는 중요한 개념이다. (같은 메서드 호출이지만 실행되는 객체에 따라 다른 구현이 실행될 수 있게 해준다.)

🌐 Native method stack

자바가 아닌 다른 프로그래밍 언어로 작성된 코드를 실행할 때 사용되는 스택이다.

🗃️ Heap

인스턴스(객체)와 배열들이 저장되는 공간이다.
힙은 GC(Garbage Collection)라는 동적 메모리 관리 시스템에 의해 관리된다. 힙의 구성 방식과 GC의 알고리즘은 JVM 구현체의 재량으로 자유롭게 구성된다.

📋 Method 영역

클래스와 인터페이스의 정보가 저장되는 공간이다. 즉, 프로그램이 실행될 때 클래스 파일(.class)에 정의된 모든 정보가 Method 영역에 로딩되어 관리된다.

Runtime Constant Pool

  • Runtime Constant Pool은 클래스, 인터페이스마다 존재하는 메모리 공간으로, 클래스 파일의 Constant Pool 테이블 영역이 저장되는 공간이다.
  • 각 클래스, 인터페이스의 필드(인스턴스 변수, 클래스 변수)와 메서드에 대한 심볼릭 레퍼런스가 존재한다.
  • 필드와 메서드에 대한 심볼릭 레퍼런스는 컴파일 시점에 결정되지만, 실제 메모리 주소는 런타임 시에 결정된다.
  • 런타임 상수 풀은 클래스가 생성되어 메서드 영역에 할당되며, 클래스가 삭제되면 사라진다.
  • 자세한 내용은 The Java® Virtual Machine Specification의 2장 The Structure of the Java Virtual Machine 확인 가능하다.

Constant Pool 테이블
Constant Pool 테이블은 자바 클래스 파일 내의 여러가지 상수들을 포함하는 테이블이다.
클래스 파일 구조에서 메타데이터 영역에 위치한다. 숫자 리터럴, 문자열 리터럴, 필드 참조, 메소드 참조 등 다양한 종류의 상수를 저장한다.

Constant Pool '테이블'
내가 아는 테이블은 데이터베이스 테이블밖에 없는데 여기서 말하는 테이블이 무슨 테이블을 말하는 걸까 궁금해서 알아봤다.
프로그래밍에서 테이블은 데이터나 정보를 조직화하는 구조적인 방법을 나타낸다. Constant Pool 테이블은 상수와 심볼 정보를 구조화된 방식으로 저장하는 데이터 집합이다. 상수 풀 내의 각 항목이 특정 형식으로 배열되어 있으며, 각 항목은 고유한 인덱스를 통해 접근된다.

🍰 자바의 동작 원리

🔧 Execution Engine

자바 실행 엔진이 어떻게 동작하는 지는 JVM 명세에 작성되어 있지 않다. 위에서 볼 수 있듯이 “JVM 명세에 포함되지 않은 구현 세부 사항은 구현자들의 창의력을 불필요하게 제약할 수 있다.” 라고만 적혀있다. 즉, 자바 실행 엔진은 JVM 마다 다른 것이다.

그렇다면 Oracle의 Hotspot은 어떻게 자바 명령을 실행하는지 간단하게 살펴보자.

실행 엔진

Hotspot은 자바 바이트코드를 명령어 단위로 읽어들인 후, 이를 기계어로 변환해 실행한다. 기본적으로는 Interpreter(인터프리터)를 통해 실행하지만, 자주 등장하는 바이트 코드일 경우 JIT Compiler를 사용해 컴파일을 하는 방법을 통해 최적화 시킨다. 자세한 내용은 Java Virtual Machine Guide을 참고하자.

Interpreter(인터프리터)
바이트코드 명령어를 하나씩 읽어서 해석하고 실행한다. 인터프리터 자체의 해석 과정은 빠르지만, 매번 해석해야 하는 오버헤드 때문에 전체적인 실행 속도가 느려진다는 단점이 있다. JVM은 바이트코드를 실행하기 위해 기본적으로 인터프리터를 사용한다.

JIT(Just-In-Time) Compiler
인터프리터의 단점을 보완하기 위해 도입된 것이 JIT 컴파일러다. 인터프리터 방식으로 실행하다가 적절한 시점에 자주 실행 되는 특정 메서드를 컴파일하여 네이티브 코드(기계어)로 변경하고, 이후에는 해당 메서드를 더이상 인터프리팅하지 않고 네이티브 코드로 직접 실행하는 방식이다. 네이티브 코드를 실행하는 것이 하나씩 인터프리팅 하는 것보다 빠르고, 네이티브 코드는 캐시(코드 캐시, 메모리에 저장)에 보관하기 때문에 한 번 컴파일된 코드는 계속 빠르게 수행되게 된다.

📦 Class Loader

Class Loader

클래스 로더는 클래스 파일의 바이트코드를 읽어 런타임 데이터 영역으로 가져온다.

  • Bootstrap Class Loader, Platform Class Loader, System Class Loader 3가지로 구분된다.
  • 계층 구조를 가지고 있으며 시스템 클래스 로더는 플랫폼 클래스 로더를 부모로 가지고, 플랫폼 클래스 로더는 부트스트랩 클래스 로더를 부모로 가진다.

🗂️ Class Loader의 종류

Bootstrap Class Loader
네이티브 코드로 작성되었으며 JVM에 내장되어 있다. (JVM의 일부이며, 일반적인 자바 클래스로 구현되지 않았다.) JVM이 시작될 때 실행되며 java.lang 패키지와 같은 JVM 실행에 필수적인 기본 클래스를 로딩한다.

Platform Class Loader
java.lang.ClassLoader의 인스턴스로 Java SE platform API 등 자바에서 기본적으로 제공해주는 클래스를 로딩할 때 사용된다.
Bootstrap Class Loader를 부모로 가지고 있다.
Java 9부터 모듈 시스템이 도입되면서 Platform Class Loader의 역할이 일부 변경되었다. 모듈 시스템에서는 모듈이라는 개념을 통해 클래스를 관리하며, Platform Class Loader는 모듈 시스템과 연동하여 클래스를 로딩한다.

System Class Loader
java.lang.ClassLoader를 상속받은 서브클래스로 유저가 작성한 클래스를 로딩할 때 사용된다. CLASSPATH에 명시된 경로를 통해 클래스를 찾는다.
일반적으로 Platform Class Loader를 부모로 가지지만, 모듈 시스템이 도입된 Java 9부터 모듈 시스템의 구성에 따라 부모 클래스 로더가 달라질 수 있다.

🏷️ Class Loader의 특징

  • 위임 모델 : 클래스 로더는 기본적으로 위임 모델을 채택한다. 자신에게 클래스 로딩 요청이 들어오면 자신의 부모 클래스 로더에게 클래스 로딩 요청을 보내고 부모 클래스 로더가 클래스를 찾지못하면 그 후에 자신이 클래스를 탐색한다.
  • 계층 구조 : 상위 클래스 로더의 클래스는 하위 클래스에서 볼 수 있지만 그 반대는 불가능 하다. 이러한 계층 구조를 통해 클래스 로더의 책임은 분리하고 클래스 로더는 자신이 책임지는 클래스를 로딩할 수 있다.
  • 자세한 내용은 How the JVM Locates, Loads, and Runs Libraries, Class ClassLoader 에서 확인할 수 있다.

🍳 Loading, Linking, Initializing

Loading, Linking, Initializing

JVM은 동적으로 로드, 링크, 초기화 과정을 진행한다.
로딩은 특정 이름을 가진 클래스 또는 인터페이스의 바이트 코드를 찾은 후 클래스 또는 인터페이스를 생성하는 과정이다.
JAVA 어플리케이션의 동작은 JVM을 시작한 후 특정 클래스를 런타임 데이터 영역으로 로딩한 후 로딩, 링크, 초기화 과정을 거쳐 최종적으로 특정 클래스의 public static method void main(String []) 함수를 실행하는 것이다.
해당 과정을 실행하면서 연쇄적으로 다른 클래스들을 로딩, 링크, 초기화한다.

🚀 JVM 시작

JVM이 시작되면 런타임 데이터 영역이 생성되고 그 안에 메소드, 힙 영역이 할당된다. JVM에 내장된 BootStrap Class Loader가 먼저 실행되어 JVM 실행에 필요한 클래스들(예: java.lang 패키지)을 메소드 영역으로 로딩한다. System Class Loader는 개발자가 작성한 애플리케이션 클래스나 외부 라이브러리를 메소드 영역으로 로딩한다.

⏳ 로딩

클래스 또는 인터페이스의 생성은 해당 클래스의 필드, 메소드, 런타임 상수 풀 등 클래스가 가지고 있는 바이트코드를 찾은 후 JVM의 메소드 영역에 구성하는 것을 의미한다.
클래스 로더를 통해 로딩을 진행하며 A 클래스를 로딩했을 때 A 클래스의 부모 클래스가 존재할 경우 먼저 부모 클래스를 로딩한다.

🔗 링크

링크는 검증(verification), 준비(Prepare), 분석(Resolution) 3가지 과정으로 이루어져 있다.

  • 검증 : 로딩된 바이트코드가 JVM 명세를 따르고 있는지 검증하는 과정
  • 준비 : 정적 필드를 각 유형의 기본값으로 초기화하는 과정 - Int type은 0으로, reference type은 null로 초기화 된다.
  • 분석 : 클래스의 런타임 상수 풀 안에 있는 Symbolic Reference를 고정된 주소 값으로 바꾸는 과정

검증, 준비, 분석 3가지 과정을 거치면서 다른 클래스의 로딩을 추가적으로 요청할 수 있다.
이 때 분석 과정은 검증, 준비 과정과 같은 시간에 일어날 필요가 없다. 보통 Symbolic Reference를 고정된 주소 값으로 변환시키는 분석 과정은 해당 명령이 실행될 때 일어난다.

🛠️ 초기화

클래스 초기화 함수를 실행한다. 클래스에 작성된 static 초기화 함수를 모두 합쳐 한꺼번에 실행한다. 초기화 과정은 로딩-검증-준비 과정이 모두 끝났을 때 한번만 실행된다.

🔚 JVM 종료

일부 스레드가 Runtime 클래스의 종료 메서드나 중지 메서드, 클래스 시스템의 종료 메서드를 호출하면 JVM 종료 또는 중지 작업이 Security Manager에 의해 허용된다.


참고

Tags:

Categories:

Updated:

Leave a comment