이 글에서 얻는 것
- JVM의 메모리 영역(Runtime Data Areas: Stack/Heap/Metaspace)을 “정의”가 아니라 장애/성능 문제와 연결된 모델로 이해합니다.
OutOfMemoryError가 “힙 부족”만이 아니라, Metaspace/Direct Memory/Thread Stack 등 여러 원인으로 나뉜다는 감각을 잡습니다.- heap dump / thread dump / GC 로그를 어떤 순서로 보면 되는지, 최소한의 디버깅 루틴을 갖춥니다.
0) 이 주제가 필요한 순간(실무 신호)
아래 신호가 보이면 JVM 메모리 모델이 “필수”가 됩니다.
- 갑자기 응답이 느려지고 GC 로그에 pause가 길어진다
java.lang.OutOfMemoryError: Java heap space/Metaspace/GC overhead limit exceededStackOverflowError가 특정 요청/작업에서 반복된다- 컨테이너(K8s)에서 메모리 제한에 걸려 OOMKilled가 난다(힙만 봐서는 해결이 안 됨)
1. JVM 아키텍처 전체 구조
┌─────────────────────────────────────────────────────────┐
│ Java Application │
└────────────────────────┬────────────────────────────────┘
│ .class files
┌────────────────────────▼────────────────────────────────┐
│ JVM (Java Virtual Machine) │
│ │
│ ┌────────────────────────────────────────────────┐ │
│ │ 1. Class Loader Subsystem │ │
│ │ - Loading → Linking → Initialization │ │
│ └────────────────────┬───────────────────────────┘ │
│ │ Loaded Classes │
│ ┌────────────────────▼───────────────────────────┐ │
│ │ 2. Runtime Data Areas │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ Method Area │ │ Heap │ │ │
│ │ │ (Metaspace) │ │ (Young/Old) │ │ │
│ │ └──────────────┘ └──────────────┘ │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ PC Register│ │ JVM Stack │ │ │
│ │ └──────────────┘ └──────────────┘ │ │
│ │ │ │
│ │ ┌──────────────┐ │ │
│ │ │ Native Stack │ │ │
│ │ └──────────────┘ │ │
│ └────────────────────┬───────────────────────────┘ │
│ │ Bytecode │
│ ┌────────────────────▼───────────────────────────┐ │
│ │ 3. Execution Engine │ │
│ │ - Interpreter │ │
│ │ - JIT Compiler (C1, C2) │ │
│ │ - Garbage Collector │ │
│ └────────────────────┬───────────────────────────┘ │
│ │ Native Method │
│ ┌────────────────────▼───────────────────────────┐ │
│ │ 4. Java Native Interface (JNI) │ │
│ └────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
│
┌────────────────────────▼────────────────────────────────┐
│ Native Method Libraries │
│ (C/C++ Libraries) │
└─────────────────────────────────────────────────────────┘
2. Class Loader Subsystem
2.1 Class Loading 3단계
1단계: Loading (로딩)
// Example: Class Loading 과정
public class Main {
public static void main(String[] args) {
// 1. Bootstrap ClassLoader: java.lang.String (JDK 클래스)
String str = "Hello";
// 2. Extension ClassLoader: javax.* (확장 클래스)
// java.util.logging.Logger
// 3. Application ClassLoader: 사용자 정의 클래스
User user = new User(); // User.class 로딩
}
}
ClassLoader 계층 구조:
┌─────────────────────────┐
│ Bootstrap ClassLoader │ ← JDK 기본 클래스 (rt.jar)
│ (Native C++) │ java.lang.*, java.util.*
└──────────┬──────────────┘
│
┌──────────▼──────────────┐
│ Extension ClassLoader │ ← 확장 클래스 (jre/lib/ext)
│ (sun.misc.Launcher) │ javax.*, org.xml.*
└──────────┬──────────────┘
│
┌──────────▼──────────────┐
│ Application ClassLoader │ ← 사용자 클래스 (Classpath)
│ (sun.misc.Launcher) │ com.example.*
└──────────┬──────────────┘
│
┌──────────▼──────────────┐
│ Custom ClassLoader │ ← 사용자 정의
│ (User Defined) │ Plugin, Hot Reload
└─────────────────────────┘
ClassLoader 동작 확인:
public class ClassLoaderDemo {
public static void main(String[] args) {
// 1. Bootstrap ClassLoader (null 반환)
ClassLoader stringLoader = String.class.getClassLoader();
System.out.println("String ClassLoader: " + stringLoader); // null
// 2. Extension ClassLoader
ClassLoader extLoader = com.sun.crypto.provider.DESKeyFactory.class.getClassLoader();
System.out.println("Extension ClassLoader: " + extLoader);
// sun.misc.Launcher$ExtClassLoader
// 3. Application ClassLoader
ClassLoader appLoader = ClassLoaderDemo.class.getClassLoader();
System.out.println("Application ClassLoader: " + appLoader);
// sun.misc.Launcher$AppClassLoader
// 4. 부모 ClassLoader 조회
System.out.println("App ClassLoader Parent: " + appLoader.getParent());
// Extension ClassLoader
}
}
2단계: Linking (연결)
Linking 3단계:
1. Verification (검증)
- Bytecode Verifier가 .class 파일 검증
- 올바른 형식인지, 보안 위반 없는지 확인
- 예: final 클래스 상속 시도 → VerifyError
2. Preparation (준비)
- static 변수에 메모리 할당 (Method Area)
- 기본값으로 초기화 (int = 0, boolean = false)
3. Resolution (해석)
- Symbolic Reference → Direct Reference 변환
- 예: "com/example/User" → Heap의 실제 주소
예제:
public class LinkingExample {
static int count = 10; // Preparation: count = 0
// Initialization: count = 10
public static void main(String[] args) {
System.out.println(count); // 10
}
}
3단계: Initialization (초기화)
public class InitializationOrder {
// 1. static 변수 선언 및 초기화
static int x = 10;
// 2. static 블록 실행
static {
System.out.println("Static block executed");
x = 20;
}
// 3. 인스턴스 변수
int y = 30;
// 4. 인스턴스 초기화 블록
{
System.out.println("Instance block executed");
y = 40;
}
// 5. 생성자
public InitializationOrder() {
System.out.println("Constructor executed");
y = 50;
}
public static void main(String[] args) {
System.out.println("Main method");
new InitializationOrder();
}
}
// 출력 순서:
// Static block executed
// Main method
// Instance block executed
// Constructor executed
2.2 ClassLoader 원칙
1. Delegation Principle (위임 원칙)
// ClassLoader는 항상 부모에게 먼저 위임
public Class<?> loadClass(String name) throws ClassNotFoundException {
// 1. 이미 로드되었는지 확인
Class<?> c = findLoadedClass(name);
if (c == null) {
// 2. 부모 ClassLoader에게 위임
if (parent != null) {
c = parent.loadClass(name);
} else {
// 3. Bootstrap ClassLoader
c = findBootstrapClass(name);
}
if (c == null) {
// 4. 부모가 로드 실패 → 본인이 로드
c = findClass(name);
}
}
return c;
}
2. Visibility Principle (가시성 원칙)
하위 ClassLoader는 상위의 클래스를 볼 수 있지만,
상위는 하위의 클래스를 볼 수 없다.
Application ClassLoader → Extension ClassLoader 클래스 접근 가능 ✅
Extension ClassLoader → Application ClassLoader 클래스 접근 불가 ❌
3. Uniqueness Principle (유일성 원칙)
// 같은 ClassLoader가 동일한 클래스를 2번 로드하지 않음
Class<?> class1 = classLoader.loadClass("com.example.User");
Class<?> class2 = classLoader.loadClass("com.example.User");
System.out.println(class1 == class2); // true (같은 인스턴스)
3. Runtime Data Areas
3.1 Method Area (Metaspace - Java 8+)
저장 내용:
- 클래스 메타데이터 (클래스명, 부모 클래스, 인터페이스)
- static 변수
- 메서드 정보 (메서드명, 반환 타입, 파라미터)
- 상수 풀 (Constant Pool)
public class MethodAreaExample {
// Method Area에 저장
static int staticVar = 100;
static final String CONSTANT = "Hello";
public void method() {
// 메서드 정보도 Method Area에 저장
}
}
Java 7 vs Java 8 변화:
Java 7 (PermGen):
┌─────────────┐
│ Heap │
├─────────────┤
│ PermGen │ ← 고정 크기 (-XX:PermSize, -XX:MaxPermSize)
│ - Classes │ OutOfMemoryError: PermGen space
│ - Strings │
└─────────────┘
Java 8+ (Metaspace):
┌─────────────┐
│ Heap │ ← String Pool 이동
└─────────────┘
┌─────────────┐
│ Metaspace │ ← Native Memory (동적 크기)
│ - Classes │ -XX:MetaspaceSize, -XX:MaxMetaspaceSize
└─────────────┘
JVM 옵션:
# Java 8+
-XX:MetaspaceSize=128m # 초기 크기
-XX:MaxMetaspaceSize=512m # 최대 크기
# Metaspace 모니터링
jstat -gc <pid>
3.2 Heap (힙 영역)
구조 (Generational Heap):
┌─────────────────────────────────────────────────────────┐
│ Heap │
│ │
│ ┌──────────────────────────┐ ┌──────────────────────┐ │
│ │ Young Generation │ │ Old Generation │ │
│ │ │ │ │ │
│ │ ┌──────┐ ┌───────────┐ │ │ │ │
│ │ │ Eden │ │ Survivor │ │ │ Tenured (장기) │ │
│ │ │ │ │ S0 │ S1 │ │ │ │ │
│ │ └──────┘ └───────────┘ │ │ │ │
│ │ (새 객체) (임시 보관) │ │ (오래된 객체) │ │
│ └──────────────────────────┘ └──────────────────────┘ │
│ ▲ ▲ │
│ │ Minor GC │ Major GC │
└───────────┼──────────────────────────────┼───────────────┘
│ │
빠르고 빈번 느리고 드물게
객체 생성 흐름:
public class HeapAllocationExample {
public static void main(String[] args) {
// 1. Eden 영역에 객체 생성
User user1 = new User("Alice");
// 2. Eden이 가득 차면 Minor GC 발생
for (int i = 0; i < 1000000; i++) {
User temp = new User("User" + i);
// Eden → Survivor 0 이동
}
// 3. 여러 번 Minor GC 생존 → Old Generation 이동
// user1 참조가 계속 유지 → Old로 Promotion
}
}
Age-based Promotion:
Eden → S0 → S1 → S0 → ... (15번 반복) → Old Generation
객체의 Age:
- Minor GC 때마다 Age + 1
- -XX:MaxTenuringThreshold=15 (기본값)
- Age >= 15 → Old Generation 이동
JVM 힙 옵션:
# Heap 크기 설정
-Xms2g # 초기 Heap 크기
-Xmx4g # 최대 Heap 크기
# Young/Old Generation 비율
-XX:NewRatio=2 # Old:Young = 2:1
-XX:SurvivorRatio=8 # Eden:Survivor = 8:1:1
# 예시: -Xmx4g -XX:NewRatio=2
# Heap 4GB → Young 1.33GB, Old 2.67GB
# Young 1.33GB → Eden 1.07GB, S0 133MB, S1 133MB
3.3 JVM Stack (스택 영역)
구조:
Thread 1 Stack Thread 2 Stack
┌─────────────┐ ┌─────────────┐
│ Frame 3 │ │ Frame 2 │
├─────────────┤ ├─────────────┤
│ Frame 2 │ │ Frame 1 │
├─────────────┤ └─────────────┘
│ Frame 1 │
└─────────────┘
각 Frame 구조:
┌─────────────────────────┐
│ Local Variables │ ← 지역 변수, 매개변수
├─────────────────────────┤
│ Operand Stack │ ← 연산 중간 결과
├─────────────────────────┤
│ Frame Data │ ← 메서드 정보, 리턴 주소
└─────────────────────────┘
예제:
public class StackExample {
public static void main(String[] args) { // Frame 1
int x = 10; // Local Variable
int result = add(x, 20); // Frame 2 생성
System.out.println(result);
} // Frame 1 pop
public static int add(int a, int b) { // Frame 2
int sum = a + b; // Local Variable
return sum; // Frame 2 pop, 결과를 Frame 1의 Operand Stack으로
}
}
Stack 메모리 흐름:
1. main() 호출 → Frame 1 push
Local Variables: args, x=10, result=?
2. add(10, 20) 호출 → Frame 2 push
Local Variables: a=10, b=20, sum=30
Operand Stack: 30 (리턴값)
3. add() 종료 → Frame 2 pop
result = 30 (Operand Stack에서 가져옴)
4. main() 종료 → Frame 1 pop
StackOverflowError:
public class StackOverflowExample {
public static void main(String[] args) {
recursiveMethod(0);
}
public static void recursiveMethod(int depth) {
System.out.println("Depth: " + depth);
recursiveMethod(depth + 1); // 무한 재귀
// StackOverflowError 발생!
}
}
// JVM 옵션으로 Stack 크기 조정
// -Xss1m (기본값: 1MB)
3.4 PC Register (Program Counter)
역할:
- 현재 실행 중인 JVM 명령어 주소 저장
- 각 스레드마다 독립적으로 존재
public class PCRegisterExample {
public static void main(String[] args) {
int a = 10; // PC: 라인 3
int b = 20; // PC: 라인 4
int c = a + b; // PC: 라인 5
// PC Register는 다음에 실행할 명령어 주소를 가리킴
}
}
Bytecode로 보는 PC:
Bytecode:
0: bipush 10 ← PC = 0
2: istore_1 ← PC = 2
3: bipush 20 ← PC = 3
5: istore_2 ← PC = 5
6: iload_1 ← PC = 6
7: iload_2 ← PC = 7
8: iadd ← PC = 8
9: istore_3 ← PC = 9
3.5 Native Method Stack
JNI (Java Native Interface) 호출 시 사용:
public class NativeMethodExample {
// Native 메서드 선언
public native void nativeMethod();
static {
// C/C++ 라이브러리 로드
System.loadLibrary("native-lib");
}
public static void main(String[] args) {
new NativeMethodExample().nativeMethod();
// Native Method Stack 사용
}
}
4. Execution Engine
4.1 Interpreter vs JIT Compiler
Interpreter (인터프리터):
┌──────────────┐
│ Bytecode │ → Interpreter → 기계어 실행
└──────────────┘
라인 단위로 즉시 해석 및 실행
장점: 빠른 시작
단점: 반복 실행 시 느림
JIT Compiler (Just-In-Time Compiler):
┌──────────────┐
│ Bytecode │ → JIT Compiler → Native Code → 실행
└──────────────┘ (캐싱)
자주 실행되는 코드(Hot Spot)를 기계어로 컴파일
장점: 반복 실행 시 빠름
단점: 컴파일 오버헤드
HotSpot JVM 동작:
public class JITExample {
public static void main(String[] args) {
// 1. 처음 몇 번은 Interpreter로 실행
for (int i = 0; i < 100; i++) {
calculate(i);
}
// 2. 임계값 도달 → JIT Compiler 동작
// calculate() 메서드가 Native Code로 컴파일
// 3. 이후 호출은 컴파일된 코드 실행 (매우 빠름)
for (int i = 0; i < 1000000; i++) {
calculate(i);
}
}
public static int calculate(int n) {
return n * n + n;
}
}
// JVM 옵션
// -XX:CompileThreshold=10000 (기본값: C1=1500, C2=10000)
C1 vs C2 Compiler:
C1 (Client Compiler):
- 빠른 컴파일
- 낮은 최적화
- 시작 시간 중요한 애플리케이션
C2 (Server Compiler):
- 느린 컴파일
- 높은 최적화 (인라이닝, 루프 최적화 등)
- 장시간 실행되는 서버 애플리케이션
Tiered Compilation (Java 8+):
Interpreter → C1 → C2 (단계적 최적화)
4.2 JIT Compiler 최적화 기법
1. Method Inlining (메서드 인라이닝):
// 원본 코드
public int add(int a, int b) {
return a + b;
}
public void calculate() {
int result = add(10, 20);
}
// JIT Compiler 최적화 후
public void calculate() {
int result = 10 + 20; // 메서드 호출 제거
}
2. Dead Code Elimination (데드 코드 제거):
// 원본
public void method() {
int x = 10;
int y = 20; // 사용되지 않음
System.out.println(x);
}
// 최적화 후
public void method() {
int x = 10;
System.out.println(x);
}
3. Loop Unrolling (루프 언롤링):
// 원본
for (int i = 0; i < 4; i++) {
array[i] = i;
}
// 최적화 후
array[0] = 0;
array[1] = 1;
array[2] = 2;
array[3] = 3;
5. 메모리 누수 예방 및 디버깅
5.1 흔한 메모리 누수 패턴
1. Static 컬렉션:
// ❌ 메모리 누수
public class CacheManager {
private static Map<String, Object> cache = new HashMap<>();
public void addToCache(String key, Object value) {
cache.put(key, value);
// cache는 절대 비워지지 않음 → 메모리 누수
}
}
// ✅ 해결: SoftReference 사용
public class CacheManager {
private static Map<String, SoftReference<Object>> cache = new HashMap<>();
public void addToCache(String key, Object value) {
cache.put(key, new SoftReference<>(value));
// 메모리 부족 시 GC가 자동으로 제거
}
}
2. Listener 미제거:
// ❌ 메모리 누수
public class EventSource {
private List<Listener> listeners = new ArrayList<>();
public void addEventListener(Listener listener) {
listeners.add(listener);
// removeEventListener() 호출 안 하면 누수
}
}
// ✅ 해결: WeakHashMap 사용
public class EventSource {
private Map<Listener, Object> listeners = new WeakHashMap<>();
public void addEventListener(Listener listener) {
listeners.put(listener, null);
// Listener가 외부에서 참조 해제되면 자동 제거
}
}
3. ThreadLocal 미정리:
// ❌ 메모리 누수 (ThreadPool 환경)
public class UserContext {
private static ThreadLocal<User> currentUser = new ThreadLocal<>();
public static void setUser(User user) {
currentUser.set(user);
// remove() 호출 안 하면 Thread 재사용 시 누수
}
}
// ✅ 해결: finally에서 제거
public class UserContext {
private static ThreadLocal<User> currentUser = new ThreadLocal<>();
public static void setUser(User user) {
currentUser.set(user);
}
public static void clear() {
currentUser.remove(); // ✅ 필수
}
// Filter나 Interceptor에서 사용
public void doFilter(Request req, Response res) {
try {
setUser(extractUser(req));
// 요청 처리
} finally {
clear(); // ✅ 반드시 호출
}
}
}
5.2 메모리 디버깅 도구
1. jmap (Heap Dump):
# Heap Dump 생성
jmap -dump:format=b,file=heap.bin <pid>
# Heap 사용량 확인
jmap -heap <pid>
# Heap Histogram
jmap -histo <pid> | head -20
2. jstat (GC 모니터링):
# GC 통계 (1초마다 출력)
jstat -gc <pid> 1000
# 출력 예시:
S0C S1C S0U S1U EC EU OC OU MC MU
10240 10240 0 9216 81920 61440 163840 81920 51200 49152
# S0C: Survivor 0 Capacity
# EU: Eden Used
# OU: Old Used
3. VisualVM / JProfiler:
Heap 사용량 시각화
GC 활동 모니터링
CPU 프로파일링
스레드 덤프 분석
4. Eclipse MAT (Memory Analyzer Tool):
Heap Dump 분석
메모리 누수 원인 파악
Dominator Tree (가장 많은 메모리 차지하는 객체)
Leak Suspects (메모리 누수 의심 객체)
6. JVM 튜닝 가이드
6.1 Heap 크기 설정
# 기본 설정
-Xms2g -Xmx4g
# 권장 사항:
# 1. -Xms와 -Xmx를 동일하게 (Heap 리사이징 오버헤드 제거)
-Xms4g -Xmx4g
# 2. Young Generation 크기
-Xmn1g # Young = 1GB, Old = 3GB
# 3. Survivor 비율
-XX:SurvivorRatio=8 # Eden:S0:S1 = 8:1:1
6.2 GC 알고리즘 선택
# Serial GC (단일 스레드)
-XX:+UseSerialGC
# Parallel GC (멀티 스레드, Throughput 중시)
-XX:+UseParallelGC
-XX:ParallelGCThreads=4
# CMS GC (Low Latency, Deprecated in Java 14)
-XX:+UseConcMarkSweepGC
# G1 GC (Java 9+ 기본, Balanced)
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200 # 목표 pause time
# ZGC (Java 15+, Ultra-low Latency)
-XX:+UseZGC
6.3 GC 로깅
# Java 8
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:gc.log
# Java 9+
-Xlog:gc*:file=gc.log:time,uptime,level,tags
7. 실무 활용 시나리오
시나리오 1: OutOfMemoryError 해결
증상:
java.lang.OutOfMemoryError: Java heap space
해결 과정:
# 1. Heap Dump 생성
jmap -dump:live,format=b,file=heap.bin <pid>
# 2. Eclipse MAT로 분석
# Leak Suspects Report 확인
# 3. 원인 파악 예시
# → ArrayList에 1000만 개 객체 누적
# → Static 변수로 유지되어 GC 대상 아님
# 4. 코드 수정
# Before:
private static List<Data> cache = new ArrayList<>();
# After:
private static SoftReference<List<Data>> cache =
new SoftReference<>(new ArrayList<>());
시나리오 2: Full GC 빈발
증상:
Full GC 1분에 10회 발생 → 응답 지연
해결:
# 1. GC 로그 분석
jstat -gcutil <pid> 1000
# 2. Old Generation 사용률 80% 이상
# → Heap 크기 증가 또는 객체 생명주기 단축
# 3. Young Generation 크기 증가
-Xmn2g # Young을 크게 → Minor GC 빈도 감소
# 4. GC 알고리즘 변경
-XX:+UseG1GC # CMS → G1GC
-XX:MaxGCPauseMillis=100 # Pause time 목표
요약: 꼭 남겨야 하는 감각
JVM 아키텍처(큰 그림)
- Class Loader Subsystem(Loading → Linking → Initialization)
- Runtime Data Areas(Method Area/Heap/Stack/PC/Native Stack)
- Execution Engine(Interpreter/JIT/GC)
메모리 영역(무엇이 어디에 있나)
- Method Area/Metaspace: 클래스 메타데이터, 일부 static 영역
- Heap: 객체 인스턴스(Young/Old)
- Stack: 호출 프레임/지역 변수(스레드별)
- PC Register: 현재 실행 중인 명령어 위치(스레드별)
운영 관점(문제 좁히기)
- 힙 OOM만이 아니라 Metaspace/Direct/Stack 같은 다양한 메모리 한계가 있다
- GC 로그/heap dump/thread dump를 조합하면 원인을 빨리 좁힐 수 있다
- 플래그 튜닝은 마지막이고, “할당/생존/구조”를 먼저 본다
💬 댓글