이번에는 가비지 컬렉터 사용법과 가비지 컬렉터 사용 시 주의사항에 대해 알려드리겠습니다. 아래는 실제 사례입니다.
가비지 수집기는 양날의 검입니다. 장점은 메모리 관리를 위해 프로그래머의 작업이 필요하지 않기 때문에 프로그램의 메모리 관리 코드를 크게 단순화할 수 있다는 것입니다. 이를 통해 장기 실행 프로그램에서 메모리 누수를 줄입니다(근절하지는 않음). 일부 프로그래머의 경우 코드 성능을 향상시킬 수도 있습니다.
반면에 가비지 컬렉터를 선택한다는 것은 프로그램이 메모리를 완전히 제어할 수 없다는 것을 의미하며 이것이 모바일 단말기 개발의 핵심입니다. JavaScript의 경우 프로그램에서 메모리 관리가 불가능합니다. ECMAScript 표준은 가비지 수집기 인터페이스를 노출하지 않습니다. 웹 애플리케이션에는 메모리를 관리하거나 가비지 수집기를 요청할 방법이 없습니다.
엄밀히 말하면 가비지 컬렉터를 사용하는 언어가 가비지 컬렉터를 사용하지 않는 언어보다 성능이 반드시 더 좋거나 나쁜 것은 아닙니다. C 언어에서 메모리 할당 및 해제는 매우 비용이 많이 드는 작업일 수 있습니다. 할당된 메모리를 나중에 해제하려면 힙 관리가 복잡해지는 경향이 있습니다. 관리되는 메모리 언어에서는 메모리 할당이 포인터만 추가하는 경우가 많습니다. 그러나 메모리가 고갈되면 수집을 위해 개입하는 가비지 컬렉터의 막대한 비용을 보게 될 것입니다. 탐색되지 않은 가비지 수집기는 프로그램 실행 시 길고 예측할 수 없는 일시 중지를 유발하며, 이는 대화형 시스템(특히 애니메이션 효과가 있는 시스템) 사용 경험에 직접적인 영향을 미칩니다. 참조 계산 시스템은 종종 가비지 수집의 대안으로 선전되지만 큰 하위 그래프의 마지막 개체가 역참조될 때 예기치 않은 일시 중지가 발생할 수도 있습니다. 게다가, 참조 카운팅 시스템은 읽기, 다시 쓰기, 저장 작업을 자주 수행할 때 상당한 성능 부담을 갖게 됩니다.
좋든 나쁘든 JavaScript에는 가비지 수집기가 필요합니다. V8의 가비지 수집기 구현은 이제 뛰어난 성능, 짧은 일시 중지 및 제어 가능한 성능 부담을 통해 성숙해졌습니다.
가비지 수집기가 해결해야 할 가장 기본적인 문제는 재활용해야 하는 메모리를 식별하는 것입니다. 일단 식별되면 이러한 메모리 영역은 향후 할당에서 재사용되거나 운영 체제로 반환될 수 있습니다. 객체가 활성화되지 않으면 객체는 죽은 것입니다(말도 안되는 소리). 객체는 루트 객체나 다른 활성 객체가 가리키는 경우에만 활성 상태입니다. 루트 객체는 활성 객체로 정의되며 브라우저나 V8에서 참조하는 객체입니다. 예를 들어, 지역 변수가 가리키는 객체는 해당 스택이 루트 객체로 간주되기 때문에 루트 객체에 속합니다. DOM 요소와 같은 전역 객체는 항상 액세스할 수 있기 때문에 루트 객체에 속합니다. . 어떤 경우에는 약한 참조일 뿐입니다.
측면에서 보면 위의 정의는 매우 느슨합니다. 실제로 프로그램에서 참조할 수 있을 때 개체가 활성 상태라고 말할 수 있습니다. 예:
function f() { var obj = {x: 12}; g(); // 可能包含一个死循环 return obj.x; }
def scavenge(): swap(fromSpace, toSpace) allocationPtr = toSpace.bottom scanPtr = toSpace.bottom for i = 0..len(roots): root = roots[i] if inFromSpace(root): rootCopy = copyObject(&allocationPtr, root) setForwardingAddress(root, rootCopy) roots[i] = rootCopy while scanPtr < allocationPtr: obj = object at scanPtr scanPtr += size(obj) n = sizeInWords(obj) for i = 0..n: if isPointer(obj[i]) and not inOldSpace(obj[i]): fromNeighbor = obj[i] if hasForwardingAddress(fromNeighbor): toNeighbor = getForwardingAddress(fromNeighbor) else: toNeighbor = copyObject(&allocationPtr, fromNeighbor) setForwardingAddress(fromNeighbor, toNeighbor) obj[i] = toNeighbor def copyObject(*allocationPtr, object): copy = *allocationPtr *allocationPtr += size(object) memcpy(copy, object, size(object)) return copy
이 알고리즘을 실행하는 동안 우리는 항상 out 영역에 두 개의 포인터를 유지합니다. 활성 확인을 수행합니다. scanPtr이 가리키는 주소 이전의 개체는 처리된 개체이며 그 이웃은 모두 out 영역에 있으며 해당 포인터는 scanPtr과 할당Ptr 사이의 개체가 out 영역으로 복사됩니다. 포함된 포인터는 내부 영역의 개체를 가리키며 내부 영역의 이러한 개체는 복사되지 않습니다. 논리적으로 scanPtr과 할당Ptr 사이의 개체를 너비 우선 검색에 사용되는 개체의 대기열로 생각할 수 있습니다.
번역 참고: 너비 우선 검색에서는 일반적으로 노드가 대기열의 헤드에서 꺼내어 확장되고 확장된 하위 노드는 대기열의 끝에 저장되며 프로세스가 계속해서 다시 시작됩니다. 이 프로세스는 두 포인터 사이의 개체를 업데이트하는 것과 유사합니다.
알고리즘 초반에는 루트 객체에서 도달할 수 있는 새로운 영역의 모든 객체를 복사한 후 큰 루프에 들어갑니다. 루프가 반복될 때마다 대기열에서 개체를 제거하고 scanPtr을 증가시킨 다음 개체에 액세스하는 포인터를 추적합니다. 포인터가 입력 영역을 가리키지 않으면 무시하십시오. 포인터는 이전 영역을 가리켜야 하며 이는 우리의 목표가 아니기 때문입니다. 포인터가 들어오는 영역의 개체를 가리키지만 복사하지 않은 경우(전달 주소가 설정되지 않은 경우) 이 개체는 나가는 영역에 복사됩니다. 대기열과 동시에 할당Ptr이 증가합니다. 이때 Out-zone 객체의 첫 번째 단어에도 전달 주소를 저장하고 Map 포인터를 교체하겠습니다. 이 전달 주소는 객체가 복사된 후 저장되는 주소입니다. 가비지 수집기는 맵 포인터가 표시되어 있지만 이 주소는 표시되어 있지 않기 때문에 전달 주소와 맵 포인터를 쉽게 구분할 수 있습니다. 포인터를 찾고 포인터가 가리키는 개체가 복사된 경우(전달 주소가 설정됨) 포인터를 전달 주소로 업데이트하고 표시합니다.
알고리즘은 모든 개체가 처리되면 종료됩니다(예: scanPtr 및 할당Ptr이 충족됨). 이때 해당 존에 입력된 콘텐츠는 가비지(Garbage)로 간주되어 향후 공개되거나 재사용될 수 있습니다.
비밀 무기: 쓰기 장벽
위에서 한 가지 세부 사항이 무시되었습니다. 새 영역의 개체에 해당 개체를 가리키는 포인터가 하나만 있고 이 포인터가 이전 영역의 개체 중에 있는 경우 어떻게 알 수 있습니까? 새 영역의 어떤 개체가 활성화되어 있습니까? 분명히 우리는 이전 원시 영역을 다시 탐색하고 싶지 않습니다. 왜냐하면 이전 원시 영역에는 많은 객체가 있고 한 번 그렇게 하면 너무 많은 것을 소비하게 되기 때문입니다.
이 문제를 해결하기 위해 쓰기 버퍼에는 실제로 새 영역을 가리키는 이전 영역의 모든 객체를 기록하는 목록이 있습니다. 새로운 객체가 생성되면 이에 대한 포인터가 없습니다. 이전 영역의 객체가 새 영역의 객체에 대한 포인터를 가지면 이러한 교차 영역 포인터를 기록합니다. 이러한 로깅 동작은 쓰기 작업 중에 항상 발생하므로 이를 쓰기 장벽이라고 합니다. 모든 쓰기 작업이 이러한 장벽을 통과해야 하기 때문입니다.
모든 쓰기 작업이 쓰기 장벽을 거쳐야 한다면 추가 코드가 많지 않을까요? 궁금하실 것입니다. 예, 이는 가비지 수집 메커니즘의 비용 중 하나입니다. 하지만 생각보다 상황이 심각하지는 않습니다. 쓰기 작업이 읽기 작업보다 적습니다. 일부 가비지 수집 알고리즘(V8 아님)은 읽기 장벽을 사용하므로 비용을 낮추려면 하드웨어 지원이 필요합니다. V8에는 쓰기 장벽으로 인한 소비를 줄이기 위한 몇 가지 최적화 기능도 있습니다.
대부분의 스크립트 실행 시간은 크랭크샤프트에서 발생하며, 크랭크샤프트는 개체가 새 영역에 있는지 정적으로 확인할 수 있는 경우가 많습니다. 이러한 객체에 대한 쓰기 작업에는 쓰기 장벽이 필요하지 않습니다.
크랭크샤프트에 새로운 최적화가 나타났습니다. 즉, 객체에 자신을 가리키는 비로컬 참조가 없으면 해당 객체가 스택에 할당됩니다. 스택의 객체와 관련된 쓰기 작업에는 분명히 쓰기 장벽이 필요하지 않습니다. (주석: 새 영역과 이전 영역이 힙에 있습니다.)
"old → new" 상황은 상대적으로 드물기 때문에 "new → new" 및 "old → new"라는 두 가지 일반적인 상황에 맞게 코드를 최적화하여 old", 대부분의 상황에서 성능을 상대적으로 향상시킬 수 있습니다. 각 페이지는 1MB로 정렬되므로 개체의 메모리 주소가 주어지면 하위 20비트를 필터링하여 해당 페이지를 빠르게 찾을 수 있으며 페이지 헤더에는 해당 페이지가 새 영역에 속하는지 여부를 나타내는 관련 ID가 있습니다. 또는 오래된 영역이므로 두 개체가 속하는 영역을 결정함으로써 "오래된 → 새"인지 여부도 빠르게 확인할 수 있습니다.
"old → new" 포인터를 찾으면 이를 쓰기 버퍼 끝에 기록할 수 있습니다. 일정 시간이 지난 후(쓰기 버퍼가 가득 찼을 때) 이를 정렬하고 동일한 항목을 병합한 다음 더 이상 "기존 → 새" 상황에 맞지 않는 포인터를 제거합니다. (주석: 이렇게 하면 포인터 수가 줄어들고 그에 따라 장벽을 작성하는 시간도 단축됩니다.)
"Mark-Sweep" 알고리즘과 "Mark-Compact" 알고리즘
Scavenge 알고리즘은 다음과 같은 경우에 매우 효과적입니다. 작은 메모리의 빠른 재활용 및 압축. 그러나 큰 메모리 영역의 경우 소비량이 너무 큽니다. Scavenge 알고리즘에는 아웃바운드 영역과 인바운드 영역의 두 영역이 필요하므로 이는 작은 메모리 조각에는 허용되지만 몇 MB를 초과하는 메모리에는 실용적이지 않습니다. 기존 학생 영역에는 수백 MB의 데이터가 포함되어 있으므로 상대적으로 서로 가까운 두 가지 다른 알고리즘인 "mark-clear" 알고리즘과 "mark-compact" 알고리즘을 채택합니다.
두 알고리즘 모두 표시 단계와 삭제 또는 압축 단계의 두 단계를 포함합니다.
在标记阶段,所有堆上的活跃对象都会被标记。每个页都会包含一个用来标记的位图,位图中的每一位对应页中的一字(译注:一个指针就是一字大小)。这个标记非常有必要,因为指针可能会在任何字对齐的地方出现。显然,这样的位图要占据一定的空间(32位系统上占据3.1%,64位系统上占据1.6%),但所有的内存管理机制都需要这样占用,因此这种做法并不过分。除此之外,另有2位来表示标记对象的状态。由于对象至少有2字长,因此这些位不会重叠。状态一共有三种:如果一个对象的状态为白,那么它尚未被垃圾回收器发现;如果一个对象的状态为灰,那么它已被垃圾回收器发现,但它的邻接对象仍未全部处理完毕;如果一个对象的状态为黑,则它不仅被垃圾回收器发现,而且其所有邻接对象也都处理完毕。
如果将堆中的对象看作由指针相互联系的有向图,标记算法的核心实际是深度优先搜索。在标记的初期,位图是空的,所有对象也都是白的。从根可达的对象会被染色为灰色,并被放入标记用的一个单独分配的双端队列。标记阶段的每次循环,GC会将一个对象从双端队列中取出,染色为黑,然后将它的邻接对象染色为灰,并把邻接对象放入双端队列。这一过程在双端队列为空且所有对象都变黑时结束。特别大的对象,如长数组,可能会在处理时分片,以防溢出双端队列。如果双端队列溢出了,则对象仍然会被染为灰色,但不会再被放入队列(这样他们的邻接对象就没有机会再染色了)。因此当双端队列为空时,GC仍然需要扫描一次,确保所有的灰对象都成为了黑对象。对于未被染黑的灰对象,GC会将其再次放入队列,再度处理。
以下是标记算法的伪码:
markingDeque = [] overflow = false def markHeap(): for root in roots: mark(root) do: if overflow: overflow = false refillMarkingDeque() while !markingDeque.isEmpty(): obj = markingDeque.pop() setMarkBits(obj, BLACK) for neighbor in neighbors(obj): mark(neighbor) while overflow def mark(obj): if markBits(obj) == WHITE: setMarkBits(obj, GREY) if markingDeque.isFull(): overflow = true else: markingDeque.push(obj) def refillMarkingDeque(): for each obj on heap: if markBits(obj) == GREY: markingDeque.push(obj) if markingDeque.isFull(): overflow = true return
标记算法结束时,所有的活跃对象都被染为了黑色,而所有的死对象则仍是白的。这一结果正是清理和紧缩两个阶段所期望的。
标记算法执行完毕后,我们可以选择清理或是紧缩,这两个算法都可以收回内存,而且两者都作用于页级(注意,V8的内存页是1MB的连续内存块,与虚拟内存页不同)。
清理算法扫描连续存放的死对象,将其变为空闲空间,并将其添加到空闲内存链表中。每一页都包含数个空闲内存链表,其分别代表小内存区(<256字)、中内存区(<2048字)、大内存区(<16384字)和超大内存区(其它更大的内存)。清理算法非常简单,只需遍历页的位图,搜索连续的白对象。空闲内存链表大量被scavenge算法用于分配存活下来的活跃对象,但也被紧缩算法用于移动对象。有些类型的对象只能被分配在老生区,因此空闲内存链表也被它们使用。
紧缩算法会尝试将对象从碎片页(包含大量小空闲内存的页)中迁移整合在一起,来释放内存。这些对象会被迁移到另外的页上,因此也可能会新分配一些页。而迁出后的碎片页就可以返还给操作系统了。迁移整合的过程非常复杂,因此我只提及一些细节而不全面讲解。大概过程是这样的。对目标碎片页中的每个活跃对象,在空闲内存链表中分配一块其它页的区域,将该对象复制至新页,并在碎片页中的该对象上写上转发地址。迁出过程中,对象中的旧地址会被记录下来,这样在迁出结束后V8会遍历它所记录的地址,将其更新为新的地址。由于标记过程中也记录了不同页之间的指针,此时也会更新这些指针的指向。注意,如果一个页非常“活跃”,比如其中有过多需要记录的指针,则地址记录会跳过它,等到下一轮垃圾回收再进行处理。
增量标记与惰性清理
你应该想到了,当一个堆很大而且有很多活跃对象时,标记-清除和标记-紧缩算法会执行的很慢。起初我研究V8时,垃圾回收所引发的500-1000毫秒的停顿并不少见。这种情况显然很难接受,即使是对于移动设备。
2012年年中,Google引入了两项改进来减少垃圾回收所引起的停顿,并且效果显著:增量标记和惰性清理。
증분 표시를 사용하면 5~10ms(모바일 장치)의 여러 번의 작은 일시 중지로 힙 표시가 발생할 수 있습니다. 증분 마킹은 힙 크기가 특정 임계값에 도달하면 활성화된 후 일정 양의 메모리가 할당될 때마다 스크립트 실행이 일시 중지되고 증분 마킹이 수행됩니다. 일반 마킹과 마찬가지로 증분 마킹도 깊이 우선 검색이며 동일한 흰색-회색-검정 메커니즘을 사용하여 객체를 분류합니다.
하지만 증분 마킹과 일반 마킹의 차이점은 개체의 그래프 관계가 바뀔 수 있다는 점이에요! 우리가 특별히 주의해야 할 것은 검은색 객체에서 흰색 객체를 가리키는 새로운 포인터입니다. 검은색 개체는 가비지 수집기에 의해 완전히 검색되었으며 다시 검색되지 않음을 의미합니다. 그러므로 "검은색 → 흰색"과 같은 포인터가 나타나면 우리는 실수로 흰색 물체를 놓치고 죽은 물체로 취급할 수도 있습니다. (주석: 마킹 프로세스 이후 남은 흰색 개체는 죽은 개체로 간주됩니다.) 그래서 우리는 쓰기 장벽을 다시 활성화해야 했습니다. 이제 쓰기 장벽은 "이전 → 새" 포인터뿐만 아니라 "검은색 → 흰색" 포인터도 기록합니다. 그러한 포인터가 발견되면 검은색 개체는 회색 개체로 다시 칠해지고 다시 데크에 배치됩니다. 알고리즘이 개체를 꺼내면 활성 흰색 개체가 누락되지 않도록 포함된 포인터가 다시 스캔됩니다.
증분 마킹이 완료된 후 지연 정리가 시작됩니다. 모든 개체가 처리되었으므로 죽은 상태이거나 살아 있는 상태이며, 힙의 공간이 얼마나 확보될 수 있는지는 예측할 수 없습니다. 현재로서는 해당 공간을 서둘러 공개할 수 없으며, 청소 과정을 지연시키는 것은 큰 문제가 아닙니다. 따라서 모든 페이지를 한 번에 정리할 필요가 없습니다. 가비지 수집기는 모든 페이지가 정리될 때까지 필요에 따라 페이지를 하나씩 정리합니다. 이때 증분 마크는 다시 시작할 준비가 됩니다.
Google은 최근 병렬 청소에 대한 지원도 추가했습니다. 스크립트의 실행 스레드는 더 이상 죽은 개체를 건드리지 않으므로 최소한의 동기화 작업으로 별도의 스레드에서 페이지 정리 작업을 수행할 수 있습니다. 동일한 지원이 병렬 마킹에 대해 작업 중이지만 현재 초기 실험 단계에 있습니다.
요약
가비지 수집은 정말 복잡합니다. 기사에서 많은 세부사항을 건너뛰었는데 아직도 내용이 너무 길어졌습니다. 내 동료는 가비지 컬렉터 작업이 레지스터 할당보다 더 무섭다고 느꼈고, 나는 실제로 그렇다고 말했다. 즉, 이러한 지루한 세부 사항을 모든 애플리케이션 개발자에게 맡기기보다는 런타임에 맡기고 싶습니다. 가비지 수집에는 성능 문제가 있고 가끔 이상한 점이 있기는 하지만 많은 세부 사항에서 벗어나 더 중요한 일에 집중할 수 있습니다.
이 기사의 사례를 읽은 후 방법을 마스터했다고 생각합니다. 더 흥미로운 정보를 보려면 PHP 중국어 웹사이트의 다른 관련 기사를 주목하세요!
추천 도서:
위 내용은 가비지 수집기 사용 방법의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!