
Java를 공부하면서 JVM, JRE, JDK, JIT의 개념을 하나씩 익히다 보니,
네 가지 개념이 어떻게 다른지 조금씩 구분할 수 있게 되었습니다.
처음에는 이름이 비슷하여 헷갈렸지만, 개념이 정리되니 오히려 흥미롭게 느껴졌습니다.
그러다 문득,
"그렇다면 JVM 내부에서는 실제로 어떤 일이 벌어질까?"
라는 궁금증이 생겼습니다.
그래서 이번 글에서는 JVM을 중심으로, 구조와 실행 과정을 한 번에 이해할 수 있도록 정리해 보았습니다.
JVM이란?
JVM(Java Virtual Machine)은 자바 바이트코드(.class)를 읽고 실행하는 가상 머신입니다.
운영체제 위에 JVM만 설치되어 있다면, 동일한 바이트코드가 Windows, macOS, Linux 등 어떤 환경에서든 똑같이 동작합니다.
이것이 바로 Java의 철학 "Write Once, Run Anywhere"입니다.

자바의 동작 과정을 간단한 이미지로 표현해 보았습니다.
자바 소스 코드(.java)는 자바 컴파일러(javac)에 의해 바이트코드(.class)로 변환됩니다.
이 바이트코드는 CPU가 직접 실행할 수 있는 기계어가 아니라, JVM이 이해하는 중간 언어입니다.
실행 과정에서 JVM은 이 바이트코드를 인터프리터로 한 줄씩 해석하거나, JIT(Just-In-Time) 컴파일러로 기계어로 변환해 성능을 높입니다.
이제, Java 실행에 필요한 여러 구성 요소들을 JVM 구조를 통해 더 자세히 살펴보겠습니다.
JVM 구조
JVM은 아래 이미지처럼 크게 5개의 주요 구성 요소로 이루어져 있습니다.
또한, JVM 외부에 존재하지만 실행 과정에서 밀접하게 작용하는 Native Method Library도 함께 살펴보겠습니다.

이번에는 클래스 로더(Class Loader), 런타임 데이터 영역(Runtime Data Area), 실행 엔진(Execution Engine), 가비지 컬렉터(Garbage Collector), 네이티브 메서드 인터페이스(Native Method Interface, JNI), 그리고 JVM 외부의 네이티브 메서드 라이브러리(Native Method Libarary)까지 각 요소의 역할과 구조를 하나씩 알아보겠습니다.
1) 클래스 로더 (Class Loader)
바이트코드(.class)를 찾아 메모리에 올리고(Loading), 실행 가능하도록 검증·준비·해결(Linking)한 뒤 초기화(Initialization)를 거쳐 실행 가능한 클래스로 만드는 구성요소
클래스 로더(Class Loader)는 로딩(Loading) → 링커(Linking) → 초기화(Initialization) 총 세 단계로 동작합니다.
📥 로딩(Loading) 단계
자바 클래스 라이브러리(표준 API가 담긴 JDK/JRE의 클래스 모음)나 사용자가 작성한 .class 파일에서,
실행 시점에 필요한 바이트코드(.class file)를 찾아 메서드 영역(Method Area)에 적재합니다
🔗 링크(Linking) 단계
적재된 직후에는 바로 실행하지 않고, 클래스가 안전하게 실행될 수 있도록 세 가지 절차를 거칩니다.
- 검증(Verification) : 바이트코드가 JVM 명세와 보안 규칙을 지키는지 검사
- 준비(Preparation) : 정적 변수에 필요한 메모리를 할당하고 기본값(ex: 숫자는 0, 참조는 null 등)으로 초기화
- 해결(Resolution) : 클래스 안에 있는 클래스·메서드·필드 정보 저장소인 상수 풀(Constant Pool)에 있는 심볼릭 참조를 실제 메모리 참조로 변경
💡 심볼릭 참조 vs 메모리 참조
- 심볼릭 참조 (Symbolic Reference) : "홍길동(서울시 종로구 ...)"처럼 텍스트로 된 주소
- 메모리 참조 (Memory Reference) : GPS 좌표(37.12345, 127.12345)처럼 실제 위치 정보
⚡ 초기화(Initialization) 단계
정적 블록과 정수 변수의 초기값을 실행하여, 클래스가 '실제로 사용 가능한 상태'가 됩니다.
이 과정을 거쳐야 클래스가 안전하고 일관성 있게 실행될 수 있습니다.
2) 런타임 데이터 영역 (Runtime Data Areas)
실행 중 코드·데이터·스레드 상태를 보관하는 JVM의 메모리 구역 묶음
런타임 데이터 영역(Runtime Data Areas)은 메서드 영역(Method Area), JVM 스택(JVM Stack), PC 레지스터(PC Register), 힙(Heap), 네이티브 함수 실행용 스택(Native Method Stack)으로 크게 다섯 개의 메모리 구역으로 나뉩니다.
🗂️ 메서드 영역(Method Area)
클래스 로더(Class Loader)의 로딩(Loading) 단계에서 바이트코드(.class file)를 읽어 클래스 메타데이터를 적재하는 영역입니다.
클래스 구조, 필드/메서드 정보, 상수 풀(Constant Pool), 정적 필드 값 등이 저장됩니다.
모든 스레드가 공유하며, JVM 시작 시 생성됩니다.
🏊 상수 풀
- 상수 풀 (Constant Pool) : 클래스 안에 있는 클래스·메서드·필드 정보 저장소
📚 JVM 스택(JVM Stack)
각 스레드마다 존재하며, 메서드 호출 시 생성되는 스택 프레임을 저장합니다.
스택 프레임에는 지역 변수, 피연산자 스택, 반환 주소 등이 들어갑니다.
이때 데이터 형식은 Java 타입으로 int, long, float, double, 참조 타입 등입니다.
실행 엔진이 바이트코드를 해석하거나 JIT 컴파일할 때 직접 사용하는 영역입니다.
스레드가 종료되면 JVM 스택도 함께 소멸합니다.
🎯 PC 레지스터(PC Register)
각 스레드별로 현재 실행 중인 바이트코드 명령의 주소를 저장합니다.
바이트코드의 흐름 제어를 담당하며, JVM 스택과 마찬가지로 스레드 단위로 생성되고 해제됩니다.
🗄️ 힙(Heap)
new 키워드 등으로 생성된 모든 객체와 배열을 저장하는 공유 메모리 영역입니다.
가비지 컬렉터(Garbage Collector, GC)가 관리하며, 참조가 끊긴 객체를 자동으로 회수합니다.
⚙️ 네이티브 메서드 스택(Native Method Stack)
네이티브 메서드 스택(Java Native Interface, JNI)을 통해 호출되는 C/C++ 등 네이티브 코드 실행을 위한 스택입니다.
이 스택에는 네이티브 코드에 맞는 데이터 형식이 저장됩니다.
예를 들어 Java의 int는 JNI에서 jint, String은 jstring, 배열은 포인터 타입 등으로 변환되어 저장됩니다.
또한 네이티브 함수 실행 시 필요한 네이티브 호출 컨텍스트(레지스터 상태, 네이티브 호출 스택 프레임)가 이곳에 기록됩니다.
스레드마다 별도로 존재하며, 스레드 종료 시 함께 해제됩니다.
즉, JVM 스택에는 Java 세계의 데이터, 네이티브 메서드 스택 에는 네이티브 세계의 데이터가 저장됩니다.
데이터 타입의 변환은 JNI(Native Method Interface)에서 수행하며, 이 구체적인 동작 과정은 아래의 JVM 실행 단계에서 자세히 살펴보겠습니다.
3) 실행 엔진 (Execution Engine)
바이트코드(.class file)를 해석(인터프리트)하거나 JIT 컴파일하여 실제 CPU가 수행할 수 있게 만드는 실행 핵심 모듈
실행 엔진(Execution Engine)은 런타임 데이터 영역(Runtime Data Area)과 긴밀하게 상호작용하며,
JVM Stack과 PC Register로 제어 흐름을 관리하고, Method Area와 Heap에서 메타정보와 데이터를 읽고 씁니다.
실제 실행은 인터프리터(Interpreter)와 JIT 컴파일러(JIT Compiler)가 담당합니다.
☄️ 인터프리터(Interpreter)
바이트코드를 명령 단위로 즉시 해석하여 실행합니다.
실행 준비가 끝나면 곧바로 프로그램을 시작할 수 있어 초기 실행 속도가 빠릅니다.
다만 반복되는 코드도 매번 해석을 해야 하므로 장기 실행 시에는 해석 비용이 누적되어 성능이 떨어질 수 있습니다.
그래서 주로 프로그램의 첫 실행이나 변경 직후 구간에서 이점이 큽니다.
🚀 JIT(Just-In-Time) 컴파일러
실행 중 프로파일링을 통해 '자주 실행되는 코드(핫스팟)'를 감지합니다.
해당 구간(메서드, 루프 등)을 네이티브 기계어로 즉시 컴파일한 뒤 캐싱합니다.
이후 그 경로는 인터프리터 없이 바로 CPU에서 실행되어 성능이 크게 향상됩니다.
실행 상황에 따라 더 강력한 최적화를 적용하기 위한 재컴파일(Recompile), 필요시 최적화를 해제하는 디옵티마이즈(De-optimize)를 수행하기도 합니다.
💡 인터프리터 + JIT 병행 전략
JVM은 위의 두 방식을 혼합하여 사용합니다.
인터프리터로 바이트코드를 명령 단위로 즉시 해석해 빠르게 실행을 시작합니다.
그리고 반복 실행 구간에서의 해석 비용을 줄이기 위해 JIT(Just-In-Time) 컴파일러로 구간 최적화를 합니다.
즉, 시작은 빠르게, 시간이 지날수록 반복 구간(핫스팟)을 중심으로 점점 더 빨라지는 실행이 가능하기 때문에,
JVM은 보통 인터프리터 + JIT 컴파일러를 함께 사용합니다.
실행 중 네이티브 코드가 호출이 발생하면,
제어가 네이티브 메서드 인터페이스(JNI)를 거쳐 JVM 스택에서 네이티브 메서드 스택으로 넘어가고,
네이티브 메서드 라이브러리에 있는 기계어 코드가 OS/CPU에서 직접 실행됩니다.
이와 관련된 구체적인 동작 과정도 아래의 JVM 실행 단계에서 자세히 살펴보겠습니다.
4) 가비지 컬렉터 (Garbage Collector, GC)
데이터 런타임 영역의 힙(Heap)에서 더 이상 참조되지 않는 객체를 자동으로 회수하여 메모리를 관리하는 기능
GC는 도달 가능성(Reachability) 분석을 통해 객체가 여전히 참조되고 있는지 확인합니다.
예를 들어, new로 만든 객체가 더 이상 어떤 변수에서도 참조되지 않게 되면,
GC가 이를 '쓰레기'로 판단하고 메모리에서 제거합니다.
이 덕분에 개발자가 직접 메모리를 해제하는 복잡한 작업을 하지 않아도 되며, 힙(Heap) 영역을 깔끔하게 유지할 수 있습니다.
단, GC는 JVM Stack이나 Native Method Stack의 데이터는 건드리지 않고, 오직 Heap에 있는 객체만 회수합니다.
5) 네이티브 메서드 인터페이스 (Native Method Interface, JNI)
Java와 C/C++ 같은 네이티브 코드 사이에서 메서드 이름(심볼), 시그니처(매개변수·반환형), 데이터 형식을 변환·중계하는 브리지(bridge) 역할
실행 엔진(Execution Engine)이 바이트코드를 실행하던 중 native 키워드가 붙은 메서드를 만나면,
JVM은 JNI(네이티브 메서드 인터페이스)를 통해 제어를 네이티브 세계로 넘깁니다.
이 과정에서 JNI는 JVM Stack의 Java 메서드와 Native Method Stack의 네이티브 함수의 심볼을 매핑하고,
int ↔ jint, String ↔ jstring처럼 양쪽 환경이 이해할 수 있는 데이터 형식으로 변환합니다.
변환된 데이터와 호출 정보는 Native Method Stack에 기록되며,
이후 네이티브 메서드 라이브러리에서 실제 함수가 실행됩니다.
네이티브 코드 실행이 끝나면 JNI는 반환값과 오류 상태를 Java 타입이나 예외로 변환하여 JVM Stack으로 돌려보냅니다.

위 코드에서 sayHello 메서드는 Java 내부에 구현이 없습니다.
native 키워드는 "이 메서드의 구현은 네이티브 세계에 있다."는 의미입니다.
코드로 살펴보겠습니다.
System.loadLibrary("hello")가 호출되면, OS별 규칙에 맞는 네이티브 라이브러리 파일(Windows: hello.dll, Linux: libhello.so, macOS: libhello.dylib)을 메모리에 적재됩니다.
JVM은 이 파일을 java.library.path(또는 OS 환경변수)에서 검색합니다.
sayHello("Java")가 호출되면, JVM Stack에 Java 메서드 프레임이 쌓입니다.
JVM은 바이트코드를 실행하다가 해당 메서드가 native임을 확인한 순간,
JNI가 개입하여 네이티브 함수를 실행하도록 제어를 넘깁니다.
JNI는 Java 메서드와 네이티브 함수의 이름을 매핑하고, String → jstring과 같이 데이터 타입을 변환한 뒤,
Native Method Stack에 인자와 호출 정보를 기록합니다.
그다음, 로드된 네이티브 라이브러리 안에서 동일한 심볼을 가진 함수를 찾아 호출하며, 이 함수는 OS/CPU에서 직접 실행됩니다.
실행이 끝나면 JNI가 반환값과 오류 상태를 Java에서 사용할 수 있는 형태로 변환하여 JVM Stack으로 돌려보내고,
이후 Java 코드 흐름이 계속 이어집니다.
6) 네이티브 메서드 라이브러리 (Native Method Libraries)
OS/CPU용으로 미리 컴파일된 바이너리코드(.dll/ .so/ .dylib)로, 네이티브 함수의 실제 구현부
Windows에서는 .dll, Linux에서는 .so, macOS에서는 .dylib 형식으로 제공됩니다.
Java 애플리케이션에서 System.loadLibrary("hello")를 호출하면 해당 네이티브 라이브러리가 메모리에 적재됩니다.
이후 JNI가 네이티브 메서드 호출을 중계하면, 라이브러리 내부의 함수가 OS 수준에서 직접 실행됩니다.
이때 네이티브 메서드 라이브러리는 JNI와 맞물려 동작합니다.
JNI는 Java ↔ 네이티브 간의 변환과 호출 중계를 담당하고,
네이티브 메서드 라이브러리는 변환된 인자를 받아 실제 동작을 수행하는 것입니다.
JVM 실행 과정
이제 아래 이미지의 구조를 기반으로, 실행 시 어떤 순서로 단계가 진행되는지 단계별로 살펴보겠습니다.

이미지처럼 Java의 동작 과정은 컴파일 단계 → 클래스 로딩 단계 → 실행(런타임) 단계로 이어지며,
각 단계마다 관여하는 구성요소와 동작 순서·역할을 함께 알아보겠습니다.
1) 컴파일 단계 (빌드 타임)
자바 실행 코드(.java file) → 자바 컴파일러(javac) → 바이트코드(.class file)
1️⃣ 자바 소스 코드(.java file) → 바이트코드(.class file) 변환
개발자가 작성한 소스 코드(.java file)을 JDK에 포함된 자바 컴파일러(javac)가 바이트코드(.class file)로 컴파일합니다.
이 바이트코드는 아직 기계어가 아니기 때문에 CPU가 직접 실행할 수 없는 JVM이 이해하는 중간 언어입니다.
따라서 CPU에서 실행되기 위해 JVM 단계를 거쳐야 하며, 이 단계는 JVM 외부에서 일어납니다.
2) 클래스 로딩 (로드 타임)
클래스를 실행할 수 있도록 메모리에 올리고 준비하는 과정
이 과정은 로딩(Loading) → 링크(Linking) → 초기화(Initialization)의 3단계로 진행됩니다.
클래스 로딩에서 바이트코드를 메모리에 올리는 것은 로딩 단계에서 이루어집니다.
하지만 올린 직후 바로 실행하지 않고, 링크와 초기화 과정을 거쳐야 안전하게 실행 가능합니다.
즉, "메모리 적재(로딩) → 안전성 검사 & 준비(링크) → 실제 초기화(초기화)" 순서로 진행됩니다.
1️⃣ 로딩 (Loading)
실행에 필요한 .class 파일(바이트코드)을 찾아 메서드 영역(Method Area)에 적재합니다.
이 시점에는 클래스의 원시 메타데이터가 메모리에 올라온 상태이며, 아직 실행 준비 전인 상태라고 보시면 됩니다.
2️⃣ 링크 (Linking)
로딩된 클래스가 정상적으로 실행 가능한 상태인지 확인하고, 필요한 메모리 구조를 준비하는 단계입니다.
검증(Verification), 준비(Preparation), 해결(Resolution) 총 3가지를 세부적으로 검사하게 됩니다.
검증(Verification) 단계에서 바이트코드가 JVM 명세와 보안 규칙을 준수하는지 검증합니다.
준비(Preparation) 단계에서 static 변수인 정적 필드에 필요한 메모리 할당, 기본값으로 초기화합니다.
마지막으로 해결(Resolution) 단계에서 상수 풀(Constant Pool)에 있는 클래스·메서드·필드의 심볼릭 참조를 실제 메모리 참조로 변환합니다. 이 과정을 통해 실행 엔진(Execution Engine)이 런타임에 더 빠르고 안전하게 대상을 찾을 수 있습니다.
3️⃣ 초기화 (Initialization)
정적 초기화 블록과 정적 필드의 실제 초기값 대입을 실행하여 클래스를 실사용 가능 상태로 만듭니다.
이 시점이 되어야 해당 클래스의 메서드를 호출이 가능합니다.
즉, 아직 프로그램이 실행된 것은 아니며, 실행할 준비가 된 상태가 됩니다.
3) 실행 (런타임)
런타임 데이터 영역(Runtime Data Area)을 준비한 뒤, 실행 엔진(Execution Engine)이 바이트코드 실행
클래스를 로딩(로딩, 링크, 초기화)까지 완료되어 실행 준비가 끝나면, JVM은 런타임 단계에 진입합니다.
이 단계에서는 런타임 데이터 영역(Runtime Data Area)을 준비하고,
실행 엔진(Execution Engine)이 바이트코드를 실제로 실행합니다.
1️⃣ 메모리 할당 (Runtime Data Area 구성)
클래스 로딩 시 이미 메서드 영역(Method Area)에 클래스 메타데이터가 적재됩니다.
실행이 시작되면 스레드 생성과 동시에 JVM Stack과 PC Register가 초기화되고,
네이티브 코드 실행용 Native Method Stack도 스레드별로 준비됩니다.
new 연산으로 생성된 객체와 배열은 힙(Heap)에 저장되며, 이 영역은 가비지 컬렉터(GC)가 관리합니다.
이렇게 준비된 메모리 구조를 기반으로 실행 엔진이 바이트코드를 실행하게 됩니다.
2️⃣ 코드 실행 (Execution Engine)
실행 엔진은 바이트코드를 해석하거나(인터프리터), 기계어로 변환해(JIT 컴파일러) 실행합니다.
인터프리터가 명령 단위로 해석하여 빠르게 실행을 시작하고,
실행 중 프로파일링으로 "자주 실행되는 경로(핫스팟)"가 발견되면 JIT 컴파일러가 해당 구간을 기계어로 컴파일하여 캐시합니다. 이후 해당 구간은 해석 없이 즉시 실행되어 성능이 향상됩니다.
실행 엔진(Execution Engine)은 JVM Stack과 PC Register로 제어 흐름을 관리하며,
Heap과 Method Area에서 데이터를 읽고 씁니다.
3️⃣ 네이티브 코드 실행 (JNI 중계)
코드 실행 중 바이트코드 안에 native 메서드 호출이 있으면 흐름이 잠시 JVM 밖으로 나가면서 네이티브 분기가 발생합니다.
native 키워드가 붙은 메서드 호출을 만나면 해당 구현이 JVM Stack 내부에 없음을 확인하고,
JVM은 실행 흐름을 JNI(Native Method Interface)로 넘깁니다.
JNI는 자바 세계인 런타임 데이터 영역과 네이티브 세계인 OS 사이의 번역기 역할을 하며,
JVM Stack의 네이티브 코드를 Native Method Stack으로 컨텍스트 전환하고
Java 타입을 네이티브 타입으로 변환하여 저장합니다.
변환된 데이터는 Native Method Stack에서 호출 대상 네이티브 라이브러리 함수의 심볼을 찾아 연결되며,
라이브러리 내부의 기계어 코드가 OS/CPU에서 직접 실행됩니다.
각 네이티브 라이브러리는 OS별로 미리 빌드된 완성된 기계어 코드로 존재하며(.dll, .so, .dylib),
System.loadLibrary("mylib") 호출 시 OS 환경에 맞는 파일이 메모리에 적재됩니다.
실행이 끝나면 결과값은 Native Method Stack에 보관되고, JNI가 이를 Java 타입이나 예외로 변환하여 JVM Stack으로 돌려보냅니다. 이후 실행 엔진은 나머지 바이트코드 실행을 계속 이어갑니다.
쉽게 말해, JVM 실행 엔진이 외국어 안내문을 발견하면 JNI라는 통역사에게 건네주고,
통역사는 네이티브 라이브러리라는 현지 전문가에게 물어본 뒤 그 답을 JVM이 이해할 수 있는 언어로 돌려주는 과정입니다.
4️⃣ 메모리 회수 (Garbage Collector)
프로그램이 실행되는 동안 가비지 컬렉터(GC)는 힙(Heap)에서 더 이상 참조되지 않는 객체를 자동으로 회수합니다.
스택(JVM Stack, Native Method Stack)과 PC Register는 스레드 종료 시 자동으로 해제되므로 GC의 대상이 아니며,
오직 힙 영역에서만 동작합니다.
GC는 실행 엔진의 흐름을 방해하지 않도록 백그라운드에서 실행되며, 메모리가 부족하거나 설정된 조건이 충족되면 런타임 도중에도 동작해 메모리 공간을 확보합니다.
정리하며
JVM은 단순히 코드를 실행하는 도구가 아니라, 운영체제에 독립적으로 동작하며 메모리 관리, 성능 최적화, 그리고 네이티브 코드 연동까지 담당하는 자바 실행 환경의 핵심입니다.
이번에 Java 프로그램의 실행 과정을 단계별로 살펴보면서, 클래스 로딩에서 실행, 그리고 메모리 회수에 이르기까지 복잡하지만 정교하게 맞물린 구조와 동작 원리를 확인할 수 있었습니다.
특히, JVM이 어떻게 바이트코드를 실제 기계어로 변환해 실행하는지, 실행 도중 네이티브 라이브러리와 어떻게 연동하는지, 그리고 가비지 컬렉터가 어떻게 자동으로 메모리를 관리하는지 이해하게 되면서,
단순히 "자바는 편리하다"라는 인식에서 한 걸음 더 나아가 그 편리함이 어떻게 구현되는지를 명확히 볼 수 있었습니다.
이제 자바 프로그램을 작성하고 실행할 때, 그 뒤에서 JVM이 수행하는 수많은 준비와 최적화 과정을 떠올리면,
언어와 플랫폼에 대한 이해가 한층 깊어질 것입니다.
'프로그래밍 > Java' 카테고리의 다른 글
| [Java] java vs javac - 컴파일과 실행의 차이 (4) | 2025.08.17 |
|---|---|
| [Java] JVM, JRE, JDK, JIT 차이와 포함 관계 (5) | 2025.08.14 |