dev.syw

클래스 로딩, 런타임 데이터 영역, 가비지 컬렉션, JIT 컴파일까지 JVM이 코드를 실행하는 원리를 파헤친다.

JVM 내부 구조와 메모리 모델

입문편에서는 "Java 코드는 JVM 위에서 실행된다"는 사실을 배웠습니다. 이 강의에서는 그 한 줄 뒤에 숨어 있는 구체적인 메커니즘을 파헤칩니다. 객체를 new로 생성할 때 메모리 어디에 무엇이 올라가는지, 지역 변수와 힙 객체가 어떻게 다른지, GC가 어떤 기준으로 메모리를 회수하는지를 이해하면 성능 문제를 훨씬 정확하게 진단하고 해결할 수 있습니다.

JVM 내부 구조는 4강(성능 최적화와 프로파일링), 3강(동시성과 멀티스레드)과도 직접 연결됩니다. 스레드 스택, 가시성 문제, 힙 튜닝 옵션 모두 이 강의에서 다루는 메모리 모델을 전제로 하기 때문입니다.

학습 목표

  • 클래스 로더 계층과 클래스가 로딩·링크·초기화되는 3단계 흐름을 설명할 수 있다.
  • 런타임 데이터 영역(힙, 스택, 메타스페이스, PC 레지스터, 네이티브 메서드 스택)의 역할과 차이를 구별할 수 있다.
  • 객체가 힙에 어떻게 배치되고 참조가 스택에서 어떻게 연결되는지 설명할 수 있다.
  • 세대별 GC(Young/Old) 동작 원리와 G1·ZGC의 특성 차이를 이해한다.
  • JIT 컴파일러가 바이트코드를 기계어로 변환하는 흐름을 설명할 수 있다.

클래스 로딩: 로딩 → 링크 → 초기화

JVM은 클래스가 처음 사용될 때 .class 파일을 읽어 런타임에 필요한 형태로 변환합니다. 이 과정은 세 단계로 나뉩니다.

로딩(Loading)

클래스로더가 .class 바이너리를 읽어 java.lang.Class 객체를 메모리(메타스페이스)에 생성합니다.

링크(Linking)

링크 단계는 다시 세 부분으로 구성됩니다.

단계내용
Verification바이트코드가 JVM 명세를 위반하지 않는지 검증
Preparationstatic 필드에 기본값(0, null, false 등)을 할당
Resolution심볼릭 참조(#ClassName, #methodName)를 실제 메모리 참조로 교체

초기화(Initialization)

static 초기화 블록과 static 필드에 작성된 초기값이 실행됩니다. 초기화는 클래스가 처음 사용되는 시점에 단 한 번, 스레드 안전하게 수행됩니다.

public class Config {
    // Preparation 단계: maxSize = 0 (기본값)
    // Initialization 단계: maxSize = 100 (개발자가 지정한 값)
    private static int maxSize = 100;

    static {
        System.out.println("Config 클래스 초기화 완료. maxSize = " + maxSize);
    }
}

⚠️ 주의 위 흐름은 일반 static 필드에만 적용됩니다. 만약 private static final int maxSize = 100;처럼 컴파일타임 상수(constant expression) 로 초기화한 static final 기본형/String 필드라면 이야기가 달라집니다. 이 경우 값이 .class의 ConstantValue 속성으로 기록되어 Preparation 단계에서 곧바로 100으로 설정되며, 0 기본값을 거치지 않습니다. 게다가 사용처에서 값이 인라인되므로, 이 상수에 접근하는 것만으로는 클래스 초기화(static 블록 실행)가 트리거되지 않습니다. 'Preparation = 0 → Initialization = 100'이라는 규칙을 보고 싶다면 위 예제처럼 final을 빼거나, static final int maxSize = computeMax();처럼 런타임 계산식으로 초기화해야 합니다.

클래스로더 계층 구조

클래스로더는 세 계층으로 나뉘며, 자식 로더는 먼저 부모 로더에게 위임합니다(위임 모델, Delegation Model).

Bootstrap ClassLoader   (JDK 핵심 클래스: java.lang.*, java.util.*)
       ↑
Platform ClassLoader    (Java SE / EE 확장: javax.*, java.sql.*)
       ↑
Application ClassLoader (classpath 상의 애플리케이션 클래스)
       ↑
Custom ClassLoader      (필요시 직접 구현)

(화살표 ↑는 '자식 → 부모'로 향하는 위임 방향을 나타냅니다. Platform의 부모는 Bootstrap(논리적으로 null), Application의 부모는 Platform입니다. Custom ClassLoader의 부모는 생성 시점에 지정하며, 별도 지정이 없으면 일반적으로 Application(System) ClassLoader가 기본 부모가 됩니다.)

💡 TIP 위임 모델 덕분에 java.lang.String을 임의로 교체하는 악성 코드가 로드되지 않습니다. 자식 로더가 먼저 올리려 해도 부모가 이미 올린 클래스를 사용합니다.

아래 코드로 어떤 클래스로더가 클래스를 로드했는지 확인할 수 있습니다.

public class ClassLoaderDemo {
    public static void main(String[] args) {
        // Bootstrap ClassLoader가 담당 → null 반환 (네이티브 구현)
        ClassLoader stringLoader = String.class.getClassLoader();
        System.out.println("String loader: " + stringLoader); // null

        // Application ClassLoader가 담당
        ClassLoader myLoader = ClassLoaderDemo.class.getClassLoader();
        System.out.println("My loader: " + myLoader);
        // jdk.internal.loader.ClassLoaders$AppClassLoader@...
    }
}

런타임 데이터 영역

JVM이 프로그램을 실행하는 동안 관리하는 메모리 공간은 크게 다섯 영역으로 나뉩니다.

┌──────────────────────────────────────────────────────┐
│                   JVM 프로세스 메모리                    │
│                                                      │
│  ┌─────────────────────────┐  ← 모든 스레드 공유       │
│  │         힙(Heap)         │                          │
│  │  Young Gen  │  Old Gen  │                          │
│  └─────────────────────────┘                          │
│  ┌──────────────────────────┐  ← 모든 스레드 공유      │
│  │    메타스페이스(Metaspace)  │                         │
│  └──────────────────────────┘                         │
│                                                      │
│  ──── 아래는 스레드별 독립 영역 ────                     │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐            │
│  │  스택    │  │  스택    │  │  스택    │  Thread-1/2/3 │
│  └──────────┘  └──────────┘  └──────────┘            │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐            │
│  │PC 레지스터│  │PC 레지스터│  │PC 레지스터│             │
│  └──────────┘  └──────────┘  └──────────┘            │
└──────────────────────────────────────────────────────┘

힙(Heap)

new 키워드로 생성된 모든 객체와 배열이 저장됩니다. GC의 관리 대상이며, 모든 스레드가 공유합니다.

  • Young Generation: 새로 생성된 객체가 먼저 Eden 영역에 배치됩니다. Minor GC가 자주 발생합니다.
  • Old Generation(Tenured): Young GC를 여러 번 살아남은 장수 객체가 이동합니다. Major GC(Full GC) 대상입니다.

스택(JVM Stack)

스레드마다 독립적으로 존재하며, 메서드가 호출될 때마다 스택 프레임이 하나 쌓입니다. 각 프레임에는 지역 변수 배열, 피연산자 스택, 현재 클래스에 대한 런타임 상수 풀 참조가 들어 있습니다.

public class StackDemo {
    public static void main(String[] args) {     // 프레임 1 push
        int result = add(3, 4);                  // 프레임 2 push → pop
        System.out.println(result);
    }                                            // 프레임 1 pop

    static int add(int a, int b) {
        int sum = a + b; // 지역 변수 sum은 스택 프레임 안에만 존재
        return sum;
    }
}

⚠️ 주의 재귀 호출이 종료 조건 없이 반복되면 스택 프레임이 계속 쌓여 StackOverflowError가 발생합니다. 힙 부족인 OutOfMemoryError와 다른 오류입니다.

메타스페이스(Metaspace)

Java 8 이전에는 PermGen(Permanent Generation)이라 불렸고 힙 내부에 있었습니다. Java 8부터는 네이티브 메모리(OS 메모리)를 사용하는 메타스페이스로 대체되어, 클래스 메타데이터, 메서드 바이트코드, 상수 풀, 어노테이션 정보 등이 저장됩니다.

💡 TIP 메타스페이스는 기본적으로 상한이 없어 클래스 로딩이 많은 서버에서 메모리를 계속 늘려 쓸 수 있습니다. -XX:MaxMetaspaceSize=256m으로 상한을 설정해 예기치 않은 메모리 증가를 막는 것이 좋습니다.

PC 레지스터와 네이티브 메서드 스택

PC(Program Counter) 레지스터는 스레드마다 하나씩 존재하며, 현재 실행 중인 JVM 명령어의 주소를 기록합니다. 네이티브 메서드 스택은 C/C++로 작성된 네이티브 메서드(native 키워드)가 실행될 때 사용됩니다.

객체 메모리 배치와 참조 동작

힙에서 객체가 차지하는 구조

힙에 올라가는 객체는 JVM 내부적으로 세 파트로 구성됩니다.

파트내용
오브젝트 헤더(Object Header)Mark Word(GC 나이, 락 상태, 해시코드) + Klass Pointer(클래스 메타데이터 포인터)
인스턴스 데이터선언된 필드 값 (기본형은 값 자체, 참조형은 힙 주소)
패딩(Padding)8바이트 정렬을 위한 여백

Stack과 Heap의 상호작용

public class MemoryLayout {
    public static void main(String[] args) {
        // ① 스택 프레임에 참조 변수 p (8바이트 주소값)
        // ② 힙에 Person 객체 생성 (헤더 + name 참조 + age 값)
        // ③ name "Alice" 역시 힙의 String pool 또는 힙에 위치
        Person p = new Person("Alice", 30);

        // p2는 스택에 별도 참조 변수, 같은 힙 주소를 가리킴
        Person p2 = p;
        p2.age = 31;

        // p.age도 31 — 두 참조가 동일 객체를 가리키기 때문
        System.out.println(p.age);   // 31
        System.out.println(p == p2); // true (같은 힙 주소)
    }
}

class Person {
    String name;
    int age;

    Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

기본형(primitive) 지역 변수는 스택 프레임 안에 값 자체가 저장됩니다. 반면 Person p처럼 참조형 변수는 스택에 **힙 주소(참조)**만 저장하고, 실제 객체 데이터는 힙에 존재합니다.

void example() {
    int x = 42;          // ✅ 스택에 42 저장, 메서드 종료 시 자동 소멸
    String s = "hello";  // ✅ 스택에 참조값, 힙(String pool)에 "hello" 객체
}
// 메서드 종료 → 스택 프레임 pop → x, s 참조 모두 소멸
// 힙의 String 객체는 더 이상 참조가 없으면 GC 대상

⚠️ 주의 "변수가 스택에 저장된다"는 말은 지역 변수에 해당합니다. 클래스의 인스턴스 필드(멤버 변수)는 항상 힙에 있는 객체 내부에 저장됩니다.

가비지 컬렉션: 세대별 수집 원리

GC의 핵심 전제는 **약한 세대 가설(Weak Generational Hypothesis)**입니다. "대부분의 객체는 생성 직후 빠르게 죽는다"는 경험적 사실을 바탕으로 Young과 Old 세대를 분리합니다.

Young Generation과 Minor GC

Young Generation은 Eden 한 영역과 두 개의 Survivor(S0, S1) 영역으로 구성됩니다.

[Minor GC 흐름]

Eden이 가득 참
   ↓
살아남은 객체 → S0(또는 S1)으로 복사, age+1
죽은 객체 → 수거
   ↓
다음 Minor GC 때 S0 → S1 (또는 반대), age+1
   ↓
age 임계값(기본 15) 초과 시 Old Generation으로 Promotion

Survivor 두 개를 번갈아 사용하는 이유는 단편화(Fragmentation) 없이 살아남은 객체를 연속 공간으로 복사하기 위해서입니다.

Old Generation과 Major GC

오래 살아남은 객체들이 모이는 Old Generation은 크기가 크고 수거 비용이 높습니다. Major GC(또는 Full GC)가 발생하면 Stop-the-World(STW) 시간이 길어져 애플리케이션 응답 지연의 주요 원인이 됩니다.

// 객체 age를 간접 확인: -XX:+PrintGCDetails -Xmx64m 옵션 사용
// 실제 코드에서 age를 읽는 public API는 없음(JVM 내부)
// 다음은 Old 승격을 유도하는 예시 코드
public class PromotionDemo {
    public static void main(String[] args) throws InterruptedException {
        // 많은 단명 객체 생성 → Minor GC 유도
        for (int i = 0; i < 1_000_000; i++) {
            byte[] tmp = new byte[1024]; // 1KB, 곧 참조 소멸
        }
        // 장수 객체: main이 살아 있는 동안 계속 참조
        byte[] longLived = new byte[1024 * 1024]; // 1MB
        System.gc(); // 권고용 (실제 GC 보장 X)
        Thread.sleep(100);
        System.out.println("longLived length: " + longLived.length);
    }
}

주요 GC 알고리즘 비교

항목G1 GCZGC
도입 버전Java 9(기본)Java 11(실험적), Java 15(프로덕션)
힙 분할 방식고정 크기 Region(약 2MB)동적 크기 ZPage
STW 목표예측 가능한 pause (기본 200ms)수 밀리초 이하(~1ms)
적합 환경일반 서버, 힙 4GB~수십GB대용량 힙(수백GB), 초저지연
주요 특징Mixed GC로 Young+Old 동시 수거대부분 작업을 Concurrent로 처리
# G1 GC 사용 (Java 9+ 기본)
java -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -jar app.jar

# ZGC 사용
java -XX:+UseZGC -Xmx16g -jar app.jar

💡 TIP GC 알고리즘 선택보다 객체 생명주기 설계가 먼저입니다. 불필요하게 오래 살아남는 객체(캐시 누수, static 컬렉션에 누적 등)를 줄이는 것이 GC 튜닝의 가장 큰 효과를 냅니다.

JIT 컴파일: 바이트코드에서 기계어로

인터프리터와 JIT의 역할 분담

JVM은 처음에는 바이트코드를 한 줄씩 해석하는 인터프리터로 실행합니다. 실행 빈도(Hot Spot)가 높은 메서드가 감지되면 JIT(Just-In-Time) 컴파일러가 해당 바이트코드를 네이티브 기계어로 컴파일합니다.

.java → (javac) → .class(바이트코드) → JVM 실행
                                          │
                              ┌───────────┴───────────┐
                         인터프리터               JIT 컴파일러
                       (초반, 모든 코드)      (Hot Spot 감지 후)
                              │                       │
                        느린 실행             기계어 캐시(Code Cache)
                                                      │
                                               빠른 직접 실행

HotSpot JVM은 두 단계 JIT를 사용합니다.

  • C1(Client Compiler): 빠르게 컴파일, 기본 최적화 적용
  • C2(Server Compiler): 더 많은 프로파일링 정보를 수집 후 적극적으로 최적화(인라인, 루프 언롤링, 탈출 분석 등)

탈출 분석(Escape Analysis)

JIT의 강력한 최적화 중 하나입니다. 객체가 메서드 밖으로 탈출하지 않는다고 분석되면 힙 대신 스택에 할당하거나 아예 필드를 스칼라로 분해합니다.

public class EscapeDemo {
    public static void main(String[] args) {
        long sum = 0;
        for (int i = 0; i < 1_000_000; i++) {
            // ✅ Point 객체가 메서드 밖으로 참조되지 않음
            // JIT가 탈출 분석 후 힙 할당을 스킵하거나 스택에 직접 배치
            Point p = new Point(i, i);
            sum += p.x + p.y;
        }
        System.out.println(sum);
    }
}

record Point(int x, int y) {}
public class EscapeLeak {
    private static final List<Point> points = new ArrayList<>();

    public static void main(String[] args) {
        for (int i = 0; i < 1_000_000; i++) {
            // ❌ static 컬렉션에 추가 → 객체가 탈출, 힙 할당 불가피
            //    GC도 수거 못 함 → 메모리 누수 위험
            points.add(new Point(i, i));
        }
    }
}

record Point(int x, int y) {}

JIT 컴파일 상태 확인

# JIT 컴파일 로그 출력 (어떤 메서드가 컴파일됐는지 확인)
java -XX:+PrintCompilation -cp . EscapeDemo

# 샘플 출력:
#   88    1       3  java.lang.String::hashCode (49 bytes)
#   92    2       4  EscapeDemo::main (35 bytes)
# 형식: 타임스탬프  id  레벨  클래스::메서드 (바이트코드 크기)
# 레벨 4 = C2 최적화 컴파일

⚠️ 주의 JIT는 프로파일링 정보를 믿고 최적화합니다. 프로파일링 결과가 달라지면 **역최적화(Deoptimization)**가 발생해 인터프리터로 되돌아갑니다. 마이크로벤치마크에서 JIT 워밍업 없이 측정한 수치는 실제 운영 성능과 크게 다를 수 있습니다.

요약

  • 클래스는 로딩 → 링크(검증·준비·해석) → 초기화 순으로 처리되며, 클래스로더는 부모 위임 모델로 작동한다.
  • 런타임 데이터 영역은 (객체), 스택(프레임·지역변수), 메타스페이스(클래스 메타데이터)가 핵심이며, 힙과 메타스페이스는 스레드 공유, 스택은 스레드 독립이다.
  • 지역 변수는 스택, new로 생성된 객체는 힙에 배치되고, 참조형 변수는 힙 주소를 스택에 저장한다.
  • GC는 약한 세대 가설을 기반으로 Young(Eden + Survivor)과 Old로 나뉘어 동작하며, G1은 예측 가능한 일시 정지, ZGC는 초저지연을 목표로 한다.
  • JIT 컴파일러는 Hot Spot 메서드를 기계어로 변환하고, 탈출 분석 등으로 힙 할당 자체를 제거하는 강력한 최적화를 수행한다.

연습문제

  1. 다음 코드에서 변수 count, obj, 그리고 obj가 가리키는 Counter 객체 각각이 JVM의 어느 메모리 영역에 위치하는지 설명하세요. 메서드가 반환된 뒤 각 값은 어떻게 되나요?
public class Quiz1 {
    public static void main(String[] args) {
        run();
    }

    static void run() {
        int count = 10;
        Counter obj = new Counter(count);
        obj.increment();
    }
}

class Counter {
    int value;
    Counter(int v) { this.value = v; }
    void increment() { value++; }
}

힌트 스택 프레임이 push/pop 되는 시점과 힙 객체의 참조 소멸 시점을 구분해 생각해 보세요.

  1. 아래 코드를 실행하면 Young GC와 Old GC 중 어느 쪽이 더 자주 발생할 것으로 예상되나요? 이유와 함께 설명하고, 메모리 누수가 발생하는 지점도 찾아 수정 방안을 제시하세요.
import java.util.*;

public class Quiz2 {
    private static final Map<Integer, byte[]> cache = new HashMap<>();

    public static void main(String[] args) {
        for (int i = 0; i < 100_000; i++) {
            process(i);
        }
    }

    static void process(int id) {
        byte[] data = new byte[1024]; // 1KB 처리용 버퍼
        cache.put(id, data);          // 처리 완료 후 캐시에 저장
    }
}

힌트 static 필드에 담긴 컬렉션의 원소는 GC가 수거할 수 있을까요?

  1. 다음 중 탈출 분석(Escape Analysis)의 혜택을 받아 힙 할당이 최적화될 가능성이 있는 코드를 고르고, 그 이유를 설명하세요.
// A: 메서드 내에서만 사용
static long sumCoords(int n) {
    long total = 0;
    for (int i = 0; i < n; i++) {
        Point p = new Point(i, i * 2);
        total += p.x() + p.y();
    }
    return total;
}

// B: 외부로 반환
static Point createPoint(int x, int y) {
    return new Point(x, y);
}

// C: 외부 컬렉션에 저장
static List<Point> collectPoints(int n) {
    List<Point> list = new ArrayList<>();
    for (int i = 0; i < n; i++) {
        list.add(new Point(i, i));
    }
    return list;
}

record Point(int x, int y) {}

힌트 객체 참조가 메서드 경계 밖으로 나가는지 여부가 탈출 여부를 결정합니다.

  1. JVM 클래스로더의 위임 모델(Parent Delegation Model)이 없다면 어떤 보안 문제가 생길 수 있는지 설명하세요. 또한 커스텀 클래스로더를 직접 구현해야 하는 실무 사례를 한 가지 들어보세요.

힌트 java.lang.String을 악의적으로 교체하는 시나리오와 OSGi, 플러그인 시스템을 떠올려 보세요.

💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

Java 심화” 강좌에 대한 댓글입니다.

댓글을 작성하려면 로그인이 필요합니다.