☕ JVM 동작 구조 완벽 가이드

자바 가상 머신이 어떻게 코드를 실행하고 메모리를 관리하는지

1. JVM이란 무엇인가

JVM(Java Virtual Machine)은 자바 바이트코드를 실행하는 가상의 컴퓨터입니다. OS와 하드웨어 위에서 동작하며, 자바 코드가 어디서든 똑같이 실행되도록 하는 역할을 합니다.

JVM은 해외 번역가와 같습니다. 당신이 한국어(Java 코드)로 쓴 편지를 번역가(JVM)에게 주면, 프랑스(Linux), 미국(Windows), 일본(Mac) 어디로 보내든 알아서 현지 언어(기계어)로 번역해 전달합니다. 당신은 편지를 나라마다 새로 쓸 필요가 없어요.

"Write Once, Run Anywhere"의 비밀

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│  User.java  │ ──► │  User.class │ ──► │     JVM     │
│ (소스코드)    │ javac │ (바이트코드)    │     │  (해석/실행)  │
└─────────────┘     └─────────────┘     └──────┬──────┘
                                               │
                        ┌──────────────────────┼──────────────────────┐
                        ▼                      ▼                      ▼
                   Linux JVM              Windows JVM              Mac JVM
                        │                      │                      │
                        ▼                      ▼                      ▼
                   리눅스 기계어            윈도우 기계어              맥 기계어

자바 소스코드는 javac바이트코드(.class)로 컴파일합니다. 이 바이트코드는 OS와 CPU에 독립적이며, JVM만 설치되어 있으면 어디서든 실행됩니다.

2. JVM의 전체 아키텍처

JVM은 크게 세 가지 핵심 구성 요소로 이루어져 있습니다.

🗂️ 클래스 로더 (ClassLoader) .class 파일을 읽어서 메모리에 로딩
💾 런타임 데이터 영역 (Runtime Data Area) Heap, Stack, Method Area 등 JVM 메모리 공간
⚙️ 실행 엔진 (Execution Engine) 인터프리터 + JIT 컴파일러 + GC
동작 순서 한눈에 보기
  1. 클래스 로더가 .class 파일을 읽어 메모리에 올림
  2. 런타임 데이터 영역에 클래스 정보·객체·스택이 배치됨
  3. 실행 엔진이 바이트코드를 한 줄씩 해석하며 실행
  4. 자주 쓰이는 코드는 JIT 컴파일러가 네이티브 기계어로 변환 (속도 향상)
  5. 더 이상 쓰지 않는 객체는 GC가 자동으로 정리

3. 클래스 로더 (ClassLoader)

클래스 로더는 ".class 파일을 JVM 메모리에 적재하는 담당자" 입니다. 단순히 파일을 읽는 게 아니라 세 단계를 거칩니다.

1

Loading (로딩)

파일 시스템·네트워크·JAR 파일에서 .class 파일을 찾아 바이트 배열로 읽어 메모리에 올립니다.

2

Linking (연결)

바이트코드를 검증(Verify)하고, 클래스 변수의 메모리 공간을 준비(Prepare)하며, 참조된 다른 클래스를 해석(Resolve)합니다.

3

Initialization (초기화)

static 블록 실행, static 필드에 초기값을 넣습니다. 이 시점부터 클래스 사용 준비 완료.

클래스 로더의 계층 구조

JVM은 여러 종류의 클래스 로더를 계층적으로 운영합니다. 부모에게 먼저 "이 클래스 로드해줄래?" 라고 묻고, 부모가 못 찾으면 자기가 로드합니다. (위임 모델)

Bootstrap ClassLoader  ← 최상위. java.lang.* 같은 JDK 핵심 클래스
      │
Platform ClassLoader   ← JDK 확장 모듈 (과거 Extension)
      │
Application ClassLoader ← 우리가 짠 애플리케이션 클래스, CLASSPATH
      │
Custom ClassLoader      ← 사용자 정의 (Tomcat, Spring 등이 커스텀 로더 사용)
회사에서 결재 올릴 때 대리 → 과장 → 부장 순으로 올라가듯, 클래스 로딩 요청도 위로 전달됩니다. "이 파일 본 적 있어?" 물어보고 위에서 못 찾으면 자기가 처리합니다. 이렇게 하는 이유는 핵심 클래스(String 등)가 악의적으로 교체되는 걸 막기 위해서입니다.

4. 런타임 데이터 영역 (메모리 구조)

JVM이 실행 중 사용하는 메모리는 "모든 스레드가 공유하는 영역""스레드마다 따로 가지는 영역"으로 나뉩니다.

🏗️ Heap 공유

객체와 배열이 저장되는 곳. new로 만든 모든 것이 여기에. GC의 주 대상. JVM에서 가장 큰 메모리 영역.

📚 Method Area / Metaspace 공유

클래스 정의·메서드·상수가 저장됩니다. 객체의 "설계도" 보관소. JDK 8부터 네이티브 메모리로 이동.

📝 Stack 스레드별

메서드 호출 시 지역변수·매개변수·호출 정보를 담은 스택 프레임이 쌓이는 곳. 메서드가 끝나면 즉시 제거됨.

📌 PC Register 스레드별

현재 실행 중인 바이트코드의 위치(주소)를 저장. 스레드마다 독립적.

🔗 Native Method Stack 스레드별

자바가 아닌 C/C++로 작성된 네이티브 메서드 호출 시 사용하는 별도 스택.

💨 Code Cache 공유

JIT 컴파일러가 바이트코드를 네이티브 기계어로 변환한 결과를 캐시.

Heap 내부 구조 - 세대별 관리

Heap은 그냥 하나의 덩어리가 아니라 "세대(Generation)" 라는 영역으로 나뉩니다.

Eden
새 객체
S0
S1
Old
오래 살아남은 객체
영역 역할
Eden 새로 생성된 객체가 처음 놓이는 곳. 대부분 여기서 짧게 살다 사라짐.
Survivor 0/1 Eden에서 살아남은 객체가 이동하는 대기실. S0 ↔ S1 왔다갔다.
Old (Tenured) 여러 번 GC에서 살아남은 "장수 객체". 캐시, 싱글톤 등이 여기로.
회사 수습 제도와 같습니다. 신입(Eden)이 입사하면 대부분 빠르게 퇴사하고, 일부가 정규직 전환(Survivor)을 거쳐, 오래 살아남은 사람이 시니어(Old)가 됩니다. JVM도 "대부분의 객체는 짧게 산다(Weak Generational Hypothesis)" 라는 가설을 기반으로 이렇게 설계됐습니다.

5. 코드가 실행되는 전체 과정

간단한 코드 하나가 JVM에서 실행되는 과정을 따라가봅시다.

public class Hello {
    public static void main(String[] args) {
        String msg = "Hello, JVM!";
        System.out.println(msg);
    }
}
1

컴파일

javac Hello.javaHello.class 바이트코드 생성

2

JVM 시작

java Hello 실행 → JVM 프로세스 생성 → OS로부터 메모리 할당받음

3

클래스 로딩

ClassLoader가 Hello.class와 의존 클래스(String, System 등)를 Method Area에 로딩

4

main 스레드 생성

JVM이 main 스레드를 만들고 전용 Stack과 PC Register를 할당

5

main 메서드 실행

Stack에 main 메서드 프레임 push, PC Register가 첫 바이트코드 위치를 가리킴

6

문자열 객체 생성

"Hello, JVM!" 문자열이 Heap의 String Pool에 생성, 참조가 Stack의 msg 변수에 저장

7

바이트코드 실행

Execution Engine이 한 명령씩 읽어 인터프리터로 실행. System.out.println 호출이 Native Method로 전달되어 화면에 출력

8

종료

main 메서드 반환 → Stack 프레임 pop → 모든 사용자 스레드 종료 → JVM 종료 → OS가 메모리 회수

6. JIT 컴파일러 - JVM의 성능 비밀

JVM은 "인터프리터 + JIT 컴파일러" 하이브리드 방식으로 동작합니다. 이게 JVM이 Python보다 훨씬 빠른 이유입니다.

왜 하이브리드인가?

방식 장점 단점
인터프리터 시작이 빠름 실행이 느림 (매번 해석)
AOT 컴파일 (C/C++) 실행이 빠름 컴파일 시간 길고 플랫폼 종속
JIT (JVM) 빠른 시작 + 실행 속도 점점 상승 초기 메모리 사용

JIT의 핵심 전략: "핫스팟 감지"

JVM은 실행 중에 "어떤 메서드가 자주 호출되는지" 를 모니터링합니다. 일정 횟수 이상 호출되면 "핫(Hot)"으로 판단하고, 그 메서드를 네이티브 기계어로 컴파일해 Code Cache에 저장합니다.

실행 초기 (느림)
├─ getUserName() 1번째 호출 → 인터프리터 실행 (100ms)
├─ getUserName() 2번째 호출 → 인터프리터 실행 (100ms)
├─ ... (계속 호출)
└─ getUserName() 10000번째 호출
       ↓
   JIT가 "핫 메서드"로 판단 → 기계어로 컴파일 → Code Cache에 저장
       ↓
실행 중반 이후 (빠름)
├─ getUserName() 10001번째 호출 → 네이티브 실행 (2ms)
└─ getUserName() 10002번째 호출 → 네이티브 실행 (2ms)
이게 자바 서버가 "워밍업" 후에 빨라지는 이유입니다. 배포 직후엔 느리지만 트래픽을 받을수록 JIT가 최적화되어 속도가 올라갑니다. 성능 테스트할 때 처음 몇 분은 무시하고 측정하는 이유이기도 합니다.

JIT의 고급 최적화

7. 가비지 컬렉션 (GC)

자바의 가장 큰 특징 중 하나. 더 이상 사용되지 않는 객체를 JVM이 자동으로 정리합니다.

GC의 기본 원리: "도달 가능성(Reachability)"

GC는 "GC Root" 에서 시작해 참조를 따라가며 도달 가능한 객체만 살리고, 나머지는 쓰레기로 판단해 제거합니다.

GC Root (Stack 변수, 정적 필드, JNI 참조 등)
    │
    ├─► 객체 A ─► 객체 B ─► 객체 C    ✅ 도달 가능 (살림)
    │
    └─► 객체 D                         ✅ 도달 가능 (살림)

        객체 E ─► 객체 F               ❌ 어디서도 참조 X (수거)
당신의 책상 위 물건들 중, 파일 캐비닛이나 책꽂이에 보관된 것(참조되는 것)은 남기고, 아무 곳에도 연결 안 된 메모지(참조 없는 객체)는 버리는 것. 청소부(GC)가 주기적으로 돌며 처리합니다.

GC의 두 종류: Minor GC vs Major GC

구분 Minor GC Major GC (Full GC)
대상 Young 영역 (Eden + Survivor) Old 영역 (전체 Heap)
속도 빠름 (수 ms) 느림 (수백 ms~초)
빈도 자주 발생 드물게 발생
영향 거의 없음 애플리케이션 일시 정지 (STW)
Stop-The-World (STW) - GC가 동작하는 동안 모든 애플리케이션 스레드가 멈춥니다. 사용자 요청이 지연되고, 심하면 타임아웃이 발생할 수 있어요. 최신 GC(G1, ZGC)는 STW 시간을 최소화하는 데 집중합니다.

주요 GC 알고리즘

GC 특징 적합한 환경
Serial GC 단일 스레드, 단순함 작은 앱, CLI
Parallel GC 멀티스레드, 처리량 중심 배치 작업
G1 GC Heap을 region으로 분할, 예측 가능한 pause 일반적인 서버 (JDK 9+ 기본)
ZGC 10ms 미만 초저지연, 수 TB Heap 지원 대용량 실시간 서비스
Shenandoah 동시 압축, 저지연 응답 속도 중요한 서비스

8. 실전 - 컨테이너 환경에서의 JVM

도커 컨테이너에 자바 서비스를 띄울 때 꼭 알아야 할 설정들.

JVM 메모리 전체 크기 = Heap + Non-Heap

전체 프로세스 메모리 (RSS)
├─ Heap           : -Xmx로 설정  (예: 1.5GB)
├─ Metaspace      : 클래스 메타  (~200MB)
├─ Thread Stack   : 스레드×1MB   (~200MB)
├─ Code Cache     : JIT 결과     (~150MB)
├─ Direct Buffer  : NIO 버퍼     (~100MB)
└─ GC / 기타      : ~100MB
                                ─────────
                          합계  ~2.2GB
컨테이너 메모리를 2GB로 제한했는데 -Xmx2g를 주면 Non-Heap 영역 때문에 실제 사용량이 2GB를 초과해 OOMKilled로 컨테이너가 강제 종료됩니다. Heap은 전체의 70~75% 정도로 잡아야 안전합니다.

추천 설정 (도커 2GB 컨테이너)

java \
  -XX:InitialRAMPercentage=50.0 \
  -XX:MaxRAMPercentage=75.0 \
  -XX:MaxMetaspaceSize=256m \
  -XX:ReservedCodeCacheSize=128m \
  -Xss512k \
  -XX:+UseG1GC \
  -XX:+HeapDumpOnOutOfMemoryError \
  -XX:HeapDumpPath=/var/log/heap-dump.hprof \
  -jar app.jar
옵션 의미
MaxRAMPercentage=75 컨테이너 메모리의 75%를 Heap으로 (1.5GB)
MaxMetaspaceSize=256m Metaspace 무한 증가 방지
Xss512k 스레드 스택 1MB → 512KB로 절약
UseG1GC G1 가비지 컬렉터 사용 (낮은 pause)
HeapDumpOnOutOfMemoryError OOM 발생 시 힙덤프 자동 저장 → 사후 분석

장애 디버깅 명령어

# 실시간 메모리 사용량
jstat -gc <pid> 1000

# 스레드 덤프 (데드락, 느린 요청 분석)
jstack <pid> > thread-dump.txt

# 힙 덤프 (메모리 누수 분석)
jmap -dump:live,format=b,file=heap.hprof <pid>

# 네이티브 메모리 추적 (Non-Heap 영역 상세)
jcmd <pid> VM.native_memory summary

# GC 로그 활성화 (기동 옵션)
-Xlog:gc*:file=gc.log:time,uptime:filecount=5,filesize=10M
운영 환경에선 Prometheus + Grafana로 JVM 메트릭을 상시 모니터링하세요. Spring Boot는 micrometer-registry-prometheus 의존성만 추가하면 Heap, GC, 스레드, Metaspace 등 거의 모든 지표가 자동 노출됩니다.

9. 핵심 요약

JVM을 한 문장으로

자바 바이트코드를 받아, 클래스 로더로 메모리에 올리고, 인터프리터+JIT로 실행하며, GC로 메모리를 자동 관리하는 가상 머신

꼭 기억할 3가지

  1. 메모리 ≠ Heap - Heap 외에도 Metaspace, Stack, Code Cache 등이 있고, 모두 합하면 Heap의 30% 이상이 추가로 필요합니다.
  2. JIT는 시간이 필요하다 - 처음엔 느리다가 점점 빨라지는 것이 정상입니다. 성능 측정은 워밍업 후에.
  3. GC는 공짜가 아니다 - 자동 관리 대가로 STW가 발생합니다. 서비스 특성에 맞는 GC를 선택하고 Heap 크기를 넉넉히 주세요.