Language/Java & Kotlin

Java 개발자라면 알아야 할 JVM의 모든 것: 클래스 로더부터 실행 엔진까지

Joonfluence 2026. 7. 17.

Java의 탄생 배경

Java는 1991년 썬 마이크로시스템즈(이하 썬)의 제임스 고슬링과 아서 밴 호프를 주축으로 한 엔지니어들이 플랫폼으로부터 독립적으로 실행 가능하도록 개발한 언어입니다. 참고로 여기서 플랫폼이란 운영체제와 CPU 아키텍처를 말합니다. 대표적으로 Windows, Linux, Mac 그리고 x86과 핸드폰에 주로 쓰이는 arm이 있습니다. 90년대 초는 C와 C++의 사용률이 높았던 시절입니다. 썬의 개발자들이 당시 핫했던 C나 C++를 사용하지 않은 이유는 무엇일까요? 객체지향 프로그래밍은 C++로도 가능했지만, C++는 플랫폼에 따라 컴파일러에 차이가 있어 플랫폼 간 실행파일의 호환성이 보장되지 않았습니다. 이는 인터넷의 발전으로 다양한 환경에서 실행될 가능성이 높아진 프로그램들을 플랫폼에 따라 크로스 컴파일(Cross Compile)하여 실행해야 하는 불편함을 야기했습니다. 따라서 썬의 개발자들은 이 문제를 근본적으로 해결하기 위해 JVM(Java Virtual Machine)이라는 가상머신을 만들어 운영체제와 상관없이 동일한 코드(바이트 코드)를 실행할 수 있도록 설계했습니다.

추가 용어 설명

컴파일은 사람이 이해할 수 있도록 작성한 소스 코드를 컴퓨터가 이해할 수 있도록 이진수 형태인 기계어로 변환하는 과정입니다.

바이트코드는 고급 언어로 작성된 소스 코드를 가상머신이 이해할 수 있는 중간 코드로 컴파일한 것입니다. 자바의 경우, JVM이 이 바이트코드를 각각의 하드웨어 아키텍처에 맞는 기계어로 변환하여 실행합니다.

기계어는 CPU가 직접 해독하고 실행할 수 있는 비트 단위로 작성된 컴퓨터 언어입니다.

크로스 컴파일은 플랫폼에 따라 읽어올 수 있는 기계어 형태가 다르기 때문에, 해당 플랫폼에 맞춰 컴파일하는 과정을 말합니다.

JVM 동작원리 및 기본 개념

JVM의 동작 원리는 다음과 같습니다. JVM은 자바 컴파일러에 의해 컴파일된 바이트 코드(.class)를 클래스 로더를 통해 런타임 데이터 영역에 전달합니다. 그리고 실행 엔진을 통해 명령어 단위로 하나씩 실행하는 역할을 수행합니다. 따라서 클래스 로더, 런타임 데이터 영역, 실행엔진을 이해해야 JVM을 이해할 수 있습니다. 이 세 요소들이 유기적으로 상호작용함으로써 JVM이 작동하기 때문입니다.

클래스 로더

클래스 로더는 클래스 로드, 클래스 링킹 (검증, 준비, 분석), 클래스 초기화 과정을 거쳐 실행 엔진에서 실행할 수 있도록 런타임 데이터 영역에 클래스 파일을 적재하는 역할을 합니다. 이때 클래스를 메모리에 한 번에 전부 올리는 것이 아니라 애플리케이션에서 필요한 경우마다 동적으로 로딩합니다.

로딩

컴파일된 파일 안에는 단순히 사용자가 작성한 클래스만 존재하지 않습니다. Java 내장 API 및 확장 클래스들이 함께 존재합니다. 클래스 로더는 Bootstrap Class Loader, Extension Class Loader, System Class Loader, User-Defined Class Loader로 나뉘며, 이러한 특성을 계층 구조라고 부릅니다.

  1. 부트스트랩 클래스 로더(Bootstrap Class Loader) : 모든 클래스들의 상위 클래스인 Object 클래스를 포함하여 자바 API를 로드합니다.
  2. 익스텐션 클래스 로더(Extension Class Loader) : 다양한 보안 확장 기능 등 확장 클래스들을 로드합니다.
  3. 시스템 클래스 로더(System Class Loader) : 앞서 두 로더가 JVM 자체의 구성요소들을 로드한 후, 시스템 클래스 로더는 사용자가 지정한 $CLASSPATH 내의 클래스들을 로드합니다.
  4. 사용자 정의 클래스 로더(User-Defined Class Loader) : 애플리케이션 사용자가 직접 코드 상에서 생성하여 사용하는 클래스 로더입니다.

클래스 로드를 요청받으면, 이전에 로드된 적 있는지 확인하기 위해 클래스 로더 캐시를 확인하고 없으면 아래에서부터 순서대로 해당 클래스가 있는 위치를 확인합니다. 이때 올라가는 도중에 클래스를 발견하더라도 최상위 클래스 로더까지 확인하여 거기에도 동일한 클래스가 존재하면 상위 클래스를 로드합니다. 만약 최상위 클래스 로더에 찾는 클래스가 없으면 로드를 요청 받은 클래스 로더가 파일 시스템에서 해당 클래스를 찾습니다. 이러한 특성을 위임 모델이라고 부르며, 위 그림과 같은 구조를 가집니다.

링킹

로드 과정을 거친 후 검증, 준비, 분석 단계를 거칩니다.

  1. 검증 (Verification) : Bytecode Verifier를 통해 읽어들인 바이트 코드들이 바르게 생성되었는지 확인합니다. 만약 바르게 생성되지 않았다면 검증에 실패하며 오류를 발생시킵니다.
  2. 준비 (Preparation) : 클래스가 필요로 하는 메모리를 할당합니다. 필요한 메모리란 클래스에서 정의된 필드, 메서드, 인터페이스들을 나타내는 데이터 구조를 의미합니다.
  3. 분석 (Resolution) : 메모리에 적재되지 않았기 때문에 심볼릭 레퍼런스(Symbolic Reference, [예] com.example.User)로 참조되던 요소를 실제 위치한 주소로 참조하도록 변환합니다. 이 과정을 Constant Pool Resolution이라고 부릅니다.

초기화

클래스 로딩의 마지막 과정으로, 가리키던 주소 값을 참조하여 실제 사용자가 정의한 값으로 변경합니다.

런타임 데이터 영역

다음은 런타임 데이터 영역입니다. 런타임 데이터 영역은 애플리케이션이 동작하기 위해 OS에서 할당 받은 메모리 공간을 의미하며, 아래 5가지로 구성됩니다.

메서드 영역(Method Area)

메서드 영역은 JVM이 시작될 때 생성되는 메모리 영역입니다. 또한 모든 쓰레드가 공유하는 영역이므로 멀티 스레드 프로그래밍을 할 때 동기화에 주의해야 하는 영역입니다. 이곳에는 런타임 상수 풀 (Runtime Constant Pool)이 있으며, 이 런타임 상수 풀은 각 클래스와 인터페이스의 상수, 메서드와 필드에 대한 레퍼런스 정보를 보관하는 테이블로 JVM 동작의 가장 핵심적인 역할을 수행하는 정보를 보관합니다. 실제 코드를 실행할 때 메서드 영역의 런타임 상수 풀에 있는 정보를 조회하여 값을 불러옵니다. static 메서드와 인자를 객체 생성 없이 사용할 수 있는 이유도 공통 영역인 Method Area에서 값을 보관하기 때문입니다.

힙 영역(Heap Area)

모든 Object와 Instance를 저장하는 공간입니다. 또한 메서드 영역과 마찬가지로 모든 쓰레드가 공유하는 영역입니다. 프로그래머가 직접적으로 성능에 영향을 줄 수 있는 메모리 영역 중 하나이기도 합니다. 생성 후 더 이상 참조되지 않는 객체는 자동으로 GC(Garbage Collector)가 정리하지만, memory leak 현상이 발생하지 않도록 메모리 관리에 유의해야 합니다.

스택 영역(Stack Area, JVM Stacks)

스택 영역은 메서드를 실행하기 위한 정보들이 저장되는 공간입니다. 각 쓰레드를 위해 분리된 런타임 환경으로서 변수나 임시 데이터, 쓰레드 또는 메서드 정보들을 저장합니다. 스레드 역할이 종료되면 바로 소멸됩니다. printStackTrace()를 통해 확인할 수 있으며, 각 라인 하나가 스택 프레임을 표현합니다.

이 프레임은 메서드가 하나 호출될 때마다 새로 생기고 작업이 완료되거나 예외가 발생하면 종료됩니다. 하나의 스택 프레임에는 LVA(Local Variable Array), OS(Operand Stack), 상수 풀 참조 주소(Reference to Constant Pool)가 존재합니다. 바이트 코드가 실행되는 과정에서 LVA에 계산 결과를 저장하고 연산 결과를 OS에 저장합니다. 또한 참조 주소를 확인하여 실제 메서드 값을 반환할 때 사용합니다.

아래와 같은 자바 코드를 실행한 상황을 가정합시다. 실행 과정을 살펴보기 위해 아래 소스코드의 바이트코드를 살펴보겠습니다.

package com.example;

public class Calculation {
public static void main(String[] args) {
Calculation calculation = new Calculation();
int result = calculation.sum(3);
}

private int sum(2️⃣ int param){
1️⃣ int localVariable = 1;
5️⃣ int sum = 3️⃣ localVariable + 4️⃣ param;
7️⃣9️⃣ print(8️⃣ sum);
🔟 return sum;
}

private void print(int param){
System.out.println("param = " + param);
}
}

실제 스택 영역의 값들은 다음과 같습니다.

실제 바이트코드의 실행 과정은 다음과 같습니다. 메서드가 실행되는 과정에서 Stack 영역의 메모리 공간을 활용하게 됩니다.

0 iconst_1 1️⃣ 스택에 정수 값 1을 올린다
1 istore 2 2️⃣ LTV의 2번 인덱스에 1을 저장한다
2 iload 2 3️⃣ 저장한 값을 불러온다
3 iload 1 4️⃣ param 1번 인덱스인 param 3을 불러온다
4 iadd 5️⃣ 더한다
5 istore 3 6️⃣ 더한 값(sum)을 저장한다
6 aload 0 7️⃣ this를 호출한다
7 iload 3 8️⃣ 저장된 값(sum)을 호출한다
8 invokestatic com/example/Calculation.print(I)V 9️⃣ print 함수를 호출한다
9 iload 3 🔟 저장된 값(sum)을 호출한다
10 ireturn

9️⃣번에서 호출된 print 함수의 내부 동작은 다음과 같습니다.

0 getstatic java/lang/System.out:java.io.PrintStream // PrintStream이란 static 객체를 불러온다
1 iload 1 // param 1번 인덱스인 param 4을 불러온다
2 invokedynamic // InvokeDynamic #0:makeConcatWithConstants
3 invokevirtual java/io/PrintStream.println(Ljava/lang/String;)V // println 메서드 호출

PC 레지스터(PC Register)

현재 수행 중인 명령의 주소 정보를 보관합니다. 스레드가 시작될 때 생성되며, 어떤 명령을 실행해야 할지에 관해 기록합니다.

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

C, C++ 등으로 작성된 Native 메서드를 실행하는 영역입니다. JNI(Java Native Interface)를 통해 Java 코드와 Native 코드 간의 상호작용을 가능하게 합니다.

실행 엔진

이제 런타임 데이터 영역에 바이트 코드가 불러와졌으므로, 실행만 남았습니다. 실행은 실행 엔진을 통해 이루어집니다. 실행 엔진은 JVM 메모리에 올라온 바이트 코드들을 명령어 단위로 하나씩 실행하는 역할을 합니다. 실행 엔진은 두 가지 방식을 이용해 동작합니다: 하나씩 읽고 해석하고 실행하는 자바 인터프리터 방식과 바이트 코드 전체를 컴파일하여 바이너리 코드로 변경하고 바이너리 코드를 직접 실행하는 JIT 컴파일러 방식입니다.

Interpreter

바이트 코드를 기계어로 번역하고 실행하는 역할을 수행합니다. 다만 같은 메서드가 여러 번 호출될 때 매번 새로 수행해야 하는 성능 문제가 있어, JVM이 반복되는 코드(hotspot)를 자동으로 감지하여 JIT 컴파일러로 최적화합니다.

JIT Complier

JIT Complier는 Interpreter의 성능 문제를 개선하기 위해 등장했는데요, 반복되는 코드를 발견하여 바이트코드를 기계어(machine code)로 컴파일하고 캐시하여 직접 실행함으로써 성능을 크게 향상시킵니다. 기계어를 직접 실행하기 때문에 실행 속도가 인터프리터 방식보다 훨씬 빨라요.

Garbage Collector

더 이상 참조되지 않는 메모리 객체(Garbage)들을 모아 정해진 시간마다 제거하는 역할을 수행해요. GC는 메모리 정리 시 일시적으로 애플리케이션 스레드를 멈춥니다(Stop-The-World). 다만 최신 GC 알고리즘(G1GC 등)은 이러한 pause time을 최소화하도록 설계되었습니다.

추가로

실제 자바 코드를 역어셈블할 수 있는 방법이 있습니다. javap 파일명.java를 사용하면 됩니다. javap -c, javap -c -verbose 등의 옵션으로 실제 JVM이 동작하는 과정을 자세히 살펴볼 수 있습니다.

정리하며

정리해보면, 자바 프로그램의 실행 과정은 다음과 같습니다.

  1. 자바 컴파일러는 자바 소스코드를 읽어 JVM이 실행할 수 있는 중간 단계인 바이트 코드로 변환합니다.
  2. 컴파일된 코드는 클래스 로더에 전달됩니다.
  3. 클래스 로더는 동적 로딩을 통해 필요한 클래스들을 로딩, 링킹, 초기화 과정을 거쳐 JVM의 Runtime Data Area에 적재합니다.
  4. 로딩된 바이트 코드는 실행 엔진(Execution Engine)을 통해 실행됩니다.

이상으로 JVM의 동작 원리에 관하여 알아보았습니다. 처음 공부하는 입장에서 어려울 수 있는 내용이지만, 코딩할 때도 알아두면 도움이 되므로 반복적으로 읽어보시기를 권장합니다. 읽어주셔서 감사합니다!

참고한 글

https://docs.oracle.com/javase/specs/jvms/se7/html/
https://d2.naver.com/helloworld/1230

반응형

댓글