쉬운코드 라이브 강의 + 추가 심화 내용 통합 정리
GC를 제대로 이해하려면 JVM이 어떤 구조로, 왜 이렇게 동작하는지를 먼저 꿰고 있어야 한다.
최대한 정리를 하였지만, 명강의 선생님을 따라 갈 수 없기에 유튜브 멤버십을 통하여 강의를 꼭 들어보시기를 추천드립니다!!
https://www.youtube.com/@ezcd
목차
- JVM이 탄생한 이유: Write Once, Run Anywhere
- Java 실행 파이프라인: .java → 프로세스
- ClassLoader와 Lazy Loading
- JVM이 바이트코드를 실행하는 두 가지 방식
- /bin/java 실행 후 main() 호출까지의 흐름
- JVM 프로세스 메모리 구조 (Virtual Address Space 전체 그림)
- GC 공부를 위한 핵심 연결 고리
1. JVM이 탄생한 이유
문제: OS × CPU 조합의 폭발
Java 이전 시대로 돌아가보자. 애플리케이션은 결국 CPU가 이해하는 기계어로 번역되어야 동작하고, **OS가 제공하는 기능들(시스템 콜, 표준 라이브러리)**을 사용한다.
그런데 여기서 문제가 생긴다.
OS: Linux / Windows / macOS / ...
CPU: Intel x86 / ARM / AMD / ...
- OS마다 시스템 콜 함수 시그니처가 다름
- CPU마다 인스트럭션 명세(ISA)가 다름
- 같은 기능을 해도 Linux+Intel vs Windows+ARM은 완전히 다른 실행 파일이 필요
동일한 프로그램을 4가지 환경에서 돌리려면 각 환경에 맞게 4번 컴파일해야 했다. 기능이 추가될 때마다, 버그를 고칠 때마다 4번씩. 너무 번거롭다.
해결책: JVM (중간 레이어 삽입)
[Java 소스] → [javac] → [바이트코드 .class] → [JVM] → [OS/CPU]
↑
"나는 알아서 처리할게.
너는 바이트코드만 줘."
캐치프레이즈: "Write Once, Run Anywhere"
- 개발자는 바이트코드로 한 번만 컴파일하면 됨
- JVM이 각 OS/CPU에 맞게 구현되어 있고, 그 JVM이 바이트코드를 해석해서 실행
- Linux+Intel용 JVM, Windows+ARM용 JVM이 따로 있지만, 바이트코드는 동일
핵심: 인터페이스(바이트코드)는 하나, 구현체(JVM)는 플랫폼마다 따로.
2. Java 실행 파이프라인
전체 흐름
.java → (javac) → .class → (jar) → .jar → (java -jar app.jar) → JVM Process 실행
단계별 상세
Step 1: 소스코드 컴파일 (javac)
# 단일 파일
javac Main.java → Main.class
# 디렉토리 전체
javac -d binary/ src/**/*.java → binary/ 아래에 .class 파일들 생성
- javac: Java Compiler. .java → .class
- .class 파일은 바이트코드(bytecode) 로 기술됨
- 바이트코드 = JVM만 이해할 수 있는 중간 코드. CPU가 직접 실행 불가.
Step 2: 패키징 (jar)
jar -cf app.jar -C binary/ . # .class 파일들을 app.jar로 압축
- .jar는 실행 파일이 아니다. 특수한 형태의 ZIP 압축 파일.
- 안을 들여다보면 그냥 .class 파일들의 묶음 + META-INF/MANIFEST.MF
Step 3: 실행 (/bin/java)
java -jar app.jar
- /bin/java가 실제 실행 파일 (ELF binary on Linux) — 이게 프로세스가 된다.
- java 실행 파일이 곧 JVM이기 때문에 "자바 프로세스 = JVM 프로세스" 다.
- JVM 프로세스가 별도로 따로 뜨는 게 아님.
- JVM 프로세스는 .jar 안의 .class 파일에 기술된 바이트코드를 읽고:
- 인터프리터로 해석하며 대신 실행하거나
- 자주 쓰이는 것은 JIT 컴파일해서 기계어로 바로 실행
JAVA_HOME 디렉토리 구조
$JAVA_HOME/
├── bin/
│ ├── java ← 실행 파일 (JVM 프로세스)
│ ├── javac ← Java 컴파일러
│ └── ...
└── lib/
├── modules ← 자바 표준 라이브러리 (jimage 포맷, 압축됨)
├── libXXX.so ← 네이티브 라이브러리 (libjvm.so, libjava.so 등)
└── server/
└── libjvm.so ← JVM 엔진 본체
3. ClassLoader와 Lazy Loading
클래스 파일은 한 번에 다 RAM에 올라가나?
아니다. Lazy Loading 방식으로 필요할 때마다 하나씩 로딩된다.
- 자바 표준 라이브러리는 lib/modules라는 jimage 포맷 파일에 모두 포함
- 우리가 만든 클래스들은 .jar 파일 안에
- java 실행 시 ClassLoader를 통해 실행에 필요한 최소한의 클래스만 klass 객체로 만들어 Metaspace에 로딩
- 이 시점에 java.lang.Class 객체도 Java Heap에 생성됨
- 로딩된 클래스를 바탕으로 바이트코드 수행
- Lazy Loading: 바이트코드를 실행하다가 아직 로딩되지 않은 클래스를 만나면, 그때 ClassLoader가 해당 클래스를 로딩
BestShop shop = new BestShop(); // 이 시점에 BestShop 클래스 로딩
List<String> list = new ArrayList<>(); // 이 시점에 ArrayList 클래스 로딩
ClassLoader 계층 구조
Bootstrap ClassLoader (C++로 구현, JVM 내부)
└── lib/modules (jimage) → java.lang.*, java.util.* 등 표준 라이브러리
↓ 위임 (Parent Delegation)
Platform ClassLoader (Java 9+ 명칭, 이전엔 Extension ClassLoader)
└── JDK 확장 모듈 담당. Java 9+ 이후 일반 개발자가 직접 쓸 일 거의 없음.
↓ 위임
Application ClassLoader (= System ClassLoader)
└── .jar 파일 안의 개발자가 작성한 클래스들 로딩
Parent Delegation Model (부모 위임 모델):
- 클래스 로딩 요청이 오면 먼저 부모에게 위임 → 부모가 못 찾으면 자신이 로딩
- 목적: java.lang.String을 악의적으로 재정의 못 하게 (보안), 같은 클래스 중복 로딩 방지
Java 9 이후 변화:
- java.sql, java.xml 등 모듈들이 jimage 포맷으로 통합되면서 Platform ClassLoader의 역할이 줄어듦
- 사실상 우리가 직접 신경 쓸 ClassLoader는 Bootstrap + Application 두 개
클래스 로딩 상세 과정
Loading → Linking → Initialization
↑
Verification → Preparation → Resolution
1. Loading
- ClassLoader가 .class 파일 바이트 스트림을 읽음
- klass 객체 → Metaspace 에 생성
- java.lang.Class 객체 → JVM Heap 에 생성
2. Verification (링킹 1단계)
- 바이트코드가 JVM 명세를 준수하는지 검증
- 타입 안전성, 스택 깊이, 브랜치 범위 등 → 악성 바이트코드 차단
3. Preparation (링킹 2단계)
- static 변수를 위한 메모리 할당 + 기본값으로 초기화
- static int count = 10; → 이 시점에는 count = 0 (기본값)
4. Resolution (링킹 3단계)
- Constant Pool의 심볼릭 참조(문자열) → 직접 참조(실제 포인터) 로 변환
- "com/example/Foo" → 실제 klass 주소
5. Initialization
- static 블록, static 변수 실제 초기화 코드 실행
- <clinit>() 메서드 호출 → 이 시점에 count = 10 이 됨
Metaspace 확인으로 로딩 여부 판단
- ClassLoader는 Metaspace부터 먼저 확인
- 없으면 → 로딩 안 됐다 → 디스크(Secondary Storage)까지 내려가서 파일 읽어서 로딩
- 있으면 → 이미 로딩된 것 → 바로 사용
Metaspace에서 내려가는 경우:
- Full GC 발생 시 (드물게)
- 개발자가 명시적으로 지정한 ClassLoader가 GC되면 → 해당 ClassLoader로 로딩된 클래스들도 함께 언로딩
실무 포인트: Warm-up
Lazy Loading의 부작용 — 배포 직후 첫 트래픽 지연:
서버 재기동 → 최소한의 클래스만 Metaspace에 로딩된 상태
→ 갑자기 대량 트래픽 유입
→ ClassLoader가 클래스 로딩을 위해 Secondary Storage(디스크) 계속 접근
→ 디스크 I/O는 CPU 레지스터 >> 메모리 >> 디스크 순으로 느림
→ 요청 처리 불가, 타임아웃 폭발
해결책: Warm-up (사전 로딩)
# 배포 후 트래픽 투입 전에 더미 요청으로 클래스 사전 로딩
curl http://localhost:8080/health
curl http://localhost:8080/api/warm-up
# ... 주요 엔드포인트들 한 번씩 호출해서 클래스 로딩 유도
// Spring Boot: ApplicationRunner로 Warm-up 구현
@Component
public class WarmUpRunner implements ApplicationRunner {
@Autowired private SomeService someService;
@Override
public void run(ApplicationArguments args) {
// 주요 서비스 메서드 더미 호출 → 관련 클래스들 Metaspace 로딩
someService.warmUp();
}
}
AOT (Ahead-Of-Time) 컴파일로 근본 해결:
- GraalVM Native Image: 빌드 타임에 모든 클래스 분석 → 네이티브 기계어로 변환
- 시작 시간 극단적으로 단축, Warm-up 불필요
- 단점: 리플렉션 제약, 빌드 시간 증가, 동적 클래스 로딩 불가
네카라쿠배 수준의 대용량 트래픽 서비스에서는 Warm-up 전략이 실제 장애 예방과 직결된다.
4. JVM이 바이트코드를 실행하는 두 가지 방식
방식 1: 인터프리터 (Interpreter)
"바이트코드를 한 줄씩 해석해서 대신 실행"
// Java 소스
public static void superAmazingPopularMethod() {
int a = 10;
int b = a + 7;
}
// javap -c 로 본 바이트코드
0: bipush 10 // 정수 10을 operand stack에 push
2: istore_0 // stack top → local variable[0] (a = 10)
3: iload_0 // local variable[0] → stack
4: bipush 7 // 정수 7을 push
6: iadd // stack의 두 int 더하기
7: istore_1 // 결과 → local variable[1] (b = 17)
8: return
HotSpot 내부 동작 방식:
JVM 내부에 TemplateTable이 있고, 각 바이트코드 opcode에 대한 처리 코드가 미리 등록되어 있다.
// src/hotspot/share/interpreter/templateTable.cpp
void TemplateTable::initialize() {
def(Bytecodes::_bipush, ..., bipush, _);
def(Bytecodes::_iload_0, ..., iload, 0);
def(Bytecodes::_iadd, ..., iadd, _);
// ...
}
// 실제 x86 구현
void TemplateTable::bipush() {
transition(vtos, itos);
__ load_signed_byte(rax, at_bcp(1)); // x86 어셈블리로 처리
}
바이트코드 하나하나를 읽을 때마다 → 거기에 맞는 JVM 내부 코드(C++로 컴파일된 기계어)를 실행. 이게 인터프리터 방식.
단점: 같은 코드를 반복 실행해도 매번 인터프리팅 → 오버헤드.
방식 2: JIT Compiler (Just-In-Time)
"자주 호출되는 코드는 기계어로 미리 컴파일해서 Code Cache에 저장"
- 메서드나 루프 블록 중 자주 호출되는 것(Hot Code) 을 감지
- 바이트코드를 기계어 블록으로 미리 컴파일 → Code Cache에 저장
- 이후 호출 시 인터프리팅 없이 Code Cache에서 바로 실행
처음에는 Interpreter로 실행
→ 호출 횟수 카운팅 (CompileThreshold 기본: 10,000회)
→ 임계값 초과 → JIT Compiler 작동
→ 바이트코드 → 기계어 블록 생성 → Code Cache 저장
→ 이후 호출: Code Cache에서 바로 실행 (인터프리팅 스킵)
HotSpot의 두 단계 JIT (Tiered Compilation):
| 단계 | 컴파일러 | 특징 |
| Tier 0 | Interpreter | 기본 |
| Tier 1~3 | C1 (Client Compiler) | 빠른 컴파일, 프로파일링 수집 |
| Tier 4 | C2 (Server Compiler) | 느리지만 강력한 최적화 |
- C1: 빠르게 컴파일, 프로파일링 데이터 수집
- C2: 수집된 프로파일링 기반으로 공격적 최적화
JIT 핵심 최적화 기법:
① Method Inlining (메서드 인라이닝)
// 원본
int result = add(3, 4);
int add(int a, int b) { return a + b; }
// JIT 변환 후
int result = 3 + 4; // 메서드 호출 오버헤드 제거
② Escape Analysis + Scalar Replacement
// 원본: 매번 Heap에 Point 객체 생성
Point p = new Point(x, y);
int sum = p.x + p.y;
// JIT: p가 메서드 밖으로 escape 안 함을 감지
// → Heap 할당 없이 x, y를 레지스터/스택에서 직접 처리
int sum = x + y; // GC 부하 감소!
③ Loop Unrolling
// 원본
for (int i = 0; i < 4; i++) arr[i] = i;
// JIT 변환
arr[0] = 0; arr[1] = 1; arr[2] = 2; arr[3] = 3;
④ OSR (On-Stack Replacement)
- 긴 루프가 돌고 있는 도중에 JIT 컴파일된 버전으로 교체
- 메서드 완료를 기다리지 않고 실행 중간에 최적화 버전으로 전환
Code Cache 주의사항:
-XX:ReservedCodeCacheSize=256m # 기본값 240MB
# Code Cache 가득 차면?
# → JIT 컴파일 중단
# → 인터프리터 방식으로 폴백
# → 성능 급락 (워닝 로그: CodeCache is full)
5. /bin/java 실행 후 main() 호출까지
전체 시퀀스 (JVM C++ 소스 기준)
/bin/java 실행 (OS가 ELF 바이너리 로드, 프로세스 생성)
↓
java.c: main()
→ JLI_Launch()
→ CallJavaMainInNewThread()
→ pthread_create() ← 새로운 OS Thread 생성
↓ (새 OS Thread에서 시작)
ThreadJavaMain() ← 새 thread의 entry point
→ JavaMain()
→ InitializeJVM() ← JVM 초기화
→ Threads::create_vm()
→ new JavaThread() [C++ Heap에 생성]
→ initialize_java_lang_classes()
→ java.lang.Thread 객체 생성 [JVM Heap에 생성]
→ create_initial_thread()
→ java_lang_Thread::set_thread()
← java.lang.Thread ↔ JavaThread 연결
→ LoadMainClass() ← Main 클래스 로딩
→ invokeStaticMainWithoutArgs() ← main() 호출!
핵심: 3종 스레드의 1:1:1 관계
┌─────────────────┐ ┌─────────────────┐ ┌──────────────────────┐
│ OS Thread │ 1:1 │ JavaThread │ 1:1 │ java.lang.Thread │
│ (pthread) │─────▶│ (C++ 객체) │─────▶│ (Java 객체) │
│ │ │ JVM C++ Heap │ │ JVM Heap │
│ - CPU 스케줄링 │ │ - Stack 정보 │ │ - Java 개발자가 사용 │
│ - 실제 실행 단위│ │ - GC Root 관리 │ │ - thread.start() │
└─────────────────┘ └─────────────────┘ └──────────────────────┘
- OS Thread (pthread): pthread_create()로 OS 커널이 생성. 실제 CPU 스케줄링 단위.
- JavaThread (C++ 객체): JVM이 관리하는 C++ 스타일 자료구조. OS 스레드를 JVM 입장에서 관리하기 편하게 래핑.
- java.lang.Thread (Java 객체): 개발자가 코드에서 다루는 Java 객체. start() 호출 시 native를 통해 OS Thread 생성.
왜 이 1:1:1을 알아야 하는가:
- Virtual Thread (Java 21+)가 이 구조를 깰 때 뭘 어떻게 바꾸는지 이해 가능
- Thread dump 분석할 때 OS 스레드 ID와 Java 스레드의 관계 파악 가능
- synchronized, ReentrantLock 동작 원리의 기반이 이 구조
thread.start()의 내부 흐름
new Thread(() -> doSomething()).start();
java.lang.Thread.start()
→ start0() [native method, JNI 통해 C++로]
→ JavaThread::start()
→ os::start_thread()
→ pthread_create() ← OS Thread 탄생
→ Thread.run() 실행
Virtual Thread (Java 21+) — 1:1 모델의 파괴
Platform Thread (기존): java.lang.Thread 1 : OS Thread 1
Virtual Thread (Java 21+): 수천 개의 VT → 소수의 Carrier Thread (OS Thread) M:N 매핑
- Blocking I/O 발생 시 OS Thread를 점유하지 않고 release → 다른 Virtual Thread가 사용
- Spring Boot 3.2+: spring.threads.virtual.enabled=true 한 줄로 활성화
- I/O 많은 서버에서 스레드 수 폭발 문제 근본 해결
6. JVM 프로세스 메모리 구조
Virtual Address Space 전체 그림
JVM도 결국 OS 입장에서 하나의 프로세스다. 모든 프로세스처럼 Virtual Address Space 를 가진다.
Virtual Address Space는 가상의 주소 공간이다. 실제 데이터가 저장되는 별도의 공간이 아니라, "내가 이 주소 공간을 이렇게 사용하겠다"는 매핑 개념. 실제 데이터는 RAM(Physical Memory)에 있고, 페이지 단위로 나뉘어 흩어져 저장된다.
Virtual Address Space (JVM Process)
┌─────────────────────────────────────┐
│ Kernel Space │ ← OS 커널. 실제로는 훨씬 넓은 영역
├─────────────────────────────────────┤
│ JVM Stack (Thread 1) │ ─┐
│ JVM Stack (Thread 2) │ │← OS Thread 생성마다 추가
│ JVM Stack (Thread N) │ ─┘
├─────────────────────────────────────┤
│ Native Library (libjvm.so) │ ─┐
│ Native Library (libjava.so) │ │← 산발적으로 여러 위치에 존재 가능
│ Native Library (libnet.so) │ ─┘
├─────────────────────────────────────┤
│ Metaspace │ ← klass 객체, 바이트코드 (연속적이지 않을 수 있음)
├─────────────────────────────────────┤
│ JVM Heap │ ← 자바 객체/배열 (GC 관리 대상 ★)
├─────────────────────────────────────┤
│ Code Cache │ ← JIT 컴파일된 기계어
├─────────────────────────────────────┤
│ ↓ JVM 내부 C++ 영역 ↓ │
├─────────────────────────────────────┤
│ heap (C++) │ ← JavaThread 객체 (C++로 정의) 등 JVM 내부 객체
│ data (+bss) │ ← JVM 전역 데이터
│ code (text) │ ← JVM 자체 실행 코드 (C++ 컴파일 결과)
└─────────────────────────────────────┘
실제 배치 순서는 OS가 결정한다. Code Cache가 Heap 위에 있을 수도 있고, Native Library가 Metaspace 아래에 있을 수도 있다. 위 그림은 논리적 이해를 위한 것.
6-1. JVM Stack
- OS Thread가 생성될 때마다 스택 공간이 함께 잡힌다. (pthread_create 시)
- HotSpot JVM은 이 OS Thread 스택을 Java Thread가 그대로 재사용한다.
- 별도로 추가 스택 공간을 잡지 않음. OS가 준 것을 그대로 활용하는 전략.
- JVM 명세상 JVM stack / native method stack을 구분하지만, HotSpot 구현에서는 하나로 통합.
Stack Frame 구조:
┌──────────────────────────────┐
│ Local Variable Array │ ← 메서드 파라미터 + 지역변수
│ [0] = this │
│ [1] = param1 │
│ [2] = localVar │
├──────────────────────────────┤
│ Operand Stack │ ← 바이트코드 연산의 작업대 (push/pop)
├──────────────────────────────┤
│ Frame Data │ ← Constant Pool 참조, 이전 Frame 정보
└──────────────────────────────┘
- 메서드 호출 → Frame push, 메서드 리턴 → Frame pop
- StackOverflowError = 재귀 등으로 Stack 공간 초과
6-2. Native Library
- libjvm.so, libnet.so, libjava.so 등 공유 라이브러리
- OS의 시스템 콜을 Java에서 호출할 수 있게 해주는 다리
- JNI(Java Native Interface)를 통해 Java → C/C++ 네이티브 코드 호출 경로
6-3. Metaspace (JVM 명세상 "Method Area")
Java 8 이전: PermGen (힙 내부 고정 크기 영역, OutOfMemoryError: PermGen space 유명한 에러)
Java 8 이후: Metaspace — Native 메모리 사용, 동적으로 크기 조절
저장되는 것들:
- klass 객체: 각 클래스의 메타 정보 (필드, 메서드, 상속 관계, 접근 제어자 등)
- 메서드 바이트코드: 각 메서드의 실제 바이트코드 명령어들
- Runtime Constant Pool: 클래스 파일의 Constant Pool 런타임 버전
- (static 변수: Java 8 이후 Heap으로 이동)
내부 구현 특징:
- mmap() 시스템 콜로 OS에서 직접 메모리 확보 (Native Memory)
- 연속적인 주소 공간이 아닐 수 있음 (링크드 리스트 형태로 관리)
- GC는 Full GC 시에만 Metaspace 정리 (클래스 언로딩)
- 기본적으로 상한선 없음 → 메모리 누수 시 무한 증가 가능
-XX:MaxMetaspaceSize=256m # 반드시 상한 설정 권장
klass 객체 vs java.lang.Class 객체:
| 구분 | klass (C++ 객체) | java.lang.Class (Java 객체) |
| 위치 | Metaspace | JVM Heap |
| 계층 | JVM C++ 레벨 | Java 레벨 |
| 접근 | JVM 내부에서 포인터로 | Java 코드에서 .getClass() 등 |
| 관계 | klass가 java.lang.Class를 mirror로 참조 | klass를 내부적으로 참조 |
클래스 로딩 시 둘 다 동시에 생성되고 서로 연결됨.
6-4. JVM Heap ★ (GC의 주 무대)
모든 자바 객체와 자바 배열이 여기에 생성된다.
- java.lang.Thread 객체도 여기 (Java 레이어 스레드 객체)
- java.lang.Class 객체도 여기
- String pool도 Java 7+부터 Heap으로 이동 (이전엔 PermGen)
- 우리가 배울 GC는 이 영역에 대한 이야기
java.lang.Thread와 JavaThread의 관계 (Heap 관점):
JVM C++ Heap: [JavaThread 객체 (C++)] ──참조──▶ [java.lang.Thread (Java)] in JVM Heap
↑
OS Thread가 1:1 매핑
기본 Heap 구조 (G1GC 이전 Parallel GC 기준):
┌─────────────────────────────────────────────────┐
│ JVM Heap │
│ ┌──────────────────┐ ┌───────────────────────┐ │
│ │ Young Gen │ │ Old Gen │ │
│ │ ┌──────┐ ┌─────┐ │ │ │ │
│ │ │ Eden │ │ S0 │ │ │ 오래 살아남은 객체들 │ │
│ │ │ │ ├─────┤ │ │ │ │
│ │ │ │ │ S1 │ │ │ │ │
│ │ └──────┘ └─────┘ │ │ │ │
│ └──────────────────┘ └───────────────────────┘ │
└─────────────────────────────────────────────────┘
6-5. Code Cache
- JIT 컴파일러가 최적화하여 기계어로 변환한 코드들이 상주
- 한번 컴파일되면 여기 저장 → 이후 호출 시 인터프리팅 없이 바로 기계어 실행
- Segmented Code Cache (Java 9+):
- Non-method code: JVM 내부 인프라 코드
- Profiled code: C1 컴파일 코드 (프로파일링 포함)
- Non-profiled code: C2 컴파일 코드 (최고 최적화)
6-6. JVM 내부 C++ 영역 (heap / data / code)
JVM 자체가 C++로 작성된 프로그램이므로, C++ 프로그램으로서의 메모리 영역이 별도로 존재한다.
- code (text): JVM 소스 코드가 컴파일된 기계어 (JVM 자체의 실행 코드)
- data (+bss): JVM 전역 변수, 정적 데이터
- heap (C++): JVM 내부 C++ 객체들. **JavaThread 객체 (C++로 정의)**가 여기 상주.
JavaThread ≠ java.lang.Thread. 전자는 C++ Heap, 후자는 JVM Heap. 1:1로 연결되어 있지만 서로 다른 레이어.
7. GC 공부를 위한 핵심 연결 고리
Thread와 GC의 관계
각 Thread가 독립적으로 소유하는 것:
- JVM Stack (Stack Frames)
- PC Register (현재 실행 중인 바이트코드 주소)
- Native Method Stack
Thread 간 공유하는 것:
- JVM Heap → 동기화가 필요한 이유
- Metaspace
- Code Cache
GC Root와 Stack Frame:
void method() {
Object obj = new Object(); // obj 참조: Stack에, 실제 객체: Heap에
// obj가 Stack에 살아있는 동안 → Heap의 Object는 GC 대상 제외
}
// method() 종료 → Frame pop → obj 참조 사라짐 → Object는 GC 수거 대상
GC가 Heap을 수거할 때 살아있는 객체 판단 기준(GC Root) 중 하나가 각 Thread의 Stack에 있는 지역 변수/파라미터 참조다.
메모리 영역별 GC 연관성
| 영역 | GC와의 관련성 |
| JVM Heap | GC가 관리하는 주 대상 ★ |
| Stack Frame의 참조 | GC Root — 살아있는 객체 판단 기준 |
| Metaspace | Full GC 시 클래스 언로딩 처리 |
| Code Cache | JIT 최적화 코드. 가득 차면 성능 급락 (GC와 간접 연관) |
| klass 객체 | GC가 객체 크기/내부 참조 파악에 사용 |
GC 발생 흐름 예고
new 객체 생성
→ JVM Heap의 Young Gen(Eden) 에 할당
→ Eden 가득 참 → Minor GC (Young GC)
→ 살아남은 객체 → Survivor(S0/S1) 이동, age 증가
→ age 임계값 초과 → Old Gen으로 Promotion
→ Old Gen 가득 참 → Major GC (Full GC)
→ Full GC 시 Metaspace도 정리 (클래스 언로딩 시도)
주요 JVM 튜닝 플래그
# === Heap ===
-Xms512m # 초기 Heap 크기
-Xmx4g # 최대 Heap 크기 (Xms = Xmx 동일하게 권장, 크기 조정 오버헤드 제거)
# === Metaspace ===
-XX:MetaspaceSize=128m # 초기 Metaspace 크기 (첫 Full GC 기준점)
-XX:MaxMetaspaceSize=256m # 최대 Metaspace 크기 (필수 설정!)
# === GC 선택 ===
-XX:+UseG1GC # G1GC (Java 9+ 기본)
-XX:+UseZGC # ZGC (저지연, Java 15+ GA)
-XX:+UseShenandoahGC # Shenandoah (저지연, OpenJDK)
-XX:+UseParallelGC # Parallel GC (처리량 최적화)
# === Code Cache ===
-XX:ReservedCodeCacheSize=256m # Code Cache 크기
# === JIT ===
-XX:CompileThreshold=10000 # JIT 컴파일 임계값 (기본)
-XX:+TieredCompilation # Tiered Compilation (기본 활성화)
# === GC 로깅 (Java 9+) ===
-Xlog:gc*:gc.log:time,uptime,level,tags
# === 진단 ===
-XX:+PrintFlagsFinal # 모든 JVM 플래그 출력
-XX:+HeapDumpOnOutOfMemoryError # OOM 시 Heap Dump
-XX:HeapDumpPath=/tmp/heapdump.hprof
정리: 큰 그림
.java ──(javac)──▶ .class (바이트코드) ──(jar)──▶ .jar (ZIP)
│
java -jar app.jar
│
▼
┌────────────────────────────────────┐
│ JVM Process │
│ (Virtual Address Space) │
│ │
│ [Kernel Space] │
│ [JVM Stack × N threads] │ 지역변수
│ [Native Library] │ OS 시스템콜 브릿지
│ [Metaspace] │ klass, 바이트코드
│ [JVM Heap] ★GC★ │ 자바 객체/배열
│ [Code Cache] │ JIT 결과
│ [C++ heap/data/code] │ JVM 자체 내부
└────────────────────────────────────┘
│
ClassLoader: Lazy Loading
실행: Interpreter → JIT (C1→C2)
스레드: OS Thread : JavaThread : java.lang.Thread = 1:1:1
Warm-up: 배포 후 트래픽 투입 전 필수
'자바 > 자바' 카테고리의 다른 글
| (JAVA) GC 동작 원리와 튜닝 가이드 *유튜브 쉬운코드 (0) | 2026.04.02 |
|---|---|
| [Java] 제네릭 와일드카드 완벽 정리 (feat. 공변과 반공변) (1) | 2026.01.22 |
| [Java] 제네릭은 왜 불변(Invariant)일까? (feat. 공변과 서브타입) (1) | 2026.01.21 |
| (java) 패턴 매칭 instanceof 캐스팅 제거 (Flow Scoping 완벽 이해) (0) | 2026.01.20 |
| (자바) Apache Kafka 핵심 개념 (RabbitMQ <->Redis (Pub/Sub) <->Apache Kafka 차이 ) (0) | 2025.12.15 |