🤖 JVM이란?

JVM(Java Virtual Machine)은 자바 프로그램을 실행하기 위한 가상 컴퓨터(추상화된 기계)이다.

WORA?

자바에는 WORA라는 중요한 특징이 존재한다. 이는 "Write Once Run Anywhere" 의 준말로, 한 번 작성된 코드는 어디에서든 사용 가능하다는 뜻이다.

즉, 한 번 컴파일을 해 두면 어떤 OS 위에서도 사용할 수 있다는 것이며, 이를 OS 종속적이지 않다고도 부를 수 있다.

해당 특징은 기존의 어셈블리어, C언어의 단점을 보완해주었고, 이로 인해 많은 사람들이 자바를 애용하게 되었다.

그리고 이를 가능하게 해 주는 것이 바로 JVM의 역할이다.

컴파일

Java는 *.java, Python은 *.py와 같은 원시코드를 만들게 된다.

이는 인간이 읽을 수 있는 고수준 언어로, CPU가 읽게 하기 위해서는 기계어로 컴파일을 해 주는 과정이 필요하다.

여기서 자바는 JVM을 거쳐서 OS로 도달하기 때문에 바로 기계어로 컴파일 하는 것이 아니라, JVM이 인식할 수 있는 Java Bytecode로 변환된다.

Java Bytecode

자바 바이트코드는, JVM의 명령어 집합이다. 해당 바이트코드와 JVM의 동작은 일관되기 때문에, OS의 구분 없이 실행할 수 있게 된다.

여기서 바이트코드는 각 명령어가 1바이트 단위로 구성되어 있기 때문에 이런 이름이 붙었다.

Java Compiler

바이트코드를 만들어 주는 역할은 누가 할까?

이는 Java Compiler가 맡으며, JDK를 설치하면 bin 내에 존재하는 javac.exe를 말한다.

test.java라는 파일이 존재한다고 가정해 보자. 아래 명령어를 실행하면, .java(원시 코드) -> .class(바이트코드)로 변환할 수 있다.

javac test.java

그럼 동일한 디렉토리 내에 바이트코드 .class 파일이 생기게 된다.

JIT 컴파일러

바이트코드는 JVM이 읽을 수 있는 명령어이다. JVM은 이를 읽고 컴퓨터가 인식할 수 있는 바이너리 코드로 변환하게 된다.

바이너리 코드

이진 코드라고도 부르며, 0과 1로만 구성되어 있다. CPU가 이해하는 명령어 집합인 기계어는 해당 이진 코드로 이루어진다.

여기서 바이트코드 -> 바이너리 코드로 변환해 주는 것이 JIT 컴파일러이다.

JIT 컴파일러는 동적 번역을 한다고도 말하는데, 프로그램을 실행하며 실시간으로 기계어로 번역하는 작업을 수행하기 때문이다.

그렇기에 Just-In-Time(JIT) 컴파일러인 것이다.

JIT 컴파일러가 생긴 이유

JIT 컴파일러는 처음부터 있던 요소는 아니다.

자바는 WORA라는 장점이 존재하지만, 이를 위해서는 아래의 과정이 필요했다.

  1. 원시코드를 자바 컴파일러가 바이트코드로 컴파일한다.
  1. 바이트코드를 JVM이 한 줄씩, 즉 인터프리터 방식으로 읽어서 실행한다.

그렇기에 다른 언어에 비해서 성능이 느리다는 단점이 존재했고, 이를 개선하기 위해서 탄생한 것이 JIT 컴파일러이다.

이러한 특징 때문에 자바는 컴파일 언어가 아닌 하이브리드 언어라 부르는 것이 맞으며, 이에 대해서는 글로 정리해두었다.


🛠️ JVM의 구성 요소

JVM은 아래와 같이 구성되어 있다.

  1. 클래스 로더(Class Loader)
  2. 실행 엔진(Execution Engine)
  • 인터프리터(Interpreter)
  • JIT 컴파일러(Just-In-Time Compiler)
  • 가비지 콜렉터(Garbage Collecto**r)
  1. 런타임 데이터 영역(Runtime Data Area)

1. 클래스 로더(Class Loader)

이미지 출처 및 자세한 설명 글

클래스 로더는 이름 그대로, JVM 내에 .class 파일을 로드하고 링크를 통해 배치하는 작업을 수행하는 모듈이다.

자바는 컴파일 타임이 아닌, 런타임 시점에 클래스 로딩 및 링크가 이루어지는 동적 로딩(Dynamic Loading) 방식을 따른다.

여기서 JVM의 메소드 영역에 동적으로 클래스 로드를 담당하는 부분이 클래스 로더이다.

클래스 로더 3단계

클래스 로더는 3단계로 나뉘어져 있다.

로딩

  • 바이트코드(.class)를 메소드 영역에 저장한다.
  • 각 바이트코드는 JVM에 의해서 메소드 영역에 다음 정보들을 저장한다.
    1. 로드된 클래스 + 그의 부모 클래스의 정보
    2. 클래스 파일과 Class, Interface, Enum의 관련 여부
    3. 변수 & 메소드 등 정보

링크

  1. 검증 : 읽은 클래스(바이트코드)가 자바 언어 명세 및 JVM 명세에 명시된대로 잘 구성되어 있는지 검사한다.
  2. 준비 : 클래스가 필요로 하는 메모리를 할당하고, 클래스에서 정의된 필드 & 메소드 & 인터페이스를 나타내는 데이터 구조를 준비한다.
  3. 분석 : 심볼릭 메모리 레퍼런스를 메소드 영역에 있는 실제 레퍼런스로 교체한다.
  • 여기서 검증은 JVM 내부의 바이트코드 검증기(Verifier)가 수행하며, Java Language Specification (JLS, 자바 명세) & Java Virtual Machine Specification (JVMS, JVM 명세)를 참조한다.

초기화

  • 클래스 변수들을 적절한 값으로 초기화, 즉 static 필드들이 설정된 값으로 초기화된다.

초기화 단계에서 static 필드만 초기화하는 이유?

클래스 초기화 단계에서는 static 필드에 대해서만 초기화를 수행한다.

왜냐하면 static 필드는 클래스 단위로 관리되며, 모든 인스턴스가 공유하는 전역적인 자원이기 때문이다.

또한 해당 자원은 인스턴스 생성 여부와 무관하게 사용될 수 있기 때문에, JVM은 클래스가 처음 로딩될 때 한 번만 초기화를 수행하고, 이후에는 해당 필드를 메모리에 올려 두고 재사용한다.

반면, non-static 필드는 각 인스턴스마다 독립적으로 존재하며, 인스턴스가 생성될 때마다 별도로 초기화되므로 클래스 초기화 단계에서는 처리 대상이 아니게 된다.

즉, 클래스 초기화는 전역적인 자원의 일관성과 효율적인 관리를 위해 static 필드와 static 블록만을 대상으로 수행된다.

2. 실행 엔진(Execution Engine)

실질적으로 바이트코드를 실행시키는 곳이다.

클래스 로더가 JVM 런타임 데이터 영역 내에 바이트코드를 배치시키게 되고, 이를 실행 엔진이 실행한다.

여기에는 아래 3가지 구성요소가 존재한다.

  1. 인터프리터
  1. JIT 컴파일러
  2. GC(Garbage Collector)

1, 2번은 위 JIT 컴파일러가 생긴 이유에서 설명했으니 넘어가고, 3번 GC에 대해서만 간단히 설명하고자 한다.

GC(Garbage Collector)

가비지 컬렉터JVM이 사용하지 않는 객체(더 이상 참조되지 않는 객체)를 자동으로 탐지하고 힙 메모리에서 제거해주는 기능이다.

개발자가 직접 메모리를 해제하지 않아도 되므로 메모리 누수를 방지하고 자바의 안전성을 높여주는 역할을 한다.

GC에 대해서는 해당 글로 정리해두었다.

3. 런타임 데이터 영역(Runtime Data Area)

런타임 데이터 영역JVM이 자바 프로그램을 실행할 때 사용하는 메모리 공간의 구조를 뜻한다.

이는 클래스 정보, 객체, 변수, 스레드 실행 정보 등을 저장하는 여러 영역으로 나뉜다.

PC Register

각 스레드마다 1개씩 존재하는 작은 메모리 공간으로, 스레드가 새로 생성될 때 함께 생성된다.

현재 실행 중인 자바 바이트코드 명령어의 주소를 저장하며, JVM이 어떤 명령어를 다음에 실행할지를 결정하는 데 사용된다.

스레드마다 독립적이므로, 스레드 간 간섭 없이 명령어 실행 흐름을 추적할 수 있다.

JVM Stack

프로그램 실행과정에서 임시로 할당되었다가, 메서드를 빠져나가면 소멸되는 특성의 데이터를 저장하기 위한 영역이다.

메서드를 호출할 때마다, 해당 영역에 고유한 스택 프레임이 생성된다. 메서드 수행이 완료되면 프레임이 제거된다.

이 영역에는 지역 변수, 매개 변수, 리턴 값, 연산 중간 결과, 그리고 힙 영역의 객체를 가리키는 참조 값(주소) 등이 저장된다.

PC Register 영역과 동일하게 스레드당 1개씩 생성된다. 또한 스레드 종료 시 함께 소멸한다.

Native Method Stack

Native Method Stack은 자바 코드가 아닌, C/C++ 등 네이티브 언어로 작성된 메서드(native method)를 실행할 때 사용되는 스레드별 스택 영역이다.

JVM은 JNI(Java Native Interface)를 통해 네이티브 코드를 호출할 수 있으며, 이때 자바 바이트코드가 아닌 기계어 수준의 네이티브 코드 실행을 위한 별도의 호출 스택이 사용된다.

이 영역은 운영체제의 호출 규약에 따라, 함수 호출 정보(리턴 주소, 매개변수, 지역 변수 등)를 저장하며, 일반적인 C 프로그램에서 사용하는 스택 메모리 구조와 매우 유사하게 동작한다.

이 스택 역시 크기에는 제한이 있으며, 과도한 네이티브 호출이나 스레드 생성 시 StackOverflowError나 OutOfMemoryError가 발생할 수 있다.

토스 SLASH22 - Java Native Memory Leak 원인을 찾아서

Method Area(=Static Area)

Method Area는 JVM이 클래스 정보를 처음 메모리에 로드할 때, 해당 클래스 수준의 정보를 저장하는 영역이다.

여기에는 클래스 이름, 부모 클래스 정보, static 변수, 메서드 정보, 상수 풀(Runtime Constant Pool) 등이 저장된다.

또한 클래스가 로딩될 때 static 필드의 기본값 할당 및 static 블록 실행 전까지의 정보가 이 영역에 준비된다.

Runtime Constant Pool(런타임 상수 풀)

클래스 또는 인터페이스가 사용하는 리터럴(문자열, 숫자 상수 등) 이나 기호 참조 정보(메서드/필드 참조, 클래스 이름 등) 를 저장하는 구조로, JVM이 바이트코드를 실행하는 데 필요한 다양한 상수 및 참조 정보를 제공한다.

Heap

Heap은 자바에서 객체와 배열을 저장하는 메모리 공간이다. new 연산자를 통해 생성된 모든 객체는 Heap에 저장되며, 가비지 컬렉터(GC)의 관리 대상이 된다.

힙은 다음과 같이 세 부분으로 나뉜다.

1. Young Generation 새롭게 생성된 객체가 저장되는 공간으로, Minor GC가 자주 발생하여 짧은 생명주기의 객체를 빠르게 제거하게 된다. Eden, Survivor 영역(S0, S1)으로 구성된다.

2. Old Generation Young 영역을 지나 살아남은 오래된 객체들이 저장되는 곳이다. YG에 비해서 상대적으로 GC가 적게 발생하며, Major GC 또는 Full GC의 대상이 된다.

3. Metaspace(Permanent Generation) 클래스 메타정보가 저장되는 공간으로, JVM이 아닌 OS가 직접 관리하는 네이티브 메모리 공간을 사용한다.

왜 Metaspace는 네이티브 메모리를 사용하는가?

1. PermGen의 단점 보완 목적 Java 8 이전에는 PermGen이라는 고정된 크기의 메모리 영역을 사용했는데, 크기가 고정이라 OOM이 자주 발생하였다.

2. 유연한 메모리 사용 Java 8부터 도입된 Metaspace는 JVM 내부 메모리 대신, OS가 관리하는 네이티브 메모리 영역을 사용하게 되었다. 따라서 필요한 만큼 동적으로 증가할 수 있어, PermGen보다 훨씬 안정적인 메모리 관리를 제공한다.


참고한 블로그 1 참고한 블로그 2 참고한 블로그 3