Programming Language/Java

[Java] JVM(Java Virtual Machine)에 대하여

kimyoungrok 2025. 9. 16. 18:37
728x90

이 글에서는 Java의 JVM(Java Virtual Machine)의 정의와 구성, 구체적인 동작 원리, 관련 지식에 대해 다룹니다.

  • JVM란 무엇인가?
  • JVM을 사용하는 이유
  • JVM의 동작 원리
    • JVM: Class Loader Sub-System
    • JVM: Memory Area Of JVM - RDA
      • Method Area
      • Heap Area
      • Thread
    • JVM: Execution Engine
  • 관련 지식
  • 마치며.

1. JVM란 무엇인가?

JVM(Java Virtual Machine)은 자바 바이트 코드를 실행하는 가상머신입니다.

자바 애플리케이션이 다양한 플랫폼에서 실행될 수 있도록 하는 핵심 실행 엔진으로 다음과 같이 구성됩니다.

  • Class Loader Sub-System
  • Memory Area Of JVM
  • Execution Engine

자바 소스 코드는(.java)는 javac에 의해 기계어가 아닌, 바이트 코드(.class)로 변환됩니다. 이 바이트 코드는 오직 JVM에 의해서만 실행됩니다. JVM은 1차적으로 변환된 바이트 코드를 해석해 프로그램을 실행하거나, 런타임 중 JIT(Just-In-Time)컴파일을 통해 캐싱으로 성능을 최적화합니다.


2. JVM을 사용하는 이유

JVM은 다양한 플랫폼에서 독립적으로 자바 애플리케이션 실행하기 위해 사용합니다.

JVM이 OS별로 제공되는 구현체를 통해 바이트 코드 해석, 컴파일, 실행 등의 작업을 자동으로 수행할 수 있기 때문입니다. 또한 단순히 바이트 코드를 실행하는 것이 아닌, 메모리 관리, 예외 처리, 보안, JIT 최적화 등의 기능을 제공해 안정적인 운영을 지원합니다.


3. JVM의 동작 원리

자바 코드 작성부터 실행까지 JVM의 동작 흐름을 살펴보겠습니다.

우선 JVM이 자바 애플리케이션을 실행시킬 수 있도록 개발자는 코드 작성 후, javac로 바이트 코드를 생성해야 합니다.

코드 작성(.java) 및 바이트 코드(.class) 생성

개발자가 자바 소스 코드를 작성합니다.

public class Main {
    public static void main(String[] args) {
        String output = "Hello!";
        System.out.print(output);
    }
}

이후 javac에 의해 컴파일되어 즉시 실행 가능한 기계어가 아닌,

javac Main.java

중간 단계의 바이트 코드(.class)로 변환됩니다.

아래 바이트 코드를 통해 변수(output)명의 변경 사항을 확인할 수 있습니다.

public class Main {
    public static void main(String[] var0) {
        String var1 = "Hello!";
        System.out.print(var1);
    }
}

이제 JVM이 바이트 코드를 어떻게 실행하는지 살펴보겠습니다.


JVM: Class Loader Sub-System

JVM은 클래스 로더를 사용해 바이트 코드(.class)를 읽고, 클래스 구조 정보를 메서드 영역(Method Area)에 지연 로딩합니다. 이는 아래 단계를 거쳐 로딩됩니다.

  • Loading
  • Linking(Verifying / Preparing / Resolving)
  • Initializing

Loading

JVM이 바이트 코드 속 클래스 정보를 식별하는 단계입니다.

클래스 로더는 외부 저장소에서 바이트 코드를 찾아 읽고, 해당 클래스 정보를 JVM 메모리 중 메소드 영역에 올립니다.

Linking

로딩된 클래스를 실행하기 전 검증과, 실행 준비 작업을 수행합니다.

이 과정은 아래와 같이 VerifyingPreparingResolving 세 단계로 나뉩니다.

  • Verifying(검증) : 클래스가 JVM 명세에 맞게 정확히 작성되었는지 문법 검사를 합니다.
  • Prepairing(준비) : static변수들을 위한 메모리를 JVM이 확보합니다.
  • Resolving(해석) : 심볼릭(Symbolic) 레퍼런스를 다이렉트(Direct) 레퍼런스로 변환합니다

Initializing

static 변수들이 코드에 정의된 값으로 초기화 또는 static 블록을 실행하는 단계

이제 로딩된 바이트 코드를 실행하기 전, 메모리 영역을 어떻게 구성하는지 살펴보겠습니다.


JVM: Memory Area Of JVM

클래스 로더에 의해 로딩된 바이트 코드를 실행하기 위해, JVM은 아래와 같이 여러 영역으로 구성된 런타임 데이터 영역(Runtime Data Area)을 준비합니다.

  • Method Area
  • Heap Area
  • Thread
    • JVM Stack
    • PC Register
    • Native Method Stack

Method / Heap Area은 모든 Thread에 공유되며, 각 Thread는 독립된 메모리 구조를 가집니다.

RDA: Method Area

클래스 핵심 정보(클래스 구조와 실행 정보)가 저장되는 영역입니다.

생명 주기는 클래스 첫 로드부터, 언로드까지입니다.

메서드 영역에는 아래와 같은 영역 생성 또는 데이터가 저장됩니다.

  • 영역
    • 런타임 상수 풀(Runtime Constant Pool)
  • 데이터
    • 필드 / 메서드 데이터(Field and Method Metadata) : 메서드 시그니처(Method Signature) 정보
    • 메서드 코드(Method Bytecode)
    • 생성자 코드(Constructor ByteCode) : 인스턴스 생성자의 바이트 코드

‘런타임 상수 풀’과 ‘메서드 코드’에 대해 추가적으로 알아보겠습니다.

런타임 상수 풀(Runtime Constant Pool)

바이트 코드의 상수 테이블(Constant Pool)을 JVM이 런타임 중 사용 가능한 형태로 변환한 영역입니다.

리터럴 문자열이나 메서드 참조처럼 중복되는 값을 최소화 및 공유를 통해 메모리 효율을 높잎니다.

메서드 코드(Method Bytecode)

메서드 본문의 바이트 코드와 예외 테이블 등이 저장되며, 로직 호출 시 스택 영역에 프레임을 생성해 실행합니다.

RDA: Heap Area

Method Area에서는 주로 정적인 바이트 코드들을 저장했습니다. 힙(Heap) 영역은 JVM이 실해 중 동적으로 생성하는 모든 객체 또는 배열을 저장하는 영역입니다.

때문에 JVM이 자동으로 메모리를 할당하고, 참조가 되지 않는 객체는 가비지 컬렉션(Garbage Collection)에 의해 제거됩니다.

힙에 저장된 객체는 오직 스택 영역에 존재하는 다이렉스 레퍼런스를 통해 접근할 수 있습니다.

즉 객체는 힙에 저장되지만, 그 객체를 가리키는 주소값은 스택에 저장됩니다.

RDA: Thread

독립적인 메모리 구조를 가지는 스레드에 대해 알아보겠습니다.

JVM Stack

스택 영역(Stack Area) 또는 JVM Stack은 메서드 실행 시 사용하는 임시 저장 공간입니다.

각 스레드의 고유한 스택에는 메서드 호출에 대한 스택 프레임(Stack Frame)이 쌓이고, 메서드 종료시 제거됩니다.

메서드 내 변수는 자료형에 따라 저장 위치가 달라집니다.

PC Register

현재 해당 스레드가 실행중인 바이트 코드의 JVM 명령어를 저장하는 공간입니다.

스레드 실행 중 지속 갱신되며, 스레드 종료시 제거됩니다.

자바는 JVM 명령어 단위로 프로그램을 실행하므로, JVM은 이를 기반으로 각 스레드의 실행 흐름을 제어합니다.

Native Method Stack

스레드에서 다른 언어로 만들어진 네이티브 메서드를 실행 중일 때 실행 흐름을 관리하기 위한 스택 영역입니다.

네이티브 메서드 실행 중 생성되며 스레드 종료시 제거됩니다.

자바 JNI는 하드웨어와 밀접한 작업 시 C/C++ 등의 네이티브 메서드를 호출합니다. 이 때 PC Register는 undefined 상태가 되고, 이곳에서 작업을 대신 수행합니다.

JIT 컴파일러에 의해 바이트 코드가 기계어로 변환되는 경우에도 마찬가지로 네이티브 상식으로 실행됩니다.


JVM: Execution Engine

이제 메모리에 적재된 바이트 코드를 실제로 실행하는 단계입니다.

실행 엔진은 바이트 코드를 실제로 실행하는 핵심 구성 요소로 아래와 같은 두 실행 전략을 병행해 프로그램을 실행합니다.

  • Interpreter(인터프리터 방식)
  • JIT Compiler(JIT 컴파일러 방식)

Interpreter

바이트 코드를 명령어 단위로 하나씩 읽고 해석해 실행하는 JVM의 기본 방식입니다.

프로그램 첫 실행시 빠르게 시작할 수 있지만, 반복된 코드가 호출되는 경우 동일한 바이트 코드를 반복해 해석하므로 동작 속도가 느려질 수 있습니다.

JIT Compiler

인터프리터 방식의 단점을 해결하기 위해 자주 실행되는 코드(HotSpot)를 감지해, 기계어로 컴파일 후 코드 캐시(Code Cache)에 캐싱해 재사용 하는 전략입니다.

단 기계어로 변환하는 과정에도 리소스가 소모됩니다.

JVM은 인터프리터 방식으로 빠른 실행 후 HotSpot으로 판단되는 경우 JIT 컴파일을 적용하는 하이브리드 전략을 사용합니다.


관련 지식

  • 상수 테이블(Constant Poll): 바이트 코드(.class) 내 리터럴 값(ex: 문자열), 메서드와 필드에 대한 참조 정보
  • 코드 캐시(Code Chche): JIT 컴파일러가 바이트 코드를 번역한 기계어를 저장하는 전용 메모리 공간
  • 심볼릭 레퍼런스(Symbolic Reference): 바이트 코드 내 문자열 형태의 논리적 참조, 실제 메모리 주소 대신, 이름으로 간접적으로 가리킨다.
  • 다이렉트 레퍼런스 (Direct Referemce): 클래스 로딩 과정 중 Resolving되어 실제 메모리 주소를 직접 가리키게 된 참조
  • 메서드 시그니처(Method Signature): 메서드를 고유하게 식별할 수 있는 정보 조합(ex: 메서드 이름과 타입 목록)
  • 스택 프레임(Stack Frame): 메서드가 호출 될 때 생성되는 독립 실행 단위. 메서드 실행에 필요한 모든 정보가 담겨있다.

마치며.

JVM의 구조와 원리를 학습하며 제가 작성한 코드가 메모리 위에서 어떤 준비과정을 거쳐 실행되는지 구체적으로 이해할 수 있었습니다.

특히 JIT 컴파일러가 단순히 바이트코드를 변환하는 것을 넘어, 인터프리터 방식과 하이브리드 전략으로 런타임 성능을 최적화하도록 설계되었다는 점이 흥미로웠습니다. 또한, 네이티브 메서드 스택의 역할을 통해 자바 코드와 네이티브 코드가 어떻게 상호작용하며 실행되는지 알게 되었습니다.

이제 이론에 머무르지 않고, JVM이 제공하는 실행 옵션을 활용해 힙 메모리를 조절하고 GC 로그를 분석하는 등 직접 성능을 테스트하며 메모리 사용량과 GC 동작의 변화를 직접 확인하고자 합니다.

728x90