JVM 튜닝 글을 읽다 보면 거의 예외 없이 이런 문장을 만난다. "이스케이프 분석(Escape Analysis)이 켜지면, 메서드 밖으로 새어나가지 않는 객체는 힙 대신 스택에 할당된다." 나도 오래 이렇게 믿었다.
그런데 OpenJDK HotSpot 소스를 실제로 뒤져 보면 이상하다. new 로 만든 객체를 스택 프레임에 통째로 올리는 "스택 할당" 경로가 안 보인다. C++로 짠 다른 런타임이나 Go에는 있는 그 동작이, 정작 프로덕션 HotSpot에는 없다. 그럼 이스케이프 분석이 켜졌을 때 임시 객체는 대체 어디로 사라지는 걸까?
이 글은 그 질문을 소스 레벨까지 따라간 기록이다. 결론부터 말하면, HotSpot은 객체를 스택에 올리는 게 아니라 아예 없앤다. 그 메커니즘의 이름이 스칼라 치환(Scalar Replacement) 이다.
먼저 용어를 정확히 갈라야 한다. 흔히 하나로 뭉뚱그리는데, 사실 두 단계다.
즉 이스케이프 분석은 판단이고, 스칼라 치환은 그 판단에 따른 실행이다. -XX:+DoEscapeAnalysis 가 기본 on이라는 말은 "분석을 켠다"는 뜻이지 "할당이 반드시 사라진다"는 보장이 아니다. 이 구분을 놓치면 "EA 켰는데 왜 임시 객체가 안 사라지냐"에서 막힌다.
C2는 모든 new 할당에 대해 객체의 탈출 상태(EscapeState) 를 세 단계로 매긴다. HotSpot 소스 escape.cpp 의 PointsToNode::EscapeState 가 이 값이다.
| 상태 | 의미 | 가능한 최적화 |
|---|---|---|
NoEscape | 메서드 밖으로도, 다른 스레드로도 안 나감 | 스칼라 치환 + 락 제거 |
ArgEscape | 호출한 메서드에 인자로만 넘어감 (힙·스레드로는 안 샘) | 락 제거만 가능, 할당 제거는 불가 |
GlobalEscape | 필드·static에 저장되거나 반환·throw됨 | 최적화 없음 |
핵심은 NoEscape 하나뿐이다. 이 상태여야만 할당 자체를 지울 수 있다. ArgEscape 만 돼도 객체는 힙에 남는다.
그럼 탈출 상태는 어떻게 계산할까. HotSpot은 connection graph 라는 points-to 자료구조를 만든다 (Choi et al., "Escape Analysis for Java", OOPSLA 1999 기반). 노드는 세 종류다.
Object node : new 로 생긴 객체
LocalVar node : 지역 참조 변수
Field node : 객체가 가진 필드
에지는 참조 관계다. 어떤 변수가 어떤 객체를 가리키는지(PointsTo), 객체가 어떤 필드를 소유하는지(Field), 그리고 b = a 같은 참조 복사를 당장 펼치지 않고 지연시키는 Deferred 에지가 있다. Deferred를 두는 이유는 복사 관계를 미뤘다가 한 번의 전파로 모아서 수렴시키기 위해서다.
알고리즘의 골자는 의외로 단순하다.
1. GlobalEscape 시드를 표시한다
(static 필드, 반환값, throw, 다른 스레드 진입점)
2. 시드에서 그래프를 따라 "도달 가능한" 모든 객체 노드를
GlobalEscape 로 전파한다 (reachability propagation)
3. 콜에 인자로 넘어간 객체는 ArgEscape 로 낮춘다
4. 어느 시드에서도 도달 불가능한 객체 → NoEscape 확정
정리하면 이스케이프 분석은 본질적으로 그래프 도달성 계산이다. "이 객체 참조가 붙잡은 사슬을 끝까지 따라가면, 결국 힙·static·다른 스레드에 닿는가?"를 묻는 것이다. 닿지 않으면 그 객체는 이 메서드 안에서 태어나 이 메서드 안에서 죽는, 순수하게 지역적인 존재다.
NoEscape 가 확정되면 PhaseMacroExpand 가 개입한다. 이때 일어나는 핵심 최적화가 스칼라 치환이다. 이름 그대로, 객체를 그 필드(스칼라)들로 분해해서 각각을 레지스터나 스택 슬롯에 흩뿌린다. 객체라는 실체 자체가 사라진다.
메모리 관점에서 보면 이렇게 달라진다.
치환 전 (힙 객체) 치환 후 (레지스터/스택 슬롯)
┌──────────────┐
│ mark word │ 8B
│ klass ptr │ 4~8B (객체 없음)
│ dx │ 4B → %r1 = dx
│ dy │ 4B %r2 = dy
└──────────────┘ + 정렬 패딩 헤더·패딩·할당 포인터 bump 전부 소멸
여기서 핵심은 힙 할당만 없어지는 게 아니라는 점이다. 객체 헤더(mark word, klass 포인터)도, 정렬 패딩도, 할당 포인터를 밀어 올리는 bump 연산도, 나중에 이 객체를 수거할 GC 부담도 전부 함께 사라진다. Point p = new Point(x, y) 가 있으면 힙에 Point 는 만들어지지 않고 x, y 라는 두 스칼라 값만 남는다.
이것이 "스택 할당"과 결정적으로 다른 지점이다. 스택 할당은 객체를 스택이라는 다른 장소에 여전히 하나의 덩어리로 만든다. 스칼라 치환은 객체라는 개념 자체를 해체한다. HotSpot이 하는 건 후자다.
NoEscape 객체에는 두 가지가 더 따라온다.
synchronized 는 의미가 없으므로 모니터 획득·해제를 삭제한다.synchronized 블록을 하나로 합친다.실무에서 가장 자주 부딪히는 지점이다. 이스케이프 분석은 절차 내(intraprocedural) 분석이다. 한 메서드 범위 안에서만 그래프를 그린다. 그래서 객체가 다른 메서드로 인자로 넘어가는 순간, 그 콜리(callee)가 인라인되지 않으면 객체는 최소 ArgEscape 로 강등된다. 콜리가 그 객체를 필드에 저장하기라도 하면 GlobalEscape 다.
C2는 인라이닝을 먼저 하고, 그렇게 펼쳐진 IR 위에서 이스케이프 분석을 돌린다. 그래서 핫 메서드가 인라인 한계(-XX:MaxInlineSize, -XX:FreqInlineSize 등)에 걸리면 이스케이프 분석도 같이 무력화된다. "왜 이 임시 객체가 안 사라지지?"의 답은 대개 "그 호출 경로가 인라인되지 않았다"이다. 스칼라 치환은 인라이닝의 함수라고 봐도 크게 틀리지 않는다.
여기에 더해 알아둘 한계가 있다.
의문이 하나 생긴다. 객체를 스칼라로 흩어버렸는데, C2의 가정이 깨져 역최적화(deoptimization)가 일어나면 어떻게 되나? 인터프리터는 "진짜 객체"를 기대하는데 그게 없다.
HotSpot은 deopt 지점에 스칼라화된 객체를 다시 힙에 재할당하고 필드를 채워 넣는(reallocate & reassign) 코드를 심어둔다. 이걸 재구체화(rematerialization)라 한다.
[C2 컴파일 코드] x, y 스칼라만 존재
│ deopt (가정 붕괴)
▼
[deopt 핸들러] new Point 재할당 → p.x=x, p.y=y 복원
▼
[인터프리터] 진짜 Point 객체로 계속 실행
이 재구체화 덕분에 스칼라 치환이 프로그램의 관찰 가능한 의미를 바꾸지 않는다. 최적화가 sound한(정확성을 해치지 않는) 이유가 여기에 있다.
작은 예시와 진단 플래그로 흐름을 확인할 수 있다.
// NoEscape 예시 — p 는 메서드 밖으로 안 샌다
int dist2(int x1, int y1, int x2, int y2) {
Point p = new Point(x2 - x1, y2 - y1); // 이 할당이 사라져야 정상
return p.dx * p.dx + p.dy * p.dy; // p.dx, p.dy 는 스칼라로 대체
}
-XX:+PrintEliminateAllocations 를 켜면 위 할당에 대해 Scalar replaced ... Point 류의 로그가 뜬다. 힙 할당이 실제로 제거됐다는 신호다.-XX:+PrintGC 로 할당 압력을 본다. -XX:-DoEscapeAnalysis 로 끄면 Young GC 빈도가 눈에 띄게 오르고, 켜면 거의 무할당으로 수렴한다.escape.cpp 의 ConnectionGraph::compute_escape() 가 도달성 전파를, macro.cpp 의 PhaseMacroExpand::scalar_replacement() 가 실제 노드 치환을 담당한다.이스케이프 분석은 객체의 탈출 상태를 points-to 그래프로 계산하는 분석이고, 그 결과로 HotSpot이 실제로 하는 최적화는 스택 할당이 아니라 스칼라 치환 — 객체를 필드 스칼라로 분해해 할당 자체를 지우는 것이다.
정리하면서 스스로 고친 오해 두 가지.
ArgEscape/GlobalEscape 로 강등돼 할당이 남는다. 켜져 있다 ≠ 항상 제거된다.더 파고들 만한 것: C2 인라이닝 정책(MaxInlineSize, late inlining)이 스칼라 치환 성공률에 미치는 영향, 그리고 biased locking 폐지 이후 락 제거의 상호작용.
src/hotspot/share/opto/escape.cpp, opto/macro.cpp