박종훈 기술블로그

JDK-8378128 기여기: PLABStats 데이터 멤버 private 전환

JDK-8378128에 기여해보기로 했다.

이슈의 제목은 다음과 같다.

Make PLABStats data members private

이슈 내용

이슈 본문은 다음과 같다.

PLABStats has a number of (atomic) data members that have protected access class. The information is also available via accessor functions, and the derived G1EvacStats uses those accessor functions (after JDK-8375971) rather than direct access to the base class data members. As a result, these members can be made private rather than protected.

요약하면, PLABStats 클래스의 데이터 멤버들이 protected로 선언되어 있는데, accessor 함수가 이미 제공되고 있고, 파생 클래스인 G1EvacStats도 JDK-8375971 이후로 직접 접근 대신 accessor 함수를 사용하도록 변경되었으므로, 이 멤버들을 private으로 변경할 수 있다는 내용이다.

배경 지식

작업 자체는 간단하지만, 이 코드가 어디에 쓰이는 것인지 이해하고 싶었기에 관련 개념들을 정리해 보았다.

G1 GC의 동작 방식

아래 내용은 JavaOne 2017에서 발표된 The G1 GC in JDK 9 영상을 참고하여 정리하였다.

G1 GC는 처리량(throughput)과 낮은 지연 시간(low latency)을 동시에 달성하는 것을 목표로 한다. 기본 일시 중지 목표(pause goal)는 200밀리초이며, 이 값은 튜닝 가능하다. 일시 중지 목표를 높이면 처리량은 늘어나지만 지연 시간이 길어지고, 낮추면 그 반대가 된다.

G1 GC 소개 - 처리량과 지연 시간 목표

G1 GC는 세대별 영역 기반 메모리 관리(generational region-based memory management)를 사용한다. 힙은 여러 개의 region으로 분할된다. G1은 힙을 약 2048개의 region으로 나누는 것을 목표로 하므로, region 크기는 대략 힙 크기 / 2048로 계산되고, 2의 거듭제곱으로 반올림된다. 최소 1MB, 최대 32MB 범위 내에서 결정된다. 예를 들어 4GB 힙이면 4096MB / 2048 = 2MB 크기의 region으로 나뉜다. -XX:G1HeapRegionSize로 직접 지정할 수도 있다.

힙이 여러 region으로 분할된 모습

새로운 객체는 Eden(E) 영역에 할당된다.

새 객체가 Eden 영역에 할당

Eden 영역이 일정량 채워지면 Young Collection이 발생한다.

Young Collection 발생 조건

Young Collection 시, Eden 영역의 살아있는 객체들은 Survivor(S) 영역으로 복사된다.

Eden에서 Survivor로 객체 복사

Young Collection이 끝나면 다시 Eden 영역에 새 객체가 할당된다.

Young Collection 이후 Eden에 다시 할당

여러 번의 Young Collection을 거쳐 살아남은 객체들은 Old(O) 영역으로 승격(promotion)된다.

Survivor에서 Old 영역으로 승격

시간이 지나면 힙이 Eden, Survivor, Old 영역으로 채워진다.

Eden, Survivor, Old로 채워진 힙

Old 영역이 늘어나면, Concurrent Mark 단계에서 Old 영역의 살아있는 객체들을 동시적(concurrently)으로 마킹한다. 이 과정에서 Java 애플리케이션은 중단되지 않는다.

Concurrent Mark 단계

Concurrent Mark가 완료되면, Eden, Survivor, Old 영역을 함께 수집하는 Mixed Collection이 수행된다. 살아있는 객체들은 새로운 Survivor 또는 Old 영역으로 복사된다.

Mixed Collection - 여러 영역을 함께 수집

Mixed Collection - 객체 복사 과정

Mixed Collection을 통해 힙의 단편화가 해소되고 빈 공간이 확보된다.

Mixed Collection 이후 정리된 힙

더 이상 수집할 Old 영역이 없으면 다시 Young Collection으로 돌아간다.

다시 Young Collection으로 전환

정리하면, G1 GC는 다음 세 가지 상태를 순환한다.

  1. Young Collection (YC): Eden과 Survivor 영역만 수집
  2. Young Collection + Concurrent Mark (YC + CM): Young Collection을 수행하면서 동시에 Old 영역을 마킹
  3. Mixed Collection (MC): Eden, Survivor, Old 영역을 함께 수집

G1 GC 상태 전환 다이어그램

PLAB (Promotion Local Allocation Buffer)

PLAB은 Promotion Local Allocation Buffer의 약자다.

위에서 살펴본 것처럼, G1 GC의 Young Collection과 Mixed Collection에서는 살아있는 객체를 Survivor나 Old 영역으로 복사하는 Evacuation이 발생한다. 이때 여러 GC 워커 스레드가 동시에 대상 Region의 빈 공간을 차지하려고 경쟁하면 성능이 저하된다. 그래서 대상 Region 안의 공간을 스레드별로 미리 떼어주는데, 이 전용 할당 공간이 바로 PLAB이다.

[ Survivor Region                                 ]
[ PLAB(스레드1) | PLAB(스레드2) | PLAB(스레드3) | ... ]

앞서 본 G1 GC 이미지에서 사각형 하나는 Region이고, PLAB은 그 Region 안에서 각 스레드가 전용으로 사용하는 영역이다. 별도의 임시 공간이 아니라 대상 Region 메모리의 일부분을 떼어 쓰는 것이므로, 스레드가 자기 PLAB 안에 객체를 복사하면 그 객체는 해당 Region 안에 실제로 존재하게 된다.

참고로, 애플리케이션 스레드가 Eden에 새 객체를 할당할 때 사용하는 TLAB(Thread Local Allocation Buffer)과 유사한 개념이다. TLAB이 Eden에 새 객체를 할당할 때 애플리케이션 스레드 간의 경쟁을 줄이듯, PLAB은 Evacuation 시 GC 워커 스레드 간의 경쟁을 줄인다.

PLABStats

PLABStats는 PLAB이 얼마나 효율적으로 사용되고 있는지를 기록하는 통계 클래스다. JVM은 이 통계를 바탕으로 다음 GC 사이클에서 PLAB의 크기를 동적으로 조절한다.

PLAB의 크기가 동적이어야 하는 이유는, GC마다 복사해야 할 객체의 양이 달라지기 때문이다. PLAB이 너무 작으면 금방 다 소진하여 새 PLAB을 반복해서 할당받아야 하므로 오버헤드가 증가하고, 반대로 너무 크면 할당받고도 못 채운 공간이 낭비된다. 애플리케이션의 객체 할당 패턴은 런타임에 계속 변하기 때문에 고정 크기로는 최적의 균형을 잡을 수 없다.

주요 지표는 다음과 같다.

지표의미
Desired sizeJVM이 계산한 이상적인 PLAB 크기
WastedPLAB을 다 쓰지 못한 채 폐기(retire)되면서 버려진 공간. GC가 끝나거나 스레드가 더 이상 그 PLAB을 사용하지 않을 때 발생한다.
Unused현재 PLAB에서 아직 사용하지 않은 나머지 공간. PLAB이 폐기되는 시점에 Wasted로 전환된다.
Direct allocatedPLAB이 너무 작아서 버퍼를 거치지 않고 직접 메모리에 할당된 크기

G1EvacStats

G1EvacStats는 PLABStats를 상속한 클래스로, G1 GC의 Evacuation 과정에서의 PLAB 통계를 수집한다.

앞서 살펴본 G1 GC의 동작 과정에서, Evacuation이 발생하는 단계는 Young CollectionMixed Collection이다. 이 단계들에서 객체가 Survivor나 Old 영역으로 복사될 때, 각 GC 워커 스레드는 PLAB을 통해 대상 영역에 객체를 할당한다. G1EvacStats는 이 과정에서 PLAB이 얼마나 사용되었는지, 낭비 공간은 얼마나 되는지 등을 추적한다.

PLAB의 할당과 통계 수집은 G1PLABAllocator를 통해 이루어진다. G1PLABAllocator는 글로벌이 아니라 GC 워커 스레드마다 하나씩 존재하며, 각각 Survivor용 PLAB과 Old용 PLAB을 관리한다. 스레드별로 독립적으로 동작하기 때문에 동기화 없이 객체를 복사할 수 있다.

GC가 끝나면 G1PLABAllocator::flush_and_retire_stats()에서 각 스레드의 통계를 글로벌한 G1EvacStats에 합산한다. 합산된 통계를 바탕으로 G1EvacStats::adjust_desired_plab_size()가 다음 GC 사이클의 적정 PLAB 크기를 계산한다. 다음 GC가 시작되면 각 워커 스레드의 G1PLABAllocator가 이 크기를 참고하여 PLAB을 할당받는다. 즉, 할당은 스레드별로 분산되고, 통계는 글로벌로 합산된 뒤, 그 결과가 다시 각 스레드에 반영되는 피드백 루프 구조다.

flowchart LR
    subgraph 스레드별["Evacuation (스레드별)"]
        T1["스레드1
G1PLABAllocator
(Survivor PLAB, Old PLAB)"] T2["스레드2
G1PLABAllocator
(Survivor PLAB, Old PLAB)"] T3["스레드3
G1PLABAllocator
(Survivor PLAB, Old PLAB)"] end G["G1EvacStats
(글로벌 통계)"] A["adjust_desired_plab_size()
적정 PLAB 크기 계산"] T1 -- "통계 합산" --> G T2 -- "통계 합산" --> G T3 -- "통계 합산" --> G G --> A A -- "다음 GC에 반영" --> 스레드별

G1CollectedHeap에서의 활용

G1CollectedHeap은 survivor용과 old용 두 개의 G1EvacStats 인스턴스를 가지고 있다.

  • _survivor_evac_stats — Survivor 영역으로의 복사 통계
  • _old_evac_stats — Old 영역으로의 promotion 통계

create_g1_evac_summary()에서 allocated(), wasted(), used() 등 accessor를 통해 이 통계를 읽어 GC 로그 및 모니터링에 활용한다.

선행 이슈: JDK-8375971

이 이슈에서 G1EvacStats가 부모 클래스인 PLABStats의 데이터 멤버에 직접 접근하는 대신 accessor 함수를 사용하도록 변경되었다. 이 변경이 이루어졌기 때문에, PLABStats의 protected 멤버들을 private으로 전환할 수 있게 된 것이다.

JDK-8378128의 작업 내용

해야 할 일은 명확하다. PLABStats 클래스에서 protected로 선언된 데이터 멤버들을 private으로 변경하면 된다.

캡슐화(encapsulation) 관점에서, 외부에서 직접 접근할 필요가 없는 멤버는 가능한 한 접근 범위를 좁히는 것이 좋다. 파생 클래스가 더 이상 직접 접근하지 않으므로, protected에서 private으로 변경하는 것이 적절하다.

변경사항 검증

  • make images가 에러 없이 완료되는 것을 확인하였다.
  • Tier1 테스트가 통과하는 것을 확인하였다.

마무리

코드 변경사항 자체는 작지만, 그 배경에 있는 G1 GC의 PLAB 메커니즘과 통계 수집 구조를 이해하는 것이 흥미로웠다. 아직 JDK 내부 동작에 대해 깊은 지식을 가진 상태는 아니지만, 접근 제어자 하나를 바꾸는 작업이라도 이것이 어떤 역할을 하는 코드인지 이해하려고 노력하고 있다. 이런 과정들이 쌓이면 JVM 내부 구조에 대한 이해도 자연스럽게 깊어질 것이라 생각한다.

categories: 개발

tags: JDK , OpenJDK , GC , G1 , PLAB , 오픈소스 기여