.NET 가비지 수집기(GC)는 개체를 크고 작은 개체로 나눕니다. 개체가 크면 개체가 작은 경우보다 일부 특성이 더 중요해집니다. 예를 들어, 힙의 다른 메모리에 데이터를 복사하는 압축 작업이 비용이 많이 들 수 있습니다. 이 때문에 가비지 수집기는 큰 개체를 LOH(큰 개체 힙)에 배치합니다. 이 문서에서는 개체를 큰 개체로 한정하는 항목, 큰 개체를 수집하는 방법 및 큰 개체가 적용하는 성능에 미치는 영향에 대해 설명합니다.
중요합니다
이 문서에서는 Windows 시스템에서만 실행되는 .NET Framework 및 .NET Core의 큰 개체 힙에 대해 설명합니다. 다른 플랫폼의 .NET 구현에서 실행되는 LOH는 다루지 않습니다.
개체가 LOH에서 끝나는 방법
개체의 크기가 85,000바이트보다 크거나 같으면 큰 개체로 간주됩니다. 이 숫자는 성능 튜닝에 의해 결정되었습니다. 개체 할당 요청이 85,000바이트 이상인 경우 런타임은 큰 개체 힙에 할당합니다.
이것이 의미하는 바를 이해하려면 가비지 수집기의 몇 가지 기본 사항을 검사하는 것이 유용합니다.
가비지 수집기는 세대별 수집기입니다. 0세대, 1세대, 2세대의 3세대가 있습니다. 3세대가 있는 이유는 잘 조정된 앱에서 대부분의 개체가 gen0에서 죽기 때문입니다. 예를 들어 서버 앱에서 각 요청과 연결된 할당은 요청이 완료된 후 중단되어야 합니다. 배정 요청은 gen1에 전달되어 그곳에서 소멸됩니다. 기본적으로 gen1은 젊은 개체 영역과 수명이 긴 개체 영역 사이의 버퍼 역할을 합니다.
새로 할당된 개체는 새로운 개체 세대를 구성하며 암시적으로 0세대 수집입니다. 그러나 큰 개체인 경우 3세대라고도 하는 LOH(큰 개체 힙)로 이동합니다. 3세대는 물리적 세대로, 논리적으로는 2세대의 일부로 수집됩니다.
큰 개체는 2세대 컬렉션 중에만 수집되므로 2세대에 속합니다. 한 세대가 수집되면 모든 젊은 세대도 수집됩니다. 예를 들어 1세대 GC가 발생하면 1세대와 0세대가 모두 수집됩니다. 그리고 2세대 GC가 발생하면 전체 힙이 수집됩니다. 이러한 이유로 2세대 GC를 전체 GC라고도 합니다. 이 문서에서는 전체 GC 대신 2세대 GC를 참조하지만 용어는 서로 교환할 수 있습니다.
세대는 GC 힙의 논리적 보기를 제공합니다. 물리적으로 개체는 관리되는 힙 세그먼트에 있습니다. 관리되는 힙 세그먼트는 관리 코드 대신 VirtualAlloc 함수를 호출하여 GC가 OS에서 예약하는 메모리 청크입니다. CLR이 로드되면 GC는 두 개의 초기 힙 세그먼트를 할당합니다. 하나는 작은 개체(작은 개체 힙 또는 SOH) 및 큰 개체(큰 개체 힙)에 대한 세그먼트입니다.
그런 다음 이러한 관리되는 힙 세그먼트에 관리되는 개체를 배치하여 할당 요청을 충족합니다. 개체가 85,000바이트 미만이면 SOH의 세그먼트에 배치됩니다. 그렇지 않으면 LOH 세그먼트에 배치됩니다. 세그먼트는 더 많은 개체들이 할당됨에 따라 작은 청크 단위로 커밋됩니다. SOH의 경우 GC에서 살아남는 개체는 다음 세대로 승격됩니다. 0세대 컬렉션에서 유지되는 개체는 이제 1세대 개체로 간주됩니다. 그러나 가장 오래된 세대에서 살아남은 개체는 여전히 가장 오래된 세대로 간주됩니다. 즉, 2세대의 생존자는 2세대 개체입니다. LOH의 생존자는 LOH 개체(gen2로 수집됨)입니다.
사용자 코드는 0세대(작은 개체) 또는 LOH(큰 개체)에서만 할당할 수 있습니다. GC만 1세대(0세대에서 생존자를 승격)와 2세대(1세대에서 생존자를 승격)에서 개체를 "할당"할 수 있습니다.
가비지 수집이 활성화되면, GC는 활성 객체를 추적하여 압축합니다. 하지만 압축 비용이 많이 들기 때문에 GC는 LOH를 정리합니다; 대용량 개체 할당 요청을 충족하기 위해 나중에 다시 사용할 수 있는 삭제된 개체의 빈 목록을 만듭니다. 인접한 휴면 개체는 하나의 사용 가능한 개체로 전환됩니다.
.NET Core 및 .NET Framework(.NET Framework 4.5.1부터 시작)에는 사용자가 다음 전체 차단 GC 중에 LOH를 압축하도록 지정할 수 있는 GCSettings.LargeObjectHeapCompactionMode 속성이 포함됩니다. 그리고 나중에 .NET은 LOH를 자동으로 압축하기로 결정할 수 있습니다. 즉, 큰 개체를 할당하고 이동하지 않도록 하려면 여전히 고정해야 합니다.
그림 1은 0세대 GC 이후 Obj1
와 Obj3
가 사라진 후 1세대를 형성하는 시나리오와, 1세대 GC 이후 Obj2
와 Obj5
가 사라진 후 2세대를 형성하는 시나리오를 보여 줍니다. 이 그림과 다음 그림은 그림 용도로만 사용됩니다. 힙에서 발생하는 작업을 더 잘 표시할 수 있는 개체가 거의 포함되어 있지 않습니다. 실제로 더 많은 개체가 일반적으로 GC에 포함됩니다.
그림 1: 0세대 및 1세대 GC
그림 2는 2세대 GC가 Obj1
와 Obj2
가 죽었음을 확인한 후 GC가 Obj1
와 Obj2
가 차지하던 메모리에서 연속된 여유 공간을 형성하여 Obj4
의 할당 요청을 충족했음을 보여줍니다. 마지막 개체 뒤의 공간( Obj3
세그먼트의 끝까지)을 사용하여 할당 요청을 충족할 수도 있습니다.
그림 2: 2세대 GC 이후
큰 개체 할당 요청을 수용하기에 충분한 여유 공간이 없는 경우 GC는 먼저 OS에서 더 많은 세그먼트를 획득하려고 시도합니다. 실패하면 공간을 확보할 수 있도록 2세대 GC가 트리거됩니다.
1세대 또는 2세대 GC에서 가비지 수집기는 VirtualFree 함수를 호출하여 활성 개체가 없는 세그먼트를 운영 체제로 반환합니다. 마지막 라이브 객체부터 세그먼트의 끝까지의 공간은 해제됩니다(단, gen0/gen1이 있는 임시 세그먼트에서는 애플리케이션이 바로 할당하기 때문에 가비지 수집기가 일부 공간을 유지합니다). 또한 사용 가능한 공간은 다시 설정되지만 커밋된 상태로 유지됩니다. 즉, OS가 디스크에 데이터를 다시 쓸 필요가 없습니다.
LOH는 2세대 GC 중에만 수집되므로 이러한 GC 중에만 LOH 세그먼트를 해제할 수 있습니다. 그림 3은 가비지 수집기가 세그먼트 2를 OS로 다시 반환하고, 나머지 세그먼트에서 더 많은 공간을 반환하는 시나리오를 보여줍니다. 세그먼트의 끝에 커밋되지 않은 공간을 사용하여 큰 개체 할당 요청을 충족해야 하는 경우 메모리를 다시 커밋합니다. 커밋/커밋 해제에 대한 설명은 VirtualAlloc에 대한 설명서를 참조하세요.
그림 3: 2세대 GC 이후의 LOH
큰 객체는 언제 수거되나요?
일반적으로 GC는 다음 세 가지 조건 중 하나에서 발생합니다.
할당이 0세대 임계값 또는 큰 개체 임계값을 초과합니다.
임계값은 세대의 속성입니다. 가비지 수집기가 개체를 특정 세대에 할당할 때 해당 세대를 위한 임계값이 설정됩니다. 임계값을 초과하면 해당 생성 시 GC가 트리거됩니다. 작거나 큰 개체를 할당하는 경우 0세대와 LOH의 임계값을 각각 사용합니다. 가비지 수집기가 1세대 및 2세대에 할당할 때, 해당 임계값을 소모합니다. 이러한 임계값은 프로그램이 실행될 때 동적으로 조정됩니다.
일반적인 경우입니다. 대부분의 GC는 관리되는 힙의 할당으로 인해 발생합니다.
GC.Collect 메서드가 호출됩니다.
매개 변수 없는 GC.Collect() 메서드가 호출되거나 다른 오버로드가 인수로 전달되는 GC.MaxGeneration 경우 LOH는 관리되는 힙의 나머지 부분과 함께 수집됩니다.
시스템이 메모리 부족 상황에 있습니다.
가비지 수집기가 OS에서 높은 메모리 알림을 받을 때 발생합니다. 가비지 수집기가 세대 2의 GC를 수행하는 것이 효율적이라고 판단되면 이를 실행합니다.
LOH 성능에 미치는 영향
큰 개체 힙에 대한 할당은 다음과 같은 방법으로 성능에 영향을 줍니다.
할당 비용.
CLR은 새 개체를 할당할 때마다 해당 메모리가 초기화되도록 보장합니다. 즉, 큰 개체의 할당 비용은 메모리 지우기(GC를 트리거하지 않는 한)에 의해 좌우됩니다. 1 바이트를 지우는 데 두 주기가 걸리는 경우 가장 작은 큰 개체를 지우려면 170,000 주기가 걸립니다. 2GHz 컴퓨터에서 16MB 개체의 메모리를 지우려면 약 16ms가 걸립니다. 이는 다소 큰 비용입니다.
수집 비용
LOH와 2세대가 함께 수집되기 때문에 하나의 임계값을 초과하면 2세대 컬렉션이 트리거됩니다. LOH로 인해 2세대 컬렉션이 트리거되는 경우 GC 이후에 2세대가 더 작을 필요는 없습니다. 2세대에 대한 데이터가 많지 않은 경우 이는 최소한의 영향을 미칩니다. 그러나 2세대가 크면 많은 2세대 GC가 트리거되면 성능 문제가 발생할 수 있습니다. 많은 큰 개체가 일시적으로 할당되고 SOH가 큰 경우 GC를 수행하는 데 너무 많은 시간을 할애할 수 있습니다. 이와 더불어, 정말 큰 개체를 계속 할당하고 해제하면, 그에 따른 할당 비용이 점점 증가할 수 있습니다.
참조 형식이 있는 배열 요소입니다.
LOH의 매우 큰 개체는 일반적으로 배열입니다(실제로 큰 인스턴스 개체가 있는 경우는 매우 드뭅니다). 배열의 요소가 참조가 풍부한 경우 요소가 참조가 풍부하지 않은 경우 존재하지 않는 비용이 발생합니다. 요소에 참조가 없는 경우 가비지 수집기는 배열을 전혀 통과할 필요가 없습니다. 예를 들어 배열을 사용하여 노드를 이진 트리에 저장하는 경우 이를 구현하는 한 가지 방법은 실제 노드에서 노드의 오른쪽 및 왼쪽 노드를 참조하는 것입니다.
class Node { Data d; Node left; Node right; }; Node[] binary_tr = new Node [num_nodes];
num_nodes
이 크면 가비지 수집기는 요소당 적어도 두 개의 참조를 통과해야 합니다. 다른 방법은 오른쪽 및 왼쪽 노드의 인덱스를 저장하는 것입니다.class Node { Data d; uint left_index; uint right_index; } ;
왼쪽 노드 데이터를
left.d
가 아닌binary_tr[left_index].d
로 참조합니다. 또한 가비지 수집기는 왼쪽 및 오른쪽 노드에 대한 참조를 볼 필요가 없습니다.
세 가지 요인 중에서 처음 두 가지는 일반적으로 세 번째 요소보다 더 중요합니다. 따라서 임시 개체를 할당하는 대신 다시 사용하는 큰 개체 풀을 할당하는 것이 좋습니다.
LOH에 대한 성능 데이터 수집
특정 영역에 대한 성능 데이터를 수집하기 전에 다음을 이미 수행해야 합니다.
- 이 지역을 살펴봐야 한다는 증거를 찾았습니다.
- 본 성능 문제를 설명할 수 있는 항목을 찾지 못한 채 알고 있는 다른 영역을 모두 사용했습니다.
메모리 및 CPU의 기본 사항에 대한 자세한 내용은 솔루션을 찾기 전에 문제 이해 블로그를 참조하세요.
다음 도구를 사용하여 LOH 성능에 대한 데이터를 수집할 수 있습니다.
.NET CLR 메모리 성능 카운터
.NET CLR 메모리 성능 카운터는 일반적으로 성능 문제를 조사하는 좋은 첫 번째 단계입니다( ETW 이벤트를 사용하는 것이 좋습니다). 성능 카운터를 보는 일반적인 방법은 성능 모니터(perfmon.exe)를 사용하는 것입니다. 추가(Ctrl + A)를 선택하여 관심 있는 프로세스에 대한 흥미로운 카운터를 추가합니다. 성능 카운터 데이터를 로그 파일에 저장할 수 있습니다.
.NET CLR 메모리 범주의 다음 두 카운터는 LOH와 관련이 있습니다.
# 2세대 컬렉션
프로세스가 시작된 이후 2세대 GC가 발생한 횟수를 표시합니다. 2세대 컬렉션(완전 가비지 수집이라고도 함)이 끝날 때 카운터가 증가합니다. 이 카운터는 마지막으로 관찰된 값을 표시합니다.
큰 개체 힙 크기
LOH의 현재 크기(여유 공간 포함)를 바이트 단위로 표시합니다. 이 카운터는 메모리 할당 시마다가 아니라, 가비지 수집 과정이 끝난 후에 업데이트됩니다.
PerformanceCounter 클래스를 사용하여 프로그래밍 방식으로 성능 카운터를 쿼리할 수도 있습니다. LOH의 경우 CategoryName에 ".NET CLR 메모리"를, CounterName에 "큰 개체 힙 크기"를 지정합니다.
PerformanceCounter performanceCounter = new()
{
CategoryName = ".NET CLR Memory",
CounterName = "Large Object Heap size",
InstanceName = "<instance_name>"
};
Console.WriteLine(performanceCounter.NextValue());
일상적인 테스트 프로세스의 일부로 프로그래밍 방식으로 카운터를 수집하는 것이 일반적입니다. 일반 값이 아닌 값이 있는 카운터를 발견할 때 다른 방법을 사용하여 조사에 도움이 되는 더 자세한 데이터를 가져옵니다.
비고
ETW는 훨씬 더 풍부한 정보를 제공하기 때문에 성능 카운터 대신 ETW 이벤트를 사용하는 것이 좋습니다.
ETW 이벤트
가비지 수집기는 힙이 수행하는 작업과 이유를 이해하는 데 도움이 되는 풍부한 ETW 이벤트 집합을 제공합니다. 다음 블로그 게시물은 ETW를 사용하여 GC 이벤트를 수집하고 이해하는 방법을 보여 줍니다.
임시 LOH 할당으로 인한 과도한 2세대 GC를 식별하려면 GC에 대한 트리거 이유 열을 참조하세요. 임시 큰 개체만 할당하는 간단한 테스트의 경우 다음 PerfView 명령을 사용하여 ETW 이벤트에 대한 정보를 수집할 수 있습니다.
perfview /GCCollectOnly /AcceptEULA /nogui collect
결과는 다음과 같습니다.
보시다시피 모든 GC는 2세대 GC이며 모두 AllocLarge에 의해 트리거됩니다. 즉, 큰 개체를 할당하면 이 GC가 트리거됩니다. LOH 생존율 %열에%1이 표시되기 때문에 이러한 할당이 일시적이라는 것을 알고 있습니다.
이 대규모 객체를 할당한 주체를 알려주는 추가 ETW 이벤트를 수집할 수 있습니다. 다음 명령줄:
perfview /GCOnly /AcceptEULA /nogui collect
는 할당의 약 100k 값마다 실행되는 AllocationTick 이벤트를 수집합니다. 즉, 큰 개체가 할당될 때마다 이벤트가 발생합니다. 그런 다음, 큰 개체를 할당한 호출 스택을 보여 주는 GC 힙 할당 보기 중 하나를 볼 수 있습니다.
이 테스트는 메서드에서 Main
큰 개체를 할당하는 매우 간단한 테스트입니다.
디버거
메모리 덤프만 있고 LOH에서 실제로 어떤 개체가 있는지 확인해야 하는 경우 .NET에서 제공하는 SoS 디버거 확장을 사용할 수 있습니다.
비고
이 섹션에 언급된 디버깅 명령은 Windows 디버거에 적용할 수 있습니다.
다음은 LOH 분석의 샘플 출력을 보여줍니다.
0:003> .loadby sos mscorwks
0:003> !eeheap -gc
Number of GC Heaps: 1
generation 0 starts at 0x013e35ec
sdgeneration 1 starts at 0x013e1b6c
generation 2 starts at 0x013e1000
ephemeral segment allocation context: none
segment begin allocated size
0018f2d0 790d5588 790f4b38 0x0001f5b0(128432)
013e0000 013e1000 013e35f8 0x000025f8(9720)
Large object heap starts at 0x023e1000
segment begin allocated size
023e0000 023e1000 033db630 0x00ffa630(16754224)
033e0000 033e1000 043cdf98 0x00fecf98(16699288)
043e0000 043e1000 05368b58 0x00f87b58(16284504)
Total Size 0x2f90cc8(49876168)
------------------------------
GC Heap Size 0x2f90cc8(49876168)
0:003> !dumpheap -stat 023e1000 033db630
total 133 objects
Statistics:
MT Count TotalSize Class Name
001521d0 66 2081792 Free
7912273c 63 6663696 System.Byte[]
7912254c 4 8008736 System.Object[]
Total 133 objects
LOH 힙 크기는 (16,754,224 + 16,699,288 + 16,284,504) = 49,738,016 바이트입니다. 주소 023e1000과 033db630 사이에 8,008,736바이트가 개체 배열 System.Object 에 의해 점유되고, 6,663,696바이트가 개체 배열 System.Byte 에 의해 점유되고, 2,081,792바이트가 사용 가능한 공간이 차지합니다.
경우에 따라 디버거는 LOH의 총 크기가 85,000바이트 미만임을 보여 줍니다. 런타임 자체가 LOH를 사용하여 큰 개체보다 작은 일부 개체를 할당하기 때문에 발생합니다.
LOH가 압축되지 않으므로 LOH가 조각화의 원인으로 생각되는 경우도 있습니다. 조각화란 다음과 같습니다.
관리되는 힙의 조각화는 관리되는 개체들 사이의 여유 공간 양으로 나타납니다. SoS
!dumpheap –type Free
에서 명령은 관리되는 개체 간의 여유 공간의 양을 표시합니다.로 표시된
MEM_FREE
메모리인 VM(가상 메모리) 주소 공간의 조각화입니다. windbg에서 다양한 디버거 명령을 사용하여 가져올 수 있습니다.다음 예제에서는 VM 공간의 조각화를 보여줍니다.
0:000> !address 00000000 : 00000000 - 00010000 Type 00000000 Protect 00000001 PAGE_NOACCESS State 00010000 MEM_FREE Usage RegionUsageFree 00010000 : 00010000 - 00002000 Type 00020000 MEM_PRIVATE Protect 00000004 PAGE_READWRITE State 00001000 MEM_COMMIT Usage RegionUsageEnvironmentBlock 00012000 : 00012000 - 0000e000 Type 00000000 Protect 00000001 PAGE_NOACCESS State 00010000 MEM_FREE Usage RegionUsageFree … [omitted] -------------------- Usage SUMMARY -------------------------- TotSize ( KB) Pct(Tots) Pct(Busy) Usage 701000 ( 7172) : 00.34% 20.69% : RegionUsageIsVAD 7de15000 ( 2062420) : 98.35% 00.00% : RegionUsageFree 1452000 ( 20808) : 00.99% 60.02% : RegionUsageImage 300000 ( 3072) : 00.15% 08.86% : RegionUsageStack 3000 ( 12) : 00.00% 00.03% : RegionUsageTeb 381000 ( 3588) : 00.17% 10.35% : RegionUsageHeap 0 ( 0) : 00.00% 00.00% : RegionUsagePageHeap 1000 ( 4) : 00.00% 00.01% : RegionUsagePeb 1000 ( 4) : 00.00% 00.01% : RegionUsageProcessParametrs 2000 ( 8) : 00.00% 00.02% : RegionUsageEnvironmentBlock Tot: 7fff0000 (2097088 KB) Busy: 021db000 (34668 KB) -------------------- Type SUMMARY -------------------------- TotSize ( KB) Pct(Tots) Usage 7de15000 ( 2062420) : 98.35% : <free> 1452000 ( 20808) : 00.99% : MEM_IMAGE 69f000 ( 6780) : 00.32% : MEM_MAPPED 6ea000 ( 7080) : 00.34% : MEM_PRIVATE -------------------- State SUMMARY -------------------------- TotSize ( KB) Pct(Tots) Usage 1a58000 ( 26976) : 01.29% : MEM_COMMIT 7de15000 ( 2062420) : 98.35% : MEM_FREE 783000 ( 7692) : 00.37% : MEM_RESERVE Largest free region: Base 01432000 - Size 707ee000 (1843128 KB)
가상 메모리(VM) 조각화는 일시적인 대형 객체로 인해 발생하는 경우가 더 많습니다. 이러한 객체들은 가비지 수집기가 OS에서 새 관리 힙 세그먼트를 빈번하게 가져오고 비어 있는 힙 세그먼트를 OS에 다시 반환해야 하는 상황을 초래합니다.
LOH로 인해 VM 조각화가 발생하는지 확인하려면 VirtualAlloc 및 VirtualFree 에서 중단점을 설정하여 호출한 사람을 확인할 수 있습니다. 예를 들어 OS에서 8MB보다 큰 가상 메모리 청크를 할당하려고 한 사용자를 확인하려면 다음과 같이 중단점을 설정할 수 있습니다.
bp kernel32!virtualalloc "j (dwo(@esp+8)>800000) 'kb';'g'"
이 명령은 디버거로 분할되고 할당 크기가 8MB(0x800000)보다 큰 VirtualAlloc 가 호출된 경우에만 호출 스택을 표시합니다.
CLR 2.0에는 세그먼트(크고 작은 개체 힙 포함)가 자주 획득되고 릴리스되는 시나리오에 유용할 수 있는 VM Hoarding 이라는 기능이 추가되었습니다. VM Hoarding을 지정하기 위해서는 호스팅 API를 통해 시작 플래그인 STARTUP_HOARD_GC_VM
를 지정합니다. CLR은 빈 세그먼트를 OS로 다시 해제하는 대신 이러한 세그먼트의 메모리를 커밋 해제하고 대기 목록에 배치합니다. (CLR은 너무 큰 세그먼트에 대해서는 이 작업을 수행하지 않습니다.) CLR은 나중에 해당 세그먼트를 사용하여 새 세그먼트 요청을 충족합니다. 다음에 앱에 새 세그먼트가 필요할 때 CLR은 충분히 큰 세그먼트를 찾을 수 있는 경우 이 대기 목록의 세그먼트를 사용합니다.
VM 비장은 메모리 부족 예외를 방지하기 위해 시스템에서 실행되는 주요 앱인 일부 서버 앱과 같이 이미 획득한 세그먼트를 유지하려는 애플리케이션에도 유용합니다.
이 기능을 사용할 때 애플리케이션을 신중하게 테스트하여 애플리케이션의 메모리 사용량이 상당히 안정적인지 확인하는 것이 좋습니다.
.NET