10 분 소요

JVM 이란?

JVM(Java Virtual Machine)은 자바 언어의 핵심 철학인 “Write Once, Run Anywhere “를 가능하게 하는 추상 컴퓨팅 머신이다.
이는 개발자가 작성한 자바 소스 코드가 javac 컴파일러에 의해 플랫폼 독립적인 바이트코드(.class 파일)로 변환된 후, JVM이 설치된 모든 운영체제에서 문제없이 실행될 수 있도록 런타임 환경을 제공하는 역할을 한다.

JVM 을 학습해야 하는 이유

JVM은 자바 애플리케이션의 실행에 필요한 메모리를 운영체제로부터 할당받아 이를 용도에 따라 여러 영역으로 나누어 관리한다. 이러한 메모리 관리 방식은 애플리케이션의 성능, 안정성, 확장성에 직접적인 영향을 미친다. 따라서 JVM 메모리 구조에 대한 깊이 있는 이해는 개발자가 코드 최적화, 리팩토링, 그리고 복잡한 메모리 관련 문제를 진단하고 해결하는 데 필수적인 역량으로 간주된다.

아키텍처 개요

JVM은 자바 애플리케이션의 실행을 위한 포괄적인 런타임 환경을 제공하며, 여러 핵심 구성 요소들이 유기적으로 상호작용하여 바이트코드를 실행한다.

graph TB
    subgraph "Java Application"
        JAVA[".java 소스 코드"]
        JAVAC["javac 컴파일러"]
        CLASS[".class 바이트코드"]
    end

    subgraph "JVM (Java Virtual Machine)"
        subgraph "Class Loader Subsystem"
            CL1["로딩 (Loading)"]
            CL2["링킹 (Linking)"]
            CL3["초기화 (Initialization)"]
        end
        
        subgraph "Runtime Data Areas"
            subgraph "Thread Shared"
                MA["Method Area<br/>(Metaspace)"]
                HEAP["Heap Area<br/>(Young/Old Generation)"]
                CC["Code Cache"]
            end
            
            subgraph "Thread Independent"
                STACK["Stack Area<br/>(Stack Frames)"]
                PC["PC Register"]
                NMS["Native Method Stack"]
            end
        end
        
        subgraph "Execution Engine"
            INTER["Interpreter"]
            JIT["JIT Compiler"]
            GC["Garbage Collector"]
        end
        
        subgraph "Native Interface"
            JNI["JNI<br/>(Java Native Interface)"]
            NML["Native Method<br/>Libraries"]
        end
    end

    subgraph "Operating System"
        OS["OS & Hardware"]
    end

    %% 컴파일 과정
    JAVA --> JAVAC
    JAVAC --> CLASS
    
    %% 클래스 로딩 과정
    CLASS --> CL1
    CL1 --> CL2
    CL2 --> CL3
    
    %% 런타임 데이터 영역으로 로딩
    CL3 --> MA
    CL3 --> HEAP
    
    %% 실행 엔진과의 관계
    MA --> INTER
    HEAP --> INTER
    STACK --> INTER
    
    INTER --> JIT
    JIT --> CC
    
    %% 가비지 컬렉션
    HEAP --> GC
    
    %% JNI 관계
    INTER --> JNI
    JNI --> NML
    NML --> OS
    
    %% PC Register와 Native Method Stack
    INTER --> PC
    JNI --> NMS
    
    %% 스타일링
    classDef sourceCode fill:#e1f5fe
    classDef classLoader fill:#f3e5f5
    classDef sharedMemory fill:#e8f5e8
    classDef threadMemory fill:#fff3e0
    classDef execution fill:#ffebee
    classDef native fill:#f1f8e9
    
    class JAVA,JAVAC,CLASS sourceCode
    class CL1,CL2,CL3 classLoader
    class MA,HEAP,CC sharedMemory
    class STACK,PC,NMS threadMemory
    class INTER,JIT,GC execution
    class JNI,NML native

JVM의 핵심 구성 요소

JVM은 크게 클래스 로더 서브시스템(Class Loader Subsystem), 런타임 데이터 영역(Runtime Data Areas), 실행 엔진(Execution Engine), 네이티브 메서드 인터페이스(Native Method Interface, JNI), 그리고 네이티브 메서드 라이브러리(Native Method Libraries)로 구성된다.

  • 클래스 로더 서브시스템:
    • .class 파일을 JVM 메모리인 런타임 데이터 영역으로 로드하고, 로드된 클래스들을 JVM의 런타임 상태에 통합하기 위한 링크(Linking) 및 초기화(Initialization) 작업을 담당한다.
  • 런타임 데이터 영역:
    • JVM이 애플리케이션 실행 중 데이터를 저장하는 핵심 메모리 공간이다.
    • 이 영역은 메서드 영역, 힙 영역, 스택 영역, PC 레지스터, 네이티브 메서드 스택으로 세분화된다.
  • 실행 엔진:
    • 런타임 데이터 영역에 적재된 바이트코드를 기계어(네이티브 코드)로 변환하여 명령어 단위로 실행하는 역할을 한다.
    • 여기에는 인터프리터(Interpreter), JIT(Just-In-Time) 컴파일러, 그리고 가비지 컬렉터(Garbage Collector)가 포함된다.
  • 네이티브 메서드 인터페이스 (JNI):
    • 자바 애플리케이션이 C/C++ 등 자바 외의 언어로 작성된 네이티브 코드를 호출하거나 상호작용할 수 있도록 하는 표준 프로그래밍 인터페이스이다.
  • 네이티브 메서드 라이브러리:
    • JNI를 통해 실행되는 네이티브 메서드에 필요한 라이브러리들의 집합이다.

JVM 클래스 로더 서브시스템

클래스 로더 서브시스템(Class Loader Subsystem)은 컴파일된 .class 파일을 JVM 메모리로 로드하고, 이를 실행 가능한 상태로 변환하는 핵심 구성 요소이다.
이는 자바의 “Write Once, Run Anywhere” 철학을 실현하는 핵심 메커니즘으로, 런타임에 필요한 클래스들을 동적으로 로딩하여 메모리 효율성과 유연성을 제공한다.

클래스 로딩의 3단계 과정

클래스 로딩은 로딩(Loading), 링킹(Linking), 초기화(Initialization)의 세 단계로 이루어진다.

graph TB
    subgraph "Class Files"
    CF1[".class 파일 1"]
    CF2[".class 파일 2"]
    CF3[".class 파일 3"]
    end

    subgraph "Class Loading Process"
        subgraph "1. Loading Phase"
            FIND["클래스 파일 탐색"]
            READ["바이너리 데이터 읽기"]
            CREATE["Class 객체 생성"]
        end
        
        subgraph "2. Linking Phase"
            VERIFY["Verification<br/>(검증)"]
            PREPARE["Preparation<br/>(준비)"]
            RESOLVE["Resolution<br/>(분석)"]
        end
        
        subgraph "3. Initialization Phase"
            STATIC["static 변수 초기화"]
            CLINIT["static 블록 실행"]
        end
    end

    subgraph "Runtime Data Areas"
        MA["Method Area<br/>(Metaspace)"]
        HEAP["Heap Area"]
    end

    %% 클래스 로딩 흐름
    CF1 --> FIND
    CF2 --> FIND
    CF3 --> FIND
    
    FIND --> READ
    READ --> CREATE
    CREATE --> VERIFY
    
    VERIFY --> PREPARE
    PREPARE --> RESOLVE
    RESOLVE --> STATIC
    
    STATIC --> CLINIT
    
    %% 메모리 적재
    CREATE --> MA
    CLINIT --> HEAP

    %% 스타일링
    classDef classFiles fill:#e3f2fd,stroke:#1976d2
    classDef loading fill:#e8f5e8,stroke:#388e3c
    classDef linking fill:#fff3e0,stroke:#f57c00
    classDef initialization fill:#fce4ec,stroke:#c2185b
    classDef memory fill:#f3e5f5,stroke:#7b1fa2
    
    class CF1,CF2,CF3 classFiles
    class FIND,READ,CREATE loading
    class VERIFY,PREPARE,RESOLVE linking
    class STATIC,CLINIT initialization
    class MA,HEAP memory

1. 로딩(Loading) 단계

로딩 단계에서는 클래스 로더가 클래스 파일을 찾아서 JVM 메모리로 가져온다.

  • 클래스 파일 탐색: 클래스패스(Classpath)에서 해당 클래스의 .class 파일을 찾는다.
  • 바이너리 데이터 읽기: 클래스 파일의 바이트코드를 읽어들인다.
  • Class 객체 생성: 읽어들인 바이트코드로부터 java.lang.Class 객체를 생성하여 메서드 영역에 저장한다.

2. 링킹(Linking) 단계

링킹 단계는 로드된 클래스를 JVM에서 안전하게 실행할 수 있도록 준비하는 과정이다.

  • 검증(Verification): 로드된 바이트코드가 JVM 명세에 적합한지, 보안 규칙을 위반하지 않는지 검사한다. 이는 악의적이거나 손상된 클래스 파일로부터 JVM을 보호하는 중요한 보안 메커니즘이다.

  • 준비(Preparation): 클래스의 static 변수들을 위한 메모리를 할당하고 기본값(0, null, false 등)으로 초기화한다. 이 단계에서는 아직 개발자가 코드에서 지정한 값으로 초기화하지 않는다.

  • 분석(Resolution): 클래스 내의 심볼릭 참조(Symbolic Reference)를 실제 메모리 주소를 가리키는 직접 참조(Direct Reference)로 변환한다. 예를 들어, 다른 클래스의 메서드를 호출하는 코드가 있다면, 해당 메서드의 실제 메모리 위치를 찾아 연결한다.

3. 초기화(Initialization) 단계

초기화 단계에서는 클래스가 실제로 사용되기 직전에 실행되는 최종 준비 과정이다.

  • static 변수 초기화: 개발자가 코드에서 지정한 값으로 static 변수들을 초기화한다.
  • static 블록 실행: 클래스에 정의된 static 초기화 블록들을 실행한다.

JVM 런타임 데이터 영역

JVM은 운영체제로부터 할당받은 메모리를 효율적으로 사용하기 위해 여러 런타임 데이터 영역으로 나눈다.
이 영역들은 크게 모든 스레드가 공유하는 영역과 스레드마다 독립적으로 생성되는 영역으로 구분된다.

1. 메서드 영역 (Method Area / Metaspace)

메서드 영역은 클래스 파일에서 로드된 클래스 및 인터페이스에 대한 정의, 필드 정보(이름, 데이터 타입, 접근 제어자), 메소드 정보(이름, 리턴 타입, 파라미터, 접근 제어자), 타입 정보, 상수 풀(Constant Pool), static 변수, final static 변수 등 클래스 레벨의 메타데이터를 저장하는 영역이다. 이 영역은 JVM이 시작될 때 생성되며, JVM 내의 모든 스레드가 공유하는 단일 영역이다.

2. 힙 영역 (Heap Area)

힙 영역은 new 키워드를 사용하여 생성된 모든 객체(Object), 인스턴스 변수(Instance Variable), 배열(Array)이 저장되는 런타임 데이터 영역이다.

이 영역은 JVM 시작 시 생성되며, JVM 내의 모든 스레드가 공유하는 단일 영역이다. 힙에 생성된 객체는 애플리케이션 내 어디에서든 참조될 수 있다.

힙 영역은 Garbage Collector(GC)에 의해 자동으로 관리되며, 더 이상 참조되지 않는 객체는 GC에 의해 제거되어 메모리가 회수된다.

효율적인 가비지 컬렉션을 위해 힙은 여러 “세대(Generations)”로 분리된다. 이는 대부분의 객체가 짧은 수명을 가진다는 “약한 세대 가설(Weak Generational Hypothesis)”에 기반한다.

graph TB
    subgraph "Java Application"
        APP["애플리케이션"]
        NEW["new 키워드"]
    end

    subgraph "Heap Memory"
        subgraph "Young Generation"
            EDEN["Eden Space<br/>새 객체 생성"]
            S0["Survivor 0<br/>GC 1회 생존"]
            S1["Survivor 1<br/>GC 2회+ 생존"]
        end
        
        subgraph "Old Generation"
            OLD["Old Space<br/>장기 생존 객체"]
        end
    end

    subgraph "Garbage Collector"
        MINOR["Minor GC<br/>(Young 영역)"]
        MAJOR["Major GC<br/>(Old 영역)"]
        FULL["Full GC<br/>(전체 힙)"]
    end

    subgraph "GC Process"
        MARK["1. Mark<br/>사용중인 객체 표시"]
        SWEEP["2. Sweep<br/>미사용 객체 제거"]
        COMPACT["3. Compact<br/>메모리 정리"]
    end

    %% 객체 생성 흐름
    APP --> NEW
    NEW --> EDEN
    
    %% GC 트리거
    EDEN -->|Eden 가득참| MINOR
    OLD -->|Old 가득참| MAJOR
    
    %% Minor GC 흐름
    MINOR --> MARK
    MARK --> SWEEP
    SWEEP --> COMPACT
    
    %% 객체 이동
    EDEN -->|생존 객체| S0
    S0 -->|다음 GC 생존| S1
    S1 -->|나이 임계값 도달| OLD
    
    %% GC 정리 후
    MINOR -->|정리 완료| EDEN
    MAJOR -->|정리 완료| OLD
    
    %% Full GC
    MAJOR -->|심각한 상황| FULL
    FULL --> EDEN
    FULL --> OLD

    %% 스타일링
    classDef application fill:#e1f5fe,stroke:#01579b
    classDef young fill:#c8e6c9,stroke:#2e7d32
    classDef old fill:#ffecb3,stroke:#f57c00
    classDef gc fill:#ffcdd2,stroke:#c62828
    classDef process fill:#f3e5f5,stroke:#7b1fa2
    
    class APP,NEW application
    class EDEN,S0,S1 young
    class OLD old
    class MINOR,MAJOR,FULL gc
    class MARK,SWEEP,COMPACT process
  • Young Generation (젊은 세대): 새로 생성된 대부분의 객체가 할당되는 공간이다. Eden Space와 두 개의 Survivor Spaces(S0, S1)로 구성된다. 새 객체는 주로 Eden Space에 할당되며, Eden Space가 가득 차면 Minor GC(Young GC)가 발생한다. Minor GC 후 살아남은 객체들은 Survivor Space(S0 또는 S1)로 이동하며, 이 과정을 반복하면서 객체의 “나이(age)”가 증가한다. 일정 나이 임계값(Tenuring Threshold)에 도달하면 Old Generation으로 승격된다.

  • Old Generation (오래된 세대 / Tenured Generation): Young Generation에서 여러 Minor GC 사이클을 거쳐 살아남은(오랫동안 참조된) 객체들이 저장되는 공간이다. Old Generation이 가득 차면 Major GC(Full GC)가 발생하며, 이는 Minor GC보다 시간이 오래 걸리고 애플리케이션 전체에 영향을 줄 수 있다.

힙에 있는 객체는 해당 참조 타입에 따라 가비지 컬렉션 조건에 맞춰 동작한다.

  • Strong Reference (강한 참조): 기본 참조 타입으로, 가장 흔하게 사용되며, 강한 참조가 있는 객체는 GC 대상이 되지 않는다.
  • Weak Reference (약한 참조): 다음 GC 프로세스에서 지워질 수 있으며, 지워진 후 참조 시 null이 반환된다.
  • Soft Reference (부드러운 참조): 애플리케이션이 메모리가 부족한 상황에서 실행될 때 수집된다. OutOfMemoryError가 발생하기 전에 GC에 의해 수집될 수 있다.
  • Phantom Reference (유령 참조): GC가 객체를 수집하기로 결정한 후에도 객체가 완전히 제거되기 전에 특정 작업을 수행할 수 있도록 한다. 주로 객체 소멸 시 자원 정리(finalization)에 사용된다.

3. 스택 영역 (Stack Area)

JVM 내에서 각 스레드가 생성될 때마다 독립적인 스택 영역이 생성되며, 이는 해당 스레드에만 할당되고 다른 스레드에서 참조할 수 없다. 스레드가 종료되면 해당 스택도 함께 소멸된다. 메서드가 호출될 때마다 해당 메서드를 위한 “스택 프레임(Stack Frame)”이라는 데이터 구조가 스택에 푸시된다. 스택 프레임은 메서드의 지역 변수(Local Variables), 파라미터(Parameters), 리턴 값(Return Values), 연산에 사용되는 임시 값, 그리고 메서드 호출 정보 등을 저장한다. 스택은 LIFO(Last-In, First-Out) 순서로 동작한다. 메서드 실행이 완료되면 해당 스택 프레임은 스택에서 팝(pop)되어 제거된다. 스택의 크기는 JVM 옵션(-Xss)으로 설정할 수 있으며, 스레드 생성 시 고정됩니다. 스택 공간이 부족해지면 java.lang.StackOverflowError가 발생한다.

4. PC 레지스터 (Program Counter Register)

각 스레드에는 고유한 PC(Program Counter) 레지스터가 할당된다. 이 레지스터는 현재 스레드가 실행 중인 JVM 명령어의 주소(Address)를 저장한다. 이를 통해 JVM은 스레드를 번갈아 가며 수행할 수 있다. 네이티브 메서드(Java 코드가 아닌 C/C++ 등)의 경우 PC 레지스터의 값은 정의되지 않는다.

5. 네이티브 메서드 스택 (Native Method Stack)

네이티브 메서드 스택은 자바 외의 언어(주로 C/C++)로 작성된 네이티브 코드(Native Code)를 위한 스택 메모리 영역이다. JNI(Java Native Interface)를 통해 JVM과 상호 작용하며, 네이티브 메서드 호출 시 해당 스택이 사용된다. 네이티브 스택의 성능은 운영체제에 따라 다르다. 이 영역에서도 StackOverflowError가 발생할 수 있다.

6. 코드 캐시 (Code Cache)

코드 캐시는 JIT(Just-In-Time) 컴파일러가 바이트코드를 네이티브 코드로 변환하여 저장하는 영역이다. 자주 접근하는 컴파일된 코드 블록이 이곳에 캐시되어 있어, 애플리케이션의 실행 속도를 빠르게 한다.

[표 1] JVM 런타임 데이터 영역 요약

영역 이름 주요 역할 스레드 공유 여부 저장되는 데이터 유형
메서드 영역 (Method Area / Metaspace) 클래스 메타데이터 및 static 정보 저장 공유 (단일) 클래스/인터페이스 정의, 필드/메소드 정보, 상수 풀, static 변수, final static 변수
힙 영역 (Heap Area) new 키워드로 생성된 모든 객체 및 배열 저장 공유 (단일) 객체 인스턴스, 인스턴스 변수, 배열 (Young/Old Generation으로 세분화)
스택 영역 (Stack Area) 메서드 호출 시 지역 변수, 파라미터, 리턴 값, 임시 데이터 저장 독립 (스레드별) 스택 프레임 (지역 변수, 파라미터, 리턴 값, 연산 임시 값, 메서드 호출 정보)
PC 레지스터 (Program Counter Register) 현재 실행 중인 JVM 명령어의 주소 저장 독립 (스레드별) 현재 JVM 명령어 주소 (네이티브 메서드 시 undefined)
네이티브 메서드 스택 (Native Method Stack) JNI를 통해 호출되는 네이티브 코드(C/C++ 등)의 스택 영역 독립 (스레드별) 네이티브 메서드의 지역 변수, 파라미터 등
코드 캐시 (Code Cache) JIT 컴파일러에 의해 변환된 네이티브 코드 저장 공유 (단일) JIT 컴파일된 코드 블록

JVM 실행 엔진

JVM(Java Virtual Machine)의 실행 엔진(Execution Engine)은 런타임 데이터 영역에 적재된 바이트코드를 기계어(네이티브 코드)로 변환하여 명령어 단위로 실행하는 핵심 구성 요소입니다. 이는 중간 수준의 바이트코드를 컴퓨터가 직접 실행할 수 있는 형식으로 변환하는 역할을 합니다.

실행 엔진은 주로 다음 세 가지 부분으로 구성됩니다.

1. 인터프리터 (Interpreter):

인터프리터는 바이트코드 명령어를 한 줄씩 읽고 즉시 실행합니다.

이는 자바의 “Write Once, Run Anywhere” 철학을 가능하게 하는 플랫폼 독립성을 보장하지만, 코드가 반복적으로 실행될 경우 비효율적일 수 있습니다.

2. JIT 컴파일러 (Just-In-Time Compiler):

JIT 컴파일러는 인터프리터의 느린 속도를 개선하기 위해 도입되었습니다.

이는 런타임에 자주 반복되는 바이트코드(일명 “핫 스팟” 코드)를 식별하고, 이를 해당 시스템의 네이티브 기계어 코드로 동적으로 컴파일합니다.

이렇게 컴파일된 네이티브 코드는 인터프리터 방식보다 훨씬 빠르게 직접 실행될 수 있어 전반적인 애플리케이션 성능을 향상시킵니다.

JVM은 실행 빈도에 따라 인터프리터와 JIT 컴파일러 사이를 지능적으로 전환합니다. 이 과정은 자바 애플리케이션의 “웜업(warm-up)” 현상을 설명하는데, 애플리케이션이 처음 시작될 때는 인터프리터 모드로 인해 다소 느릴 수 있지만, 시간이 지남에 따라 JIT 컴파일러가 최적화를 수행하면서 성능이 점진적으로 향상됩니다. JIT 컴파일러에 의해 변환된 네이티브 코드는 코드 캐시(Code Cache)라는 영역에 저장되어 빠르게 실행될 수 있습니다.

3. 가비지 컬렉터 (Garbage Collector, GC):

가비지 컬렉션(GC)은 JVM의 핵심 기능 중 하나로, 자바 애플리케이션의 메모리 관리를 자동화하여 개발자가 수동으로 메모리를 할당하고 해제할 필요 없도록 한다.

GC의 기본 원리

GC는 더 이상 참조되지 않는(Unreachable) 객체를 자동으로 탐색하고 제거하여 메모리를 회수한다. 이는 메모리 누수와 같은 문제를 방지하고 개발 생산성을 높이는 데 기여한다.

“GC의 기본적인 동작 과정은 Mark & Sweep 2단계로 이루어지며, 메모리 단편화를 해결하기 위해 선택적으로 Compact 단계가 추가될 수 있다.”

  • Mark (표시) 단계: GC는 “루트(Root) 객체”(예: 프로그램의 전역 변수, 스택 변수, 레지스터에 저장된 객체)에서 시작하여 참조 체인을 따라가며 도달 가능한(Reachable) 모든 객체를 식별하고 “마크”한다.

  • Sweep (정리) 단계: 마크되지 않은(즉, 도달 불가능한) 모든 객체들을 “가비지”로 판단하고 메모리에서 해제하여 공간을 회수한다.

  • Compact (압축) 단계: 일부 GC 알고리즘은 Sweep 이후 메모리 단편화(Fragmentation)를 해결하기 위해 살아있는 객체들을 메모리의 한쪽으로 이동시켜 연속적인 빈 공간을 확보한다.

GC가 힙 메모리를 정리하는 동안, 애플리케이션의 모든 스레드가 일시적으로 정지되는 현상을 “Stop-The-World (STW)”라고 한다. STW 시간은 애플리케이션의 응답성에 직접적인 영향을 미치므로, GC 성능 튜닝의 주된 목표는 이 STW 시간을 최소화하는 것이다.

태그:

카테고리:

업데이트:

댓글남기기