가비지 컬렉션 핵심 개념

블러거·2026년 1월 2일

JVM

목록 보기
6/26

아래의 6가지 방법은 Jvm 이 GC 를 효율적으로 실행하기 위한 대표적인 방법이다.

루트 노드 열거 - oopMap

루트 노드 열거는 도달 가능성 분석 알고리즘에서 GC Root 를 찾는 것을 말한다.
이 과정에서 GC Root 의 참조가 바뀌면 안되므로, Stop-The-World 를 발생시킨다. 따라서 이 과정이 길면 길수록 성능에 안좋은 영향을 끼친다.

루트 노드에서 참조 체인을 찾아가며 객체 그래프를 탐색하는건 유저 스레드를 멈추지 않아도 되지만, 이 루트 노드 열거에서만큼은 모든 유저 스레드가 정지해야한다.

GC Root 는 참조타입 이다. 참조타입을 찾기 위해 거대한 힙과 메서드 영역 메모리를 차근차근 찾아갈 때, 기본타입이 저장된 메모리는 아무 쓸모가 없다. 메모리상 참조 타입만을 빠르게 얻어낼 수 있는 방법이 있다면 효율적으로 루트 노드의 열거가 가능 할 것이다.

OopMap 은 이 해결책이다. 메모리상 참조타입의 위치를 저장해둔다. 공간을 소비하여 시간을 얻는 것이다. OopMap 은 아래와 같이 저장된다.

ImmutableOopMap{ebx=oop [16]=oop off=142}

hsdis 가 설치된 jdk 에서 -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly 를 하면 로그로 볼 수 있다.

GC Root 는 대부분 정적 변수 또는 스택에 존재하므로 이 두 공간에서 OopMap 을 관리한다.

  1. 메서드 영역 : 클래스 정보의 참조타입은 연속적인 메모리 공간에서 클래스 로딩시에 위치가 확정된다. 이 참조들을 저장한다.
  2. 스택 : JIT 컴파일 과정에서 메서드에 생기는 참조들을 OopMap 에 저장한다. 메서드에서 객체를 계속 생성한다면 컴파일된 코드 상에서도 참조타입도 계속해서 생겨날것이다. 따라서 이론상 명령어 실행마다 OopMap 을 만들어야 한다.
    • 스택의 경우 참조타입은 스택에 저장되어 있을 수도 있고, 레지스터에 올라가 있을수도 있다. 따라서 oopMap 에는 위 예시처럼 참조타입이 있는 스택의 위치 또는 레지스터가 저장된다.

가비지 컬렉터는 GC 를 해야할 시점에 모든 메모리를 스캔하는 대신에, oopMap 을 보고서 GC Root 를 빠르게 얻을 수 있다.

안전 지점

스택(메서드) 에는 OopMap 의 내용을 수정할 수 있는 명령어가 너무 많다. 이 모든 명령어에서 OopMap 을 저장한다면, 공간 오버헤드가 비효율적이다.

핫스팟은 안전 지점(safe point) 라고 불리는 특정 지점에만 OopMap 을 기록한다. 가비지 컬렉터는 안전 지점에서만 OopMap 을 읽을 수 있다.

가비지 컬렉터는 사용자 스레드들이 안전 지점에 도달할때까지 기다렸다가 스레드를 멈춘다. 모든 스레드가 안전 지점에서 멈추면, 그 때 가비지 컬렉션을 실행한다.

안전 지점을 만나기까지 너무 오랜 시간이 걸린다면 GC 를 재때 실행할 수 없어 메모리가 가득찰 수 있고, 안전 지점이 너무 많다면 Stop The World 가 너무 자주 일어나 퍼포먼스에 문제가 생길 수 있다. 따라서 안전 지점을 정하는 적절한 규칙이 필요하다.
안전 지점의 위치를 선택하는 기본적적인 기준은 프로그램이 장시간 실행될 가능성이 있는가 이다.

  1. 메서드 호출
  2. 반복문의 끝
  3. 예외 처리 위치

각 스레드가 멈추는 방법에는 두가지가 있다.
선제적 멈춤(preemptive suspension) 은 가비지 컬렉션이 실행되면 시스템이 모든 유저 스레드에게 인터럽트 신호를 날린다. 스레드가 안전 지점이 아니라면 계속해서 실행하고, 시스템이 인터럽트를 날리고를 반복한다.
자발적 멈춤(voluntary suspension) 은 스레드 자신이 멈춤을 제어한다. 가비지 컬렉션이 일어나야함을 나타내는 flag 를 두고, 각 스레드는 이 flag 를 폴링한다. flag 가 true 라면 각 스레드는 가장 가까운 안전 지점에서 스레드를 멈춘다.

멈춰야하는가를 poll 하는 작업은 자주 일어나므로 효율적이어야 한다.
jvm 은 어셈블리 명령어 하나만으로 polling 후

JNI 함수는 네이티브 함수이므로 OopMap 에 대한 정보가 없다. HotSpot 은 JNI 메서드 호출시 특별한 핸들러를 래핑/언래핑한다. JNI 함수 호출이 느린 이유 중 하나다.
https://blog.csdn.net/dyingstarAAA/article/details/88559806

안전 지역

안전 지역은 안전 지점을 보완하는 개념이다.
만약 스레드가 프로세서를 할당받지 못해 실행중이지 않다면, 안전 지점까지 실행 후 멈추는 동작을 수행할 수 없다.

따라서 참조 관계가 변하지 않는 부분을 안전 지역으로 정하고, 사용자 스레드는 이 영역 안에 진입한지 여부를 기록한다.

가비지 컬렉터는 이 영역 안이라고 표시된 스레드들은 신경쓰지 않는다. 안전 지역을 벗어날 때에는 스레드가 안전 지역을 벗어나도 괜찮은지(루트 노드 열거같은 사용자 스레드를 멈춰야 하는 작업이 끝났는지) 확인한다.

-Xlog:safepoint 로 safepoint 에서 멈춘 시간을 로깅할 수 있다.
그 외에 status 관련 로그만 찍고 싶다면 -Xlog:safepoint+status 로 하면 된다. (+ 는 jvm 옵션에서 교집합)
https://docs.oracle.com/javase/9/tools/java.htm#GUID-BE93ABDC-999C-4CB5-A88B-1994AAAC74D5__CONVERTGCLOGGINGFLAGSTOXLOG-A5046BD1

기억 집합과 카드 테이블

세대간 참조를 효율적으로 하기 위한 기법이다.
세대 구분 가비지 컬렉션에서 Old Gen 에서 참조하고 있는 Young Gen 객체가 있을 수 있으므로, Young Gen 만 보고 가비지 컬렉션을 하지 못한다. 하지만, 그렇다고 매번 Old Gen 까지 모두 GC Root 에 추가하는건 너무 비용 비효율적이다.

세대 구분 가비지 컬렉션이 아니더라도, 부분 GC 를 하려는 곳이면 어디든 적용되는 내용이다.
기억 집합은 비회수 영역(Minor GC 를 예로 들면 Old Gen) 에서 회수 영역(Minor GC 를 예로 들면 Young Gen) 으로의 참조 포인터를 비회수 영역에 저장해놓은 구조체를 말한다.

하지만 그렇다고 또 비회수 영역(Old Gen) 에서의 참조를 모두 저장해야할까? 예를 들어 100 의 Young Gen 에 대해 각각 참조하는 100 개의 Old Gen 이 있다면 그 100개의 참조를 모두 저장해야 할까? 이는 공간 관리 비용이 너무 높다.

따라서 정밀도를 조정하여, 카드 테이블이라는 방식으로 기억 집합을 구현한다.
카드 테이블은 바이트 배열이며, 값은 0 또는 1 를 갖는다. dirty 한지 안한지를 나타낸다.(세대간 참조가 있는지 없는지)

비트 배열이 아닌 바이트 배열인 이유는 속도 때문이라 한다.(A Fast Write Barrier for Generational Garbage Collectors 논문을 찾아보자)

Old Gen 을 128Bytes 단위로 페이지를 나눠보자. Old Gen 의 시작 주소는 0x1000 이라 가정한다. 그럼 각 페이지의 메모리 범위는 다음과 같다.

카드 테이블 인덱스카드 페이지 메모리 범위
00x1000 ~ 0x107F
10x1080 ~ 0x10FF
20x1100 ~ 0x117F
30x1180 ~ 0x11FF

만약 Old Gen 의 Ox11AA 위치의 객체가 Young Gen 에 세대 참조를 가지게 될 때,
(OldGen 참조 주소 - Old Gen Base 주소) / 페이지 단위 = 카드 테이블 인덱스
계산에 의해 카드테이블의 3 인덱스에 dirty 표시를 하게 된다.

Minor GC 시에는 Old Gen 을 모두 볼 필요 없이 카드 테이블만 봐서 dirty 된 영역의 Old Gen 객체들만 Root GC 에 추가하면 된다.

추가적인 공간과 연산을 필요로 하며 뒤에 나올 쓰기 장벽에 대한 오버헤드도 있다.
하지만 Old Gen 을 모두 스캔하는 비용에 비해선 엄청난 효율을 가져올 수 있다.

쓰기 장벽

세대간 참조가 발생했을때 카드 테이블에 Dirty 를 표시하는 방법.

인터프리터의 경우 JVM 이 해석하는 바이트코드를 실행하기에 끼어들어 실행하면 된다.
반면 JIT 컴파일러에 의해 컴파일된 코드는 순수 기계어다. JVM 이 이를 해석하여 끼어들기는 힘들다.

쓰기 장벽(write barrier) 은 참조 타입에 객체의 대입 전/후로 기계어 수준의 동작을 수행할 수 있게 하는 것이다. 세대간 참조가 객체에 추가되면 쓰기 장벽 후크를 이용하여 카드 테이블의 dirty 를 마킹한다.

읽기 장벽은 CPU 또는 컴파일러가 최적화를 위해 명령어 순서를 바꾸는 것을 막기 위해 사용한다. 동시성 문제와 관련이 있다. 이 토픽에서도 load/store barrier 가 등장하는데, 책에서는 jvm 의 쓰기 장벽과 이 CPU/컴파일러단의 장벽과 구별해서 설명해놓았다.

쓰기 장벽은 결국 카드 테이블 방식을 선택함에 따른 오버헤드다. 위에서도 언급했듯, 그럼에도 불구하고 세대간 참조 문제를 해결하기 위해 Old Gen 을 모두 스캔하는 것 보다 낫다.

쓰기 장벽으로 인해 거짓 공유(False Sharing) 이 발생할 수 있다.
카드 테이블은 모든 스레드가 쓰기에 공유하는 자원이고, 바이트 배열이라 크기도 작아서 CPU 캐시 라인에 같이 올라갈 것이다. 여러 스레드가 카드 테이블에 수정을 가한다면 거짓 공유 현상에 의해 캐시 일관성 프로토콜이 동작하며 성능에 영향을 줄 수 있다.

JVM 은 -XX:+UseCOndCardMark 옵션으로 카드 테이블을 먼저 확인 하여 dirty 되지 않은 경우만 dirty 쓰기를 할 수 있다. 이러면 거짓 공유에 의한 문제는 사라지지만, dirty 되지 않았나 에 대한 연산이 추가된다. 애플리케이션을 실행해보며 튜닝해야 한다.

동시 접근 가능성 분석

도달 가능성 분석 알고리즘에서 루트 노드 열거는 무조건 Stop The World 를 유발한다고 했다. GC Root 는 전체 객체에 비해 양이 적고, OopMap 같은 최적화도 있어서 이 시간은 그리 길지 않다. 또한 힙이 늘어남에 따라 시간이 비약적으로 오래걸리지 않는다.

하지만 GC Root 로 부터 모든 객체 그래프를 탐색하며 살아있는 객체를 마킹하는 작업은 오래걸린다. 또한 힙의 크기와 비례하여 이 시간이 증가한다.

mark-sweep, mark-copy, mark-compact 모든 방식에서 mark 는 공통 부분이므로 이 작업 때문에 유저 스레드를 멈추는 것을 막는 것은 모든 컬렉터의 공통적인 목표다.

GC Root 로부터 객체 그래프 탐색을 하는 도중 유저 스레드에 의해 참조가 변경되어 버리면 다음 두가지 경우가 생길 수 있다.

1. 죽은 객체를 살았다고 잘못 표시
2. 산 객체를 죽었다고 잘못 표시

1번의 경우 다음 GC 때 정리될 것이기에 크게 문제가 안된다.
반면 2번은 해당 객체를 참조중인 객체를 포함해서 큰 문제가 된다.

산 객체를 죽었다고 잘못 표시할 수 있는 경우는 다음 두가지 동작이 동시에 일어날 때 이다.
Tri-Color-Marking 알고리즘

1. 사용자 스레드가 검은색 객체에서 흰색 객체로의 새로운 참조를 추가
	-> 이미 검은색으로 마킹한 객체라 새로 생긴 흰색 객체로의 참조에 대해 다시 검사할 수 없음.
2. 사용자 스레드가 회색 객체에서 흰색 객체로의 직간접적인 참조를 삭제
	-> 원래는 참조가 있어서 검정색으로 살아있어야 하는 객체인데 유저 스레드가 삭제해버려서 없어진 흰색에 대해
    검은색 객체로 부터 새로운 참조가 생길 수 있다.
    -> 회색에서 아직 검색되지 않은 흰색으로의 삭제와 검은색에서 흰색으로 참조의 생성이 동시에 일어나는 경우
    -> 마찬가지로 검은색은 이미 마킹이 끝나서 흰색으로의 참조를 어떻게 할 수 없다.

(그림으로 보는 시나리오는 shipilev 의 pdf 참조)

따라서 이 두 조건중 하나만 깰 수 있다면 사용자 스레드와 GC Root 로부터의 객체 탐색은 동시에 실행될 수 있다. 아래 두가지 중 하나의 방법을 사용하면 된다.

1. 증분 업데이트 : 1번 case 를 깨는 방법
2. 시작 단계 스냅샷 : 2번 case 를 깨는 방법

증분 업데이트는 첫번째 케이스를 깨뜨린다. 중간에 새로 생긴 검은색 객체에서 흰색 객체로의 참조를 따로 저장해두고, 스캔이 끝난 후 해당 검은색 객체들을 GC Root 로 해서 다시 탐색한다.

시작 단계 스냅샷은 두번째 케이스를 깨뜨린다. 중간에 유저 스레드에 의해 끊어진 회색 > 흰색으로의 참조가 있다면 바로 끊지 않고 기록 후, 스캔이 끝난 뒤에 회색 객체를 GC Root 로 해서 다시 탐색하며 그 때 회색 > 흰색으로의 참조를 끊어낸다. 이 때에는 검은색에서 검은색으로 바뀐 흰색의 참조가 존재할 것이기 때문에 문제가 해결된다. 첫 스캔때 참조를 끊는 과정을 바로 시작하지 않기에 시작 단계의 상태로 먼저 스캔을 한다는 것에서 스냅샷이라 한다.

CMS 는 증분 업데이트, G1 과 셰넌도어는 시작 단계 스냅샷을 활용한다.


참고
https://blog.csdn.net/weixin_39765162/article/details/129413627
https://blog.csdn.net/dyingstarAAA/article/details/88559806
https://juejin.cn/post/7127951265048625188
https://stackoverflow.com/questions/26029764/what-does-oop-maps-means-in-hotspot-vm-exactly
https://shipilev.net/jvm/anatomy-quarks/22-safepoint-polls/

profile
안녕하세요!

0개의 댓글