가비지 수집 기초
Java 환경에는 몇 가지 상징적이거나 정의적인 특징들이 있으며, 그 중 가비지 컬렉션(Garbage Collection, GC)은 가장 즉각적으로 인식할 수 있는 특징 중 하나입니다. 그러나 플랫폼이 처음 출시되었을 때, GC에 대한 상당한 반대가 있었습니다.
이는 초기에는 Java의 GC 성능에 대한 불만이 있었고, 이는 전체 플랫폼에 대한 인식에 영향을 미쳤습니다. 그러나 강제적이고 사용자 제어가 불가능한 GC라는 초기 비전은 충분히 정당화되었으며, 오늘날에는 매우 적은 수의 애플리케이션 개발자만이 메모리를 수동으로 관리해야 한다는 의견을 옹호하려고 합니다. 현
Java의 가비지 컬렉션의 핵심은 프로그래머가 시스템 내의 모든 객체의 정확한 생명 주기를 이해해야 하는 대신, 런타임이 프로그래머를 대신하여 객체를 추적하고 더 이상 필요하지 않은 객체를 자동으로 제거한다는 것입니다. 자동으로 회수된 메모리는 지워지고 재사용될 수 있습니다.
모든 GC 구현이 준수해야 하는 두 가지 기본 규칙이 있습니다:
알고리즘은 모든 가비지를 수집해야 합니다.
라이브 객체는 절대 수집되지 않아야 합니다.
이 두 규칙 중 두 번째 규칙이 훨씬 더 중요합니다. 라이브 객체를 수집하면 세그멘테이션 폴트(segmentation fault)가 발생하거나(더 나쁜 경우) 프로그램 데이터가 무심코 손상될 수 있습니다. Java의 GC 알고리즘은 프로그램이 여전히 사용 중인 객체를 절대 수집하지 않도록 보장해야 합니다.
프로그래머가 모든 저수준 세부 사항을 수동으로 처리하지 않아도 되는 대가로 일부 저수준 제어를 포기하는 아이디어는 Java의 관리형 접근 방식의 본질이며, James Gosling이 생각한 Java의 개념을 표현합니다.
마크 앤 스윕 소개
대부분의 Java 프로그래머는 Java의 GC가 마크 앤 스윕(mark and sweep)이라는 알고리즘에 의존한다는 것을 기억할 수 있지만, 이 과정이 실제로 어떻게 작동하는지에 대한 세부 사항을 기억하기는 어렵습니다.
이는 알고리즘의 의도는 몇 가지 기본 개념을 소개하기 위한 의도적 단순화 형태로, 프로덕션 JVM이 실제로 GC를 수행하는 방식과는 다릅니다.
이 단순화된 마크 앤 스윕 알고리즘은 할당된 객체 목록을 사용하여 아직 회수되지 않은 각 객체에 대한 포인터를 보유합니다. 전체 GC 알고리즘은 다음과 같이 표현할 수 있습니다:
할당된 목록을 순회하며 마크 비트를 모두 클리어합니다.
GC 루트에서 시작하여 라이브 객체를 찾습니다.
도달한 각 객체에 마크 비트를 설정합니다.
할당된 목록을 다시 순회하며, 마크 비트가 설정되지 않은 각 객체에 대해: a. 힙에서 메모리를 회수하고 이를 자유 목록에 다시 놓습니다. b. 객체를 할당 목록에서 제거합니다.
라이브 객체는 보통 깊이 우선(depth-first)으로 위치하며, 생성된 객체의 그래프는 라이브 객체 그래프(live object graph)라고 불립니다. 이는 도달 가능한 객체의 전이 폐쇄(transitive closure)라고도 하며, 그림 6-1에서 예를 볼 수 있습니다.
힙의 상태는 시각화하기 어려울 수 있지만, 다행히도 이를 도와주는 몇 가지 간단한 도구가 있습니다. 그 중 가장 간단한 도구 중 하나는 jmap -histo
명령줄 도구입니다. 이는 타입별로 할당된 바이트 수와 해당 메모리 사용량에 기여하는 인스턴스 수를 보여줍니다. 다음과 같은 출력물을 생성합니다:
가비지 컬렉션 용어 사전
GC 알고리즘을 설명할 때 사용되는 전문 용어는 다소 혼란스러울 수 있으며, 일부 용어의 의미는 시간이 지남에 따라 변경되었습니다. 명확성을 위해, 특정 용어의 사용 방식을 설명하는 기본 용어 사전을 포함합니다:
Stop-the-world (STW)
GC 사이클 동안 모든 애플리케이션 스레드가 일시 중지되어야 합니다. 이는 애플리케이션 코드가 GC 스레드의 힙 상태 뷰를 무효화하지 않도록 방지합니다. 이는 대부분의 단순한 GC 알고리즘의 일반적인 경우입니다.
Concurrent
GC 스레드가 애플리케이션 스레드가 실행되는 동안 실행될 수 있습니다. 이는 매우 어렵고 계산 비용이 많이 듭니다. 사실상 거의 모든 알고리즘이 진정한 동시성을 가지지 않습니다. 대신, 대부분의 동시 컬렉션 이점을 제공하기 위해 복잡한 트릭이 사용됩니다. "CMS"에서는 HotSpot의 Concurrent Mark and Sweep (CMS) 컬렉터를 만나게 될 텐데, 이름에도 불구하고 아마도 "대부분 동시성" 컬렉터로 설명하는 것이 더 정확할 것입니다.
Parallel
가비지 컬렉션을 실행하기 위해 여러 스레드가 사용됩니다.
Exact
정확한 GC 방식은 힙 상태에 대한 충분한 타입 정보를 가지고 있어, 단일 사이클에서 모든 가비지를 수집할 수 있도록 합니다. 보다 느슨하게 말하면, 정확한 방식은 int와 포인터를 구분할 수 있는 속성을 가지고 있습니다.
Conservative
보수적인 방식은 정확한 방식의 정보를 부족하게 가지고 있습니다. 결과적으로, 보수적인 방식은 자원을 낭비할 수 있으며, 타입 시스템에 대한 근본적인 무지를 기반으로 하기 때문에 일반적으로 훨씬 덜 효율적입니다.
Moving
이동하는 컬렉터는 객체를 메모리 내에서 재배치할 수 있습니다. 이는 객체가 안정적인 주소를 가지지 않는다는 것을 의미합니다. C++와 같이 원시 포인터에 접근할 수 있는 환경에서는 이동하는 컬렉터와 자연스럽게 어울리지 않습니다.
Compacting
컬렉션 사이클이 끝난 후, 할당된 메모리(즉, 살아남은 객체)는 하나의 연속적인 영역(보통 영역의 시작 부분)에 배치되며, 빈 공간의 시작을 나타내는 포인터가 있습니다. 컴팩팅 컬렉터는 메모리 단편화를 피합니다.
Evacuating
컬렉션 사이클이 끝난 후, 수집된 영역은 완전히 비어 있으며, 모든 라이브 객체는 다른 메모리 영역으로 이동(회수)되었습니다.
다른 언어나 환경에서도 동일한 용어가 사용됩니다. 예를 들어, Firefox 웹 브라우저의 JavaScript 런타임(SpiderMonkey)도 가비지 컬렉션을 사용하며, 최근에는 Java의 GC 구현에 이미 존재하는 정확성(exactness)과 컴팩션(compaction)과 같은 기능을 추가하고 있습니다.
HotSpot 런타임 소개
GC 용어 외에도, HotSpot은 구현에 더 특화된 용어를 도입합니다. HotSpot JVM에서 가비지 컬렉션이 작동하는 방식을 완전히 이해하기 위해서는 HotSpot 내부의 몇 가지 세부 사항을 숙지해야 합니다.
이를 위해, Java에는 두 가지 종류의 값만 있다는 것을 기억하는 것이 매우 도움이 됩니다:
기본 타입(Primitive types) (byte, int 등)
객체 참조(Object references)
많은 Java 프로그래머는 객체에 대해 느슨하게 이야기하지만, 우리에게 중요한 것은 Java는 C++와 달리 일반적인 주소 역참조 메커니즘을 가지고 있지 않으며, 오직 오프셋 연산자(.)만을 사용하여 객체 참조의 필드에 접근하고 메서드를 호출할 수 있다는 점입니다. 또한, Java의 메서드 호출 의미론은 순수하게 값에 의한 호출(call-by-value)임을 염두에 두어야 합니다. 객체 참조의 경우, 이는 복사된 값이 힙 내의 객체 주소라는 것을 의미합니다.
런타임에서 객체 표현
HotSpot은 런타임에 Java 객체를 oop라는 구조를 통해 표현합니다. 이는 "ordinary object pointer"의 약자로, C 언어 의미에서의 실제 포인터입니다. 이 포인터는 참조 타입의 로컬 변수에 배치될 수 있으며, Java 메서드의 스택 프레임에서 힙 메모리 영역을 가리킵니다.
oop는 여러 가지 데이터 구조로 구성되며, Java 클래스의 인스턴스를 나타내는 oop 유형은 instanceOop
라고 합니다.
instanceOop
의 메모리 레이아웃은 다음과 같습니다:
모든 객체에 존재하는 두 개의 머신 워드의 헤더
마크 워드(mark word): 첫 번째 워드로, 인스턴스별 메타데이터를 가리키는 포인터입니다.
클래스 워드(klass word): 클래스 전체의 메타데이터를 가리키는 포인터입니다.
Java 7 및 이전 버전에서는 instanceOop
의 클래스 워드가 PermGen이라는 메모리 영역을 가리켰습니다. PermGen은 Java 힙의 일부였습니다. 일반적인 규칙은 Java 힙 내의 모든 것은 객체 헤더를 가져야 한다는 것입니다. 이러한 이전 Java 버전에서는 메타데이터를 klassOop
라고 합니다. klassOop
의 메모리 레이아웃은 간단하며, 객체 헤더 바로 다음에 클래스 메타데이터가 위치합니다.
Java 8부터는 클래스가 Java 힙의 주요 부분 외부(하지만 JVM 프로세스의 C 힙 외부는 아님)에 보관됩니다. 이 버전의 Java에서는 클래스 워드가 객체 헤더를 필요로 하지 않으며, Java 힙 외부를 가리킵니다.
HotSpot은 oop를 다양한 구조로 정의하며, OpenJDK 8 소스 트리의 hotspot/src/share/vm/oops
디렉터리에 .hpp
파일로 유지됩니다. oop의 전체 상속 계층은 다음과 같습니다:
oop 구조를 사용하여 런타임에서 객체를 표현하는 것은 특별히 비정상적이지 않습니다. 많은 다른 JVM 및 실행 환경에서도 유사한 메커니즘을 사용합니다.
GC 루트와 아레나
HotSpot에 대한 기사와 블로그 포스트는 자주 GC 루트(GC roots)를 언급합니다. 이는 메모리의 "앵커 포인트"로, 관심 있는 메모리 풀 외부에서 시작하여 내부를 가리키는 알려진 포인터입니다. 이는 메모리 풀 내부에서 시작하여 다른 메모리 위치를 가리키는 내부 포인터와는 다릅니다.
스택 프레임(Stack frames)
JNI (Java Native Interface)
레지스터(Register) (호이스팅된 변수 경우)
코드 루트(Code roots) (JVM 코드 캐시에서)
글로벌(Global)
로드된 클래스의 메타데이터(Class metadata from loaded classes)
이 정의가 다소 복잡하게 느껴진다면, 가장 단순한 예는 참조 타입의 로컬 변수가 null이 아닌 객체를 가리킬 때입니다.
HotSpot 가비지 컬렉터는 아레나(arena)라고 하는 메모리 영역 단위로 작업합니다. 이는 매우 저수준의 메커니즘으로, Java 개발자는 일반적으로 메모리 시스템의 동작을 이렇게 세부적으로 고려할 필요가 없습니다. 그러나 성능 전문가들은 JVM의 내부를 더 깊이 파고들 필요가 있을 수 있으며, 이를 위해서는 문헌에서 사용되는 개념과 용어에 익숙해지는 것이 도움이 됩니다.
중요한 사실 중 하나는 HotSpot이 Java 힙을 관리하기 위해 시스템 호출을 사용하지 않는다는 것입니다. 대신, "Basic Detection Strategies"에서 논의한 것처럼, HotSpot은 사용자 공간 코드에서 힙 크기를 관리하므로 GC 서브시스템이 일부 유형의 성능 문제를 일으키고 있는지 여부를 간단한 관찰 가능 항목을 사용하여 결정할 수 있습니다.
할당 및 생명 주기
Java 애플리케이션의 가비지 컬렉션 동작을 주도하는 두 가지 주요 요소는 다음과 같습니다:
할당 속도(Allocation rate)
일정 기간 동안(보통 MB/s로 측정) 새로 생성된 객체에 의해 사용되는 메모리의 양입니다. 이는 JVM에 의해 직접 기록되지 않지만, 상대적으로 쉽게 추정할 수 있으며, Censum과 같은 도구는 이를 정확하게 측정할 수 있습니다.
객체 생명 주기(Object lifetime)
이는 일반적으로 측정(또는 추정)하기가 훨씬 더 어렵습니다. 실제 애플리케이션의 객체 생명 주기를 진정으로 이해하는 데 수반되는 복잡성 때문에, 수동 메모리 관리를 사용하는 것에 대한 주요 반대 논점 중 하나입니다. 결과적으로, 객체 생명 주기는 할당 속도보다 더 근본적입니다.
Weak Generational Hypothesis(약한 세대 가설)은 JVM의 메모리 관리에서 중요한 부분을 차지하며, 다음과 같은 결론을 이끌어냅니다: 가비지 컬렉션된 힙은 단기적으로 살아남은 객체를 쉽게 빠르게 수집할 수 있도록 구조화되어야 하며, 이상적으로는 장기적으로 살아남은 객체를 단기 객체와 분리해야 합니다.
HotSpot은 Weak Generational Hypothesis를 활용하기 위해 여러 메커니즘을 사용합니다:
각 객체의 "세대 수(generational count)"를 추적합니다(객체가 지금까지 생존한 GC 횟수).
대형 객체를 제외하고, 새 객체는 "에덴(Eden)" 공간(또는 "Nursery")에 생성되며, 살아남은 객체는 이동될 것으로 예상됩니다.
별도의 메모리 영역("old" 또는 "Tenured" generation)을 유지하여 충분히 오래 살아남았다고 판단되는 객체를 보관합니다.
이 접근 방식은 그림 6-4에 단순화된 형태로 나타나 있으며, 특정 횟수의 GC 사이클을 생존한 객체는 Tenured 세대로 승격(promoted)됩니다.
메모리를 세대별로 나누어 컬렉션하는 것은 HotSpot이 마크 앤 스윕 컬렉션을 구현하는 방식에 몇 가지 추가적인 영향을 미칩니다. 중요한 기술 중 하나는 젊은 세대(young generation)로의 외부 포인터를 추적하는 것입니다. 이는 전체 객체 그래프를 순회하여 살아있는 젊은 객체를 식별하는 과정을 줄여줍니다.
이를 용이하게 하기 위해, HotSpot은 카드 테이블(card table)이라는 구조를 유지하여 오래된 세대에서 젊은 세대로의 잠재적인 포인터를 기록합니다. 카드 테이블은 JVM에 의해 관리되는 바이트 배열입니다. 배열의 각 요소는 오래된 세대 공간의 512바이트 영역에 해당합니다.
핵심 아이디어는 오래된 객체(o)의 참조 타입 필드가 수정될 때, 해당 객체의 instanceOop
가 포함된 카드 테이블 항목이 더럽혀진 상태(dirty)로 표시된다는 것입니다. HotSpot은 참조 필드를 업데이트할 때 간단한 쓰기 장벽(write barrier)을 사용하여 이를 달성합니다. 이는 본질적으로 다음과 같은 코드가 필드 저장 후에 실행되는 것입니다:
카드의 더러운 값은 0이며, 9비트 오른쪽 시프트는 카드 테이블의 크기가 512바이트임을 나타냅니다.
마지막으로, 힙을 old와 young 영역으로 나누는 설명은 Java의 컬렉터가 메모리를 관리해온 역사적인 방식입니다. Java 8u40부터는 새로운 컬렉터("Garbage First" 또는 G1)가 프로덕션 품질에 도달했습니다. G1은 힙 레이아웃에 대한 다소 다른 접근 방식을 가지고 있으며, 이는 "CMS"에서 만나게 될 내용입니다. Oracle의 의도는 Java 9부터 G1을 기본 컬렉터로 만드는 것입니다.
HotSpot에서의 가비지 컬렉션
C/C++ 및 유사한 환경과 달리, Java는 운영 체제를 사용하여 동적 메모리를 관리하지 않습니다. 대신, JVM은 실행 시 메모리를 미리 할당(또는 예약)하고, 사용자 공간에서 단일 연속 메모리 풀을 관리합니다.
앞서 본 것처럼, 이 메모리 풀은 전용 목적을 가진 여러 영역으로 구성되며, 객체가 힙에서 위치하는 주소는 종종 이동되므로 시간이 지남에 따라 변경될 수 있습니다. 이동을 수행하는 컬렉터는 "evacuating" 컬렉터라고 하며, 이는 대부분의 HotSpot 컬렉터가 수행하는 작업입니다.
스레드-로컬 할당(Thread-Local Allocation)
JVM은 Eden을 효율적으로 관리하기 위해 성능 향상을 위한 기법을 사용합니다. 이는 대부분의 객체가 생성되는 영역이기 때문에 매우 중요하며, 매우 단기간에 소멸되는 객체(다음 GC 사이클까지의 남은 시간보다 짧은 생명 주기를 가진 객체)는 다른 위치에 배치되지 않습니다. 효율성을 위해, JVM은 Eden을 버퍼로 분할하고 애플리케이션 스레드에 새 객체를 위한 할당 영역으로 개별 Eden 영역을 제공합니다. 이 접근 방식의 장점은 각 스레드가 해당 버퍼 내에서 다른 스레드가 할당 중일 가능성을 고려하지 않아도 된다는 것입니다. 이러한 영역을 스레드-로컬 할당 버퍼(Thread-Local Allocation Buffers, TLABs)라고 합니다.
주의 사항: HotSpot은 애플리케이션 스레드에 제공하는 TLAB의 크기를 동적으로 조정하므로, 스레드가 메모리를 빠르게 소모하는 경우 할당 오버헤드를 줄이기 위해 더 큰 TLAB을 제공할 수 있습니다.
애플리케이션 스레드가 TLAB을 독점적으로 제어할 수 있다는 점은 할당이 JVM 스레드에 대해 O(1)임을 의미합니다. 이는 스레드가 새 객체를 생성할 때, 객체에 대한 메모리가 할당되고 스레드-로컬 포인터가 다음 자유 메모리 주소로 업데이트되기 때문입니다. C 런타임에서 이는 단순한 포인터 증가(pointer bump)에 해당하며, 이는 "다음 자유" 포인터를 앞으로 이동시키는 한 줄의 추가 명령어입니다.
헤미스피어컬 컬렉션(Hemispheric Collection)
Evacuating 컬렉터의 한 특별한 경우는 헤미스피어컬 컬렉터(hemispheric evacuating collector)라고 합니다. 이 유형의 컬렉터는 보통 동일한 크기의 두 개의 공간을 사용합니다. 기본 아이디어는 살아있는 객체가 아닌 객체를 임시로 보관하는 공간으로 사용하여 짧은 생명 주기를 가진 객체가 Tenured 세대를 어지럽히지 않도록 하고, 전체 GC 빈도를 줄이는 것입니다. 이 공간들은 다음과 같은 기본 속성을 가집니다:
현재 살아있는 헤미스피어(hemisphere)를 수집할 때, 객체는 다른 헤미스피어로 컴팩팅 방식으로 이동되며, 수집된 헤미스피어는 재사용을 위해 비워집니다.
공간의 한 쪽 절반은 항상 완전히 비어 있습니다.
이 접근 방식은 컬렉션의 헤미스피어 부분이 실제로 수용할 수 있는 메모리의 두 배를 사용하는 다소 낭비적일 수 있지만, 공간의 크기가 지나치지 않다면 유용한 기법이 될 수 있습니다. HotSpot은 이 헤미스피어 접근 방식을 Eden 공간과 결합하여 젊은 세대를 위한 컬렉터를 제공합니다.
HotSpot의 젊은 힙의 헤미스피어 부분은 survivor 공간(survivor spaces)이라고 합니다. VisualGC의 시각에서 보면, survivor 공간은 일반적으로 Eden보다 상대적으로 작으며, 각 젊은 세대 컬렉션 시에 survivor 공간이 교체됩니다.
병렬 컬렉터(Parallel Collectors)
Java 8 및 이전 버전에서는 JVM의 기본 컬렉터가 병렬 컬렉터(parallel collectors)입니다. 이들은 젊은 세대와 전체 컬렉션 모두에 대해 완전히 Stop-the-world(STW)이며, 처리량을 최적화하는 데 중점을 둡니다. 모든 애플리케이션 스레드를 정지시킨 후, 병렬 컬렉터는 가능한 모든 CPU 코어를 사용하여 메모리를 가능한 한 빨리 수집합니다. 사용 가능한 병렬 컬렉터는 다음과 같습니다:
Parallel GC
젊은 세대를 위한 가장 단순한 컬렉터
ParNew
CMS 컬렉터와 함께 사용되는 병렬 GC의 약간 변형된 형태
ParallelOld
오래된(Tenured) 세대를 위한 병렬 컬렉터
병렬 컬렉터는 일부 면에서 서로 유사합니다. 그들은 여러 스레드를 사용하여 살아있는 객체를 가능한 한 빨리 식별하고 최소한의 부기(bookkeeping)를 수행하도록 설계되었습니다. 그러나 그들 사이에는 몇 가지 차이점이 있으며, 두 가지 주요 유형의 컬렉션을 살펴보겠습니다.
젊은 세대 병렬 컬렉션(Young Parallel Collections)
가장 일반적인 컬렉션 유형은 젊은 세대(generational collection)입니다. 이는 보통 스레드가 Eden에서 객체를 할당하려 하지만, 해당 스레드의 TLAB에 충분한 공간이 없고 JVM이 스레드에 새 TLAB을 할당할 수 없을 때 발생합니다. 이 경우, JVM은 모든 애플리케이션 스레드를 정지시킬 수밖에 없습니다—하나의 스레드가 할당할 수 없으면 곧 모든 스레드가 할당할 수 없게 될 것이기 때문입니다.
모든 애플리케이션(사용자) 스레드가 정지된 후, HotSpot은 젊은 세대(에덴과 현재 비어 있지 않은 survivor 공간)를 살펴보고 모든 가비지를 식별합니다. 이는 GC 루트(오래된 세대에서 오는 GC 루트를 식별하기 위해 카드 테이블을 사용)로부터 시작하는 병렬 마킹 스캔을 사용합니다.
병렬 GC 컬렉터는 살아남은 모든 객체를 현재 비어 있는 survivor 공간으로 회수(이동)하고, 그들이 이동될 때 세대 수를 증가시킵니다. 마지막으로, 에덴과 방금 회수된 survivor 공간을 비어 있는 재사용 가능한 공간으로 표시하고, 스레드가 다시 시작되어 애플리케이션 스레드에 TLAB을 다시 할당할 수 있도록 합니다. 이 과정은 그림 6-7과 6-8에서 볼 수 있습니다.
이 접근 방식은 Weak Generational Hypothesis의 이점을 최대한 활용하려고 하며, 살아있는 객체만을 다루려고 합니다. 또한 가능한 한 많은 코어를 사용하여 STW 일시 정지를 단축하려고 합니다.
오래된 세대 병렬 컬렉션(Old Parallel Collections)
ParallelOld 컬렉터는 현재(2024년 기준 Java 8) 오래된 세대를 위한 기본 컬렉터입니다. 이는 Parallel GC와 몇 가지 강력한 유사점을 가지지만, 근본적인 차이점도 있습니다. 특히, Parallel GC는 헤미스피어컬(evacuating) 컬렉터인 반면, ParallelOld는 단일 연속 메모리 공간을 가진 컴팩팅 컬렉터(compacting collector)입니다.
이는 오래된 세대가 이동할 다른 공간이 없기 때문에, 병렬 컬렉터가 오래된 세대 내에서 객체를 재배치하여 오래된 객체가 소멸된 후 남은 공간을 회수하려고 시도한다는 것을 의미합니다. 따라서 컬렉터는 메모리를 매우 효율적으로 사용할 수 있으며, 메모리 단편화 문제를 겪지 않습니다. 이는 전체 GC 사이클 동안 많은 CPU를 사용할 수 있는 대신, 메모리 레이아웃을 매우 효율적으로 유지합니다. 두 접근 방식의 차이는 그림 6-9에서 볼 수 있습니다.
두 메모리 공간의 동작은 매우 급격히 다릅니다. 젊은 세대 컬렉션의 목적은 단기 객체를 처리하는 것이므로, 할당과 클리어링 시에 젊은 공간의 점유율이 급격히 변합니다. 반면, 오래된 세대 공간은 크게 변하지 않습니다. 가끔 큰 객체가 Tenured에 직접 생성되지만, 그 외에는 컬렉션 시에만 변경됩니다
병렬 컬렉터의 한계(Limitations of Parallel Collectors)
병렬 컬렉터는 세대의 전체 내용을 한 번에 처리하고, 가능한 한 효율적으로 수집하도록 설계되었습니다. 그러나 이러한 설계에는 몇 가지 단점이 있습니다.
그러나 오래된 세대를 수집하는 것은 매우 다른 이야기입니다. 오래된 세대는 기본적으로 젊은 세대의 7배 크기입니다. 이 사실만으로도 전체 컬렉션의 STW 길이가 젊은 컬렉션보다 훨씬 길어질 것입니다.
또 다른 주요 사실은 마킹 시간은 영역 내의 살아있는 객체 수에 비례한다는 것입니다. 오래된 객체는 장기 생존할 수 있으므로, 전체 컬렉션 시에 살아있는 객체 수가 많을 수 있습니다. 이는 ParallelOld 컬렉션의 또 다른 약점—일시 정지 시간이 힙 크기에 따라 대략 선형적으로 확장된다는 점을 설명합니다. 힙 크기가 증가함에 따라 ParallelOld는 일시 정지 시간이 나빠지는 경향이 있습니다.
새로운 GC 이론 학습자는 마크 앤 스윕 알고리즘을 약간 수정하여 STW 일시 정지를 완화할 수 있다고 생각할 수 있지만, 이는 사실이 아닙니다. 가비지 컬렉션은 40년 넘게 컴퓨터 과학의 매우 잘 연구된 연구 영역이었으며, 이러한 "간단한" 개선은 발견되지 않았습니다.
할당의 역할(Role of Allocation)
Java의 가비지 컬렉션 프로세스는 주로 메모리 할당이 요청되었지만, 필요한 만큼의 자유 메모리가 없을 때 트리거됩니다. 이는 GC 사이클이 고정되거나 예측 가능한 일정에 따라 발생하지 않고, 순전히 필요에 따라 발생함을 의미합니다. 이는 가비지 컬렉션의 가장 중요한 측면 중 하나입니다: 이는 결정론적이지 않으며, 규칙적인 간격으로 발생하지 않습니다. 대신, 힙의 메모리 공간 중 하나 이상이 본질적으로 가득 차서 추가 객체 생성을 할 수 없게 될 때 GC 사이클이 트리거됩니다.
GC가 발생하면, 모든 애플리케이션 스레드가 일시 중지됩니다(객체를 더 이상 생성할 수 없고, 대부분의 Java 코드는 새로운 객체를 생성하지 않고 오랫동안 실행될 수 없기 때문입니다). JVM은 모든 코어를 인수로 받아 GC를 수행하고, 메모리를 회수한 후 애플리케이션 스레드를 다시 시작합니다.
할당이 왜 중요한지 더 잘 이해하기 위해, 다음과 같은 매우 단순화된 사례 연구를 고려해 봅시다. 힙 매개변수는 다음과 같이 설정되며, 시간이 지남에 따라 변경되지 않는다고 가정합니다. 실제 애플리케이션은 동적으로 크기가 조정되는 힙을 가지지만, 이 예는 할당 속도와 생명 주기의 영향을 단순하게 설명하는 데 도움이 됩니다.
Heap Parameters:
애플리케이션이 정상 상태에 도달한 후, 다음과 같은 GC 메트릭이 관찰됩니다
이는 에덴이 4초 만에 가득 찰 것임을 보여줍니다. 따라서 정상 상태에서는 젊은 세대 GC가 4초마다 발생합니다. 에덴이 가득 차면 GC가 트리거됩니다. 대부분의 객체는 죽었지만, 살아남은 객체는 survivor 공간(SS1)으로 이동됩니다. 이 단순 모델에서 GC0는 다음과 같습니다:
다음으로, 이 할당 시나리오에 대한 매우 간단한 시뮬레이터를 살펴보겠습니다. 이는 객체를 할당하는 모델링을 시뮬레이션하며, 할당 매개변수는 다음과 같습니다:
x, y: 각 객체의 크기 정의
할당 속도(mbPerSec)
단기 생명 주기(shortLivedMS)
애플리케이션이 시뮬레이션해야 하는 스레드 수(nThreads)
할당기 메인 러너는 다음과 같은 간단한 모의 객체와 결합됩니다:
VisualVM에서 볼 때, 이는 Java 애플리케이션의 메모리 동작에서 흔히 관찰되는 단순한 톱니 모양(sawtooth pattern)을 표시합니다. 이는 Java 애플리케이션이 힙을 효율적으로 사용하는 메모리 패턴을 보여줍니다.
Last updated