안녕하세요 코헤에욤 🙌
요즘 JVM을 열심히 읽고 있는데, 주니어 분들이 "너무 어려운 책이야..." 하실까 봐 제가 먼저 읽어보고 여러분께 핵심 포인트들을 쏙쏙 뽑아서 알려드리려고 해요! 💪
오늘은 가비지 컬렉터와 메모리 할당 전략만 집중적으로 다뤄볼게요~ 전체 목차 한번 보실까요?
📋 오늘의 스터디 목차
3장 가비지 컬렉터와 메모리 할당 전략
✅ 3.1 들어가며 → GC가 가진 원론적 의문점들 (어떤 메모리를? 언제? 어떻게?)
✅ 3.2 대상이 죽었는가? → 객체 삭제를 위한 생사 판단 이야기
✅ 3.3 가비지 컬렉션 알고리즘 → 기본 알고리즘들 파헤치기
✅ 3.4 핫스팟 알고리즘 상세 구현 → 실제로 어떻게 구현되어 있는지
✅ 3.5 클래식 가비지 컬렉터 → Serial, Parallel, CMS, G1까지
✅ 3.6 저지연 가비지 컬렉터 → Shenandoah, ZGC 같은 최신 기술들
✅ 3.7 적합한 가비지 컬렉터 선택하기 → 내 프로젝트엔 뭘 써야 할까?
✅ 3.8 실전: 메모리 할당과 회수 전략 ← 여기가 제일 재미있었어요! 🔥
📌 이런 분들께 추천해요
- GC가 뭔지는 아는데 깊이 있게 알고 싶은 분
- 성능 튜닝할 때 GC 옵션을 제대로 알고 쓰고 싶은 분
- "우리 서버 왜 갑자기 멈춰?" 하며 고민해본 적 있는 분
🎯 목표: 여러분도 JVM 밑바닥 파헤치기를 읽을 수 있도록...
3.1 들어가며
GC의 진짜 역사 - 자바가 원조가 아니었다고?!
"GC는 자바에서 나온 기술이지!" 라고 생각하셨나요? 놀랍게도 가비지 컬렉션은 1960년대 MIT에서 개발된 LISP 언어에서 시작되었어요! 자바보다 훨씬 오래된 기술이었던 거죠. 존 매카시(John McCarthy)라는 분이 GC를 할 때 고민해야 할 3가지 핵심 질문을 정리해주셨어요:
💭 GC의 3가지 근본적 질문
- 어떤 메모리를 회수할 것인가?
- 언제 회수할 것인가?
- 어떻게 회수할 것인가?
단순해 보이지만 이 3가지가 GC의 모든 것을 담고 있어요!
왜 GC를 알아야 할까요? 🎯
"어차피 자동으로 해주는 건데 굳이 알아야 해?" 라고 생각하실 수 있어요. 하지만요!
GC를 이해하면 이런 문제들을 해결할 수 있어요:
- 🚨 메모리 오버플로우 - "OutOfMemoryError 왜 뜨지?"
- 🐌 메모리 누수 - "서버가 점점 느려져요..."
- ⏱️ STW(Stop The World) - "갑자기 서비스가 멈춰요!"
결국 GC를 알면 '방해받지 않는 자동화된 기술'로 만들 수 있다는 거예요!
=> 으에? 싶죠.... 우리 서비스가 어떤지에 따라서 최적화된 GC를 쓰면 좀 더 좋다구 이해해보는건 우떨까용.. 물론 대부분 서버를 껐켰이 제일 효과가 좋죠.. ㅠㅠ 압니다요...
JVM 메모리 구조 - GC가 담당하는 영역은? 🏠
JVM 런타임 데이터 영역을 집으로 비유해볼게요:
🎯 GC가 청소하는 방:
- 힙(Heap) - 객체들이 사는 큰 거실
- 메서드(Method) 영역 - 클래스 정보가 있는 서재
🚫 GC가 청소 안 하는 방:
- 프로그램 카운터 - 개인 방 (스레드마다 하나씩)
- 자바 가상 머신 스택 - 개인 옷장 (스레드마다 하나씩)
- 네이티브 메서드 스택 - 개인 창고 (스레드마다 하나씩)
왜냐하면 개인 방/옷장/창고는 스레드가 없어지면 자동으로 사라지거든요! 그래서 GC가 굳이 청소할 필요가 없어요.
💡 핵심 포인트: GC는 공용 공간(힙, 메서드 영역)만 관리한다는 거예요!
좀 더 자세히 알고싶다면 요기를 가보셔요 => 자바 가상 머신(JVM)의 구조
🤨 "앗 무슨 말인지 모르겠다고요??"
괜찮아요! 저도 잘 모르겠어요 ㅎ
지금은 "아~ GC가 힙이랑 메서드 영역을 청소하는구나" 정도만 기억하시면 돼요. 뒤에서 하나씩 천천히 파헤쳐 보면서 "아하!" 순간이 올 거예요!
3.2 대상이 죽었는가?
"이 객체 살아있어? 죽었어?" GC가 가장 먼저 해야 하는 일이에요! 살아있는 객체를 치우면 안되니까요..
tip? 저는 GC가 약간 장례식을 치뤄주는 쪽으로 보고 있어서요 살아있는 사람의 장례를 치루면 안되듯.. GC 입장에서는 객체의 생사가 참 중요하지 않을까요?
3.2.1 참조 카운팅 알고리즘 (Reference Counting)
💡 원리: 객체를 가리키는 참조의 수를 세어서 관리하는 방식이에요. 안쓰니까 슝하고 넘어가셔도 오케이~
잠깐, "참조"가 뭐죠?
참조(Reference)는 쉽게 말해서 "객체의 주소를 가리키는 포인터"예요.
🏠 집 주소로 비유해볼게요:
Person person = new Person("코헤");
// ↑ ↑ ↑
// 변수명 참조 실제 객체
- Person("코헤") → 실제 집 (객체가 메모리에 저장된 곳)
- person → 집 주소가 적힌 메모지 (참조 변수)
- 참조 → 그 주소 자체
📝 더 쉬운 예시:
String name1 = "안녕하세요"; // name1이 "안녕하세요" 객체를 가리킴 (참조)
String name2 = name1; // name2도 같은 객체를 가리킴 (참조 +1)
name1 = null; // name1 참조 끊기 (참조 -1)
// 이제 "안녕하세요" 객체는 name2만 가리키고 있어요
🎯 참조 카운팅에서:
- 새로운 변수가 객체를 가리키면 → 참조 +1
- 변수가 null이 되거나 다른 걸 가리키면 → 참조 -1
- 참조가 0이 되면 → "아무도 이 객체를 안 쓰네? 삭제하자!"
💡 이제 이해되시죠? 참조는 단순히 "누가 이 객체를 쓰고 있는지"를 세는 거예요!
참조 생성 → 카운터 +1 📈
참조 삭제 → 카운터 -1 📉
카운터 = 0 → "이 객체 죽었어!" ⚰️
🤔 언뜻 보면 완벽해 보이는데...
❌ 치명적 문제: 순환 참조
// objA가 objB를 참조하고
// objB가 다시 objA를 참조하면?
objA.ref = objB; // objB 카운터 = 1
objB.ref = objA; // objA 카운터 = 1
// 둘 다 카운터가 0이 안 돼서 영원히 안 죽어요 💀
그래서 자바는 이 방식을 안 써요! 다른 똑똑한 방법을 쓰죠
3.2.2 도달 가능성 분석 알고리즘 (Reachability Analysis)
현재 자바를 포함한 대부분 언어가 사용하는 방식이에요!
여기서 대부분의 언어라고 하면 GC가 있는 언어들을 말해요 C#, 리스프 등등..
핵심 아이디어: "GC 루트에서 출발해서 쭉쭉 따라가면서, 도달할 수 있으면 살려두고, 못 가면 죽이자!"
GC Root → 객체A → 객체B → 객체C ✅ (살아있음)
→ 객체D ✅ (살아있음)
외딴섬에 혼자 있는 객체E ❌ (죽었음)
🏠 주요 GC 루트들
- 스택 내 지역 변수 - 현재 실행 중인 메서드의 변수들
- 정적 필드 - 클래스에 속한 static 변수들
- JNI 참조 - 네이티브 코드에서 만든 변수들
- 활성 스레드 - 실행 중인 모든 스레드들
- synchronized 객체 - 동기화로 잠긴 객체들
- 다른 세대 참조 - Old → Young 세대 참조 등
말이 넘 어렵져..? 간단히 말하자면 "지금 당장 어딘가에서 쓰이고 있거나, 시스템이 꼭 필요로 하는 객체들"이라고 이해하심 돼요 (전 그냥 원점으로 이해하고 넘어가긴 했어요)
🏠 GC 루트를 집으로 비유해보면:
1️⃣ 현재 실행 중인 코드들 (가장 중요!) 🏃♂️
public void someMethod() {
String name = "코헤"; // ← 이런 지역 변수들!
Person person = new Person(); // ← 이런 매개변수들!
}
// "지금 실행되고 있는 메서드에서 쓰는 모든 변수들"
2️⃣ 클래스의 정적(static) 변수들 🏛️
public class MyClass {
public static String APP_NAME = "내앱"; // ← 이런 static 변수들!
}
// "프로그램이 살아있는 동안 계속 살아있어야 하는 변수들"
3️⃣ 문자열 상수들 📝
String str = "안녕하세요"; // ← 이런 문자열 리터럴들!
// "문자열 상수풀에 저장된 문자열들"
4️⃣ 시스템이 내부적으로 쓰는 것들 ⚙️
- JVM이 동작하려면 필요한 기본 클래스들
- 에러 객체들 (NullPointerException 등)
- synchronized로 잠긴 객체들
🎯 실무에서 가장 중요한 건:
- 지역 변수 - 메서드 실행 중에 쓰는 변수들
- static 변수 - 클래스에 속한 정적 변수들
- 활성 스레드 - 현재 실행 중인 스레드들
나머지는 "아~ 그런 것들도 있구나" 정도로만 알아두시면 돼요!
물론 모든일에 예외사항이 존재하듯, 저렇게 정의된 GC루트가 아님에도 불구하고 다른 객체들이 임시로 추가될 수 있어요(세대 단위 컬렉션이나 부분컬렉션의 경우) 때문에 연관 영역 객체들도 GC루트에 포함시켜야지 정확한 분석이 가능하겠죠? 어렵다면 스슥 넘어가는걸루 해요
3.2.3 참조 유형 (Reference Types)
JDK 1.2부터 "죽음"을 더 세밀하게 컨트롤할 수 있게 되었어요!
으엉? 참조 이야기를 하는데 갑자기 죽음이라뇨?
전통적인 참조는(jdk 1.2 전의 자바에서 말하길) "데이터에 저장된 값이 다른 메모리 조각의 시작 주소를 뜻한다면 이 참조 데이터를 해당 메모리 조각이나 객체를 참조한다고 말한다"라고 해요
즉, 참조 되었거나, 참조되지 않았거나만 판별한다는거에요.. 근데 뭔가 버리기 아까운 객체가 있을 수 있잖아요? 마치 like 신발 상자를 모으는 저장장애(Hoarding Disorder)가 있는 우리처럼요....
메모리가 여유롭다면 그냥 두고 GC하고나서도 메모리가 메무 부족하면 그 때 회수하는 객체를 표현할 길이 없나 싶죠
때문에 전통적 참조의 개념을 벗어나 4가지 참조의 개념을 도입했어요
1) 강한 참조 (Strong Reference)
Object obj = new Object(); // 이게 바로 강한 참조!
특징: GC가 절대 건드리지 않아요. 가장 일반적인 참조예요.
2) 부드러운 참조 (Soft Reference)
SoftReference<Object> softRef = new SoftReference<>(obj);
특징: 메모리 부족할 때만 회수. 캐시 구현에 완벽해요! "메모리 여유 있으면 놔두고, 부족하면 정리하자"
만일 부드러운 참조만 남은 상태라면 메모리 오버플로우가 나기 직전에 GC가 TODO 리스트를 세워두죠.. 저건 필히 정리하리다...
3) 약한 참조 (Weak Reference)
WeakReference<Object> weakRef = new WeakReference<>(obj);
특징: GC 돌면 바로 회수. 정규화 맵에 주로 써요! "GC야, 이거 마음대로 가져가~"
강한 참조들 사이에 weakReference가 있다면 죽지 않아요 (이게 왜 그런지 궁금하다면 이 책을 같이 읽어봅시다)
4) 유령 참조 (Phantom Reference)
PhantomReference<Object> phantomRef = new PhantomReference<>(obj, queue);
특징: get()이 항상 null. 객체 소멸 알림용이에요! "이 객체가 죽을 때 알려줘!"
이 참조를 통해서 객체 인스턴스를 더 가져올 수도 없고 진짜 객체 수명에 관련이 없어요 ㄹㅇ 알림용
3.2.4 살았나 죽었나?
이 내용은 참 재밌는 이야기니까 한 번 읽어보셔요!
🧟♂️ finalize() - 죽음 직전의 마지막 몸부림
💀 객체의 죽음은 두 단계로 이루어져요!
"도달 불가능 = 바로 죽음"이 아니에요! 아직 유예 기간이 있어요 😱
1단계: "이 객체 도달 불가능하네?" → 첫 번째 표시 ⚠️
↓
필터링: "finalize() 실행해야 해?"
↓
2단계: 진짜 죽음 또는 부활! ⚰️🧟♂️
🔄 finalize() 실행 과정
1️⃣ 도달 불가능 객체 발견
// 이 객체가 GC 루트에서 도달 불가능해졌다!
SomeObject obj = null; // 참조 끊어짐
2️⃣ 필터링 검사
❓ finalize() 메서드를 오버라이드했나?
❓ 이미 finalize()를 호출한 적 있나?
둘 다 NO → 바로 죽음 💀
하나라도 YES → F-Queue로 이동! 📋
3️⃣ F-Queue에서 실행
// JVM이 저우선순위 "종료자 스레드" 생성
// F-Queue의 객체들 finalize() 실행
// ⚠️ 주의: 끝날 때까지 기다려주지 않음!
4️⃣ 마지막 기회 - 부활 또는 죽음
🧟♂️ 실제 부활 예제
public class ZombieObject {
public static ZombieObject SAVE_HOOK = null;
public void isAlive() {
System.out.println("이야, 나 아직 살아있어! :)");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize() 실행! 부활 시도!");
// 🧟♂️ 마지막 몸부림: 자신을 다시 참조시키기!
ZombieObject.SAVE_HOOK = this;
}
public static void main(String[] args) throws Throwable {
SAVE_HOOK = new ZombieObject();
// 🔪 첫 번째 죽음 시도
SAVE_HOOK = null; // 참조 끊기
System.gc(); // GC 실행
Thread.sleep(500); // 종료자 스레드 기다리기
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive(); // 부활 성공! 🧟♂️
} else {
System.out.println("안 돼, 내가 죽다니 :(");
}
// 🔪 두 번째 죽음 시도 (같은 코드!)
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("안 돼, 내가 죽다니 :("); // 이번엔 진짜 죽음 💀
}
}
}
🎭 실행 결과
finalize() 실행! 부활 시도!
이야, 나 아직 살아있어! :)
안 돼, 내가 죽다니 :(
🔑 핵심:
- 첫 번째는 부활 성공! 🧟♂️
- 두 번째는 실패! finalize()는 단 한 번만 실행되거든요 💀
🚨 finalize()의 심각한 문제들
1️⃣ 실행 보장 안 됨 ⏰
// JVM은 finalize() 시작만 시켜줄 뿐, 끝날 때까지 안 기다려요!
@Override
protected void finalize() throws Throwable {
// 만약 이 코드가 무한루프에 빠지면?
while(true) { /* 무한루프 */ }
// 다른 객체들은 무작정 기다려야 해요 😱
}
2️⃣ 순서 보장 안 됨 🎲
- 어떤 객체부터 finalize() 호출될지 모름
- 그냥 마구잡이로 냅다 참조 체인으로 연결해서 살리는 경우도 있거덩요 (왤까 진짜)
- 예측 불가능한 동작!
3️⃣ 성능 저하 🐌
- 저우선순위 스레드 생성
- F-Queue 관리 오버헤드
- GC 지연
4️⃣ 시스템 마비 위험 💥
- finalize()가 무한루프에 빠지면
- F-Queue의 다른 객체들 대기
- 최악의 경우 전체 GC 시스템 마비!
🚫 finalize() 사용하지 마세요!
❌ 잘못된 생각:
// "외부 자원 정리에 좋을 것 같은데?"
@Override
protected void finalize() throws Throwable {
file.close(); // 언제 실행될지 모름!
socket.close(); // 실행 안 될 수도 있음!
}
✅ 올바른 방법:
// try-with-resources 사용하세요!
try (FileInputStream fis = new FileInputStream("file.txt")) {
// 파일 처리
} // 자동으로 close() 호출! 확실함!
// 또는 try-finally
FileInputStream fis = null;
try {
fis = new FileInputStream("file.txt");
// 처리
} finally {
if (fis != null) fis.close(); // 확실한 정리!
}
🎯 결론
finalize()는 "비극적인 몸부림"이에요! 😭
📢 JDK 9부터 deprecated된 이유
- C/C++ 개발자들을 유혹하려던 타협안일 뿐
- 실행 비용 높고 불확실성 큼
- try-finally로 더 잘 처리 가능
💡 저자의 조언 😃
"자바에 이런 메서드가 있다는 사실조차 머릿속에서 완전히 지워버리세요!" 🗑️
💡 코헤의 조언
finalize()는 대충 이런 느낌이에요..
✨ 기억할 것
- 객체 부활은 가능하지만 단 한 번만
- finalize()는 절대 사용 금지
- 자원 정리는 try-with-resources 또는 try-finally 사용
3.2.5 메서드 영역 회수
요기는 쪼오오끔 이해하기 어려웠어요ㅠㅠ
메서드 영역(Method Area)은 JVM 메모리 구조의 한 부분이에요.
JVM 메모리 구조 - 집으로 비유
개인 공간 (스레드별로 따로)
- 프로그램 카운터 - 개인 방
- JVM 스택 - 개인 옷장
- 네이티브 메서드 스택 - 개인 창고
공용 공간 (모든 스레드가 공유)
- 힙(Heap) - 객체들이 사는 큰 거실
- 메서드 영역 - 클래스 정보가 저장된 도서관 <- 먼 도서관은 청소하는지 궁금하져? 그래서 까다로워서 잘 안해요
메서드 영역에 저장되는 것들
public class Person {
public static String SPECIES = "Homo sapiens"; // 정적 변수
private String name; // 인스턴스 변수 정보
public void sayHello() { // 메서드 정보
System.out.println("안녕하세요!");
}
}
메서드 영역에 저장:
- 클래스 정보 (Person 클래스의 구조)
- 메서드 정보 (sayHello 메서드 코드)
- 정적 변수 (SPECIES)
- 상수 풀 ("안녕하세요!" 같은 문자열 리터럴)
- 클래스 메타데이터
힙에 저장:
- 실제 Person 객체들 (new Person()으로 만든 것들)
역사적 변화
JDK 8 이전: PermGen (영구 세대)
- 힙의 일부였음
- 고정 크기 (-XX:MaxPermSize)
- 메모리 부족으로 OutOfMemoryError 자주 발생
JDK 8 이후: Metaspace (메타스페이스)
- 네이티브 메모리 사용
- 동적 크기 조정
- 메모리 부족 문제 크게 개선
쉬운 이해
메서드 영역은 "클래스의 설계도가 보관된 창고"라고 생각하면 됩니다.
- 집(객체)을 짓기 위한 설계도(클래스)가 저장된 곳
- 모든 스레드가 같은 설계도를 참고함
- 설계도가 더 이상 필요 없으면 GC가 치울 수 있음 (까다로운 조건 하에)
🤔 "메서드 영역은 GC 대상이 아니라던데?"
많은 사람들이 그렇게 생각해요! 실제로 JVM 명세에서도 "메서드 영역을 반드시 청소하라"고 강제하지 않거든요.
예를 들어
- JDK 11의 첫 ZGC는 클래스 언로딩을 아예 지원 안 했어요 (JDK 12부터 지원)
- 일부 JVM은 메서드 영역 GC를 구현하지 않기도 해요
🤷♀️ 왜 그럴까요?
💰 비용 효율이 정말 안 좋아요
일반적인 힙 GC 효율 (신세대 기준) : 70~99% 메모리 회수!
메서드 영역 GC 효율 : 회수율이 매우매우 낮음!
그래서 "굳이 할 필요가 있나?" 싶은 거예요.
🗑️ 메서드 영역에서 회수하는 2가지
1️⃣ 사용되지 않는 상수들
예시
// 코드에서 "java" 문자열을 더 이상 사용하지 않음
// 어떤 변수도 "java"를 참조하지 않음
// 가상 머신 내부에서도 "java"를 사용하는 곳 없음
→ 상수 풀에서 "java" 제거 🗑️
상수 회수 조건:
- 해당 상수를 참조하는 객체가 없어야 함
- 가상 머신에서 사용하는 코드도 없어야 함
이건 비교적 간단해요!
2️⃣ 사용되지 않는 클래스들 (까다로움!)
클래스가 죽는 3가지 조건 (모두 만족해야 함):
// 예시 클래스
public class MyClass {
// ...
}
조건 1: 모든 인스턴스가 사라져야 함 🧹
MyClass obj1 = new MyClass(); // 이런 객체들이
MyClass obj2 = new MyClass(); // 모두 사라져야 함
// obj1 = null; obj2 = null; 이런 식으로
조건 2: 클래스 로더가 회수되어야 함 📦
// 이 클래스를 로드한 ClassLoader도 사라져야 함
// OSGi, JSP 리로딩 같은 특수한 경우가 아니면 어려워요
조건 3: Class 객체 참조도 없어야 함 🔍
Class<?> clazz = MyClass.class; // 이런 참조도 없어야 함
// 리플렉션으로 사용하는 곳도 없어야 함
⚖️ "허용"할 뿐, "강제"는 아니에요
JVM 명세에서는 이런 조건을 만족하는 클래스를 회수하도록 "허용"한다고 해요.
"반드시 회수하라"가 아니라 "회수해도 된다"는 뜻이죠!
🛠️ HotSpot JVM의 제어 옵션
클래스 GC 끄기/켜기:
-XX:+/-UseClassUnloading # 클래스 언로딩 제어
클래스 로딩/언로딩 로그 보기:
-verbose:class # 클래스 로딩 정보
-Xlog:class+load=info # 상세 로딩 정보
-Xlog:class+unload=info # 언로딩 정보
실제 명령어로 확인해보기:
java -verbose:class -Xlog:class+unload=info MyApp
🏗️ 언제 메서드 영역 GC가 필요할까?
이런 환경에서는 꼭 필요해요
실행 중 새로운 클래스를 계속 만들어내는 환경에서 필요하다구 생각해도 오케
1️⃣ 리플렉션 많이 쓰는 곳
// 동적으로 클래스를 계속 생성하는 경우
Class.forName("com.example.DynamicClass" + i);
2️⃣ 동적 프록시 프레임워크
- CGLib
- 동적 프록시
- AOP 프레임워크들
3️⃣ JSP 환경
<!-- JSP가 컴파일될 때마다 새로운 클래스 생성 -->
<% // 동적으로 생성되는 서블릿 클래스들 %>
4️⃣ OSGi 환경
- 클래스 로더를 자주 교체
- 번들 설치/제거 반복
이런 곳에서는 클래스가 계속 쌓여서 메서드 영역이 터질 수 있어요!
🎯 핵심 정리
메서드 영역 GC 특징
- 효율이 낮아서 구현 안 하는 JVM도 있어요
- 상수 회수는 비교적 쉬워요
- 클래스 회수는 조건이 까다로워요 (3가지 모두 만족!)
- 동적 클래스 생성이 많은 환경에서는 꼭 필요해요
💡 실무 팁:
- 리플렉션이나 동적 프록시 많이 쓰면 클래스 언로딩 로그 확인해보세요
- 메모리 누수 의심되면 -Xlog:class+unload=info 옵션 써보세요
- OSGi나 JSP 환경이면 메서드 영역 모니터링 필수에요!
3.3 가비지 컬렉션 알고리즘
드디어 실제 GC 알고리즘들을 알아볼 차례에요!
3.3.1 세대별 GC (Generational GC) - 핵심 이론
대부분 JVM의 기본 전략이에요!
기본 아이디어: 객체의 수명에 따라 힙을 나눠서 관리하자
세대별 GC의 3가지 가설
1️⃣ 약한 세대 가설 (Weak Generational Hypothesis)
대부분 객체는 생성 후 금방 죽는다
// 이런 객체들 - 메서드 끝나면 바로 사라짐
public void processData() {
String temp = "임시 데이터"; // 금방 죽음
List<String> list = new ArrayList<>(); // 금방 죽음
// 메서드 끝 -> 위 객체들 모두 사라짐
}
2️⃣ 강한 세대 가설 (Strong Generational Hypothesis)
오래 살아남은 객체는 계속 살아남을 가능성이 높음
// 이런 객체들 - 한번 살아남으면 오래 살아있음
public class DatabaseConnection { // 애플리케이션 종료까지 살아있음
private static final String URL = "jdbc://..."; // 계속 살아있음
}
3️⃣ 세대 간 참조 가설
Old -> Young 참조는 적고
Young -> Old 참조는 많음
🏗️ 힙 구조 나누기
이게 맞다는게 아니라 그냥 구조화를 한거에요!
┌─────────────────┬─────────────────┐
│ Young Gen │ Old Gen │
├─────┬───┬───────┤ │
│Eden │S0 │S1 │ (구세대) │
│(신생)│(생존)│(생존) │ │
└─────┴───┴───────┴─────────────────┘
Young Generation (신생대)
- Eden: 새 객체들이 태어나는 곳
- Survivor 0, 1: 첫 GC에서 살아남은 객체들
Old Generation (구세대):
- 여러 번 GC에서 살아남아 승격된 객체들
기본 GC 알고리즘 3형제
3.3.2 Mark-Sweep (표시-삭제) - 원조 알고리즘
1960년대 존 매카시가 LISP에서 만든 최초 알고리즘!
동작 과정
1. Mark 단계: 살아있는 객체에 표시 ✅
GC Root -> A -> B -> C (모두 표시)
2. Sweep 단계: 표시 안 된 객체들 삭제 🗑️
표시 없는 D, E, F 객체들 제거
장점:
- 구현이 단순해요
- 메모리 사용량 효율적 (복사 공간 불필요)
단점:
- 메모리 단편화 발생
삭제 후: [A][ ][B][ ][ ][C][ ]
빈공간들이 조각조각 흩어져 있음
3.3.3 Mark-Copy (표시-복사)
1969년 로버트 페니첼이 제안
동작 과정
메모리를 반으로 나누기:
┌─────────────┬─────────────┐
│ 사용 중 │ 비어있음 │
│ A B C D E │ │
└─────────────┴─────────────┘
GC 실행:
1. 살아있는 객체만 오른쪽으로 복사
2. 왼쪽 전체 삭제
┌─────────────┬─────────────┐
│ 비어있음 │ A C (복사됨) │
│ │ │
└─────────────┴─────────────┘
장점:
- 단편화 문제 해결
- 메모리 할당 속도 빠름 (빈 공간이 연속적)
- Young Gen에서 효율 좋음 (대부분 객체가 죽어서 복사할 게 적음)
단점:
- 메모리 절반만 사용 가능
- 살아있는 객체 많으면 복사 비용 증가
그래서 주로 Young Generation에서만 써요!
* 오잉 그래도 가용 메모리 절반 너무 아깝지 않나요?
-> 이 때문에 아펠 스타일 컬렉션이 있어요!
- 메모리 할당 보증와 에덴과 생존자 공간에 대해서 한 번 공부해보세요 ^^
3.3.4 Mark-Compact (표시-압축) - Old Generation 전용
Mark-Copy의 치명적 단점들
1. 객체 생존율이 높을수록 비효율
// Young Generation: 대부분 객체가 죽음
Eden: [A][B][C][D][E][F][G][H] → 복사: [A] (1개만 살아남음)
// Old Generation: 대부분 객체가 살아있음
Old: [A][B][C][D][E][F][G][H] → 복사: [A][B][C][D][E][F][G] (7개나 복사!)
2. 메모리 50% 낭비 Old Generation은 보통 힙의 2/3 정도를 차지하는데, 그 절반을 비워둔다면 전체 힙의 1/3이나 낭비하게 됩니다.
3. 할당 보증 공간 문제 극단적으로 모든 객체가 살아남는 경우를 대비해 추가 공간까지 필요합니다.
이 떄문에 에드워드 위더스가 제안했져 "구세대 객체들의 생존 특성을 고려하자!"
Old Generation 특성
- 객체 생존율이 높음
- 메모리 효율성이 중요함
- 어느 정도 STW는 감수할 수 있음
동작 과정
1단계: Mark (표시) - Mark-Sweep과 동일
GC Root에서 시작해서 살아있는 객체들 표시
[A✓][ ][B✓][ ][ ][C✓][ ][D✓]
살아있음 죽음 살아있음 죽음 죽음 살아있음 죽음 살아있음
2단계: Compact (압축) - 핵심 차이점
Mark-Sweep이라면: 죽은 객체만 삭제
[A✓][ ][B✓][ ][ ][C✓][ ][D✓] → [A][ ][B][ ][ ][C][ ][D]
단편화 발생!
Mark-Compact: 살아있는 객체들을 한쪽 끝으로 이동
[A✓][ ][B✓][ ][ ][C✓][ ][D✓] → [A][B][C][D][ ][ ][ ][ ]
연속된 빈 공간!
장점
1. 단편화 해결
- 모든 살아있는 객체가 연속적으로 배치
- 새로운 객체 할당이 간단하고 빠름
2. 메모리 효율성
- 전체 힙 공간을 모두 활용
- Mark-Copy처럼 50% 낭비하지 않음
3. Old Generation에 최적화
- 높은 생존율 상황에서 효율적
- 복사 오버헤드 없음
단점
1. Stop-The-World (STW) 시간
// 객체 이동 중에는 애플리케이션 정지
compacting... // 이 시간동안 모든 스레드 대기
// 참조 주소들도 모두 업데이트 필요
2. 복잡한 구현
- 객체 이동 시 모든 참조 주소 업데이트
- 다중 스레드 환경에서 동시성 처리에 난관
3. 처리 시간
- Mark-Sweep보다 시간이 더 걸림
- 객체 이동과 참조 업데이트 오버헤드
=> 뭐 파편화 없는 할당 연결 리스트로 해결 가능해요... ^^
실제 사용 사례
HotSpot JVM에서의 활용:
- Serial Old GC: Mark-Compact 사용
- Parallel Old GC: 병렬 Mark-Compact
- CMS GC: Mark-Sweep 기반이지만 필요시 Mark-Compact로 대체
언제 Mark-Compact를 선택할까:
- Old Generation 정리
- 메모리 효율성이 중요한 환경
- STW 시간보다 처리량이 중요한 경우
🎯 알고리즘 선택 전략
Young Generation:
- Mark-Copy 사용
- 대부분 객체가 죽어서 복사할 게 적음
- 메모리 절반 사용해도 괜찮음
Old Generation:
- Mark-Sweep 또는 Mark-Compact 사용
- 살아있는 객체가 많아서 복사 비효율
- 단편화 해결 필요하면 Compact, 속도 중시하면 Sweep
실제 JVM은 이 3가지를 조합해서 사용해요!
💡 왜 세대별로 나눌까?
전체 힙 스캔 vs 세대별 스캔:
전체 힙: 모든 객체 확인 (오래 걸림) 😰
세대별: Young만 확인 (빠름) 😊
세대 간 참조 문제:
- Old에서 Young 참조하는 경우 어떻게 찾지? → 카드 테이블, 기억 집합 같은 기술로 해결 (나중에 설명)
결국 "효율성"이 핵심이에요!
3.4 핫스팟 알고리즘 상세 구현
"아니 코헤님... 이거 너무 어려운 거 아니에요? 🥲"
그럴 수 있어요. 근데 실무에서 애플리케이션이 느려지거나 메모리 문제가 생겼을 때, 이런 내부 동작을 이해하면 정말 많은 도움이 돼요. 언젠간 고치겠지.. 나 힘들어... 하지 말고, 차근차근 알아가 봅시다! 💪
왜 이걸 알아야 하나요?
세상은 "GC가 알아서 해준다"와 "GC 튜닝을 실제로 할 줄 안다" 사이에 큰 벽이 있어요. 그래서 많은 개발자들이 메모리 문제가 생기면 그냥 힙 메모리만 늘리고 끝내곤 하죠.
괜찮아요, 저도 처음엔 그랬거든요. 하지만 내부 동작을 이해하면 훨씬 효율적인 해결책을 찾을 수 있어요!
근데 사실 이 아래부터 헬이에요
3.4.1 루트 노드 열거 - GC의 시작점
루트 노드 열거가 뭔가요?
루트 노드 열거는 도달 가능성 분석 알고리즘에서 GC 루트 집합으로부터 참조 체인을 찾는 작업이에요. 쉽게 말해, "절대 지우면 안 되는 객체들의 시작점을 찾는 일"이라고 생각하면 돼요.
GC 루트는 어디에 있나요?
GC 루트로 고정할 수 있는 노드들:
- 전역 참조: 상수, 클래스의 정적 속성들
- 실행 콘텍스트: 스택 프레임의 지역 변수 테이블들
"그냥 쫙 훑어보면 되는 거 아닌가요?"
음... 그렇게 간단하지 않아요. 오늘날 자바 애플리케이션은 정말 거대해졌거든요.
현실적인 문제들
- 메서드 영역 크기가 수백 GB에 달하는 경우도 있어요
- 그 안의 클래스와 상수 개수는 무수히 많아요
- 모든 참조를 하나하나 확인? → 엄청난 시간 소요! ⏰
스톱 더 월드를 피할 수 없는 이유
"어? 그냥 실행하면서 찾으면 안 되나요?"
안타깝게도 안 돼요. 루트 노드 열거는 반드시 일관성이 보장되는 스냅숏 상태에서 해야 해요.
🎯 일관성이란?
- 열거 작업 중에 실행 시스템이 '특정 시점으로 고정'된 것처럼 보여야 함
- 루트 노드들의 참조 관계가 절대 변하면 안 됨
- 이 조건을 안 지키면 → 분석 결과를 신뢰할 수 없어요! 💥
왜냐하면 GC가 "이 객체는 살려둘게요~" 하고 있는 동안, 다른 스레드가 "아 이거 이제 안 쓸래요!" 하면서 참조를 바꿔버릴 수 있거든요. 그러면 GC가 완전히 헷갈려해요. 🤯
📌 중요한 사실: CMS, G1, ZGC 같은 저지연 컬렉터들도 루트 노드 열거할 때만큼은 스톱 더 월드를 피할 수 없어요.
✅ OopMap으로 똑똑하게 해결하기
현재 주류 자바 가상 머신들은 '정확한 가비지 컬렉션'을 사용해요.
"그럼 어떻게 효율적으로 찾죠?"
핫스팟은 OopMap이라는 똑똑한 방법을 써요
OopMap 동작 원리
1단계: 클래스 로딩 완료 시
- 객체에 포함된 각 데이터의 타입을 확인해요
2단계: JIT 컴파일 과정에서
- 스택의 어느 위치에 참조가 있는지 기록
- 어느 레지스터의 데이터가 참조인지 기록
3단계: GC 실행 시
- 추적해보지 않고도 스캔 과정에서 이 정보를 직접 얻어내요!
💻 실제 코드 예시
핫스팟이 String.hashCode() 메서드를 컴파일해서 생성한 네이티브 코드를 볼까요?
코드 해석:
- OopMap{ebx=Oop [16]=Oop off=142} 레코드가 보여요
- 이건 EBX 레지스터와 스택 오프셋 16 지점에 객체 포인터 참조가 있다고 알려주는 거예요
- 유효 영역: call 명령어 시작 ~ hlt 명령어까지
📌 핵심 포인트: 런타임에 일일이 찾지 않고, 컴파일할 때 미리 "여기에 객체 참조 있어요!"라고 표시해두는 거예요.
3.4.2 안전 지점 - 언제 멈출까요?
🤔 OopMap의 딜레마
OopMap으로 GC 루트들을 빠르고 정확하게 열거할 수 있게 됐어요. 하지만 진짜 큰 문제가 기다리고 있었어요!
💥 문제 상황:
- 참조 관계나 OopMap 내용을 변경할 수 있는 명령어가 너무 많아요
- 모든 명령어에 OopMap을 만들어 넣으면? → 메모리 사용량 폭증
- 실제로 가비지 컬렉션에 드는 공간 비용이 감당하기 어려울 만큼 커져요
"그럼 어떡하죠? 🥲"
해결책: 안전 지점 (Safe Point)
핫스팟은 똑똑한 방법을 찾았어요. 모든 명령어에 OopMap을 생성하지 않고, 안전 지점(Safe Point)이라고 하는 특정 위치에만 기록하는 거예요!
📌 핵심 규칙: 가비지 컬렉터는 사용자 프로그램이 안전 지점에 도달할 때까지는 절대 멈춰 세우지 않아요.
안전 지점 설정의 균형
안전 지점 설정은 정말 미묘한 균형이 필요해요:
❌ 너무 적게 설정하면: 컬렉터가 너무 오래 기다려야 해요
❌ 너무 많이 설정하면: 런타임 메모리 부하가 지나치게 커져요
안전 지점 위치 선택 기준
기본 원칙: "프로그램이 장시간 실행될 가능성이 있는가?"
왜 이 기준일까요?
- 명령어 하나의 실행 시간은 매우 짧아요
- 단순히 명령어 스트림이 길다고 해서 실행 시간이 길어질 가능성은 적어요
'장시간 실행'될 가능성을 보여주는 건 뭘까요?
🚀 명령어 흐름 다중화 (Multiplexing)
안전 지점이 설정되는 곳들
✅ 메서드 호출
✅ 순환문 (반복문)
✅ 예외 처리
이런 기능들이 명령어 흐름을 다중화하는 대표적인 예이고, 이런 명령어만이 안전 지점을 생성해요. 예를 들어 볼까요?
// 안전 지점이 설정되는 곳들
public void example() {
method1(); // ← 메서드 호출 (안전 지점!)
for (int i = 0; i < 1000000; i++) { // ← 반복문 (안전 지점!)
// 반복문 실행 중
}
try {
riskyMethod();
} catch (Exception e) { // ← 예외 처리 (안전 지점!)
// 예외 처리
}
}
스레드를 어떻게 멈출까요?
가비지 컬렉션이 시작되면 JNI 호출을 실행 중인 스레드를 제외한 모든 스레드가 가장 가까운 안전 지점까지 실행하고 멈춰야 해요.
두 가지 방법이 있어요:
1️⃣ 선제적 멈춤 (Preemptive Suspension)
동작 방식
- 스레드 코드는 가비지 컬렉터를 특별히 신경 쓸 필요 없음
- GC 시작하면 → 시스템이 모든 사용자 스레드를 인터럽트
- 중단된 위치가 안전 지점이 아니면? → 스레드 재개하고 다시 인터럽트 반복
가비지 컬렉션에 이 방식을 쓰는 가상 머신은 거의 없어요.
2️⃣ 자발적 멈춤 (Voluntary Suspension) ✅
동작 방식
- 가비지 컬렉터가 스레드 수행에 직접 관여하지 않음
- 간단히 플래그 비트를 설정
- 각 스레드가 실행 중에 플래그를 적극적으로 폴링(polling)
- 플래그 값이 true면? → 가장 가까운 안전 지점에서 스스로 멈춤
🚩 폴링 플래그 위치
폴링 플래그는 어디에 둘까요?
✅ 안전 지점에 위치
✅ 객체 생성 등 자바 힙 메모리를 소비하는 장소
두 번째가 중요한 이유: 메모리가 부족해서 새로운 객체를 할당하지 못하는 일을 예방하기 위해 적절한 시점에 가비지 컬렉션을 수행하기 위함이에요!
효율적인 폴링 구현
폴링은 코드에서 자주 일어나므로 매우 효율적이어야 해요.
핫스팟은 메모리 보호 트랩(Memory Protection Trap)이라는 방법을 써서 폴링을 어셈블리 명령어 하나만으로 수행할 수 있게 단순화했어요!
💻 실제 폴링 코드

🎭 메모리 보호 트랩의 마법
동작 원리:
- 평소: test %eax,0x160100 명령어는 그냥 실행돼요
- GC 시작할 때: 가상 머신이 0x160100 메모리 페이지를 읽을 수 없게 설정
- 트랩 발생: 스레드가 test 명령어 실행할 때 → 트랩에 걸렸다는 예외 시그널
- 스레드 정지: 사전 등록된 예외 핸들러에서 스레드를 일시 정지
🎯 결과: 안전 지점 폴링과 스레드 인터럽트를 단 하나의 어셈블리 명령어 (test)로 처리하는 거예요!
💭 정리하면
안전 지점은 여기서 멈춰도 안전해요라는 체크포인트 같은 거예요. "실행중"인 프로그램에서는 모든 곳에서 멈출 수는 없으니까, 전략적으로 선택된 지점에서만 멈추도록 하는 똑똑한 방법이에요.
3.4.3 안전 지역 - 잠자는 스레드는 어떡하죠?
🤔 안전 지점만으로는 부족해요
안전 지점을 사용하면 사용자 스레드를 멈춰 세운 후 가비지 컬렉션을 수행하는 문제가 완벽하게 해결되는 듯 보여요. 하지만 실상은 꼭 그렇지만은 않아요.
안전 지점 메커니즘은 실행 중인 프로그램이 그리 길지 않은 시간에 안전 지점에 도달하여 가비지 컬렉션 프로세스가 제대로 임무를 다할 수 있게끔 보장해요.
"그럼 문제없는 거 아닌가요? 🤷♂️"
😴 문제 상황: 실행 중이 '아닌' 프로그램
하지만 실행 중이 '아닌' 프로그램이라면 어떨까요?
실행 중이 아닌 프로그램이란?
- 프로세서를 할당받지 못한 프로그램을 말해요
- 일반적으로 잠자기 상태(sleep)이거나 블록된 상태의 사용자 스레드들이 여기 속해요
구체적인 문제들
문제 1: 응답 불가능
- 이 상태의 스레드들은 가상 머신의 인터럽트 요청에 응답할 수 없어요
- 따라서 안전 지점까지 수행한 후 인터럽트되어 스스로를 일시 정지시킬 수 없어요
문제 2: 무한 대기
- 이런 스레드가 다시 활성화되어 프로세서를 할당받을 때까지 가상 머신이 무한정 기다리는 것도 말이 안 돼요
"그럼 어떡하죠? GC가 영원히 못 시작하는 건가요? 😱"
해결책: 안전 지역 (Safe Region)
이런 경우를 위해 안전 지역이라는 개념이 필요해요!
🎯 안전 지역의 정의:
- 일정 코드 영역에서는 참조 관계가 변하지 않음을 보장하는 구간이에요
- 안전 지역 안이라면 어디서든 가비지 컬렉션을 시작해도 무방해요
- 안전 지점을 확장한 개념이라고 생각하면 좋아요
🔄 안전 지역 동작 원리
1단계: 진입 선언
// 사용자 스레드는 안전 지역의 코드를 실행하기 앞서
// 안전 지역에 진입했음을 표시해요
thread.enterSafeRegion();
// 예를 들어, 이런 상황들:
Thread.sleep(1000); // 잠자기 상태
inputStream.read(); // I/O 대기 상태
synchronized(lock) { // 락 대기 상태
// 블록된 상태
}
2단계: GC의 무시
- 가비지 컬렉터는 안전 지역에 있다고 선언한 스레드들을 신경 쓸 필요가 없어요
- "아, 저 스레드는 어차피 참조 관계를 못 바꾸니까 신경 안 써도 돼!" 하는 거죠
3단계: 탈출 시 확인
// 안전 지역에서 벗어나려는 스레드는 확인해야 해요
if (thread.exitSafeRegion()) {
// 확인 사항들:
// 1. 가상 머신이 루트 노드 열거를 완료했는지?
// 2. 사용자 스레드를 일시 정지시켜야 하는
// 다른 가비지 컬렉션 단계를 완료했는지?
if (gcCompleted) {
// 완료했다면 계속 실행해도 아무 일도 일어나지 않아요
continueExecution();
} else {
// 아직 완료되지 않았다면
// 안전 지역을 벗어나도 좋다는 신호를 받을 때까지 기다려야 해요
waitForGCCompletion();
}
}
🎭 안전 지역 vs 안전 지점
구분안전 지점안전 지역
개념 | 특정 위치 (점) | 코드 영역 (면) |
대상 | 실행 중인 스레드 | 실행 중이지 않은 스레드 |
동작 | 스레드가 도달하면 멈춤 | 영역 안에서는 언제든 GC 가능 |
예시 | 메서드 호출, 반복문 | Sleep, I/O 대기, 락 대기 |
📝 정리하면
안전 지역은 "잠자는 스레드 때문에 GC가 영원히 시작 못하는 문제"를 해결하는 똑똑한 방법이에요.
✅ 핵심 아이디어:
- 어차피 참조 관계를 바꿀 수 없는 상태라면
- 그 영역 전체를 "안전 구역"으로 선언해버리자!
- GC는 그런 스레드들은 무시하고 진행하면 돼요
"아하! 그럼 잠자는 스레드 때문에 GC가 멈추지 않는구나!" 😊
안전 지점과 안전 지역, 이 두 개념이 함께 협력해서 모든 상황에서 GC가 안전하게 실행될 수 있도록 보장하는 거예요!
3.4.4 기억 집합과 카드 테이블
이 개념은 참 어려울거에요.......... 패쓰하셔도 됩니다....
🤯 문제: 구세대 전체를 스캔해야 한다고?
앞에서 세대 단위 컬렉션 이론을 설명할 때, 가비지 컬렉터는 신세대에 기억 집합이라는 데이터 구조를 두어 객체들의 세대 간 참조 문제를 해결한다고 했어요. 구세대와 GC 루트 전부를 스캔해야 하는 사태를 기억 집합을 이용하여 방지하는 거죠.
그런데 세대 간 참조가 신세대와 구세대 사이로만 국한되는 것은 아니에요. G1, ZGC, 셰넌도어 컬렉터 등 부분 GC를 지원하는 모든 가비지 컬렉터가 세대 간 참조 문제를 겪을 수 있어요.
따라서 기억 집합의 원리와 구현을 더 명확하게 이해해야 해요!
기억 집합의 정의
기억 집합은 비회수 영역(회수 대상이 아닌 영역)에서 회수 영역을 가리키는 포인터들을 기록하는 추상 데이터 구조예요.
🤷♂️ 가장 단순한 구현법
효율과 비용을 고려하지 않는다면, 비회수 영역에 있는 세대 간 참조들을 Object 배열에 담아 이렇게 간단히 구현할 수 있어요:
class RememberedSet {
Object[] set = new Object[OBJECT_INTERGENERATIONAL_REFERENCE_SIZE];
}
하지만 이 방식으로 세대 간 참조 객체를 기록하려면 차지하는 공간과 관리 비용이 모두 상당히 높을 거예요.
핵심 아이디어: 정밀도 조절
가비지 컬렉션 시 컬렉터는 기억 집합을 이용해 특정 비회수 영역에서 회수 영역을 가리키는 포인터가 존재하는지만 확인하면 돼요. 세대 간 포인터들 각각에 대해 더 이상 자세한 내용을 알 필요는 없어요.
따라서 정밀도를 낮춰서, 즉 기록 단위를 더 크게 잡아서 공간과 관리 비용을 절약할 수 있어요!
세 가지 정밀도 선택지
1️⃣ 워드 정밀도
- 레코드 하나가 메모리의 워드 하나에 매핑
- 워드 길이: 32비트 기기에서는 32비트, 64비트 기기에서는 64비트
- 특정 레코드가 마킹되어 있다면 → 해당 메모리 워드가 세대 간 포인터
2️⃣ 객체 정밀도
- 레코드 하나가 객체 하나에 매핑
- 특정 레코드가 마킹되어 있다면 → 해당 객체에 다른 세대의 객체를 참조하는 필드가 있다
3️⃣ 카드 정밀도 ✅
- 레코드 하나(카드)가 메모리 블록 하나에 매핑
- 특정 레코드가 마킹되어 있다면 → 해당 블록에 세대 간 참조를 지닌 객체가 존재
카드 테이블 = 카드 정밀도 구현
카드 정밀도로 기억 집합을 구현한 것을 카드 테이블이라 해요. 현재 가장 널리 쓰이는 방식이며, 그래서 어떤 문헌에서는 카드 테이블을 기억 집합 자체와 혼동하기도 해요.
🔍 기억 집합 vs 카드 테이블 관계
중요한 구분:
- 기억 집합: 사실 '추상' 데이터 구조. 동작 의도만 정의했을 뿐, 구체적인 구현 방법은 정의하지 않았어요
- 카드 테이블: 기록 정밀도와 힙 메모리의 매핑 관계 등을 정의하여 기억 집합을 구체적으로 구현한 방법 중 하나
관계: 자바 언어에서 HashMap과 Map의 관계 정도로 이해하면 좋아요.
💻 핫스팟의 카드 테이블 구현
카드 테이블을 구현하는 가장 간단한 형태는 바이트 배열이며, 실제로 핫스팟 가상 머신도 정확히 이렇게 구현했어요.
// 핫스팟의 기본 카드 테이블 표시 로직
CARD_TABLE[this_address >> 9] = 1;
🔍 코드 해석:
- 바이트 배열인 CARD_TABLE의 원소 각각이 메모리 영역에서 특정 크기의 메모리 블록 하나에 대응
- 이 메모리 블록을 카드 페이지라고 해요
- 카드 페이지의 크기는 일반적으로 2의 N제곱 바이트
- 핫스팟은 2의 9제곱, 즉 512바이트로 설정 (주소를 9비트 오른쪽으로 시프트한 값 = 주소를 512로 나눈 값)
🗺️ 카드 테이블 매핑 구조
카드 테이블로 관리하는 메모리 영역의 시작 주소가 0x0000이라 가정하면:

더럽혀졌는가(dirty)?라는 상태 정보를 각 카드가 가져요.
🎯 동작 원리
1단계: 더럽힘 표시
- 카드 페이지 하나의 메모리에는 보통 하나 이상의 객체가 들어 있어요
- 이 객체들 중 하나에라도 세대 간 포인터를 갖는 필드가 있다면 → 카드 테이블에서의 해당 원소(카드)를 1로 표시
- 그 원소는 '더럽혀졌다(dirty)'고 말해요
- 세대 간 포인터를 갖는 객체가 하나도 없다면 0으로 표시
2단계: 효율적 스캔
- 객체를 회수할 때는 카드 테이블에서 더럽혀진 원소만 확인
- 어떤 카드 페이지의 메모리 블록이 세대 간 포인터를 포함하는지 쉽게 파악
- 세대 간 참조를 포함한 블록만 GC 루트에 추가해 함께 스캔
📊 효과
이런 식으로 구세대 전체를 스캔하는 대신, 실제로 세대 간 참조가 있는 작은 블록들만 스캔하면 되니까 엄청난 성능 향상이 있어요!
"구세대가 몇 GB인데 전체를 스캔한다고? 그럼 GC가 몇 분씩 걸리겠네!" → "아니야, 더럽혀진 카드만 보면 돼!" 🎉
📝 핵심 포인트
왜 바이트 배열을 쓸까? 비트 배열 대신 바이트 배열을 쓰는 주된 이유는 속도예요. 현대 컴퓨터 하드웨어는 주소를 처리하는 최소 단위가 바이트이며, 비트 하나만 저장하는 명령어는 제공하지 않아요. 따라서 비트를 사용하려면 시프트(shift)와 마스크(mask) 명령어를 곁들여야 해요.
3.4.5 쓰기 장벽 - 카드 테이블 관리의 핵심
🤔 새로운 문제: 카드 테이블을 언제 어떻게 업데이트할까요?
앞 절에서 기억 집합을 이용해 GC 루트의 스캔 범위를 줄이는 문제를 해결했어요. 하지만 카드 테이블 원소를 관리하는 문제가 남아 있어요. 예를 들어 언제 더럽혀지고 더럽히는 주체는 무엇인지 같은 문제 말이에요.
🎯 언제 카드가 더럽혀질까요?
카드 테이블의 원소가 언제 더럽혀지는지는 명확해요. 다른 세대의 객체가 현 블록 안의 객체를 참조하면 카드 테이블의 해당 원소가 더럽혀져요.
원칙적으로 더럽혀지는 시점은 참조 타입 필드에 값이 대입되는 순간이에요.
💥 진짜 문제는 "어떻게" 감지할까?
하지만 문제는 더럽혀졌다는 표시를 어떻게 하느냐, 즉 객체가 대입되는 순간 해당 카드 테이블을 어떻게 갱신하느냐예요.
바이트코드 해석 실행: 상대적으로 쉬워요
- 가상 머신이 모든 바이트코드 명령의 실행을 담당하니 끼어들 여지가 충분해요
JIT 컴파일 후 실행: 문제가 복잡해져요 🤯
- JIT 컴파일 후의 코드는 순수한 기계어 명령어들
- 따라서 대입 연산 시 카드 테이블을 갱신하려면 기계어 코드 수준의 방법이 동원되어야 해요
🛡️ 해결책: 쓰기 장벽 (Write Barrier)
핫스팟 가상 머신은 쓰기 장벽 기술을 이용해 카드 테이블을 관리해요.
⚠️ 중요한 구분:
- 쓰기 장벽: 지금 이야기하는 것
- 읽기 장벽: 뒤에서 저지연 컬렉터를 이야기할 때 언급할 것
🔍 읽기 장벽 vs 쓰기 장벽
읽기 장벽:
- 동시 비순차 실행(concurrent out-of-order execution) 문제를 해결하기 위한 메모리 장벽 기술
- 컴파일러 최적화나 CPU 실행 최적화로 명령어 실행 순서가 바뀌는 것을 방지
쓰기 장벽:
- 가상 머신 수준에서 **'참조 타입 필드 대입' 시 끼어드는 AOP 애스팩트(aspect)**에 비유할 수 있어요
- 참조 타입에 객체가 대입되면 **어라운드 어드바이스(around advice)**가 생성되어, 대입 전후로 추가 동작을 수행할 수 있게 해요
📝 쓰기 장벽의 종류
- 사전 쓰기 장벽: 대입 전 쓰기 장벽
- 사후 쓰기 장벽: 대입 후 쓰기 장벽
핫스팟 가상 머신의 컬렉터 다수가 쓰기 장벽을 이용해요. 하지만 G1 컬렉터가 등장하기 전까지 컬렉터들은 모두 사후 쓰기 장벽만 이용했어요.
💻 사후 쓰기 장벽 코드 예시
void oop_field_store(oop* field, oop new_value) {
// 참조 타입 필드에 대입
*field = new_value;
// 쓰기 완료 후, 장벽이 카드 테이블 상태를 갱신
post_write_barrier(field, new_value);
}
⚖️ 성능 트레이드오프
쓰기 장벽을 적용하면 가상 머신은 추가로 실행할 명령어를 생성해 대입 연산 모두에 추가해요.
그래서 컬렉터가 쓰기 장벽으로 카드 테이블 갱신 연산을 추가한다면:
- 참조가 갱신될 때마다 오버헤드가 더해져요
- 구세대 객체가 신세대를 참조하는 대입이 아니라도 마찬가지예요
- 그래도 마이너 GC 때 구세대 전체를 스캔하는 비용보다는 훨씬 저렴해요
🚨 거짓 공유 (False Sharing) 문제
쓰기 장벽에 의한 오버헤드 말고도, 카드 테이블은 멀티스레드 시나리오에서 거짓 공유 문제를 일으킬 수 있어요.
🧠 거짓 공유란?
거짓 공유는 낮은 수준에서 동시성을 다룰 때 고려해야 하는 문제예요.
동작 원리:
- 현대적인 CPU의 캐시 시스템은 데이터를 캐시 라인 단위로 관리
- 여러 스레드가 서로 다른 변수를 수정하는 상황에서
- 그 변수들이 마침 같은 캐시 라인에 저장되어 있다면
- 라이트백(write back), 무효화, 동기화 등의 작업 시 서로 영향을 주어 성능을 떨어뜨려요
- 실제로는 공유하고 있지 않음에도 마치 공유하는 것처럼 서로 영향을 준다고 해서 거짓 공유 문제라고 해요
📊 구체적인 계산
프로세서의 캐시 라인 크기가 64바이트라고 가정하면:
- 카드 테이블 원소 하나가 1바이트를 차지하므로
- 총 64개의 원소가 캐시 라인 하나를 공유할 거예요
- 이 64개 원소에 대응하는 카드 페이지의 총 메모리 크기는 32KB(64 × 512바이트)
따라서 서로 다른 스레드가 갱신하는 객체들이 32KB 영역 안에 존재한다면, 카드 테이블 갱신 시 같은 캐시 라인에 쓸 것이고 성능에 영향을 줄 거예요.
✅ 거짓 공유 해결방법: 조건부 쓰기 장벽
거짓 공유 문제는 쓰기 장벽을 조건부로 사용하여 간단히 피할 수 있어요. 카드 테이블을 먼저 확인하여 원소가 더럽혀지지 않았을 때만 더럽히는 것이에요.
// 기존 방식
CARD_TABLE[this_address >> 9] = 1;
// 조건부 방식
if (CARD_TABLE[this_address >> 9] != 1)
CARD_TABLE[this_address >> 9] = 1;
🔧 JVM 매개 변수
JDK 7부터 핫스팟 가상 머신은 -XX:+UseCondCardMark 매개 변수를 새로 추가하여 카드 테이블 갱신 시 조건을 판단할 수 있는 길을 열어 주었어요.
트레이드오프:
- 이 매개 변수를 설정하면 조건을 판단하는 오버헤드가 더해지지만
- 거짓 공유 문제는 피할 수 있어요
💡 실무 적용 팁
어느 쪽이든 성능 저하는 있으니 애플리케이션을 실제로 수행해 보며 성능을 비교해 결정하기 바라요.
📌 JDK 버전별 주의사항:
- JDK 7과 8: 핫스팟 가상 머신을 서버 모드로 실행할 때만 인식 → -server 매개 변수도 함께 지정
- JDK 9부터: 모드에 상관없이 인식
📝 정리하면
쓰기 장벽은 "언제 카드 테이블을 업데이트할까?"라는 핵심 문제를 해결하는 똑똑한 방법이에요. AOP의 어라운드 어드바이스처럼 참조 대입 전후에 끼어들어서 카드 테이블을 자동으로 관리해주는 거죠.
물론 성능 오버헤드와 거짓 공유 같은 문제들이 있지만, 구세대 전체를 스캔하는 것보다는 훨씬 효율적이에요!
3.4.6 동시 접근 가능성 분석 - 삼색 표시법
🤯 동시 실행의 딜레마
3.2절에서 현재 주류 프로그래밍 언어의 가비지 컬렉터들은 기본적으로 도달 가능성 분석 알고리즘을 써서 객체의 생사를 판단한다고 했어요.
이론적으로 도달 가능성 분석 알고리즘은 일관성이 보장되는 스냅숏 상태에서 전체 과정을 진행해야 해요. 다시 말해 사용자 스레드는 분석 과정 내내 멈춰 있어야 해요.
⏱️ 루트 노드 열거는 빠르지만...
루트 노드 열거 단계에서는:
- GC 루트는 전체 자바 힙에 존재하는 모든 객체와 비교해 그 수가 아주 적어요
- OopMap 같은 다양한 최적화 기법 덕에 스레드가 멈춰 있는 시간은 매우 짧으며 상대적으로 일정해요
- 힙 용량이 늘어난다고 해서 더 오래 걸리지는 않아요
진짜 문제는 객체 그래프 탐색
루트 노드 열거가 끝나면 가비지 컬렉터는 GC 루트로부터 객체 그래프를 탐색할 수 있어요. 이 단계의 일시 정지 시간은 자바 힙 크기에 비례해요.
- 힙이 클수록 더 많은 객체를 담게 되고
- 객체 그래프 구조도 복잡해져요
- 더 많은 객체를 확인해 표시하려면 일시 정지 시간은 당연히 길어져요
💥 모든 GC의 공통 문제
참조 관계를 추적하는 가비지 컬렉션 알고리즘들에는 공통적으로 '표시' 단계가 등장해요.
표시 단계의 일시 정지 시간이 힙 크기에 비례해 증가한다면:
- 거의 모든 가비지 컬렉터에 악영향을 줘요
- 반대로 이 단계의 일시 정지 시간을 줄일 수 있다면 거의 모든 컬렉터에 득이 돼요
🎨 삼색 표시 (Tricolor Marking) 기법
사용자 스레드의 일시 정지 문제를 해결하거나 줄이고 싶다면 일관성이 보장되는 스냅숏 상태에서 객체 그래프를 탐색해야 하는 이유를 먼저 파악해야 해요.
삼색 표시 기법으로 이 문제를 명확히 설명해볼게요. 객체 그래프를 여행하는 과정에서 마주치는 객체들에 '방문한 객체인가'라는 조건에 따라 세 가지 색 중 하나를 칠하는 기법이에요.
🎯 세 가지 색의 의미
⚪ 흰색:
- 가비지 컬렉터가 방문한 적 없는 객체
- 도달 가능성 분석을 시작하면 처음에는 당연히 모든 객체가 흰색
- 분석을 마친 뒤에도 흰색인 객체는 도달 불가능함을 뜻해요
⚫ 검은색:
- 가비지 컬렉터가 방문한 적이 있으며, 이 객체를 가리키는 모든 참조를 스캔했어요
- 검은 객체는 스캔되었고 확실히 생존함을 뜻해요
- 다른 객체에서 검은 객체를 가리키는 참조가 있다면 다시 스캔하지 않아도 돼요
- 중요: 검은 객체가 흰 객체를 곧바로 가리키는 건 불가능해요 (회색 객체를 거쳐 가리킬 수는 있어요)
🔘 회색:
- 가비지 컬렉터가 방문한 적 있으나
- 이 객체를 가리키는 참조 중 스캔을 완료하지 않은 참조가 존재해요
🌊 회색 물결의 전진
접근 가능성 분석의 스캔 과정은 마치 회색 물결이 일렁이며 하얀 객체 그래프를 검은색으로 칠해 가는 모습과 비슷해요.
그동안 사용자 스레드들은 멈춘 채로 컬렉터의 GC 스레드만 실행된다면 아무 문제가 없어요.
🚨 동시 실행시 발생하는 문제
하지만 사용자 스레드와 컬렉터가 동시에 실행된다면 어떨까요? 컬렉터가 객체 그래프에 색을 칠해 가는 도중에 사용자 스레드가 참조 관계를 변경하는 거예요.
그러면 두 가지 결과를 가져올 수 있어요:
1️⃣ 죽은 객체를 살았다고 잘못 표시
좋지 않은 일이지만 그래도 감내할 수는 있어요. 컬렉터의 눈을 피해 굴러다니는 쓰레기가 좀 생기지만 다음번 청소때 회수할 수 있을 거예요.
2️⃣ 살아 있는 객체를 죽었다고 표시 💥
아주 치명적인 데다 프로그램 오류로 이어질 거예요.
📊 객체 사라짐 문제 시각화
다음은 이런 심각한 오류가 발생하는 과정이에요:
1단계: 정상적인 시작
GC Root(검은색) → 회색 객체 → 흰색 객체
처음에는 GC 루트만 검은색이고, 회색 물결이 흰색 쪽으로 전진해요.
2단계: 참조 관계 변경
① 회색 객체에서 흰색 객체로의 참조가 끊어짐
② 동시에 검은색 객체가 그 흰색 객체를 참조하기 시작
3단계: 문제 발생
- 검은색 객체는 이미 스캔이 끝났으니 다시 스캔되지 않아요
- 결과적으로 검은색 객체가 참조함에도 불구하고 흰색으로 남는 객체들이 생겨나요
- 이 객체들은 회수될 터라 아주 위험한 상황에 처해요
🔬 월슨의 정리 (1994년)
1994년 월슨은 다음 두 조건이 동시에 만족될 때만 객체 사라짐 문제가 나타남을 증명했어요:
✅ 조건 1: 사용자 스레드가 흰색 객체로의 새로운 참조를 검은색 객체에 추가 ✅ 조건 2: 사용자 스레드가 회색 객체에서 흰색 객체로의 직간접적인 참조를 삭제
💡 해결 방법: 두 조건 중 하나만 깨뜨리면 됨!
동시 스캔 도중 객체 사라짐 문제를 해결하려면 두 조건 중 하나만 깨뜨리면 돼요. 따라서 해법도 두 가지예요:
1️⃣ 증분 업데이트 (Incremental Update)
첫 번째 조건을 깨뜨려 줘요:
- 검은색 객체에 흰색 객체로의 참조가 추가되면 새로 추가된 참조를 따로 기록해 둬요
- 동시 스캔이 끝난 후 기록해 둔 검은색 객체들을 루트로 하여 다시 스캔해요
쉽게 이해하기: "검은색 객체에 흰색 객체로의 참조가 추가되면 검은색이 다시 회색으로 바뀐다"고 생각하면 돼요.
2️⃣ 시작 단계 스냅숏 (SATB - Snapshot At The Beginning)
두 번째 조건을 깨뜨려 줘요:
- 회색 객체가 흰색 객체로의 참조 관계를 끊으려 하면 그 사실을 기록해요
- 동시 스캔이 끝난 후 기록해 둔 회색 객체들을 루트로 하여 다시 스캔해요
쉽게 이해하기: "참조 관계 삭제 여부와 상관없이 스캔을 막 시작한 순간의 객체 그래프 스냅숏을 기준으로 스캔한다"고 생각하면 돼요.
🛠️ 구현 방법: 쓰기 장벽 활용
이상의 두 방식에서:
- 하나는 참조가 추가될 때 기록
- 다른 하나는 참조가 끊길 때 기록
어느 쪽이든 가상 머신은 쓰기 장벽을 이용해 기록 작업을 구현해요.
🏭 핫스팟의 실제 적용
핫스팟 가상 머신은 증분 업데이트와 시작 단계 스냅숏을 모두 활용해요:
- CMS: 증분 업데이트 사용
- G1과 셰넌도어: 시작 단계 스냅숏 사용
📝 정리하며
지금까지 핫스팟 가상 머신이:
- 메모리 회수를 시작하는 방법 (루트 노드 열거, 안전 지점/지역)
- 성능을 높이는 방법 (OopMap, 카드 테이블)
- 정확성을 보장하는 방법 (쓰기 장벽, 삼색 표시)
을 간략하게 소개했어요.
하지만 구체적인 내용까지는 들어가지 못했어요. 메모리를 다시 확보하는 구체적인 방법은 가상 머신이 사용하는 가비지 컬렉터에 따라 달라요.
동시 GC는 정말 복잡한 문제예요. "GC 하면서 동시에 프로그램도 실행하고 싶어요!"라는 욕심에서 시작되었지만, 객체 사라짐이라는 치명적인 문제를 해결해야 했어요.
하지만 월슨의 정리와 두 가지 해결책(증분 업데이트, SATB) 덕분에 현대의 저지연 GC들이 탄생할 수 있었답니다. 정말 대단하지 않나요?
🎯 정리하자면
핫스팟 GC의 핵심 기법들을 정리하면:
✅ OopMap: 객체 참조 위치를 미리 기록
✅ 안전 지점: GC가 안전하게 멈출 수 있는 지점
✅ 안전 지역: 잠자는 스레드 문제 해결
✅ 카드 테이블: 세대 간 참조 효율적 추적
✅ 쓰기 장벽: 카드 테이블 자동 업데이트
✅ 삼색 표시: 동시 GC 실행 시 정확성 보장
"으아악 너무 어려워요! 🤯"
그럴 수 있어요. 처음엔 저도 이해하기 힘들었어요. 하지만 실무에서 GC 로그 보면서 "아, 이래서 이렇게 동작하는구나!" 하는 순간이 오면 정말 뿌듯할 거예요.
자책하지 말고, 저벅저벅 걸어가면 언젠가 마스터할 수 있어요! 💪
혹시 지금 당장 이해하기 어렵다면 북마크 해두고, 실무에서 GC 튜닝 필요할 때 JVM을 스스로 읽으십시오 강하게 크셔야 합니다.
'개발 언어 > JAVA' 카테고리의 다른 글
18. JDBC (0) | 2024.04.01 |
---|---|
고객 관리 프로그램 작성 (0) | 2024.03.31 |
17. Network, 서버 만들기! (0) | 2024.03.28 |
16. ParallelStream, Thread (0) | 2024.03.27 |
15. Operator, Stream (0) | 2024.03.27 |