본문 바로가기

Java

[JVM 밑바닥까지 파헤치기] 3장 가비지 컬렉터와 메모리 할당 전략

3.6 저지연 가비지 컬렉터 

 

GC를 측정하는 가장 중요한 지표 세 가지

- 처리량

- 지연시간 

- 메모리 사용량 

=> 보통 세 가지를 만족할 수는 없고, 두 가지를 만족시키면 좋은 컬렉터 

 

지연 시간의 중요성 증가

 - 하드웨어의 발전으로 해결된 문제 : 메모리 사용량이 늘어나는 것, 소프트웨어 시스템 처리량

- 하지만 메모리를 늘리면 지연 시간에 악영향 (ex. 힙 메모리 1TB 청소하는데 걸리는 시간과 1GB를 청소할 때 걸리는 시간)

 

동시성 비교

- CMS 등장 전에는 모두 'stop the world' 스레드 정지가 발생

- CMS, G1은 차례로 증분 업데이트와 시작 단계 스냅숏을 통해 표시 단계를 동시에 수행 => 표시 단계 이후의 처리는 제대로 해결 못함

- 셰넌도어와 ZGC는 거의 모든 과정이 동시에 수행, 최초 표시와 최종 표시에서만 일시 정지가 짧게 일어남 => 저지연 가비지 컬렉터

 

3.6장은 저지연 가비지 컬렉터인 셰넌도어, ZGC, 세대 구분 ZGC에 대한 설명

 

 

3.6.1 셰넌도어

G1 계승

- 셰넌도어와 G1은 힙 레이아웃이 비슷하며, 최초 표시와 동시 표시 등 여러 단계의 처리 방식에서 공통점

- 코드 일부를 직접 공유하여 G1의 개선사항이 셰넌도어에 동시에 반영되기도 함

 

G1에서 개선된 사항

1) 가장 중요한 차이 : 동시 모으기 지원

G1은 여러 스레드를 이용해 모으기 단계를 병렬로 수행하지만 사용자 스레드와 동시에 수행할 수는 없다.

 

2) JDK21까지의 셰넌도어는 신세대, 구세대 리전을 구별하지 않는다. 

 

3) 메모리와 컴퓨팅 자원을 많이 사용하는 기억 집합 대신 '연결 행렬'로 리전 간 참조 관계를 기록한다. 

관리 비용도 줄고 거짓 공유문제가 발생할 가능성도 줄었다.

- 리전 N에 리전M을 가리키는 객체가 있다면 이차원 표의 N행 M열에 표시하는 식. 회수 때 다시 표를 참고해서 리전 간 참조를 포함하는 리전을 알아내는 것 

 

셰넌도어 동작 방식 : 9단계

1. 최초 표시 (stop the world)

G1과 동일. GC루트에서 직접 참조하는 객체들에 표시. 매우 짧고 힙 크기와 상관없이 GC루트 수에만 영향을 받음

 

2. 동시 표시

G1과 동일. 객체 그래프를 타고 힙을 탐색, 도달 가능한 모든 객체를 표시. 사용자 스레드와 동시 수행

 

3. 최종 표시 (stop the world)

G1과 동일. 보류중인 모든 표시를 완료하고 회수 가치가 큰 리전을 추려 회수 집합을 생성

 

4. 동시 청소

살아 있는 객체가 하나도 없는 리전을 청소

 

5. 동시 이주 : 셰넌도어의 핵심 특징

회수 집합 안에 살아 있는 객체들을 다른 빈 리전으로 복사

사용자 스레드와 동시에 수행하기 위해서 읽기 장벽과 포워딩 포인터를 이용

실행시간은 회수 집합의 크기에 따라 달라진다.

 

6. 최초 참조 갱신 (stop the world)

동시 이주 단계에서 객체를 복사한 다음, 힙에서 옛 객체를 가리키는 모든 참조를 복사 후의 새로운 주소로 수정

이 단계에서는 모든 GC 스레드와 애플리케이션 스레드가 대피를 완료했는지 확인한다.

 

7. 동시 참조 갱신

참조 갱신을 실제로 시작하며 사용자 스레드와 동시에 수행

수행 시간은 메모리에 존재하는 참조의 수에 영향을 받음

객체 그래프를 탐색할 필요 없이, 물리 메모리 주소의 순서 대로 참조 타입을 선형 검색해서 이전 값을 새로운 값으로 수정

 

8. 최종 참조 갱신 (stop the world)

힙의 참조 갱신이 끝나면 GC루트 집합의 참조도 갱신

 

9. 동시 청소

이주와 참조 갱신이 끝나면 회수 집합의 모든 리전에는 살아있는 객체가 없다. 동시 청소를 다시 수행하여 새로운 객체를 할당할 공간을 확보한다.

 

 

동작 방식 정리

- 가장 중요한 단계는 동시 표시, 동시 이주, 동시 참조 갱신

 

포워딩 포인터 : 동시 이주 핵심

 

동시 이주 구현하기

기존

1. 이동될 객체의 원래 메모리에 메모리 보호 트랩을 설정한다.

2. 사용자 프로그램이 옛 객체가 저장된 메모리 공간에 접근하려 하면 트랩이 발동해서 미리 설정한 예외 처리기가 실행된다.

3. 이 처리기에서 새 객체가 복사 된다.

단점 : 운영 체제의 지원 없이는 사용자 모드와 커널 모드를 수시로 전환해야 해서 비용이 크다. 

브룩스가 제안한 새로운 해법 : 원래의 객체 레이아웃 구조 상단에 참조 필드를 추가 한다.

단점 : 우회하여 객체에 접근하기 때문에 오버헤드 발생

장점 : 포인터 하나의 값만 수정하면 된다.

이동전 위치의 객체가 아직 회수되지 않더라도 기존 참조 통해 자동으로 새로운 객체로 포워딩

구현에서 주의할 점 

1. 스레드 경쟁

2. 실행 빈도 

 

 

- 브룩스 포인터, 간접 포인터라고 부르기도 한다.

- 객체 이동과 사용자 스레드를 동시에 수행하는 방법

- 이동된 신규 객체에 대한 참조를 기존 객체의 포워딩 포인터에 추가한다.

- 기존 객체가 아직 회수되지 않았더라도 포워딩 포인터를 이용해 신규 객체에 포워딩된다.

 

계속되는 개선

 

로드 참조 장벽 도입

포워딩 포인터를 객체 헤더에 통합

스택 워터마크를 활용한 스레드 스택 동시 처리 

 

3.6.2 ZGC

오라클, JDK

- ZGC는 오라클이 개발한 저지연 가비지 컬렉터

- JDK 11에 실험 버전으로 탑재, JDK15에 정식 버전 탑재, JDK 21에는 세대 구분 ZGC 탑재

 

셰넌도어와 비교

- 공통 : 둘 다 처리량에 미치는 영향을 최소로 억제하면서 힙 크기에 상관없이 일시 정지 시간을 10밀리초 안쪽으로 줄이고자 함

- 차이 : 레드햇이 개발한 셰넌도어는 오라클이 만든 G1과 유사. ZGC는 어줄 시스템스의 PGC와 C4컬렉터와 유사

 

리전 기반 메모리 레이아웃

ZGC의 리전은 동적으로 생성, 파괴되고 크기도 동적으로 달라진다.

- small : 2MB 고정. 256KB 미만 객체

- medium : 32MB 고정. 256KB - 4MB 미만 객체

- large : 2MB의 배수로 크기가 동적으로 변화. 4MB 이상의 객체. 하나의 객체만 담기 때문에 medium region 보다 작을수도 있음

 

병렬 모으기와 컬러 포인터

- 컬러 포인터 (태그 포인터, 버전 포인터라고도 부름) : ZGC를 상징하는 설계

- ZGC 이전에는 가비지 컬렉터나 가상 머신 자체에서만 이용하는 추가 데이터를 객체 헤더 필드에 추가했다.

- 컬러 포인터는 포인터 자체의 추가 정보를 직접 저장하는 기술 

 

컬러 포인터의 장점

1. 한 리전 안의 생존객체들이 이동하면 그 즉시 해당 리전을 재활용할 수 있다. 전체 힙에서 해당 리전으로의 참조들을 전부 수정할 때까지 기다릴 필요가 없다.

2. 가비지 컬렉션 과정에서 메모리 장벽의 수를 크게 줄일 수 있다. 메모리 장벽, 특히 쓰기 장벽을 설정하는 이유는 주로 객체 참조를 변경하기 위해서인데 이 정보를 포인터에 직접 저장하기 때문에 쓰기 장벽을 사용하지 않아도 된다. 

3. 컬러 포인터를 객체 표시 및 재배치와 관련해 더 많은 정보를 담을 수 있는 확장 가능한 저장 구조로 쓸 수 있다. 

 

ZGC의 동작 방식 : 4단계

네 단계 모두 사용자 스레드와 동시에 실행되지만, 사용자 스레드를 일시 정지시키는 작은 단계가 끼어있다.

 

0. 최초 표시 

셰넌도어와 동일

 

1. 동시 표시

객체 그래프를 탐색하며 도달 가능성을 분석

 

2. 동시 재배치 준비

청소해야 할 리전을 선정하여 재배치 집합을 만든다.

- G1 리전을 나눈 이유 : 회수 효율 순서대로 점진적 회수를 위해서

- ZGC 리전을 나눈 이유 : 리전 자체를 회수할지 여부만 결정 

G1과 다르게 ZGC는 가바지 컬렉션마다 모든 리전을 스캔한다.

G1처럼 관리 집합을 관리하지 않고 스캔 비용을 많이 사용하는 방식

 

3. 동시 재배치 : ZGC 핵심 단계

재배치 집합 안의 생존 객체들을 새로운 리전으로 복사한다.

재배치 집합에 속한 각 리전의 포워드 테이블에 옛 객체와 새 객체의 이주 관계를 기록한다. 컬러 포인터 덕분에 객체가 재배치 집합에 속하는지 참조만 보고 알 수 있다.

 

사용자 스레드가 재배치 집합에 포함된 객체에 동시에 접근하려고 하면 미리 설정한 메모리 장벽이 끼어들어, 즉시 해당 리전의 포워드 테이블에 기록된 정보를 보고 새로운 객체로 포워드 시킨다. 그와 동시에 해당 참조의 값도 새로운 객체를 직접 가리키도록 갱신한다. 이 동작을 포인터의 자가 치유라고 부른다.)

 

4. 동시 재매핑 

재매핑이란 힙 전체에서 재배치 집합에 있는 옛 객체들을 향하는 참조 전부를 갱신하는 작업이다. 급하지 않은 작업이라 해당 단계를 다음 가비지 컬렉션 주기가 시작되는 동시 표시 단계와 통합해서, 객체 그래프 전체를 탐색하는 부하를 줄였다.

 

3.6.3 세대 구분 ZGC (generational ZGC)

ZGC를 확장하여 신세대와 구세대를 구분하여, 수명이 짧은 젊은 객체를 자주 회수하는 것이 이점 (약한 세대 가설)

 

옵션

ZGC와의 비교

공통점 : 컬러 포인터와 읽기 장벽을 사용한다.

차이점 : 

 

다중 매핑 메모리 제거 

- ZGC는 읽기 장벽의 부하를 줄이기 위해 다중 매핑 메모리 기법을 사용한다. 다중 매핑은 같은 힙 메모리를 세 개의 독립된 가상 주소로 매핑한다. 그래서 유닉스의 ps 같은 도구로 메모리 사용량을 확인하면 실제보다 3배가량 높게 측정된다.

- 세대 구분 ZGC는 읽기 장벽과 쓰기 장벽의 코드를 명확히 구분한다. 그래서 메모리 사용량을 정확히 측정할 수 있다.

- 컬러 포인터에서 다중 매핑 관련 메타데이터 비트들이 제거되어, 확보된 비트를 다른 용도로 활용 가능하다.

 

다양한 장벽 최적화

처리량을 극대화 하기 위해 기억 집합 장벽, 시작 단계 스냅숏 표시 장벽, 쓰기 장벽 버퍼, 장벽 패치 등 수많은 기법 적용

 

이중 버퍼를 이용한 기억 집한 관리

- 기존 가비지 컬렉터는 기억 집합을 이용해 세대 간 포인터 관리

- 세대 구분 ZGC는 비트맵을 이용해서 객체 필드의 위치를 정확히 기록한다. 비트맵의 비트 하나가 객체 필드 주소 하나를 표현한다.

 

밀집도 기반 리전 처리

세대 구분 ZGC는 회수 순서를 정하기 위해 신세대 리전의 밀집도를 분석한다. 회수 대상으로 선정되지 않은 리전은 생존자 리전이 되거나, 나이를 더 먹으면 구세대 리전으로 숭격된다. 리전을 그대로 둔 채 노화시키는 방식으로 신세대 리전을 회수하는 비용을 줄인다.

 

거대 객체 처리

세대 구분 ZGC에서는 거대한 객체도 신세대에 바로 할당한다. 객체 재배치 없이 리전을 노화시킬 수 있기 때문에 구세대로 재배치하는 비용을 신경쓸 필요가 없다.