이번 글은 "JVM 밑바닥까지 파헤치기" 책의 3.6 "저지연 가비지 컬렉터"의 객체 돌아보기에 대한 부분을 정리 및 요약한 글입니다.
완벽한 가비지 컬랙터 존재 가능성
자바 개발자라면 한 번쯤은 GC로 인한 이슈를 겪게 됩니다. 응답 시간이 튀거나, 시스템이 멈춘 듯한 순간들은 대부분이 GC로부터 비롯됩니다. 이로 인해, 사람들이 성능 좋은 GC를 찾아 다양한 실험을 하게 됩니다.
그런데 여기서 한 가지 의문점이 생깁니다.
"왜 완벽한 가비지 컬렉터는 아직도 없는 걸까요?"
GC의 꾸준한 진화
GC는 오랜 시간 동안 진화를 거듭해왔습니다. 초기에 Serial GC에서 시작해 CMS(Concurrent Mark Sweep)로, 최근에는 G1GC(Garbage First)로 기본 GC가 변경되었습니다. 요즘은 ZGC나 Shenandoah처럼 지연 시간을 줄이는데 초점을 둔 저지연 GC들도 주목을 받고 있습니다.
하지만, 여전히 완벽하다고 부를 수 있는 GC는 존재하지 않습니다. 왜 그럴까요?
그렇다면, 좋은 GC를 위해 충족해야할 목표는 무엇이 있을까요?
GC가 추가하는 3가지 목표
좋은 가비지 컬렉터가 되기 위한 조건은 크게 3가지가 있습니다.
- 처리량 : 얼마나 많은 작업을 처리할 수 있는지
- 지연 시간 : GC가 응답 시간을 얼마나 지연시키는지
- 메모리 사용량 : 얼마나 적은 메모리를 효율적으로 사용하는지
이 3가지는 흔히 불가능한 삼각이라고 불립니다. 이 3가지의 목표를 동시에 완벽히 달성하는 것은 사실상 불가능하다는 의미를 내포합니다.
완벽한 GC를 만들 수 없는 이유
예를 들어, 지연 시간을 최소화하려면 GC 작업을 나눠서 자주 수행해야 합니다. 하지만 이렇게 하면 처리량이 줄어들 수 밖에 없습니다.
반대로 처리량을 높이려면 GC 횟수를 줄이고 한 번에 크게 처리를 해야하는데, 이 경우엔 지연 시간이 길어질 수 있습니다.
또 메모리 사용량을 줄이려고 하면, 자주 GC를 돌려야 하니 역시 성능이 희생될 수 밖에 없습니다.
즉, 하나를 챙기면 나머지 둘 중 적어도 하나는 포기해야 하는 구조입니다.
그렇다면, 3가지 조건중 어떠한 조건이 가장 중요할까요?
지연 시간이 점점 더 중요해지는 이유
GC의 성능 지표에는 처리량, 지연 시간, 메모리 사용량이 있습니다.
최근 들어 지연 시간의 중요성이 점점 커지고 있습니다. 왜일까요?
하드웨어의 발전을 바꾼 GC 우선순위
에전에는 GC가 메모리를 얼마나 효율적으로 사용하는지가 중요한 이슈였지만, 요즘은 상황이 많이 달라졌습니다.
- 메모리는 저렴해졌고, 용량은 늘었습니다.
- 성능 좋은 CPU와 SSD도 보급되면서, 처리 속도는 점점 빨라지고 있습니다.
결과적으로 GC가 메모리를 조금 낭비하는 것은 큰 문제가 되지 않게 되었고,
오히려 GC로 인해 애플리케이션의 멈추는 지연 시간이 더 큰 문제로 떠오르게 됩니다.
그렇다고 메모리 사용량을 완전히 배제할 수 있을까요?
지연시간과 메모리 사용량의 관계
메모리를 많이 사용한다는 것은 또 다른 문제를 불러옵니다.
GC는 결국 메모리를 청소하는 작업입니다. 메모를 많이 사용한다는 것은 이 청소할 대상이 많다는 것이고 이는 청소하는데 걸리는 시간이 더 많아집니다.
그래서 지연 시간은 단순히 소프트웨어의 문제가 아니라, 메모리 크기 자체에 영향을 받는 구조적인 문제이기도 합니다.
그렇다면, GC는 언제 사용자 응답을 늦추게 될까요?
GC가 사용자 응답을 늦추는 구간
GC가 아무리 빠르게 동작하더라도, 프로그램의 모든 스레드를 일시적으로 멈추는 구간이 있다면 사용자 입장에서는 멈춘 것처럼 느껴질 수 있습니다. 이 구간을 Stop the World(STW)라고 부릅니다.
피할 수 없는 Stop the World
위 그림은 여러 GC가 메모리를 수집하는 과정에서 '일시적으로 모든 스레드가 멈추는 구간(Stop the World)'이 언제 발생하는지를 나타낸 것입니다.
과거에는 대부분의 GC가 거의 모든 단계에서 Stop the World를 유발했습니다.
CMS나 G1처럼 발전된 GC들도 개선은 되었지만, 여전히 완전히 피하지는 못했습니다.
개선된 GC인 CMS나 G1도 왜 여전히 Stop the World 현상을 완전히 피하지 못할까요?
CMS와 G1의 구조적 한계
CMS는 마크-스윕(mark-sweep) 방식의 알고리즘을 사용합니다.
이 방식은 마지막 단계에서 Stop the World를 회피할 수 있게 해주긴 하지만, 파편화 문제가 심각하게 발생합니다.
파편이 계속 쌓이면 결국 더 이상 GC로는 해결할 수 없게 되어 'Full GC'라는 무거운 정리 작업이 필요해집니다.
G1 GC는 큰 객체를 작은 단위로 나누어 관리하고, 마지막 일시 정지 시간을 짧게 만들었지만, 여전히 최종 단계에서 완전한 동시 처리는 불가능합니다.
CMS와 G1의 구조적인 한계를 위해 등장한 GC는 어떤게 존재할까요?
새넌 도어와 ZGC
새넌도어와 ZGC는 기존의 가비지 컬렉터들과는 다른 접근 방식을 택합니다. 이들은 대부분의 GC 단계를 사용자 스레드와 동시에 실행하도록 설계되었습니다. 특히 초기 표시와 최종 표시 단계에서만 짧은 일시 정지가 발생하며, 이 정지 시간은 거의 고정된 수준을 유지합니다.
즉, 힙의 크기나 객체 수가 증가하더라도 일시 정지 시간이 길어지지 않습니다.
예를 들어, ZGC는 JDK 13 이후 최대 16TB의 힙에서도 수집 과정 중 일시 정지 시간이 10밀리초를 넘지 않습니다. 이는 기존 GC 설계로는 도달하기 어려운 수준으로, ZGC와 새넌도어는 저지연 가비지 컬렉터로 분류됩니다.
세넌 도어의 배경과 현실
책에서 소개된 다양한 가비지 컬렉터 중 세넌도어는 가장 독특한 배경을 가진 컬렉터입니다. 세넌도어는 오라클 내부가 아닌 외부 팀(레드햇 등)이 주도적으로 개발한 GC로, JDK 내부 팀의 주도 없이 독자적으로 개발되었습니다. 이러한 배경 때문에 처음부터 JDK의 공식 가비지 컬렉터로 채택되기 어려웠습니다.
오라클은 JDK 12 시점에 세넌도어를 공식적으로 지원하지 않겠다는 입장을 밝힌 바 있으며, JDK 21부터는 아예 지원 목록에서 제외되었습니다. 이후로도 오라클이 세넌도어를 다시 지원할 가능성은 낮아 보인다. 실제로 JDK 패키지 내에서는 세넌도어 코드가 점차 제거되고 있습니다.
세넌 도어와 오라클과의 관계
세넌도어는 오라클의 JDK에는 포함되어 있지 않으며, 레드햇이나 아마존 등 OpenJDK를 사용하는 다른 기업들의 JDK에만 존재하는 컬렉터입니다. ‘무료 오픈 소스’ 버전과 ‘유료 상용’ 버전 사이에 기능 차이가 점차 벌어지는 상황에서, 세넌도어는 유료 지원 없이도 사용할 수 있는 대안으로 선택됩니다.
원래 세넌도어는 레드햇이 독립적으로 시작한 프로젝트였습니다. 이후 2014년에 OpenJDK에 기증되었고, JDK 12부터 공식 기능으로 포함되게끔 지원되었습니다(JEP 189). 세넌도어의 가장 큰 목표는 힙의 크기와 상관없이 GC 일시 정지를 10밀리초 이내로 줄이는 것입니다.
이 목표를 달성하기 위해 세넌도어는 CMS나 G1과 달리, 객체 회수 후 마무리 작업까지 사용자 스레드와 동시에 수행되도록 설계되었습니다.
코드 통합과 G1과의 차이점
세넌도어는 코드 수준에서도 G1과 비슷한 점이 많아 G1 컬렉터에 새로 추가된 기능을 세넌도어에 쉽게 적용할 수 있습니다. 예를 들어, G1에서 발생한 동시 실패 회복 로직을 멀티스레드 기반으로 처리하는 방식이 세넌도어에도 자연스럽게 통합될 수 있었습니다.
그렇다면, G1대비 세넌도어의 개선된 점은 무엇일까요?
G1 대비 세넌도어의 개선점
G1대비 세넌도어의 대표적인 개선점은 다음 세 가지가 있습니다.
- 동시 모으기 지원
- G1도 여러 스레드를 통해 모으기 단계를 병렬로 수행하지만, 사용자 스레드와 동시에 실행하지는 못합니다. 반면, 세넌도어는 이 과정을 사용자 스레드와 병행 수행할 수 있어 지연 시간이 훨씬 짧습니다.
- 세대 구분 없음
- JDK21 부터 세넌도어는 세대 구분 없이 전체 힙을 대상으로 GC를 수행합니다. 이 방식은 구조를 단순화하고 예측 가능한 시간을 확보하는 데에 유리합니다.
- 거대한 리전(Region)처리 최적화
- 새넌도어는 G1보다 리전을 더 쪼개어 처리하고, 큰 객체가 들어가는 거대 리전(huge region)에 대한 특별 처리를 지원합니다. 기본적으로 먼저 큰 리전을 회수해 전체 회수 시간을 줄이도록 설계되어 있습니다.
리전 기반 구조와 연결 행렬
세년도어는 G1과 다르게 리전을 세대별로 구분하지 않습니다. 이는 단지 세대 단위 컬렉션을 사용하지 않는다는 의미일 뿐이며, 세대 구분 자체가 불필요하다는 뜻이 아닙니다. 복잡도와 일정의 우선순위를 고려한 설계일 가능성이 큽니다.
또한 세넌도어는 메모리와 컴퓨팅 자원을 많이 사용하는 GC에서 성능을 개선하기 위해, 리전 간 참조 관계를 별도의 '연결 행렬'로 관리합니다. 이 방식은 메모리 사용량과 GC 성능에 직결되는 참조 정보 관리 비용을 줄여주고, 거짓 공유와 같은 문제 발생 가능성도 줄입니다.
연결 행렬
연결 행렬은 리전 간 참조 관계를 2차원 표로 관리합니다. 예를 들어, 리전 5의 객체 A가 리전 3의 객체 B를 참조하고, B는 다시 리전 1의 객체 C를 참조한다고 가정하면, 행렬의 5행 3열과 3행 1열에 표시가 됩니다. 이후 GC는 이 표를 기반으로 어떤 다른 리전을 참조하는지를 빠르게 파악할 수 있습니다.
이러한 구조는 GC가 불필요한 리전을 스캔하지 않게 도와주기 때문에 성능 최적화에 크게 기여합니다.
그렇다면, 세넌 도어의 동작 방식은 어떻게 될까요?
새넌도어의 동작 방식
세넌도어의 GC 수행 과정은 여러 단계로 나뉘며, 대략 9단계로 나눌 수 있습니다.
- 최초 표시(Mark Start)
- G1처럼 GC 루트에서 직접 참조되는 객체부터 표시하는 단계입니다. 이 과정은 여전히 Stop the World 상황이 발생하지만, 정지 시간은 매우 짧고, 힙 크기와 관계 없이 오직 GC 루트의 개수에만 영향을 받습니다. 따라서 지연 시간이 거의 일정합니다.
- 동시 표시(Concurrent Mark)
- G1처럼 객체 그래프를 따라 힙을 탐색하며 도달 가능한 객체들을 모두 표시하는 단계입니다. 이 작업은 사용자 스레드와 동시에 수행되며, 실행 시간은 살아 있는 객체 수와 그래프의 복잡도에 따라 달라집니다. 사용자 스레드가 계속 객체를 생성할 수 있기 때문에 힙 사용량이 일시적으로 늘어날 수 있습니다.
- 최종 표시(Final Mark)
- 표시 작업을 마무리하는 단계로, GC 루트 집합을 다시 스캔하여 놓치 객체가 없는지 점검합니다. 이 과정에서 회수 가치가 큰 리전들을 우선 추려 회수 대상 리스트를 생성합니다. 짧은 정지 시간이 존재하지만, 지연 시간은 매우 제한적입니다.
- 동시 청소(Concurrent Cleanup)
- 살아 있는 객체가 전혀 없는 리전을 즉시 정리합니다. 이로써 사용하지 않는 공간을 빠르게 확보할 수 있습니다.
- 동시 이주(Concurrent Evacation)
- 동시 이주는 기존의 CMS나 G1과 차별되는 핵심 기능입니다. 회수 대상 리전 안에 있는 살아 있는 객체들을 빈 리전으로 옮기는데, 사용자 스레드가 멈추지 않고도 이 작업을 수행할 수 있습니다. 다만, 객체 이동 중에도 사용자 스레드가 계속 해당 객체를 읽고 쓸 수 있어야 하므로, 매우 정교한 동기화가 필요합니다. 이를 위해 새넌도어는 읽기 장벽(read barrier)와 포워딩 포인터(forwarding pointer)를 활용하여, 이동 중인 객체의 위치를 정확히 추적하고 참조 관계를 수정합니다.
- 최초 참조 갱신(Initial Update Reference)
- 객체를 복사한 다음, 힙에 존재하는 모든 참조 정보를 새로운 주소로 바꾸는 작업입니다. 해당 단계에서는 실제 갱신을 수행하지 않으며, 대신 쓰레드 간 동기화를 통해 동시 이주가 끝났음을 보장합니다. 이때도 사용자 스레드는 중단되지 않습니다.
- 동시 참조 갱신(Concurrent Update Reference)
- 실제로 참조를 갱신하는 단계입니다. 사용자 스레드와 동시에 실행되며, 메모리에 존재하는 참조 개수에 따라 시간이 달라집니다. 객체 그래프를 탐색할 필요 없이 메모리 주소 순서대로 스캔하면서 기본 참조를 새로운 주소로 바꿉니다.
- 최종 참조 갱신
- 힙 내 객체들이 새로운 주소로 이동한 이후, GC 루트들이 참조하고 있는 주소 역시 최신 주소로 갱신해야 합니다. 이는 세넌도어의 마지막 Stop-the-world 단계이며, 정지 시간은 GC 루트 수에 따라 결정됩니다.
- 동시 청소
- 객체 이주와 참조 갱신이 모두 완료되면, 회수 대상 리전에는 더 이상 살아있는 객체가 존재하지 않게 됩니다. 이때 새로운 객체를 수용할 수 있도록 해당 리전을 다시 청소합니다.
Q. 객체를 빈 리전으로 이주 시키는 것은 무엇인가요?
객체를 빈 리전으로 이주시킨다는 것은 단순히 데이터를 복사하는 것뿐만 아니라, 객체가 위치한 메모리 주소가 바뀐다는 의미입니다.
즉, 다음과 같은 두 가지 작업이 포함됩니다.
첫째, 객체 데이터를 새 리전(빈 공간)에 복사합니다. 기존 리전에 있던 객체의 데이터를 동일하게 새로운 리전에 복제하는 작업입니다.
둘째, 해당 객체를 참조하고 있던 모든 주소값을 새 주소로 갱신해야 합니다. 기존에 그 객체를 참조하던 다른 객체나 사용자 스레드가 여전히 옛 주소값을 들고 있을 수 있기 때문에, 이 참조를 안전하게 새 주소로 유도해야 합니다.
이 과정에서 두 가지 핵심 기술이 사용됩니다.
포워딩 포인터(Forwarding Pointer)는 옛 객체에 “이 객체는 이동했어, 새로운 위치는 여기야” 라는 정보를 남기는 역할을 합니다. 누군가가 이전 주소로 접근하려 하면, 포워딩 포인터가 새 주소로 안내합니다.
읽기 장벽(Read Barrier)은 객체를 참조할 때마다, 현재 참조 중인 주소가 유효한지 확인하는 검사를 거치는 기술입니다. 포워딩 포인터가 있으면 이를 따라가 새 주소를 참조하게 합니다.
즉, 객체를 이주한다는 것은 객체를 복사하고 관련된 모든 참조를 새로운 주소로 전환한다는 뜻이며, GC가 동작 중인 와중에도 스레드가 안정적으로 새로운 주소를 찾아갈 수 있도록 하기 위해 이러한 동기화 기법이 필수적입니다. 세넌도어 GC는 이 부분에서 강력한 기술력을 발휘합니다.
Q. 최초 참조 갱신과 동시 참조 갱신의 차이점은 무엇인가요?
최초 참조 갱신 (Initial Update References)
객체를 복사한 직후, 즉 복사 완료 후에 한 번만 참조를 갱신합니다.
최초 참조 갱신의 동작 방식은 아래와 같습니다.
- GC 스레드가 객체를 새 주소로 복사합니다.
- 복사가 끝난 후, 기존에 참조하고 있던 모든 포인터(주소값)를 새 주소로 한 번에 갱신합니다. (이 시점에서 사용자 스레드는 일시적으로 정지(stop-the-world)될 수 있습니다.)
최초 참조 갱신의 장점과 단점은 아래와 같습니다.
- 장점: 참조 갱신이 한 번만 수행되므로 성능적으로 효율적입니다.
- 단점: 모든 참조를 한꺼번에 갱신해야 하기 때문에, 사용자 스레드를 멈춰야 할 가능성이 큽니다.
동시 참조 갱신 (Concurrent Update References)
객체가 참조될 때마다, 실시간으로 참조를 새 주소로 갱신합니다.
동시 참조 갱신의 동작 방식은 아래와 같습니다.
- 객체를 참조할 때마다 Read Barrier를 통해 해당 주소가 이동되었는지를 확인합니다.
- 이동된 경우, 포워딩 포인터를 통해 새 주소를 참조하도록 즉시 갱신합니다.
동시 참조 갱신의 장점과 단점은 아래와 같습니다.
- 장점: stop-the-world 시간이 거의 발생하지 않아 애플리케이션 지연이 최소화됩니다.
- 단점: 참조를 읽을 때마다 확인 작업이 필요하므로, 실행 중 오버헤드가 발생할 수 있습니다.
Q. 최초 참조 갱신과 동시 참조 갱신 두 방식이 모두 필요한 이유가 뭔가요?
최초 참조 갱신은 모든 참조를 한 번에 갱신해 정확성을 보장하지만, Stop-the-World가 필요합니다. 반면 동시 참조 갱신은 애플리케이션을 멈추지 않고 참조를 실시간으로 갱신할 수 있지만, 모든 참조를 완전히 갱신하지 못할 수 있습니다. 따라서 두 방식은 서로를 보완하며 함께 사용되어야 정확성과 성능을 모두 확보할 수 있기 때문입니다.
동시 이주의 핵심, 포워딩 포인터
Shenandoah가 객체를 동시에 이주할 수 있도록 해주는 핵심 개념 중 하나가 바로 포워딩 포인터(Forwarding Pointer)입니다. 이 개념을 제안한 사람의 이름을 따서 ‘브룩스 포인터(Brooks Pointer)’라고도 부릅니다.
기존의 방식에서는 원래 객체의 메모리 주소에 메모리 보호 트랩(memory trap)을 설정해, 사용자 프로그램이 그 주소를 접근하면 예외를 발생시켜 객체를 복사한 다음 사용하게 만드는 방식이었습니다. 하지만 이 방식은 운영 체제의 특별한 지원이나 사용자 커널 전환이 필요해 비용이 많이 들었습니다.
이에 반해 브룩스는 추가적인 메모리 보호 없이도 객체를 이동할 수 있는 새로운 방법을 제안했습니다. 그 핵심은 객체의 레이아웃 구조 상단에 참조 필드(포워딩 포인터)를 하나 추가하는 것입니다.
- 이 포인터는 객체가 이동된 경우 “새로운 주소는 여깁니다”라고 알려주는 역할을 합니다.
- 만약 객체가 이동되지 않았다면, 해당 필드는 자기 자신을 가리킵니다.
포워딩 포인터의 구조적 특징과 주의점
포워딩 포인터는 자바 가상 머신의 일부 구현 방식에서 사용되는 핸들 방식(handle-based access)과 구조적으로 유사합니다. 둘 다 객체 접근을 우회하는 방식이라는 점은 같지만, 차이점은 핸들은 여러 개를 핸들 풀에 모아 두는 반면, 포워딩 포인터는 각 객체의 헤더 내부에 위치한다는 점입니다.
이 방식은 간단하고 효율적인 장점이 있지만, 단점도 존재합니다.
우회 접근 방식은 비록 성능에 큰 영향을 주지 않을 수 있지만, 비트 단위로 최적화된 코드 수준에서는 명령어 하나가 추가되는 것도 전체 성능에 영향을 줄 수 있습니다.
즉, 객체 하나하나의 필드 접근이 포워딩 포인터를 거치게 되면 전체 프로그램의 오버헤드가 누적될 수 있습니다.
예시 명령어 mov r13, QWORD PTR [r12 + r14 * 8 - 0x8] 처럼, 객체를 메모리에서 참조할 때 우회 접근이 필요하다는 점은 분명한 비용입니다. 하지만, 이 방식의 장점은 포인터 한 개만 바꾸면 전체 참조를 수정할 수 있다는 것입니다.
즉, 예전 객체의 포워딩 포인터만 새 객체를 가리키게 수정하면, 기존 주소를 참조하던 코드도 자동으로 새로운 객체를 바라보게 됩니다.
이 방식은 GC 스레드와 사용자 스레드 간의 경쟁 상황을 초래할 수 있습니다.
예를 들어, GC 스레드가 객체를 이동시키는 동안 사용자 스레드도 해당 객체를 접근한다면, 동시에 필드를 수정하거나 읽는 작업이 충돌할 수 있기 때문입니다.
데이터 일관성을 유지하려면 이런 경쟁 조건에 대한 적절한 동기화 처리가 필요합니다.
동시 이주 중 발생 가능한 경쟁 상황과 방어 방법
GC가 객체를 복사하고 있는 동안 동시에 사용자 스레드가 해당 객체에 접근해 값을 수정하거나 읽으려 한다면 문제가 발생할 수 있습니다. 다음은 이와 관련한 세 가지 동시 수행 작업 시나리오입니다.
- GC 스레드가 객체의 복사본을 생성합니다.
- 사용자 스레드가 객체의 필드를 읽거나 씁니다.
- GC 스레드가 옛 객체의 포워딩 포인터를 새 객체 주소로 변경합니다.
이때, 아무런 동기화 장치 없이 2번 작업이 1번과 3번 사이에서 실행되면 사용자 스레드는 옛 객체에 접근한 채 값을 바꾸게 되는 상황이 발생합니다. 이를 방지하기 위해 포워딩 포인터 접근은 동기화해야 합니다. 즉, GC 스레드와 사용자 스레드 중 하나만 포워딩 포인터에 접근 가능하고, 나머지는 그 순서를 기다려야 합니다. 이를 위해 Shenandoah GC는 CAS(compare-and-swap) 기법을 활용하여 안전하게 객체 접근 충돌을 방지합니다.
실행 빈도와 성능 이슈
포워딩 포인터와 관련된 또 다른 주의점은 ‘실행 빈도’입니다.
앞서 말했듯 객체 헤더에 추가된 포워딩 포인터 덕분에, 동시 이주 중에도 GC와 사용자 스레드가 서로 다른 객체를 안전하게 사용할 수 있습니다. 그러나 여기서 ‘객체 접근’이라는 개념은 단순히 필드를 읽는 것 이상입니다.
예를 들어 ‘객체를 비교한다’, ‘객체의 해시 값을 계산한다’, ‘객체 락을 건다’ 등 다양한 연산이 모두 객체 접근으로 분류됩니다. 이처럼 객체 접근은 코드 전체에서 빈번히 발생하기 때문에, 이를 안전하게 수행하려면 읽기 장벽(Read Barrier)과 쓰기 장벽(Write Barrier)을 함께 적용해야 합니다.
기존 GC들과의 차이
다른 GC들은 카드 테이블 같은 구조를 만들어 동시 표시를 구현하며 많은 쓰기 장벽을 사용했습니다. 세넌도어 GC는 이 접근 방식을 유지하면서, 여기에 포워딩 포인터를 위한 추가적인 읽기 장벽까지 포함했습니다.
다만, 일반적으로 코드는 객체를 읽는 경우가 훨씬 많기 때문에, 읽기 장벽의 성능 부담이 훨씬 큽니다.
지속적인 개선
로드 참조 방벽
새넌도어 GC는 읽기 장벽을 사용하는 최초의 GC입니다.
하지만, 이 장벽으로 인해 성능 오버헤드가 생기는 문제가 발생합니다. 이 문제를 개선하기 위해서 JDK13부터 '로드 참조 장벽'이라는 새로운 방식이 도입됩니다.
이 장벽은 객체의 참조 타입 데이터를 읽거나 쓸 때만 메모리 장벽을 설정하는 방식입니다.
즉, int, float 같은 원시 타입 필드는 장벽 없이 접근 가능하고, 참조 타입 필드만 장벽을 적용해 오버헤드 비용을 줄일 수 있습니다.
이 방식 덕분에 객체 비교, 락, 원시 필드 작업 등에서 불필요한 장벽을 생략할 수 있게 되어 성능이 향상됩니다.
추가로 JDK 14에서는 ‘자가 수리 장벽(self-fixing barrier)’이 도입되어, GC 루트 처리와 클래스 언로딩을 동시에 실행하는 방식으로 최종 정지 구간(STW)까지 최적화했습니다.
포워딩 포인터를 객체 헤더에 통합
JDK 13부터 포워딩 포인터를 객체 헤더 안으로 통합하는 개선도 이루어졌습니다.
원래 객체 헤더는 다음과 같은 구조로 구성됩니다:
- 마크 워드 (mark word)
- 클래스 워드 (class word)
- 배열일 경우 배열 길이
마크 워드는 락 플래그 등 다양한 정보를 담고 있는데, 그 마지막 2비트는 락 상태를 나타냅니다.
그 중에서 0b11이라는 값은 특별한 용도가 정의되어 있지 않다는 점을 활용하여, 마크 워드를 포워딩 포인터로도 사용할 수 있게 만든 것입니다.
이 방식 덕분에, 별도의 포워딩 포인터 공간 없이도 객체 이동 정보를 관리할 수 있게 되어, 메모리 구조가 간결해지고 성능도 좋아졌습니다.
실제로, 포트 포워딩 포인터로 인해 다른 GC보다 메모리를 약 5% ~ 10%정도 더 사용하고 있었습니다.
포워딩 포인터 통합의 이점은 다음과 같습니다.
- 같은 공간에 더 많은 객체를 담을 수 있어 GC 수행 횟수가 줄어들었습니다.
- CPU 캐시에 더 많은 객체를 담을 수 있어 캐시 적중률이 높아집니다.
- 다른 GC들과 객체 복사 코드 공유가 쉬워져서 코드 구조 단순화됩니다.
이러한 개선 덕분에 세넌도어 GC는 GC에 민감한 벤치마크 상황에서 10 ~ 15% 성능 향상을 보입니다.
스택 워터마크를 활용한 스레드 스택 동시 처리
JDK 17에서는 스레드 스택을 동시에 처리하는 기능이 추가되었습니다.
자바의 각 스레드는 스택을 가지고 있으며, 그 안에는 현재 실행 중인 메서드의 정보, 지역 변수, 참조 등이 저장되어 있습니다.
GC는 이 스택 안의 참조들도 루트 객체로 간주하고 스캔해야 하며, 이를 통해 도달 가능한 객체인지 판단합니다.
기존 방식은 GC가 시작되면 모든 스레드를 일시 정지(STW)시키고, 각 스레드의 스택을 전부 스캔해서 GC 루트로 사용할 참조를 수집했습니다. 이 방식은 스레드 수가 많거나 스택이 깊으면, GC 정지 시간이 길어지는 문제가 발생합니다.
개선된 방식으로는 스택 워터마크를 활용한 방식으로 개선되었습니다.
스레드를 완전히 멈추지 않고, 스택의 안전한 지점까지만 일시 정지시킨 뒤, GC 스레드가 그 아래부터 점진적으로 스택을 스캔합니다.
동작 방식
- 모든 스레드의 최상위 스택 프레임에 워터마크를 설정합니다.(이 최상위 프레임은 현재 실행 중인 메서드에 해당되며, 변화 가능성이 있는 영역입니다.)
- GC 스레드는 워터마크 아래 스택 프레임만 스캔합니다.(사용자 스레드와 동시에 동작 가능합니다.)
- 사용자 스레드가 프레임을 빠져나가면, 워터마크가 새 위치로 이동하며 다음 구간을 GC가 스캔합니다.
- 반복적으로 처리합니다.(사용자 스레드의 실행과 GC 스레드의 스캔이 병행되며, 전체 스택을 안전하게 점진 처리)
참조
- JVM 밑바닥까지 파헤치기
'Java' 카테고리의 다른 글
[JVM] 핫스팟 VM에서의 객체 돌아보기 (0) | 2025.04.16 |
---|---|
[JVM] OutOfMemory 예외 (0) | 2025.04.15 |
리플렉션(Reflection) (0) | 2025.03.07 |
ArrayList가 크기를 늘리는 방법 (0) | 2025.02.08 |
[Java] Serialization (2) | 2025.01.27 |