-
[JVM] 가비지 컬렉터와 메모리 할당 전략 (1) - 기본 알고리즘DevBook/JVM 밑바닥까지 파헤치기 2025. 3. 17. 00:20
들어가기 전,
- 가비지 컬렉션이 처리해야 하는 문제 3가지
- 어떤 메모리를 회수해야 할까?
- 언제 회수해야 할까?
- 어떻게 회수해야 할까?
- '메모리 할당과 회수'라고 할 때의 메모리는 런타임 데이터 영역 중 힙 영역과 메서드 영역들만 지칭함
- 프로그램이 어떤 객체를 생성할지, 얼마나 많이 만들지는 오직 런타임에만 알 수 있음
- 해당 메모리 영역들의 할당과 회수는 동적으로 이루어짐
- 가비지 컬렉터는 이런 영역을 관리하는 데 집중함
- 나머지 영역(스택 영역, 프로그램 카운터 레지스터)들은 메서드가 끝나거나 스레드가 종료되면 자연스럽게 회수됨
객체 생존 판단 알고리즘
- 가비지 컬렉터가 힙을 청소하려면 가장 먼저 어떤 객체가 살아 있고, 어떤 객체가 죽었는지 판단해야 함
1. 참조 카운팅 알고리즘
- 자바에서는 해당 알고리즘을 사용하지 않음
- 특징
- 객체를 가리키는 참조 카운터(reference counter)를 추가한다. 참조하는 곳이 하나 늘어날 때마다 카운터 값을 1씩 증가시킨다.
- 참조하는 곳이 하나 사라질 때마다 카운터 값을 1씩 감소시킨다.
- 카운터 값이 0이 된 객체는 더는 사용될 수 없다.
- 한계
- 간단한 참조 카운팅만으로는 순환 참조(circular reference) 문제를 해결하기 어려움
- ex) 객체 A, B에 대한 외부 접근은 불가능하지만, 두 객체가 서로를 참조하고 있어 참조 카운터는 0이 되지 않아 회수 불가
- 간단한 참조 카운팅만으로는 순환 참조(circular reference) 문제를 해결하기 어려움
2. 도달 가능성 분석 알고리즘
- 자바, C# 등 오늘날의 주류 프로그래밍 언어들은 모두 객체 생사 판단에 해당 알고리즘 사용함
- 기본 아이디어
- GC 루트라고 하는 루트 객체들을 시작 노드 집합으로 사용
- 시작 노드들에서 출발하여 참조하는 다른 객체들로 탐색해 들어감
- 탐색 과정에서 만들어지는 경로를 참조 체인(reference chain)이라 함
- 어떤 객체와 GC 루트를 이어 주는 참조 체인이 없다면, 즉 GC 루트로부터 도달 불가능한 객체는 더 이상 사용할 수 없는 게 확실하므로 회수 대상이 됨
- 자바에서 GC 루트로 이용할 수 있는 객체 예시
- 가상 머신 스택(스택 프레임의 지역 변수 테이블)에서 참조하는 객체: 현재 실행 중인 메서드에서 쓰는 매개 변수, 지역 변수, 임시 변수 등
- 메서드 영역에서 클래스가 정적 필드로 참조하는 객체: 자바 클래스의 참조 타입 정적 변수
- 메서드 영역에서 상수로 참조되는 객체: 문자열 테이블 안의 참조
- 네이티브 메서드 스택에서 JNI(네이티브 메서드)가 참조하는 객체
- JVM 내부에서 쓰이는 참조: 기본 데이터 타입에 해당하는 Class 객체, 일부 상주 예외 객체(NPE, OutOfMemoryError 등), 시스템 클래스 로더
- 동기화 락(synchronized 키워드)로 잠겨있는 모든 객체
- JVM 내부 상황을 반영하는 JMXBean: JVMTI에 등록된 콜백, 로컬 코드 캐시 등
- 참고) Tri-color Marking
3. 객체 참조
- JDK 1.2부터 참조 개념이 확장되어 참조를 네 가지로 구분함
- 강한 참조(strong reference)
- Object obj = new Object()처럼 프로그램 코드에서 참조를 할당하는 것
- 강한 참조 관계가 남아있는 객체는 가비지 컬렉터가 절대 회수하지 않음
- 부드러운 참조(soft reference)
- 유용하지만 필수는 아닌 객체 표현
- 부드러운 참조만 남은 객체는 메모리 오버플로가 나기 직전에 두 번째 회수를 위한 회수 목록에 추가됨
- 두 번째 회수 후에도 메모리가 부족하면 그때 메모리 오버플로 예외를 던짐
- JDK 1.2 SoftReference 클래스
- 약한 참조(weak reference)
- 부드러운 참조보다 연결 강도가 더 약한 것
- 약한 참조뿐인 객체는 다음번 GC까지만 살아있음
- 가비지 컬렉터가 동작하기 시작하면 메모리가 넉넉하더라도 약한 참조된 객체는 모두 회수됨
- JDK 1.2 WeakReference 클래스
- 유령 참조(phantom reference)
- 참조 중 가장 약함
- 객체 수명에 아무런 영향을 주지 않고, 유령 참조를 통해 객체 인스턴스를 가져오는 것 불가능
- 유령 참조를 거는 유일한 목적은 대상 객체가 회수될 때 알림을 받기 위함
- JDK 1.2 PhantomReference 클래스
추가) 파이널 참조(final reference)
- JDK 내부적으로 해당 참조도 사용, 참조 강도는 약한 참조와 유령 참조 사이
- finalize() 메서드를 구현한 객체는 모두 파이널 참조의 대상이 되어 별도의 대기열(queue)에 등록됨
- 해당 객체에 도달할 수 있는 강한 참조, 부드러운 참조, 약한 참조가 모두 없어지면 finalize() 메서드를 호출함
4. 살았나 죽었나?
- 도달 가능성 분석 알고리즘이 '도달 불가능'으로 판단한 객체라고 해서 반드시 회수되는 것은 아님
- 두 번의 표시(marking) 과정을 거쳐야 함
- GC 루트와 연결된 참조 체인을 찾지 못한 객체에는 첫 번째 표시가 이루어지며 이어서 필터링이 진행됨
- 필터링 조건은 종료자, 즉 finalize() 메서드를 실행해야 하는 객체인가
- finalize()가 필요 없는 객체이거나 가상 머신이 finalize()를 이미 호출한 경우 모두 '실행할 필요 없음'으로 처리
- finalize()를 실행해야 하는 객체로 판명되면 F-Queue라는 대기열에 추가됨
- finalize()를 이용해 참조 체인상의 아무 객체와 다시 연결하는 경우 해당 객체는 두 번째 표시 과정에서 '회수 대상' 목록에서 제외됨
- 주의) finalize()는 사용하지 말자
- 실행하는 비용이 높고 불확실성이 큼. 어느 객체부터 호출되는지도 보장하지 않음
- 이런 이유로 해당 메서드는 JDK 9부터 폐기 대상으로 지정됨
- try-finally 등의 다른 방법으로 처리 가능함
5. 메서드 영역 회수하기
- 메서드 영역은 회수 조건이 까다로워 GC 효율이 훨씬 떨어짐
- 크게 두 가지를 회수함 (다른 것도 회수하긴 함)
- 상수
- 자바 힙에서 객체를 회수하는 방법과 유사
- ex) 상수 풀에서 리터럴을 회수하는 예시
- 상수 풀 안의 "java" 상수를 참조하는 문자열 객체가 없고, 가상 머신에서 이 리터럴을 사용하는 코드가 한 곳도 없음
- 상수 풀에 있는 다른 클래스(인터페이스 포함), 메서드, 필드의 심벌 참조도 비슷한 방법으로 회수함
- 클래스
- 다음 세 조건을 동시에 만족해야 더 이상 쓰이지 않는 '클래스'로 판단됨
- 클래스의 인스턴스가 모두 회수됨. 즉, 자바 힙에는 해당 클래스와 하위 클래스의 인스턴스가 하나도 존재하지 않음
- 클래스를 읽어들인 클래스 로더가 회수됨
- 이 조건은 OSGi나 JSP 리로딩처럼 세심하게 설계된 대안 클래스 로더 없이 충족하기 어려움
- 클래스에 해당하는 java.lang.Class 객체를 아무 곳에서 참조하지 않고, 리플렉션 기능으로 이 클래스의 메서드를 이용하는 곳이 전혀 없음
- JVM은 클래스 회수 여부를 제어할 수 있도록 -Xnoclassgc 매개변수를 제공함
- 클래스 로딩/언로딩 정보 확인 매개변수 : -verbose:class, -Xlog:class+load=info, -Xlog:class+unload=info
- 다음 세 조건을 동시에 만족해야 더 이상 쓰이지 않는 '클래스'로 판단됨
- 상수
가비지 컬렉션 알고리즘
- 객체의 생사를 판별하는 방식 기준으로 '참조 카운팅 GC'와 '추적 GC'로 나눌 수 있음
- 주류 JVM에서는 대부분 추적 GC 사용
1. 세대 단위 컬렉션 이론
- 대부분의 가비지 컬렉터는 세대 단위 컬렉션 이론에 기초해 설계됨
- 다음 세 가지 가정으로 구성됨
- 약한 세대 가설: 대다수 객체는 일찍 죽는다.
- 강한 세대 가설: GC 과정에서 살아남은 횟수가 늘어날수록 더 오래 살 가능성이 커진다.
- 세대 간 참조 가설: 세대 간 참조의 개수는 같은 세대 안에서의 참조보다 훨씬 적다.
- 설계 원칙
- 자바 힙을 몇 개의 영역으로 나누고 객체들을 나이에 따라 각기 다른 영역에 할당함
- *나이 : GC에서 살아남은 횟수
- 한 번 살아남은 객체는 통계적으로 잘 죽지 않으므로 다른 영역에 따로 모아 두고, 가상 머신이 그 영역을 회수하는 빈도를 줄임 (GC 전체 시간도 줄고 메모리 공간도 효율적으로 이용할 수 있음)
- 여러 영역으로 나누면 가비지 컬렉터는 한 번에 하나 또는 몇 개의 영역만 선택해 회수할 수 있음
- 이를 기준으로 마이너 GC, 메이저 GC, 전체 GC라고 부름
- 각 영역에 담긴 객체들의 생존 특성에 따라 아래의 가비지 컬렉션 알고리즘을 구분해 적용함
- mark-sweep: 표시 후 쓸기
- mark-copy: 표시 후 복사
- mark-compact: 표시 후 모으기
- 자바 힙을 몇 개의 영역으로 나누고 객체들을 나이에 따라 각기 다른 영역에 할당함
- 특징
- 세대 단위 컬렉션 이론을 적용하여 자바 힙을 최소 두 개 영역으로 나눔 (신세대, 구세대)
- 신세대에서는 GC 때마다 다수의 객체가 죽고 살아남은 소수만 구세대로 승격함
- 세대 간 참조는 아래의 방식으로 해결함 (구세대 전체의 객체들까지 탐색하려면 성능 면에서 부담이 크기 때문에 아래와 같은 방식 적용)
- 신세대에 기억 집합이라는 전역 데이터 구조를 둠
- 이 구조를 통해 구세대를 작은 조각 몇 개로 나누고, 그중 어느 조각에 세대 간 참조가 있는지 기록해 관리함
- 마이너 GC가 수행되면 세대 간 참조를 포함하는 작은 메모리 블록 안의 객체들만 GC 루트에 추가됨
- 구세대 전체 객체 추가되지 않게 됨
- 이를 위해 객체 사이의 참조 관계 변화를 정확하게 관리해야 함
- 런타임에 할 일이 늘어나지만 구세대 전체를 훑는 비용보다 효율적임
- 세대 단위 컬렉션 이론을 적용하여 자바 힙을 최소 두 개 영역으로 나눔 (신세대, 구세대)
추가) 세대 단위 컬렉션에서의 다양한 GC 방식
더보기부분 GC: 자바 힙의 일부만 회수하는 GC
- 마이너 GC(신세대 GC): 신세대만 대상으로 하는 GC
- 메이저 GC(구세대 GC): 구세대만 대상으로 하는 GC
- 오직 CMS 컬렉터만 구세대를 따로 회수함
- 혼합 GC: 신세대 전체 + 구세대 일부를 대상으로 하는 GC
- G1 컬렉터만 이렇게 동작함
전체 GC : 자바 힙 전체 + 메서드 영역까지 모두를 대상으로 하는 GC
*참고) 아래 알고리즘들의 mark 단계는 모두 도달 가능성 분석 알고리즘을 사용함
2-1. mark-sweep 알고리즘
- mark, sweep 두 단계로 나눠 진행
- 회수할 객체들에 모두 표시한 다음, 표시된 객체들을 쓸어 담는 방식
- 반대로 살릴 객체에 표시하고 표시되지 않은 객체를 회수하기도 함
- 뒤어어 나온 컬렉션 알고리즘들 대부분이 해당 알고리즘을 기초로 단점을 보완하는 식으로 발전했음
- 단점
- 실행 효율이 일정하지 않음
- 자바 힙이 다량의 객체로 가득 차 있고 대부분이 회수 대상이라면 표시와 회수하는 일 모두 커진다.
- 즉, 객체가 많아질수록 표시하고 쓸어 담는 작업의 효율이 떨어지는 구조이다.
- 메모리 파편화가 심함 (불연속적인 상태)
- 가비지 컬렉터가 회수해간 자리에는 불연속적인 메모리 파편이 만들어짐
- 파편화가 너무 심하면 큰 객체를 만들려 할 때 충분한 크기의 연속된 메모리를 찾기 어려워지고, 그 결과 또 다른 GC를 유발함
- 실행 효율이 일정하지 않음
2-2. mark-copy 알고리즘
- mark-sweep 알고리즘의 단점을 보완함
- 상용 JVM 대부분이 신세대(young generation) 영역에 해당 알고리즘 활용함
기본 전략
- 가용 메모리를 똑같은 크기의 두 블록으로 나눠 한 번에 한 블록만 사용
- 한쪽 블록이 꽉 차면 살아남은 객체들만 다른 블록 복사하고 기존 블록을 한 번에 청소함
- 장점
- 대다수가 회수된다면 생존한 소수의 객체만 복사하면 됨
- 복사 과정에서 객체들이 메모리의 한 쪽 끝에서부터 쌓이기 때문에 메모리 파편화 해소 가능함
- 단점
- 가용 메모리를 절반으로 줄여 낭비가 심함
최적화된 전략
- 신세대를 하나의 큰 에덴 공간과 두 개의 작은 생존자 공간으로 나눔
- 메모리를 할당할 때 생존자 공간 중 하나와 에덴만 사용
- 에덴과 생존자 공간에서 살아남은 객체들을 나머지 생존자 공간으로 하나씩 복사한 후 에덴과 이전 생존자 공간을 곧바로 비움
- 핫스팟 가상 머신에서 에덴과 생존자 공간의 비율은 기본적으로 8:1
- 즉, 신세대에 할당된 전체 메모리 중 90%를 활용하는 것 (에덴 공간 + 생존자 공간 1개)
- 마이너 GC에서 살아남은 객체를 나머지 생존자 공간이 다 수용하지 못할 경우(10% 초과) 메모리 할당 보증 메커니즘을 적용함
- 다른 메모리 영역(대부분의 경우 구세대)을 활용해 메모리 할당을 보증하는 것(핸들 승격)
- 해당 메커니즘을 통해 객체들을 구세대로 바로 승격시킴
2-3. mark-compact 알고리즘
- mark-copy 알고리즘은 객체 생존율이 높을수록 복사할 게 많아져서 효율이 나빠짐
- 구세대 객체들의 생존 특성을 감안(객체 생존율이 높음)하여 해당 알고리즘 등장함
- 방식
- 표시 단계는 mark-sweep과 같음
- 컴팩트 단계에서 생존한 모든 객체를 메모리 영역의 한쪽 끝으로 모은 다음, 나머지 공간을 한꺼번에 비움
- mark-sweep과 핵심적인 차이는 메모리 이동이 일어난다는 점
- 아래 장단점을 고려해야 함
- 객체를 이동시키면 회수 작업이 복잡해지고, 이동시키지 않으면 할당 작업이 복잡해짐(메모리 파편화 때문)
- GC로 인한 '일시 정지 시간'을 기준으로 판단하면 객체를 이동시키지 않는 편이 유리함
- 전체 프로그램의 '처리량'이 기준이라면 객체를 이동시키는 편이 효율적임
- 메모리를 할당하고 접근하는 빈도가 GC 수행 빈도보다 훨씬 많으므로 메모리 할당과 접근 효율이 떨어지면 전체적인 처리량도 나빠짐
- 대부분의 경우 구세대에는 메모리 파편화를 감내하면서 mark-sweep을 사용하다가, 객체 할당에 영향을 줄 만큼 파편화가 심해지면 mark-compact로 전환해 연속된 공간을 확보함
핫스팟 알고리즘 상세 구현
1. 루트 노드 열거(root-node enumeration)
- 도달 가능성 분석 알고리즘에서 GC 루트 집합으로부터 참조 체인을 찾는 작업 (도달 가능한 객체들을 찾는 것)
- GC 루트로 고정할 수 있는 노드는 주로 전역 참조(상수와 클래스 정적 속성 등)와 실행 콘텍스트(스택 프레임의 지역 변수 테이블 등)에 존재함
- 해당 작업 시 모든 사용자 스레드는 일시 정지(stop-the-world)됨 (컬렉터 종류 상관없음)
- 일관성이 보장되는 스냅샷 상태에서 수행해야 함
- 루트 노드들의 참조 관계가 변하지 않아야 하기 때문
- 일관성이 보장되는 스냅샷 상태에서 수행해야 함
- 핫스팟의 OopMap 데이터 구조를 이용해 가상 머신이 객체 참조가 저장된 위치를 직접 알아낼 수 있음
2. 안전 지점(safe point)
- 핫스팟은 모든 명령어 각각에 OopMap을 생성하지는 않음. 대신 안전 지점이라고 하는 특정한 위치에만 기록함
- 가비지 컬렉터는 사용자 프로그램이 안전 지점에 도달할 때까지는 절대 사용자 스레드를 멈추지 않음
- 안전 지점의 위치를 선택하는 기준
- 기본적으로 '프로그램이 장시간 실행될 가능성이 있는가'
- 명령어 흐름이 다중화(multiflexing)될 때 '장시간 실행'될 가능성이 있음
- ex) 메서드 호출, 순환문, 예외 처리 등
- 이런 기능을 하는 명령어만이 안전 지점을 생성함
- 사용자 스레드를 멈추는 방식
- GC가 시작되면 JNI 호출을 실행 중인 스레드를 제외한 모든 스레드가 가장 가까운 안전 지점까지 실행하고 멈추게 할 방법이 필요함
- 선제적 멈춤(preemptive suspension)
- 스레드의 코드가 가비지 컬렉터를 신경쓰지 않음
- GC가 실행되면 시스템이 모든 사용자 스레드를 인터럽트함
- 사용자 스레드가 중단된 위치가 안전 지점이 아니라면 스레드를 재개하고 안전 지점에 도달할 때까지 인터럽트를 반복함
- 해당 방식을 사용하는 가상 머신은 거의 없음
- 자발적 멈춤(voluntary suspension) --> 사용자 스레드가 직접 멈춤
- 가비지 컬렉터가 스레드 수행에 직접 관여하지 않음
- 플래그 비트를 설정하고, 각 스레드가 실행중에 플래그를 폴링함
- 플래그 값=true이면, 가장 가까운 안전 지점에서 스스로 멈춤
- 폴링 플래그들은 안전 지점에 위치함
- 객체 생성 등 자바 힙 메모리를 소비하는 장소에도 폴링 플래그가 추가됨 (메모리가 부족해 새로운 객체를 할당하지 못하는 상황 방지하기 위함)
추가) 자발적 멈춤 > polling
더보기- 폴링은 코드에서 자주 일어나므로 매우 효율적이어야 함
- 핫스팟은 메모리 보호 트랩 방법을 써서 폴링을 어셈블리어 명령어 하나만으로 수행할 수 있게 단순화함
- 사용자 스레드를 일시 정지해야 하는 경우 가상 머신은 0x160100 메모리 페이지를 읽을 수 없게 설정함
- 스레드가 test 명령어를 실행할 때 트랩에 걸렸다는 예외 시그널을 던지고, 사전 등록된 예외 핸들러에서 스레드를 일시 정지시킴
- 이런 식으로 안전 지점 폴링과 스레드 인터럽트 단 하나의 어셈블리 명령어(test)로 처리함
3. 안전 지역(safe region)
- 필요 이유
- 잠자기 상태이거나 블록된 상태의 사용자 스레드들은 가상 머신의 인터럽트 요청에 응답할 수 없고, 따라서 안전 지점까지 수행한 후 인터럽트되어 스스로를 일시 정지시킬 수 없음
- 이런 스레드가 다시 활성화될 때까지 가상 머신이 무한정 기다릴 수 없음
- 안전 지역은 일정 코드 영역에서는 참조 관계가 변하지 않음을 보장함
- 즉, 안전 지역 안이라면 어디서든 GC를 시작해도 무방하다는 뜻 (안전 지점을 확장한 개념)
- 방식
- 사용자 스레드는 안전 지역의 코드를 실행하기 앞서 안전 지역에 진입했음을 표시함
- 가비지 컬렉터는 안전 지역에 있다고 선언한 스레드들을 신경 쓸 필요가 없어짐
- 안전 지역에서 벗어나려는 스레드는 가상 머신이 루트 노드 열거를 완료했는지 또는 사용자 스레드를 일시 정지시켜야 하는 다른 GC 단계를 완료했는지 확인함
- 아직 완료되지 않았다면 안전 지역을 벗어나도 좋다는 신호를 받을 때까지 기다려야 함
4. 기억 집합과 카드 테이블
- 세대 간 참조 문제를 해결하기 위해 '기억 집합'을 사용함
- 기억 집합이 없다면 마이너 GC 때 구세대 영역의 객체들을 모두 탐색하여 신세대 영역의 객체를 참조하고 있는지 확인해야 함
- 기억 집합을 사용하여 스캔 범위와 비용을 줄임
- 기억 집합은 비회수 영역(회수 대상이 아닌 영역)에서 회수 영역을 가리키는 포인터들을 기록하는 추상 데이터 구조
- GC 수행 시 컬렉터는 기억 집합을 이용해 특정 비회수 영역에서 회수 영역을 가리키는 포인터가 존재하는지만 확인하면 됨
- 구현 방식
- '기억 집합'은 '추상' 데이터 구조이고, 카드 정밀도 방식을 사용해 기억 집합을 구현한 것을 '카드 테이블'이라 함
- 현재 가장 널리 쓰이는 방식
- 기록 정밀도와 힙 메모리의 매핑 관계 등을 정의하여 기억 집합을 구체적으로 구현한 방법 중 하나
- 카드 정밀도란?
- 메모리 공간과 관리 비용 절약을 위해 기록 단위를 크게 잡아야 함
- 레코드 하나(카드)가 메모리 블록 하나에 매핑됨
- 특정 레코드가 마킹되어 있다면, 해당 블록에 세대 간 참조를 가진 객체가 존재한다는 뜻
- 어떻게 구현할까?
- 카드 테이블을 구현하는 가장 간단한 형태는 바이트 배열 (핫스팟 가상 머신도 해당 형태 사용)
- CARD_TABLE[this address >> 9] = 1;
- 바이트 배열인 CARD_TABLE의 원소 각각이 메모리 영역에서 특정 크기의 메모리 블록 하나에 대응함
- 이 메모리 블록을 '카드 페이지'라고 함
- 일반적으로 카드 페이지의 크기는 2 ^ N 바이트로 정함
- 위 코드에서 핫스팟은 카드 페이지의 크기를 2 ^ 9 = 512 바이트로 정함
- 카드 테이블을 구현하는 가장 간단한 형태는 바이트 배열 (핫스팟 가상 머신도 해당 형태 사용)
- '기억 집합'은 '추상' 데이터 구조이고, 카드 정밀도 방식을 사용해 기억 집합을 구현한 것을 '카드 테이블'이라 함
- 카드 페이지 하나의 메모리에는 보통 하나 이상의 객체가 들어 있음
- 카드 테이블의 원소는 구세대의 특정 메모리 블록을 가리킴
- 해당 메모리 블록에는 구세대 객체가 존재함
- 이 객체들 중 하나에라도 세대 간 포인터를 갖는 필드가 있다면, 카드 테이블에서의 해당 원소(카드)를 1로 표시하고, '더렵혀졌다(dirty)'고 표현함 (세대 간 포인터를 갖는 객체가 하나도 없다면 0으로 표시)
- 비회수 영역의 객체들 중 회수 영역의 객체를 참조하고 있는 객체가 존재하는지 확인하는 것
- 객체를 회수할 때 카드 테이블에서 더렵혀진 원소만 확인하면 어떤 카드 페이지의 메모리 블록이 세대 간 포인터를 포함하는지 쉽게 파악할 수 있음
- 세대 간 참조를 포함한 블록만 GC 루트에 추가해 함께 스캔하는 것
5. 쓰기 장벽
- 카드 테이블을 어떻게 갱신하느냐의 문제를 해결하기 위한 방법
- 카드 테이블 내 원소가 더렵혀지는 시점은 객체의 참조 타입 필드에 값이 대입되는 순간
- 바이트코드를 해석해 실행하는 경우 가상 머신이 모든 바이트코드 명령의 실행을 담당하므로 끼어들 수 있음
- 컴파일해 실행하는 경우 JIT 컴파일 후의 코드는 순수한 기계어 명령어들임. 대입 연산 시 카드 테이블을 갱신하려면 기계어 코드 수준의 방법이 동원되어야 함
- 핫스팟 가상 머신은 쓰기 장벽 기술을 이용해 카드 테이블을 관리함
- 참조 타입 필드 대입 전후로 추가 동작을 수행할 수 있게 하는 것
- 대입 전 쓰기 장벽을 사전 쓰기 장벽이라 하며, 대입 후 쓰기 장벽을 사후 쓰기 장벽이라 함
- 참고) G1 컬렉터가 등장하기 전까지 컬렉터들은 모두 사후 쓰기 장벽만 이용했음
- 참조 타입 필드 대입 전후로 추가 동작을 수행할 수 있게 하는 것
카드 테이블 갱신 로직 간소화한 것 - 쓰기 장벽을 적용해 추가로 실행할 명령어를 생성해 추가함
- 컬렉터가 쓰기 장벽으로 카드 테이블 갱신 연산을 추가한다면 참조가 갱신될 때마다 오버헤드가 추가됨
- 마이너 GC 때 구세대 전체를 스캔하는 비용보다는 훨씬 저렴함
6. 동시 접근 가능성 분석
상황
- 루트 노드 열거 단계는 사용자 스레드를 일시 정지함
- 루트 노드 열거 단계에서 GC 루트는 전체 자바 힙에 존재하는 모든 객체와 비교해 그 수가 적음
- OopMap 같은 다양한 최적화 기법으로 사용자 스레드가 멈춰 있는 시간은 매우 짧고 상대적으로 일정함 (힙 용량이 늘어난다고 해서 더 오래걸리지 않음)
- 루트 노드 열거가 끝나면 가비지 컬렉터는 GC 루트로부터 객체 그래프를 탐색할 수 있음
- 이 단계의 일시 정지 시간은 자바 힙 크기에 비례함
- 힙이 클수록 더 많은 객체를 담게 되고 객체 그래프 구조도 복잡해짐
- 참조 관계를 추적하는 가비지 컬렉션 알고리즘들에는 공통적으로 '표시' 단계가 등장함
- '표시' 단계에서의 사용자 스레드의 일시 정지 시간을 줄이기 위해 이때는 사용자 스레드를 일시 정지 시키지 않고 동시에 실행함
문제
- 삼색 표시(tir-color marking) 기법을 활용해 메모리를 회수할 객체를 표시함
- 컬렉터가 객체 그래프에 색을 칠해 가는 도중에 사용자 스레드가 참조 관계를 변경할 수 있음
- 이로 인해 살아 있는 객체를 죽었다고 표시할 수 있음
- 객체 사라짐 문제 발생(검은색이었어야 할 객체를 실수로 하얗게 칠함)
방안
- 다음 두 조건이 동시에 만족될 때만 객체 사라짐 문제 발생함
- 사용자 스레드가 흰색 객체로의 새로운 참조를 검은색 객체에 추가
- 사용자 스레드가 회색 객체에서 흰색 객체로의 직간접적인 참조를 삭제
- 여기서의 흰색 객체는 첫 번째 조건에서의 흰색 객체와 동일한 것
- 참조가 삭제되어 흰색 객체에 도달할 방법이 없어짐
- 따라서 이를 해결하려면 두 조건 중 하나만 깨뜨리면 됨
- 아래 두 방안은 모두 '일관성이 보장되는 스냅샷 상태에서 객체 그래프 탐색'을 기반으로 함
- 첫 번째 조건 깨뜨리기: 증분 업데이트
- 검은색 객체에 흰색 객체로의 참조가 추가되면 새로 추가된 참조를 따로 기록해 둠
- 동시 스캔이 끝난 후 기록해 둔 검은색 객체들을 루트로 하여 다시 스캔함
- 두 번째 조건 깨뜨리기: 시작 단계 스냅샷(snapshot at the begining)
- 회색 객체가 흰색 객체로의 참조 관계를 끊으려 하면 그 사실을 기록함
- 동시 스캔이 끝난 후 기록해 둔 회색 객체들을 회색 객체들을 루트로 하여 다시 스캔함
- 즉, 참조 관계 삭제 여부와 상관없이 스캔을 시작한 순간의 객체 그래프 스냅샷 기준으로 우선 스캔하는 것
- 위 방식에서 하나는 참조가 추가될 때 기록하고, 다른 하나는 참조가 끊어질 때 기록함. 이러한 기록 작업은 '쓰기 장벽'을 이용해 구현함
- CMS 컬렉터는 증분 업데이트, G1과 셰넌도어 컬렉터는 시작 단계 스냅샷 활용함
더보기- 흰색: 가비지 컬렉터가 방문한 적 없는 객체
- 도달 가능성 분석을 시작하면 처음에는 모든 객체가 흰색
- 분석을 마친 뒤에도 흰색인 객체는 도달 불가능함을 뜻하므로 회수 대상
- 검은색: 가비지 컬렉터가 방문한 적이 있고, 이 객체를 가리키는 모든 참조를 스캔함
- 검은 객체는 스캔되었고 확실히 생존함을 뜻함
- 다른 객체에서 검은 객체를 가리키는 참조가 있다면 다시 스캔하지 않아도 됨
- 검은 객체가 흰색 객체를 곧바로 가리키는 건 불가능함. 회색 객체를 거쳐 가리킬 수는 있음
- 회색: 가비지 컬렉터가 방문한 적 있으나 이 객체를 가리키는 참조 중 스캔을 완료하지 않은 참조가 존재함
참고) 동시 수행 시 객체 사라짐 문제 예시
더보기- 아래 두 상황이 객체 사라짐 문제가 발생하는 경우임
- (1) 참조 끊김이 발생하지 않았다면 회색 객체가 참조하고 있는 흰색 객체를 탐색한 후 검은색 객체로 표시해 회수 대상이 아니게 되었을 것임
'DevBook > JVM 밑바닥까지 파헤치기' 카테고리의 다른 글
[JVM] 가상 머신 실행 서브시스템 (1) - 클래스 파일 구조 (0) 2025.03.29 [JVM] 가비지 컬렉터와 메모리 할당 전략 - 메모리 할당과 회수 전략 (0) 2025.03.27 [JVM] 가비지 컬렉터와 메모리 할당 전략 (2) - 클래식 가비지 컬렉터 > G1 GC (0) 2025.03.18 [JVM] 가비지 컬렉터와 메모리 할당 전략 (2) - 클래식 가비지 컬렉터 (0) 2025.03.18 [JVM] 자동 메모리 관리 - 자바 메모리 영역과 메모리 오버플로우 (1) 2025.03.15 - 가비지 컬렉션이 처리해야 하는 문제 3가지