"Java가 플랫폼 독립적이다"라는 말은 결국 JVM 위에서 돌아간다는 뜻인데, 그러면 JVM은 정확히 무슨 일을 하는 걸까?

JVM이 뭘 하는지 모르면 GC 튜닝도, 성능 분석도 감으로 하게 됩니다. ClassLoader, 메모리 구조, 실행 엔진까지 전체 그림을 한 번 잡아보겠습니다.

JVM 아키텍처 전체 구조

JVM은 크게 세 덩어리로 나뉘어요.

PLAINTEXT
.java 소스 → javac 컴파일 → .class (바이트코드)

                        ┌─────────────────────┐
                        │   Class Loader       │  ← 클래스 파일을 메모리에 적재
                        └────────┬────────────┘

                        ┌─────────────────────┐
                        │ Runtime Data Areas   │  ← 메모리 영역 (Heap, Stack, ...)
                        └────────┬────────────┘

                        ┌─────────────────────┐
                        │  Execution Engine    │  ← 바이트코드 실행 (Interpreter + JIT)
                        └─────────────────────┘
  1. ClassLoader.class 파일을 찾아서 메모리에 올립니다.
  2. Runtime Data Areas — 클래스 메타데이터, 객체, 스레드 스택 등을 저장하는 메모리 공간이에요.
  3. Execution Engine — 바이트코드를 실제 기계어로 바꿔서 CPU가 실행하게 만듭니다.

이 세 덩어리의 흐름을 먼저 잡아두면, 이후 GC나 메모리 이슈를 다룰 때 기반이 됩니다. 각 파트를 하나씩 파보겠습니다.

ClassLoader — 클래스를 메모리에 올리는 놈

세 가지 기본 ClassLoader

JVM은 기동 시점에 세 개의 ClassLoader를 계층적으로 만듭니다.

ClassLoader로딩 대상구현
Bootstrapjava.lang, java.util 등 핵심 API (rt.jar)네이티브 코드 (Java 코드가 아님)
Extension (Platform)$JAVA_HOME/lib/ext의 확장 라이브러리Java 구현 (ExtClassLoader)
Application (System)classpath에 있는 우리가 작성한 코드Java 구현 (AppClassLoader)

Java 9부터 모듈 시스템이 도입되면서 Extension ClassLoader가 Platform ClassLoader로 이름이 바뀌었고, rt.jar도 사라졌습니다. 핵심만 보면 전통적인 3단 구조로 이해하시면 돼요.

부모 위임 모델 (Parent Delegation Model)

클래스를 로딩할 때 자기가 바로 찾지 않고, 먼저 부모 ClassLoader한테 위임합니다.

PLAINTEXT
Application → Extension → Bootstrap

                    여기서 먼저 찾아봄
                    못 찾으면 아래로 내려옴
              Extension에서 찾아봄
              못 찾으면 아래로 내려옴
        Application에서 찾아봄
        여기서도 못 찾으면 ClassNotFoundException

왜 이렇게 하냐면, ** 핵심 API를 사용자가 덮어쓰지 못하게 보호하기 위해서 **입니다. 누군가 java.lang.String 클래스를 직접 만들어도 Bootstrap ClassLoader가 먼저 진짜 String을 로딩하니까 사용자의 가짜 클래스는 무시돼요.

동적 로딩

Java는 클래스가 ** 필요한 시점에** 로딩됩니다. 이걸 동적 로딩(Dynamic Loading)이라 하는데요, 두 가지 방식이 있습니다:

JAVA
// 1. 로드타임 동적 로딩 — 이 클래스를 로딩하면서 참조된 클래스도 같이 로딩
public class Main {
    public static void main(String[] args) {
        MyService service = new MyService(); // MyService 로딩
    }
}

// 2. 런타임 동적 로딩 — 실행 도중에 클래스 이름으로 로딩
Class<?> clazz = Class.forName("com.example.SomePlugin");
Object instance = clazz.getDeclaredConstructor().newInstance();

Class.forName()은 JDBC 드라이버 로딩할 때 많이 보셨을 텐데, 요즘은 SPI(ServiceLoader)로 대체되는 추세입니다.

클래스 로딩 과정

로딩은 세 단계로 진행됩니다:

  1. Loading.class 파일의 바이트코드를 읽어서 Class 객체를 생성합니다.
  2. Linking
    • Verify — 바이트코드가 JVM 스펙에 맞는지 검증합니다.
    • Prepare — static 변수에 메모리를 할당하고 기본값(0, null 등)으로 초기화합니다.
    • Resolve — 심볼릭 레퍼런스를 실제 메모리 레퍼런스로 바꿉니다 (선택적, lazy하게 할 수도 있어요).
  3. Initialization — static 블록 실행, static 변수에 실제 값을 대입합니다.

Runtime Data Areas — JVM의 메모리 구조

Method Area (메서드 영역)

클래스 수준의 정보를 저장하는 공간입니다. 모든 스레드가 공유해요.

  • 클래스 메타데이터 (클래스 이름, 부모 클래스, 메서드/필드 정보)
  • static 변수
  • 런타임 상수 풀 (Runtime Constant Pool) — 리터럴 상수, 심볼릭 레퍼런스
  • 메서드 바이트코드

Java 7까지는 이걸 PermGen 이라는 고정 크기 영역에 넣었습니다. Java 8부터는 Metaspace 로 바뀌면서 네이티브 메모리를 쓰게 됐어요. 이 차이는 뒤에서 다시 다루겠습니다.

Heap (힙)

new로 생성한 모든 객체가 여기 살아요. GC의 주요 대상이 되는 영역입니다. 역시 모든 스레드가 공유합니다.

PLAINTEXT
Heap
├── Young Generation
│   ├── Eden
│   ├── Survivor 0 (S0)
│   └── Survivor 1 (S1)
└── Old Generation (Tenured)

새 객체는 Eden에 할당되고, Minor GC를 살아남으면 Survivor로 이동합니다. 여러 번 살아남아서 age가 임계값을 넘으면 Old Generation으로 승격(Promotion)돼요.

JVM Stack

스레드마다 하나씩 생기는 스택입니다. 메서드 호출마다 스택 프레임(Stack Frame) 이 하나씩 쌓여요.

각 스택 프레임에는:

  • 지역 변수 배열 (Local Variable Array) — 파라미터, 로컬 변수
  • ** 오퍼랜드 스택 (Operand Stack)** — 바이트코드 연산의 피연산자를 담는 스택
  • ** 프레임 데이터** — 상수 풀 참조, 예외 테이블 등
JAVA
public int add(int a, int b) {
    int result = a + b;  // 지역 변수 배열: [this, a, b, result]
    return result;
}

StackOverflowError는 재귀 호출이 너무 깊어서 이 스택이 꽉 찰 때 발생합니다. -Xss 옵션으로 스택 크기를 조절할 수 있어요.

PC Register

각 스레드가 현재 실행 중인 바이트코드의 주소를 가리키는 작은 공간입니다. 네이티브 메서드를 실행 중이면 undefined 상태가 돼요. 크기가 워낙 작아서 별로 신경 쓸 일은 없습니다.

Native Method Stack

JNI(Java Native Interface)를 통해 호출되는 C/C++ 네이티브 메서드가 사용하는 스택입니다. 스레드마다 별도로 생성돼요.

정리

영역공유 범위저장 내용GC 대상
Method Area전체 스레드클래스 메타, static, 상수 풀Metaspace 한도 초과 시
Heap전체 스레드객체 인스턴스, 배열O (GC 핵심 대상)
JVM Stack스레드별프레임 (지역변수, 오퍼랜드)X (메서드 종료 시 프레임 제거)
PC Register스레드별현재 실행 주소X
Native Method Stack스레드별네이티브 메서드 호출 정보X

바이트코드 — .java에서 .class까지

컴파일 과정

BASH
# 소스코드 컴파일
javac Hello.java    # → Hello.class 생성

# 바이트코드 확인
javap -c Hello.class

javap -c로 바이트코드를 디스어셈블하면 이런 식으로 나옵니다:

JAVA
// 원본
public int add(int a, int b) {
    return a + b;
}
PLAINTEXT
// 바이트코드
public int add(int, int);
  Code:
     0: iload_1       // 첫 번째 파라미터(a) 로드
     1: iload_2       // 두 번째 파라미터(b) 로드
     2: iadd          // 두 값 더하기
     3: ireturn       // 결과 반환

바이트코드는 스택 기반 명령어 집합이에요. iload로 값을 오퍼랜드 스택에 올리고, iadd로 스택 상위 두 값을 꺼내 더한 뒤 결과를 다시 스택에 넣습니다. 레지스터 기반 아키텍처(x86 같은)와 대비되는 특징인데, 스택 기반이라 하드웨어에 종속되지 않아요. 이게 ** 플랫폼 독립성 **의 핵심입니다.

플랫폼 독립성

같은 .class 파일이 Windows JVM에서도, Linux JVM에서도, macOS JVM에서도 그대로 돌아갑니다. "Write Once, Run Anywhere"라는 구호가 괜히 나온 게 아니에요. 물론 JVM 자체는 OS별로 다르게 구현돼 있습니다. Java 코드가 플랫폼 독립적인 거지, JVM이 플랫폼 독립적인 건 아니라는 점을 구분해야 해요.

실행 엔진 — Interpreter vs JIT Compiler

바이트코드가 메모리에 올라왔으면 이제 실행해야 합니다. 실행 엔진은 두 가지 방식을 혼합해서 써요.

Interpreter

바이트코드 명령어를 ** 한 줄씩** 읽어서 해석하고 실행합니다. 시작 속도는 빠르지만, 같은 코드가 반복 실행되면 매번 해석하니까 느려요.

JIT Compiler

자주 실행되는 코드(핫스팟)를 발견하면 바이트코드 전체를 네이티브 기계어로 컴파일해버립니다. 한 번 컴파일하면 이후부터는 네이티브 코드를 바로 실행하니까 인터프리터보다 훨씬 빨라요.

PLAINTEXT
첫 실행: 인터프리터로 한 줄씩 실행

반복 감지: "이 메서드 1만 번 넘게 호출됐네?"

JIT 컴파일: 네이티브 코드로 변환

이후 실행: 컴파일된 네이티브 코드 직접 실행 (빠름!)

HotSpot VM

Oracle JDK와 OpenJDK에서 쓰는 JVM이 HotSpot VM입니다. 이름 자체가 "핫스팟(자주 실행되는 부분)을 찾아서 최적화한다"는 의미예요. 메서드 호출 횟수나 루프 반복 횟수를 카운트해서, 임계값을 넘으면 JIT 컴파일 대상으로 지정합니다.

-XX:CompileThreshold 옵션으로 이 임계값을 조절할 수 있어요. 기본값은 C1에서 1,500회, C2에서 10,000회 정도입니다.

JIT 컴파일러 — C1 vs C2

C1 (Client Compiler)

  • 빠르게 컴파일합니다. 최적화 수준은 낮지만 컴파일 시간이 짧아요.
  • 시작 속도가 중요한 클라이언트 애플리케이션에 적합합니다.
  • 간단한 최적화: 인라이닝, 상수 폴딩, null 체크 제거 정도예요.

C2 (Server Compiler)

  • 컴파일은 느리지만 훨씬 공격적으로 최적화합니다.
  • 장기 실행되는 서버 애플리케이션에 적합해요.
  • 루프 풀기(loop unrolling), 이스케이프 분석, 벡터화 등 고급 최적화를 수행합니다.

Tiered Compilation (계층적 컴파일)

Java 8부터 기본으로 활성화된 전략입니다. C1과 C2를 단계적으로 조합해요.

PLAINTEXT
Level 0: 인터프리터 (프로파일링 데이터 수집)

Level 1-3: C1 컴파일 (빠른 컴파일, 점진적 최적화)

Level 4: C2 컴파일 (최대 최적화)

처음에는 빠르게 C1으로 컴파일하고, 충분한 프로파일링 데이터가 쌓이면 C2로 재컴파일합니다. "빠른 시작 + 최종 성능" 둘 다 잡겠다는 전략이에요.

인라이닝 (Inlining)

메서드 호출을 없애고, 호출된 메서드의 본문을 호출 지점에 직접 복사하는 최적화입니다.

JAVA
// 최적화 전
public int square(int x) { return x * x; }
public int calc() { return square(5) + square(3); }

// 인라이닝 후 (JIT가 자동으로)
public int calc() { return 5 * 5 + 3 * 3; }
// 상수 폴딩까지 적용되면 → return 34;

메서드 호출 오버헤드(스택 프레임 생성/제거)가 사라지니까 성능이 올라갑니다. 특히 getter/setter 같은 짧은 메서드에 효과가 크죠. -XX:MaxInlineSize-XX:FreqInlineSize로 인라이닝 대상 크기를 조절할 수 있어요.

이스케이프 분석 (Escape Analysis)

객체가 메서드 밖으로 "탈출"하는지 분석해서, 탈출하지 않으면 최적화하는 기법입니다.

JAVA
public int calculate() {
    // 이 Point 객체는 메서드 밖으로 나가지 않는다 → 힙이 아니라 스택에 할당 가능
    Point p = new Point(3, 4);
    return p.x + p.y;
}

탈출하지 않는 객체에 대해 세 가지 최적화를 적용할 수 있습니다:

  • 스택 할당 — 힙 대신 스택에 객체를 만듭니다. GC 부담이 줄어들어요.
  • ** 스칼라 대체 (Scalar Replacement)** — 객체를 아예 만들지 않고 필드를 개별 지역 변수로 분해합니다. 위 예시에서 Point 객체 없이 x=3, y=4로 직접 써요.
  • ** 락 제거 (Lock Elision)** — synchronized가 걸려 있어도 객체가 스레드를 벗어나지 않으면 락을 제거합니다.

-XX:+DoEscapeAnalysis로 활성화하는데, Java 8부터 기본으로 켜져 있습니다.

GraalVM — 차세대 JVM 생태계

AOT 컴파일 (Ahead-of-Time)

기존 JIT는 런타임에 컴파일하지만, AOT는 ** 빌드 시점에** 네이티브 바이너리로 미리 컴파일합니다.

BASH
# GraalVM Native Image
native-image -jar myapp.jar
# → myapp 이라는 네이티브 실행 파일이 생성됨

Native Image

GraalVM의 핵심 기능이에요. Java 애플리케이션을 OS별 네이티브 바이너리로 만들어줍니다.

** 장점:**

  • 시작 시간이 수십 밀리초 수준으로 줄어듭니다. JVM 부팅 과정이 없으니까요.
  • 메모리 사용량이 크게 감소해요.
  • 컨테이너 환경(Docker, Kubernetes)에서 유리합니다.

** 단점:**

  • 빌드 시간이 깁니다. 몇 분에서 십 몇 분까지 걸릴 수 있어요.
  • 리플렉션, 동적 프록시, JNI 같은 동적 기능에 제약이 있습니다. 미리 설정 파일로 알려줘야 해요.
  • C2 JIT처럼 런타임 프로파일링 기반 최적화를 못 하니까, 장기 실행 시 최대 처리량(throughput)은 JIT보다 떨어질 수 있습니다.

Spring Boot 3.x부터 GraalVM Native Image를 공식 지원합니다. Spring이 자체적으로 리플렉션 힌트를 생성해주니까 설정 부담이 많이 줄었어요.

BASH
# Spring Boot에서 Native Image 빌드
./gradlew nativeCompile

Graal JIT 컴파일러

GraalVM은 AOT만 있는 게 아닙니다. C2를 대체하는 JIT 컴파일러도 포함되어 있어요. Java로 작성되어 있어서 유지보수가 쉽고, 부분적 이스케이프 분석 같은 추가 최적화도 지원합니다.

BASH
# HotSpot에서 Graal JIT 사용
java -XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler -jar app.jar

주의할 점

클래스는 언제 로딩되나?

클래스가 처음 능동적으로 사용(Active Use)되는 시점에 로딩됩니다. 능동적 사용이란:

  • new로 인스턴스 생성
  • static 필드 접근 (final 상수 제외)
  • static 메서드 호출
  • 리플렉션 (Class.forName())
  • 하위 클래스 로딩 시 상위 클래스
  • main 메서드가 있는 클래스

반대로 ** 수동적 사용 **(클래스 배열 생성, 상수 참조 등)은 초기화를 트리거하지 않습니다.

JAVA
// 이건 MyClass를 초기화하지 않는다
MyClass[] arr = new MyClass[10];

// 이것도 상수라서 초기화 안 됨 (컴파일 타임에 인라이닝)
int val = MyClass.CONSTANT; // static final int CONSTANT = 42;

static 초기화 순서

static 필드와 static 블록은 ** 소스 코드에 나타나는 순서대로** 실행됩니다.

JAVA
public class Init {
    static int a = 10;
    static int b;

    static {
        b = a + 20;   // b = 30
    }

    static int c = b + 5; // c = 35
}

상속 관계에서는 ** 부모 클래스가 먼저** 초기화돼요. 그리고 JVM은 <clinit> 메서드(클래스 초기화 메서드)를 스레드 세이프하게 실행합니다. 여러 스레드가 동시에 같은 클래스를 초기화하려 해도 딱 한 번만 실행돼요.

PermGen vs Metaspace

항목PermGen (Java 7 이하)Metaspace (Java 8 이상)
위치JVM 힙 내부의 고정 영역네이티브 메모리 (OS가 관리)
크기고정 (-XX:MaxPermSize, 기본 64~82MB)자동 확장 (기본 제한 없음)
OOMjava.lang.OutOfMemoryError: PermGen spacejava.lang.OutOfMemoryError: Metaspace
문제점크기 예측 어려움, 동적 클래스 생성 시 자주 터짐네이티브 메모리 누수 가능성

PermGen이 사라진 가장 큰 이유는 ** 동적 클래스 생성이 많은 프레임워크(Spring, Hibernate 등)에서 PermGen space OOM이 너무 자주 발생 **했기 때문입니다. Metaspace는 네이티브 메모리를 쓰니까 훨씬 유연하지만, 무한정 늘어날 수 있으므로 -XX:MaxMetaspaceSize로 상한을 설정하는 게 좋아요.

주요 JVM 옵션

** 힙 메모리:**

BASH
-Xms512m     # 초기 힙 크기 (initial)
-Xmx2g       # 최대 힙 크기 (maximum)
-Xss512k     # 스레드 스택 크기

운영 환경에서는 -Xms-Xmx를 같은 값으로 설정하는 게 일반적입니다. 힙 크기가 동적으로 변하면 GC가 자주 발생하고, 리사이징 자체도 비용이에요.

GC 관련:

BASH
-XX:+UseG1GC              # G1 GC 사용 (Java 9부터 기본)
-XX:+UseZGC               # ZGC 사용 (Java 15+ 정식)
-XX:MaxGCPauseMillis=200  # 목표 GC 중단 시간
-XX:NewRatio=2            # Old:Young 비율 (2:1)

** 디버깅/모니터링:**

BASH
-XX:+PrintGCDetails              # GC 상세 로그
-XX:+HeapDumpOnOutOfMemoryError  # OOM 시 힙 덤프 자동 생성
-XX:HeapDumpPath=/tmp/dump.hprof # 힙 덤프 저장 경로

-XX: 접두사의 의미: -X는 비표준 옵션, -XX:는 실험적/고급 옵션입니다. -XX:+는 켜기, -XX:-는 끄기, -XX:Key=Value는 값 설정이에요. 이 정도는 알고 있으면 JVM 옵션을 다룰 때 헷갈리지 않습니다.

String Pool은 어디에 있나?

Java 6까지는 PermGen에 있었는데, Java 7부터 Heap으로 옮겨졌습니다. PermGen은 크기가 고정이라 문자열이 많으면 터졌기 때문이에요. String.intern()을 호출하면 String Pool에 해당 문자열이 있는지 확인하고, 있으면 그 참조를 반환합니다.

JAVA
String s1 = new String("hello");
String s2 = s1.intern();
String s3 = "hello";

System.out.println(s2 == s3);  // true (같은 풀의 참조)
System.out.println(s1 == s3);  // false (s1은 힙의 별도 객체)

TIP 이 글의 코드 예제를 직접 실행해보고 싶다면 Java 기본기 핸드북을 확인해보세요.

정리

개념핵심
ClassLoader3단 계층(Bootstrap → Platform → Application), 부모 위임으로 핵심 API 보호
Runtime Data AreasHeap과 Method Area는 전체 스레드 공유, Stack·PC·Native는 스레드별
PermGen → Metaspace동적 클래스 생성이 많은 프레임워크에서 PermGen OOM이 빈번 → 네이티브 메모리 사용으로 전환
Interpreter vs JIT첫 실행은 인터프리터, 반복 실행 감지 시 JIT가 네이티브 코드로 컴파일
Tiered CompilationC1(빠른 컴파일) → C2(최대 최적화)를 단계적으로 조합
이스케이프 분석객체가 메서드 밖으로 탈출하지 않으면 스택 할당, 스칼라 대체, 락 제거 적용
GraalVM Native ImageAOT 컴파일로 시작 시간 수십 ms, 단 리플렉션·동적 프록시에 제약

다음 글에서는 ** 리플렉션 **을 다루겠습니다. Class 객체, 동적 프록시, Spring이 리플렉션을 쓰는 이유까지 — JVM 구조를 이해했다면 리플렉션도 자연스럽게 따라옵니다.

댓글 로딩 중...