ホームページ > 記事 > ウェブフロントエンド > ガベージコレクターの使い方
今回はガベージコレクターの使い方と、ガベージコレクターを使用する際の注意点を紹介します。 以下に実際のケースを示しますので、見てみましょう。
ガベージコレクターは諸刃の剣です。利点は、プログラムのメモリ管理コードを大幅に簡素化できることです。これは、メモリ管理にプログラマの操作が必要ないため、長時間実行されるプログラムのメモリ リークが軽減されます (根絶ではありません)。一部のプログラマーにとっては、コードのパフォーマンスを向上させることさえできます。
一方、ガベージコレクターを選択すると、プログラムがメモリを完全に制御できなくなることを意味し、これがモバイル端末開発の核心となります。 JavaScript の場合、プログラム内でメモリ管理を行うことはできません。ECMAScript 標準はガベージ コレクター インターフェイスを公開しません。 Web アプリケーションには、メモリを管理したり、ガベージ コレクターに指示を出したりする方法がありません。
厳密に言うと、ガベージ コレクターを使用する言語は、ガベージ コレクターを使用しない言語よりもパフォーマンスが必ずしも優れている、または劣っているというわけではありません。 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 領域に 2 つのポインターを維持します。allocationPtr は、新しいオブジェクトにメモリを割り当てようとしている場所を指し、scanPtr は、次に割り当てようとしているオブジェクトを指します。活性チェックを実行します。 scanPtr が指すアドレスより前のオブジェクトは処理済みオブジェクトであり、それらとその隣接オブジェクトはすべて out 領域にあり、scanPtr とallocationPtr の間のオブジェクトは out 領域にコピーされます。含まれているポインターがゾーン内のオブジェクトを指している場合、ゾーン内のこれらのオブジェクトはコピーされません。論理的には、scanPtr とallocationPtr の間のオブジェクトは、幅優先検索に使用されるオブジェクトのキューと考えることができます。
訳注: 幅優先探索では、通常、キューの先頭からノードを取り出して展開し、展開した子ノードをキューの最後尾に格納して、処理を何度も繰り返します。このプロセスは、2 つのポインター間のオブジェクトを更新することに似ています。
アルゴリズムの開始時に、ルート オブジェクトから到達できる新しい領域内のすべてのオブジェクトをコピーし、その後、大きなループに入ります。ループの各反復で、キューからオブジェクトを削除し、scanPtr をインクリメントし、オブジェクトにアクセスするポインターを追跡します。ポインタがエントリ領域を指していない場合は、それを無視してください。ポインタは古い領域を指している必要があり、これは私たちの目的ではないためです。そして、ポインターが受信領域内のオブジェクトを指しているが、それをコピーしていない (転送アドレスが設定されていない) 場合、この オブジェクトは送信領域にコピー されます。つまり、オブジェクトの末尾に追加されます。キューと同時に、allocationPtr が増加します。このとき、アウトゾーンオブジェクトの先頭ワードにも転送アドレスを格納し、Mapポインタを置き換えます。この転送アドレスは、オブジェクトがコピーされた後に保存されるアドレスです。マップ ポインターはマークされていますが、このアドレスはマークされていないため、ガベージ コレクターは転送アドレスとマップ ポインターを簡単に区別できます。ポインタを見つけ、それが指すオブジェクトがコピーされている (転送アドレスが設定されている) 場合、ポインタを転送アドレスに更新し、マークします。
すべてのオブジェクトが処理されると (つまり、scanPtr とallocationPtr が一致する)、アルゴリズムは終了します。現時点では、エリアに入力されたコンテンツはゴミとみなされ、将来公開または再利用される可能性があります。
秘密兵器: ライトバリア
上では 1 つの詳細が無視されました: 新しいエリアのオブジェクトがそれを指すポインタを 1 つだけ持っていて、そのポインタがたまたま古いエリアのオブジェクトの中にある場合、どうやってそれを知ることができるでしょうか新しいエリア内のどのオブジェクトがアクティブですか?古い raw エリアには多くのオブジェクトがあり、一度実行すると消費量が多すぎるため、古い raw エリアを再び横断する必要がないことは明らかです。
この問題を解決するために、実際には書き込みバッファーにリストがあり、新しい領域を指す古い領域内のすべてのオブジェクトが記録されます。新しいオブジェクトが生成されると、それに対するポインタは存在しません。古い領域のオブジェクトが新しい領域のオブジェクトへのポインタを持っている場合、そのようなクロスエリア ポインタを記録します。このロギング動作は書き込み操作中に常に発生するため、書き込みバリアと呼ばれます。すべての書き込み操作がこのようなバリアを通過する必要があるためです。
気になるかもしれませんが、すべての書き込み操作で書き込みバリアを通過する必要がある場合、余分なコードが大量に発生するのではないか?はい、これはガベージ コレクション メカニズムのコストの 1 つです。しかし、結局のところ、書き込み操作は読み取り操作よりも少ないため、状況はそれほど深刻ではありません。一部のガベージ コレクション アルゴリズム (V8 以外) は読み取りバリアを使用するため、コストを確実に下げるためにハードウェアの支援が必要です。 V8 には、書き込みバリアによる消費を削減するための最適化も含まれています。
スクリプトの実行時間のほとんどはクランクシャフトで発生し、多くの場合、クランクシャフトはオブジェクトが新しい領域にあるかどうかを静的に判断できます。これらのオブジェクトへの書き込み操作には書き込みバリアは必要ありません。
新しい最適化が Crankshaft に登場しました。つまり、オブジェクトがそれを指す非ローカル参照を持たない場合、オブジェクトはスタック上に割り当てられます。スタック上のオブジェクトに関連する書き込み操作には、明らかに書き込みバリアは必要ありません。 (注釈: 新しい領域と古い領域はヒープ上にあります。)
「古い→新しい」という状況は比較的まれであるため、「新しい→新しい」と「古い→→」という 2 つの一般的な状況に合わせてコードを最適化することで、 old" を使用すると、ほとんどの状況でパフォーマンスを相対的に向上させることができます。各ページは 1MB にアライメントされているため、オブジェクトのメモリ アドレスが与えられると、下位 20 ビットをフィルタリングすることでそのページが配置されているページをすばやく見つけることができ、ページ ヘッダーにはそれが新しい領域に属するかどうかを示す関連する識別情報が含まれます。または古い領域なので、2 つのオブジェクトが属する領域を決定することで、それらが「古い → 新しい」かどうかを迅速に判断することもできます。
「古い→新しい」ポインタを見つけたら、それを書き込みバッファの最後に記録できます。一定の時間が経過した後 (書き込みバッファーがいっぱいになったとき)、それを並べ替え、同一の項目をマージし、「古い→新しい」状況に適合しなくなったポインターを削除します。 (注釈: こうすることでポインタの数が減り、それに応じてバリアの書き込み時間が短縮されます)
「Mark-Soup」アルゴリズムと「Mark-Compact」アルゴリズム
Scavengeアルゴリズムは、次のような場合に非常に効果的です。小さいメモリの高速リサイクルと圧縮が可能ですが、メモリ領域が大きい場合、消費量が多すぎます。 Scavenge アルゴリズムにはアウトバウンド領域とインバウンド領域の 2 つの領域が必要であるため、これはメモリが小さい場合は許容されますが、メモリが数 MB を超える場合は実用的ではありません。古いスチューデント領域には数百 MB のデータが含まれているため、比較的近い他の 2 つのアルゴリズム、「mark-clear」アルゴリズムと「mark-compact」アルゴリズムを採用します。
どちらのアルゴリズムにも、マーキング フェーズとクリアまたは圧縮フェーズの 2 つのフェーズが含まれます。
在标记阶段,所有堆上的活跃对象都会被标记。每个页都会包含一个用来标记的位图,位图中的每一位对应页中的一字(译注:一个指针就是一字大小)。这个标记非常有必要,因为指针可能会在任何字对齐的地方出现。显然,这样的位图要占据一定的空间(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 ~ 10 ミリ秒の小さな一時停止を数回繰り返してヒープのマーキングを行うことができます (モバイル デバイス)。増分マーキングは、ヒープ サイズが特定のしきい値に達すると有効になります。有効になった後は、一定量のメモリが割り当てられるたびに、スクリプトの実行が一時停止され、増分マーキングが実行されます。通常のマーキングと同様に、インクリメンタル マーキングも深さ優先検索であり、同じ白、灰色、黒のメカニズムを使用してオブジェクトを分類します。
しかし、増分マーキングと通常のマーキングの違いは、オブジェクトのグラフ関係が変化する可能性があることです。特に注意する必要があるのは、黒いオブジェクトから白いオブジェクトへの新しいポインターです。黒いオブジェクトは、ガベージ コレクターによって完全にスキャンされており、再度スキャンされないことを意味します。したがって、「黒→白」のようなポインタが表示された場合、白いオブジェクトを見逃して、誤って死んだオブジェクトとして扱う可能性があります。 (注釈: マーキングプロセス後に残った白いオブジェクトは、デッドオブジェクトと見なされます。) そのため、書き込みバリアを再度有効にする必要がありました。現在、書き込みバリアは「古い→新しい」ポインタを記録するだけでなく、「黒→白」のポインタも記録します。そのようなポインターが見つかると、黒いオブジェクトは灰色のオブジェクトに再色付けされ、デックに戻されます。アルゴリズムがオブジェクトを取り出すと、アクティブな白いオブジェクトが見逃されないように、オブジェクトに含まれるポインターが再スキャンされます。
増分マーキングが完了すると、遅延クリーンアップが開始されます。すべてのオブジェクトが処理されているため、オブジェクトは死んでいるか生きているかのどちらかであり、ヒープ上のどれだけのスペースが解放されるかは当然の結論です。現時点では、これらのスペースを急いで解放することはできません。また、清掃プロセスが遅れても大した問題ではありません。したがって、すべてのページを一度にクリーンアップする必要はありません。ガベージ コレクターは、すべてのページがクリーンアップされるまで、必要に応じてページを 1 つずつクリーンアップします。この時点で、増分マークを再び使用できるようになります。
Google は最近、並行クリーニングのサポートも追加しました。スクリプトの実行スレッドは死んだオブジェクトに触れなくなるため、ページのクリーンアップ タスクは最小限の同期作業で別のスレッドで実行できます。並行マーキングについても同じサポートが検討されていますが、現在は初期の実験段階にあります。
まとめ
ガベージコレクションは本当に複雑です。この記事では多くの詳細を省略しましたが、それでも非常に長くなってしまいました。私の同僚は、レジスタ割り当てよりもガベージ コレクタでの作業の方が怖いと感じていると言い、私はその通りだと言いました。言い換えれば、これらの面倒な詳細はすべてのアプリケーション開発者に任せるのではなく、ランタイムに任せたいということです。ガベージ コレクションにはパフォーマンス上の問題があり、場合によっては奇妙になることもありますが、多くの詳細から解放されるため、より重要なことに集中できるようになります。
この記事の事例を読んだ後は、この方法を習得したと思います。さらに興味深い情報については、php 中国語 Web サイトの他の関連記事に注目してください。
推奨読書:
以上がガベージコレクターの使い方の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。