Chapter 3. Garbage Collector & Memory Allocation Strategy (2/2)
Last updated
Last updated
클래식 컬렉터들과 오늘날의 컬렉터들의 가장 큰 특징은, 신세대용, 구세대용 구분이 사라졌다는 점입니다. 즉, 어떤 조합이 최선일까 하는 고민을 하지 않아도 됩니다.
시리얼 컬렉터: 시리얼 올드를 흡수
페러렐: PS와 페럴렐 올드가 합쳐짐
페러렐 컬렉터: PS 컬렉터에서 옮겨감
G1: CMS 자리를 차지
핫스팟의 가비지 컬렉터는 시리얼 -> CMS -> G1으로 발전해왔습니다.
JDK 21부터는 세대 구분 ZGC라고 하여, ZGC에 세대 구분 모드가 추가 되었습니다. 앞으로는 세대 구분 모드가 기본 설정으로 될 예정입니다.
불가능의 삼각 정리(impossible trinity. 가비지 컬렉터의 성능 측정 지표):
처리량
지연 시간
메모리 사용량
보통 세개 중 두가지만 달성 가능합니다. 세개는 불가능 할지도..
다만, 하드웨어 발전에 따라 메모리 사용량은 큰 문제가 되지 않습니다. 또한, 하드웨어 성능은 소프트웨어 시스템의 처리량에 직별되며, 사양 좋은 하드웨어를 쓰면 처리량이 늘어나게 됩니다.
셰넌도어와 ZGC는 거의 모든 과정이 동시에 수행됩니다. 최초, 최종 표시에만 정지가 짧게 일어나고, 이 시간은 고정적입니다. (힙 크기와 객체 수가 많아지더라도 영향을 주지 않음)
(셰넌도어는 오라클 JDK(유료)에는 없고, OpenJDK(오픈소스)에서만 존재)
개선 사항
동시 모으기 자원
세대 단위 컬렉터를 사용하지 않음
기억 집합 대신 연결 행렬을 사용하여 참조 관계 기록
연결 행렬은 2차원 표로 이해할 수 있습니다.
객체 A가 B를 참조하고 있으면, (5,3)에 표시를 하고, B가 C를 참조 하고 있으면 (3,1)에 표시를 합니다. 이걸 활용하여 리전 간 참조를 포함하는 리전들을 알아내게 됩니다.
최초 표시 (STW)
가장 먼저 GC 루트에서 직접 참조하는 객체들에 표시합니다.
힙 크기와 상관없이 GC 루트 수에 따라 STW 발생
동시 표시
객체 그래프를 타고 힙을 탐색하며 도달 가능한 모든 객체 표시
사용자 스레드와 동시 수행
사용자 스레드가 새로운 객체를 생성할 수 있으므로 힙 사용량이 늘어날 수 있음
최종 표시 (STW)
보류 중인 모든 표시를 완료하고 GC 루트 집합을 다시 스캔.
회수 가치가 가장 큰 리전들을 추려 회수 집합을 생성
동시 청소
살아있는 객체가 하나도 없는 리전들을 청소
동시 이주 (다른 컬렉터들과의 차이)
회수 집합 안에 살아 있는 객체들을 다른 빈 리전으로 복사.
읽기 장벽과 포워드 포인터를 이용하여 사용자 스레드와 동시 수행
최초 참조 갱신(STW)
힙에서 옛 객체를 가리키는 모든 참조를 복사 후의 새로운 주소로 수정
동시 참조 갱신
참초 갱신을 실제로 수행
수행 시간은 메모리에 존재하는 참조의 수에 따름
물리 메모리 주소의 순서대로 참조 타입을 선형 검색하여 이전 값을 새로운 값으로 수정
최종 참조 갱신(STW)
GC 루트 집합의 참조 갱신
수행 시간은 GC 루트의 개수에 따름
동시 청소
회수 집합의 모든 리전에는 살아있는 객체가 없기떄문에, 회수합니다.
정리:
회수 집합 안에 살아 있는 객체들을 다른 빈 리전으로 복사.
읽기 장벽과 포워드 포인터를 이용하여 사용자 스레드와 동시 수행
메모리 보호 트랩(과거):
사용자 프로그램이 옛 객체가 저장된 메모리 공간에 접근하려 하면 트랩이 발동하여 미리 설정해 둔 예외 처리기가 실행.
그 후, 처리기에서 복사된 새 객체를 사용.
단점으로는, 운영 체제의 지원없이는 사용자, 커널모드 전환이 빈번하여 오버헤드 존재.
포워딩 포인터
각 객체의 헤더 앞에 포인터를 두는 방식(핸들 방식의 핸들 풀에 모아두는 방식과는 다름)
+옛 객체의 포워딩 포인터가 새로운 객체를 가리키도록 수정
-우회하여 접근하기 떄문에 모든 객체에오버헤드 발생.
다만, 포워딩 포인터는 스레드 경쟁이 발생.
GC 스레드가 객체에 복사본 생성
사용자 스레드가 객체의 필드를 덮어씌움
GC 스레드가 엣 객체의 포워딩 포인터 값을 복사본의 주소로 수정.
이 부분은 CAS 기법(낙관적 락)을 사용하여 해결
GC 스레드가복사 중에 사용자가 스레드가 쓰기를 시도할때,
GC 스레드가 복사 중일때
또한, 해시 계산, 객체 비교, 락 사용 상황에 문제가 발생할 수 있음.
해시계산
int hash = obj.hashCode();
obj
가 이주되었는데도, 여전히 예전 객체를 참조하고 있다면?
예전 객체에는 올바른 필드 값이 없거나, 이미 GC가 회수했을 가능성이 있음
객체 비교
if (obj1 == obj2) { }
만약 obj1은 새 주소, obj2는 포워딩 이전의 주소라면? 동일한 객체인데 다르게 인식
락(lock) 사용
synchronized(obj)
락을 거는 대상 객체가 포워딩된 상태면? JVM 내부 락 테이블이나 모니터 정보가 꼬일 수 있음
이 문제는 읽기, 쓰기 장벽으로 해결. 읽기는 빈번하기 때문에, 읽기 장벽을 최대한 가볍게 해야됨.
로드 참조 장벽 도입: 객체 참조 타입의 데이터를 읽거나 쓸 때만 끼어드는 메모리 장벽 모델. (원시 타입은 간섭X)
포워딩 포인터를 객체 헤더에 통합: 마크 워드에서 0b11 플래그는 정의가 되지 않아있음. 이를 포워딩 포인터로 활용.
Mark Word에 0b11
을 설정하고, 나머지 비트에는 새로운 주소(New Location) 를 저장함
참조 형일때만(로드 참조 장벽으로 인해) 읽기 장벽 도입
이로 인해, 포인터가 사용하던 5~10% 메모리를 활용할 수 있음. 결론적으로 10~15% 성능 개
같은 공간에 더 많은 객체를 담을 수 있음
CPU 캐시에 더 많은 객체를 넣어서 캐시히트율을 높임
다른 가비지 컬렉터들과 객체 할당 코드를 공유할 수 있음
스택 워터마크를 활용한 스레드 스택 동시 처리
GC가 시작되면 GC는 모든 스레드의 스택을 스캔하여 참조들을 표시 큐에 담습니다. (안전 지대에서 세운 후 STW)
도달 분석 알고리즘을 통해 참조 객체를 식별하고, 스택안에 참조들이 이동한 객체를 가리키도록 갱신합니다. (이떄도 STW)
이때, 위 과정에서 변화하는 부분은 최상위 스택 프레임뿐 (다른 프레임들은 이미 호출이 끝났거나 다음 호출을 기다리는 중이기 때문). "위"는 동시 실행 중인, 즉 변화 중인 구간이라 워터마크 아래부분만 GC가 건드리게 됩니다. "아래"는 참조가 고정되어 안전하게 스캔 가능.
JDK 15에 정식 버전, JDK 21부터는 신새대와 구세대를 구별하여 처리하는 세대 구분 ZGC가 추가.
동적으로 생성 파괴를 반복.
리전의 크기도 동적으로 달라짐
컬러포인터는 컬러 포인터는 병렬 모으기(parallel compaction) 를 효율적으로 구현하기 위한 테크닉. 읽기 장벽을 사용하여 동시 이주를 구현.
헤더에 필드를 추가하여 추가 데이터를 저장했다. 하지만,
객체가 이동할 수 있는 환경에서 객체로의 접근이 반드시 성공할까?
객체에 직접 접근하지는 않지만 해당 객체와 관련된 데이터가 필요할때는?
포인터가 가리키는 주소가 더 이상 유효하지 않을 수도 있음
객체가 아직 복사 중이거나 참조가 업데이트 안 됐을 수도 있음
이때 두번째, 컬러 포인터는 객체 데이터가 필요할때를 위해 포인터 자체에 소량의 추가 정보를 직접 저장합니다.
컬러 포인터 기술은:
주소 공간을 44 비트까지 제한
상위 4비트를 네 가지 플래그 정보를 저장하는데 이용.
가상 머신은 이 네 가지 플래그들을 통해 포인터만 보고도 객체의 삼색 표시 상태를 바로 알 수 있습니다. (해당 객체가 재매핑 집합에 추가 되었는지(이동), finalize() 메서드를 통해서만 접근할 수 있는지 알 수 있음)
컬러 포인터의 이점:
한 리전 안의 생존 객체들이 이동하면 그 즉시 해당 리전을 재활용할 수 있다. -> 자가 치유때문에 참조들을 모두 수정할 때까지 기다릴 필요가 없다
가비지 컬렉션 과정에서 메모리 장벽의 수를 크게 줄일 수 있다. -> 일부 기록 작업이 필요없어지기 때문
컬러 포인터를 객체 표시 및 재배치와 관련해 더 많은 정보를 담을 수 있는 확장 가능한 저장 구조로 쓸 수 있다.
하지만, 컬러포인터는 자바 가상 머신이 메모리를 가리키는데 쓰는 포인터의 의미를 재정의하고, 운영 체제(프로세서)에서도 재정의된 대로 동작할지에 대한 여부가 문제가 됩니다.
JVM이 포인터에 “컬러”를 붙여도, 이 포인터를 해석하는 운영체제나 프로세서가 그 구조를 이해하지 못하면 문제가 생길 수 있다. 왜냐하면 CPU는 포인터를 그저 “단순한 주소값”으로만 생각
해결책??
동시 표시
동작 과정은 같지만, ZGC의 표시는 객체가 아니라 포인터에서 이루지고, 이때 컬러 포인터의 플래그가 갱신됩니다.
동시 재배치 준비
청소할 리전들을 선정하여 재배치 집합을 만듭니다. ZGC는 모든 리전을 스캔하고, G1처럼 기억 집합을 관리하는 대신, 스캔을 광범위하게 하는 방식을 채택했습니다. 결국 재배치 집합안에 객체들을 다른 리전으로 복사한 후, 리전 자체를 회수할지 여부만 결정하게됩니다. -> 객체가 모두 죽은 리전을 알기때문에 재배치 집합에 없어도 해당 리전 회수 가능.
동시 재배치
재배치 집합 안의 생존 객체들을 새로운 리전으로 복사하는 과정. 이때, 각 리전의 포워드 테이블에 옛 객체와 새 객체의 이주 관계를 기록합니다.
컬러 포인터 덕에, ZGC는 객체가 재배치 집합에 속하는지 참조만 보고 알 수 있습니다. 사용자 스레드가 재배치 집합에 포함된 객체에 처음 접근하면 자가 치유 발생 (<-> 셰넌도어는 매번 접근할때마다 포워드 오버헤드 발생)
동시 재매핑
힙 전체에서 재배치 집합에 있는 옛 객체들을 향하는 참조 전부를 갱신하는 작업. 자가 치유덕에, 굳이 필요가 없는 과정이고, 컬렉션 주기가 시작되는 동시 표시 단계와 통합할 수 있습니다. -> 동시 재매핑(포워딩된 객체의 참조를 새 주소로 갱신하는 단계)에서 힙 메모리를 다시 훑어야하기 때문에 동시 표시(어떤 객체가 살아있는지를 추적하는 단계)에서 힙 메모리를 훑는 과정과 통합이 가능합니다.
G1과 비교했을때, ZGC는:
기억 집합을 사용하지 않습니다 -> 메모리 사용량 감소
쓰기 장벽을 사용하지 않습니다 -> 오버헤드 감소
세대 구분이 없어서, 세대를 구분하기 위한 카드 테이블이 필요 없습니다. -> 메모리 사용량 감소
NUMA 메모리를 고려한 메모리 할당 -> (NUMA 아키텍처가 적용된 환경에서는) 객체 생성을 요청한 스레드가 수행 중인 프로세서의 지역 메모리에 우선적으로 객체를 할당하여 메모리 접근 효율을 높임.
ZGC를 확장하여 신세대, 구세대를 구분하도록 개선. ZGC는항상 모든 객체를 대상으로 회수 작업을 진행해야했다는 점이 단점이었다.
세대간 참조를 효율적으로 추적하기 위해 쓰기 장벽 추가활용
컬러포인터에 새로운 메타데이터를 추가. ->이 데이터는 쓰기 장벽이 발동할때, 현재 값이 써지는 필드가 세대 간 참조를 포함하는지 알 수 있습니다.
읽기 장벽에서 수행하던 도달 가능 객체 표시 작업을 쓰기 장벽으로 옮김.
다중 매핑 메모리 제거
같은 힙 메모리를 세개의 독립된 가상 주소로 매핑
ZGC에서는 컬러포인터가 있지만, 다중 매핑 메모리도 함께 사용했습니다. -> 보조 수단으로 읽기/쓰기 접근 제어, 디버깅/접근 오류 감지/ 리디렉션 전후 안전성 확보
다양한 장벽 최적화
기억 집합 장벽
시작 단계 스냅숏 표시 장벽
쓰기 장벽 버퍼
장벽 패치
이중 버퍼를 이용한 기억 집합 관리
세대간 포인터 관리에 기억 집합을 이용하지만, 카드 테이블을 활용하여, 해당 범위에 더렵혀졌다고 표시.
세대 구분 ZGC는 비트맵을 활용해서, 범위가 아닌 객체 필드 주소를 매핑.
구세대 리전 각각 두개의 집합 비트맵을 사용(사용자 스레드 수정용, GC 조회용).
결과적으로 사용자 스레드와 GC스레드는 서로 신경 쓰지 않고 각자 할일을 하게됨.
밀집도 기반 리전 처리
"ZGC는 ‘GC로 인한 지연(latency)’을 줄이기 위해, 마킹(marking)을 무조건 끝까지 하지 않고, 상황에 따라 중간에 멈추기도 한다." (GC 스로틀링)
신세대에서 객체를 재배치할 때 살아 있는 객체 수는, 최근에 할당된 리전이라면 더 많이 살아 있을 가능성이 큼. -> 리전에 더 많은 객체가 있을 수록, 해당 리전내에 객체가 살아있다 표시할 가능성이 높아짐.
밀도가 낮은 리전은 살아있는 객체가 있을 확률이 더 적으므로, 먼저 마킹.
살아있는 객체가 많으면 복사 과정이 더 오래 걸림
When relocating objects out of the young generation, the number of live objects and the amount of memory they occupy will differ across regions. For example, more-recently allocated regions will likely contain more live objects.
ZGC analyzes the density of young-generation regions in order to determine which regions are worth evacuating and which regions are either too full or too expensive to evacuate. The regions that are not selected for evacuation are aged in place: Their objects remain at their locations and the regions are either kept in the young generation as survivor regions or promoted into the old generation. The objects in the surviving regions get a second chance to die in the hope that, by the time the next young-generation collection starts, enough objects will have died to make more of these regions eligible for evacuation.
This method of aging dense regions in place decreases the effort required to collect the young generation.
거대 객체 처리
세대 구분 ZGC에서는 거대한 객체는 무조건 신세대에 할당합니다.
살아남은 거대한 객체를 구세대로 재배치하지 않고, 리전 자체를 노화시킬 수 있습니다.
(p73, 74)