GC와 JIT는 무대 뒤에서 무슨 일을 하나
앞 페이지에서 "인터프리터로 시작해 JIT가 핫스팟을 기계어로 바꾸고, 그동안 GC가 힙을 청소한다"는 큰 그림을 잡았다. 이제 그 무대 뒤를 연다 — 청소부(GC)는 무엇을 버릴지 어떻게 알고, 번역가(JIT)는 어떻게 코드를 빠르게 만들며, 이 모든 게 CPU 캐시·메모리와 어떻게 맞물리는가. 그리고 이걸 실무에서 어떻게 골라 쓰는지까지 이어 본다.
1. 청소부는 무엇을 버릴지 어떻게 아는가 — 도달성
창고(힙)에 객체가 계속 쌓이는데, GC는 "이건 버려도 되는 쓰레기"를 어떻게 구분할까? 답은 **도달성(reachability)**이다. 우리가 지금 쓸 수 있는 객체란, 코드가 따라갈 수 있는 참조의 사슬로 닿는 객체뿐이다. 그 사슬의 시작점을 GC root라 부른다 — 실행 중인 스레드의 스택 변수, static 필드, JNI 참조 같은 것들.
GC는 root에서 출발해 참조를 타고 가며 닿는 객체에 전부 표시(mark)하고, 끝나고도 표시 안 된 것을 쓰레기로 친다.
여기서 자바가 참조 카운팅(객체마다 "나를 가리키는 게 몇 개"를 세는 방식)을 안 쓰는 이유가 보인다. 위 그림에서 D와 E는 서로를 가리켜 카운트가 1 이상이지만, root에서는 닿지 못한다. 참조 카운팅이라면 이 둘을 영영 못 지운다(순환 참조 누수). 반면 도달성 추적은 "root에서 못 닿으면 쓰레기"라 순환도 깔끔히 회수한다. 게다가 카운트를 멀티스레드에서 매번 원자적으로 올리고 내리는 비용도 없다 — 대신 그 대가로 청소할 땐 잠깐 멈춰야 한다(뒤에 나올 STW).
2. 그런데 객체 만드는 건 왜 이렇게 빠른가 — TLAB
자바는 new를 굉장히 자주 한다. 그런데도 빠른 비결이 **TLAB(Thread-Local Allocation Buffer)**다. 만약 모든 스레드가 공용 힙의 "다음 빈자리 포인터"를 두고 경쟁하면, 객체 하나 만들 때마다 락이나 CAS로 다퉈 병목이 된다.
그래서 JVM은 스레드마다 힙의 한 구획을 통째로 떼어 준다. 그 안에서는 "포인터를 객체 크기만큼 밀기"(bump-the-pointer) 한 번이면 할당 끝 — 락도, 경쟁도 없다. 구획을 다 쓰면 새 구획을 하나 더 받는다(이때만 잠깐 동기화). 갓 태어난 객체 대부분이 이렇게 Eden의 TLAB에서 시작한다. 이 "할당이 거의 공짜"라는 점이 다음 이야기(세대)의 전제가 된다.
3. 다 똑같이 청소하면 느리다 — 세대로 나누기
힙 전체를 매번 훑어 청소하면, 수 GB를 뒤지느라 느리다. 여기서 결정적인 관찰 하나가 들어온다 — 약한 세대 가설: 갓 만든 객체는 대부분 금방 죽는다(루프 안 임시 객체, 메서드 지역 객체…). 그렇다면 젊은 객체들이 모인 작은 영역만 자주 치우면 효율이 폭발적으로 좋아진다.
- Minor GC(Young 영역): 자주, 짧게. Eden에서 살아남은 소수만 Survivor로 복사하고 Eden은 통째로 비운다. 죽는 게 대부분이라 복사할 게 적어 빠르다.
- 여러 번 살아남은 객체는 "얘는 오래 살겠다" 판단되어 Old로 승격(tenuring).
- Major/Full GC(Old): 가끔, 길게. 비싸다.
그런데 함정이 하나 있다. Minor GC는 Young만 보고 싶은데, Old에 있는 객체가 Young 객체를 가리키면 그 Young 객체는 살아 있는 것이다. 이걸 찾자고 매번 Old 수 GB를 다 훑으면 "Young만 빠르게"가 무너진다. 그래서 write barrier + card table을 쓴다 — 참조를 쓰는 순간 JIT가 끼워 둔 짧은 코드가 "이 영역(카드, 보통 512바이트)에 Young을 가리키는 참조가 생겼다"고 표시(dirty)해 둔다. Minor GC는 Old 전체가 아니라 dirty 카드만 본다. 공간을 약간 써서 시간을 크게 버는, 전형적인 트레이드오프다.
4. 어느 청소부를 쓸까 — G1·ZGC·Shenandoah와 실무 선택
청소 알고리즘은 여러 개이고, 워크로드마다 정답이 다르다.
| GC | 메커니즘 | 성격 |
|---|---|---|
| Parallel | STW로 여러 스레드가 한꺼번에 청소 | 처리량 최우선, 멈춤은 길어도 OK(배치) |
| G1 | 힙을 region으로 쪼개 쓰레기 많은 곳부터 점진 압축 | 균형형. Java 9부터 기본 |
| ZGC | 컬러드 포인터 + load barrier로 앱과 동시에 압축 | 초저지연(sub-ms), 큰 힙 |
| Shenandoah | load-reference barrier로 동시 압축 | 초저지연(레드햇 계열) |
실무에서는 — G1이냐 ZGC냐. 정리하면:
- G1(기본 선택): 지연 SLA가 수십 ms 수준이고 힙이 대략 32GB 이하이며, 손 안 대고 예측 가능한 동작을 원할 때. 대부분의 웹 서비스가 여기.
- ZGC: "99%의 요청이 50ms 안에" 같은 엄격한 꼬리 지연(tail latency) SLO가 있거나 힙이 수백 GB~TB일 때. 멈춤이 sub-ms다. 단, 동시 수집의 대가로 메모리 15~30%·CPU 5~10% 더 쓰고, *헤드룸 25~35%*가 없으면 오히려 멈칫한다 → 작은 컨테이너에 무턱대고 ZGC를 켜지 마라. Java 21부터 세대형 ZGC라 처리량도 개선됐다.
출처: 다수 실무 GC 선택 가이드·벤치마크(JavaCodeGeeks 2026 "G1 vs ZGC vs Shenandoah", foojay GC 가이드, 200K RPS 벤치마크). 핵심 수치: ZGC sub-ms vs G1 수십~수백 ms, ZGC 메모리 헤드룸 25~35%.
5. 번역가의 일 — 인터프리터에서 JIT까지
이제 속도를 만드는 쪽이다. 06에서 본 대로 JVM은 *통역(인터프리터)*으로 시작한다. 그러다 코드가 뜨거워지면 *번역(JIT)*으로 넘긴다. 그런데 한 번에 최고 수준으로 번역하지 않고 단계적으로 올린다.
왜 처음부터 최고 수준(C2)으로 다 안 할까? C2 최적화는 느리고 비싸다. 모든 코드를 그렇게 번역하면 기동이 굼뜨고, 한 번만 실행될 코드까지 헛고생한다. 그래서 인터프리터로 즉시 시작하고, C1이 실제로 어떤 분기가 자주 타고 어떤 타입이 들어오는지 프로파일을 모은 뒤, 진짜 핫한 것만 C2가 그 프로파일을 근거로 최적화한다 — 정적 컴파일이 알 수 없는 런타임 정보를 활용하는 셈이다.
C2가 부리는 대표적 마법: 인라이닝(작은 메서드 본문을 호출 지점에 펼쳐 호출 비용 제거 — 다른 최적화의 전제), 이스케이프 분석(객체가 메서드를 못 벗어나면 힙 대신 스택/레지스터에 풀어 버리고 락도 생략), 역가상화(늘 같은 타입이면 가상 호출을 직접 호출로). 그리고 추측이 틀리면(예: 새 클래스가 로딩돼 가정이 깨짐) **디옵트(deoptimization)**로 인터프리터로 되돌아간다 — 자바가 동적인데도 공격적으로 최적화할 수 있는 안전장치다.
실무에서는 — "첫 요청이 왜 느리지?" 막 뜬 서버의 첫 수십~수백 요청은 평소보다 한참 느리다. 아직 인터프리터로 돌고 JIT가 덜 데워졌기 때문(+ 클래스 지연 로딩). 분산 환경에선 이 한 노드의 워밍업이 전체 체인을 느리게 하고, 워밍업 중 CPU도 더 쓴다. 완화책:
- 워밍업 트래픽: 배포 후 실트래픽을 받기 전, 핵심 경로로 더미 요청을 흘려 JIT를 미리 데운다.
-XX:TieredStopAtLevel=3: C2를 건너뛰면 워밍업이 ~50% 빨라지는 대신 정점 처리량이 ~5% 낮아진다(기동 잦은 서비스에 유효).- 쿠버네티스 CPU 부스트: 시작 시 CPU limit을 request보다 높게(burstable) 줘 "컴파일 폭풍"을 빨리 끝낸다.
- AOT 프로파일(최신): 훈련 실행에서 모은 프로파일을 캐시해 두면, 운영 기동 시 JIT가 무엇을 최적화할지 미리 안다 → 워밍업 ~19% 단축.
출처: Baeldung "How to Warm Up the JVM", JVM 워밍업 비교(Medium/Azul), AOT method profiling. 임계 기본값: C1 ~1K 호출, C2 ~10K.
6. 결국 CPU 위에서 돈다 — 캐시 계층
지금까지의 객체·참조는 결국 물리 메모리 위에 있고, CPU가 그걸 읽어야 일한다. 그런데 RAM은 CPU보다 ~100배 느리다. 이 격차를 메우려고 CPU는 작고 빠른 캐시를 계층으로 둔다.
빠른 메모리는 비싸고 작으니, 자주 쓰는 데이터를 가까이 캐싱하면(지역성) 평균 접근이 훨씬 빨라진다. 캐시는 64바이트 캐시 라인 단위로 통째로 올라온다. 그래서 배열은 메모리가 연속이라 한 라인에 여러 원소가 함께 실려 순회가 빠르고, 연결 리스트는 노드가 흩어져 매번 캐시 미스가 난다 — 같은 O(n)이라도 배열이 실측에서 이기는 이유다(앞 컬렉션 페이지의 그 이야기가 여기서 하드웨어로 설명된다).
이 캐시 라인이 골치를 부리기도 한다. 두 스레드가 서로 다른 변수를 갱신하는데 그 변수들이 같은 64바이트 라인에 있으면, 한쪽이 쓸 때마다 다른 코어의 라인이 무효화되어(ping-pong) 논리적으론 독립인데 서로를 느리게 만든다 — 거짓 공유(false sharing). 핫 필드를 @Contended나 패딩으로 다른 라인에 떼어 해결한다(LongAdder가 이 원리로 빠르다).
7. "보이게 만든다"는 약속 — 메모리 배리어와 CAS
마지막 퍼즐. CPU와 컴파일러는 성능을 위해 명령 순서를 재정렬한다. 단일 스레드엔 문제없지만, 멀티스레드에선 "내가 쓴 값이 저쪽에 안 보이는" 버그가 된다. 게다가 하드웨어마다 규칙이 다르다 — x86은 비교적 강한 모델(TSO), ARM은 자유롭게 재정렬하는 약한 모델이라 같은 코드가 ARM에서만 깨지기도 한다.
자바는 이 차이 위에 **JMM(Java Memory Model)**이라는 이식 가능한 약속을 둔다. happens-before로 "이 쓰기는 저 읽기에 보인다"를 규정하고, volatile 읽기/쓰기 둘레에 **메모리 배리어(펜스)**를 깔아 재정렬을 막고 캐시를 최신으로 강제한다(x86에선 lock/mfence, ARM에선 dmb로 번역). 즉 volatile은 마법이 아니라 CPU 배리어 명령으로 내려간다.
원자적 갱신도 마찬가지로 하드웨어다. AtomicInteger.compareAndSet은 라이브러리 트릭이 아니라 단일 CPU 명령 — x86의 LOCK CMPXCHG, ARM의 LDXR/STXR(Load-Linked/Store-Conditional)다. 락·ConcurrentHashMap·LongAdder가 전부 이 위에 선다. 다만 값이 A→B→A로 돌아오면 CAS가 "안 변했다"고 속는 ABA 문제가 있어, 버전 태그(AtomicStampedReference)로 막는다.
한눈에 정리
- GC는 도달성으로 청소 — root에서 못 닿으면 쓰레기. 순환도 회수(참조 카운팅 아님).
- TLAB — 스레드별 bump-pointer 할당이라
new가 거의 공짜. - 세대 — 약한 세대 가설로 Young만 자주·짧게(copying), Old는 가끔·길게. write barrier+card table로 세대 간 참조 추적.
- 실무 GC: 기본 G1(수십 ms SLA·힙<32GB), 엄격 지연·큰 힙이면 ZGC(sub-ms·단 메모리+헤드룸 필요).
- JIT: 인터프리터→C1(프로파일)→C2(인라이닝·이스케이프분석), 추측 틀리면 deopt. 실무: 워밍업으로 첫 요청 느림 → 워밍업 트래픽·
TieredStopAtLevel=3·CPU 부스트·AOT 프로파일. - 하드웨어: 캐시 라인 64B(배열>리스트), false sharing 주의.
- volatile = 메모리 배리어, JMM은 x86 TSO/ARM weak 위의 추상화. CAS = LOCK CMPXCHG/LL-SC 단일 명령, ABA는 stamped로.