Maison > Article > interface Web > Comment utiliser le garbage collector
Cette fois, je vais vous montrer comment utiliser le garbage collector et quelles sont les précautions lors de l'utilisation du garbage collector. Ce qui suit est un cas pratique, jetons un coup d'œil.
L'éboueur est une arme à double tranchant. L'avantage est qu'il peut grandement simplifier le code de gestion de la mémoire du programme, car la gestion de la mémoire ne nécessite pas l'intervention des programmeurs, réduisant ainsi (mais non éradiquant) les fuites de mémoire dans les programmes à exécution longue. Pour certains programmeurs, cela peut même améliorer les performances de leur code.
D'un autre côté, choisir un garbage collector signifie que le programme ne peut pas contrôler entièrement la mémoire, et c'est le nœud du développement des terminaux mobiles. Pour JavaScript, il n'y a aucune possibilité de gestion de la mémoire dans le programme - le standard ECMAScript n'expose aucune interface de garbage collector. Les applications Web n'ont aucun moyen de gérer la mémoire ou d'inviter le garbage collector.
À proprement parler, les langages qui utilisent des garbage collectors ne sont pas nécessairement meilleurs ou moins performants que les langages qui n'utilisent pas de garbage collectors. En langage C, l'allocation et la libération de mémoire peuvent être des opérations très coûteuses. Afin de permettre la libération de la mémoire allouée dans le futur, la gestion du tas aura tendance à être compliquée. Dans les langages à mémoire gérée, l'allocation de mémoire ajoute souvent simplement un pointeur. Mais nous verrons ensuite le coût énorme de l’intervention du garbage collector pour collecter lorsque la mémoire est épuisée. Un garbage collector inexploré provoquera des pauses longues et imprévisibles dans le fonctionnement du programme, ce qui affectera directement l'expérience d'utilisation des systèmes interactifs (en particulier ceux avec effets d'animation). Les systèmes de comptage de références sont souvent présentés comme une alternative au garbage collection, mais ils peuvent également provoquer des pauses inattendues lorsque le dernier objet d'un grand sous-graphe est déréférencé. De plus, le système de comptage de références aura également une charge de performances considérable lors de l'exécution fréquente d'opérations de lecture, de réécriture et de stockage.
Pour le meilleur ou pour le pire, JavaScript a besoin d'un garbage collector. L'implémentation du garbage collector du V8 est désormais mature, avec d'excellentes performances, de courtes pauses et une charge de performances très contrôlable.
Le problème le plus fondamental que le ramasse-miettes doit résoudre est d'identifier la mémoire qui doit être recyclée. Une fois identifiées, ces zones mémoire peuvent être réutilisées dans des allocations futures ou renvoyées au système d'exploitation. Un objet est mort (non-sens) lorsqu'il n'est pas actif. Un objet est actif si et seulement s'il est pointé par un objet racine ou un autre objet actif. L'objet racine est défini comme l'objet actif et est l'objet référencé par le navigateur ou V8. Par exemple, les objets pointés par des variables locales appartiennent à l'objet racine car leur pile est considérée comme l'objet racine ; les objets globaux appartiennent à l'objet racine car ils sont toujours accessibles, tels que les éléments DOM, appartiennent également à l'objet racine ; . Bien que dans certains cas, ce ne soient que de faibles références.
De côté, la définition ci-dessus est très vague. En fait on peut dire qu'un objet est actif lorsqu'il peut être référencé par un programme. Par exemple :
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
Lors de l'exécution de cet algorithme, nous maintenons toujours deux pointeurs dans la zone out : allocationPtr pointe vers l'endroit où nous sommes sur le point d'allouer de la mémoire pour le nouvel objet, et scanPtr indique où nous sommes sur le point d'allouer de la mémoire. Le prochain objet à vérifier activement. Les objets avant l'adresse pointée par scanPtr sont des objets traités. Eux et leurs voisins sont tous dans la zone de sortie, et leurs pointeurs ont été mis à jour. Les objets entre scanPtr et allocationPtr seront copiés dans la zone de sortie, mais à l'intérieur de ces objets If. les pointeurs contenus pointent vers des objets dans la zone, ces objets dans la zone ne seront pas copiés. Logiquement, vous pouvez considérer les objets entre scanPtr et allocationPtr comme une file d'attente d'objets utilisée pour la recherche en largeur.
Annotation : dans la recherche en largeur d'abord, le nœud est généralement retiré de la tête de la file d'attente et développé, et les nœuds enfants développés sont stockés à la fin de la file d'attente, et le processus recommence encore et encore. encore. Ce processus est similaire à la mise à jour d'un objet entre deux pointeurs.
Au début de l'algorithme, nous copions tous les objets de la nouvelle zone accessible depuis l'objet racine, puis entrons dans une grande boucle. À chaque itération de la boucle, nous supprimons un objet de la file d'attente, incrémentons scanPtr, puis suivons le pointeur accédant à l'objet. Si le pointeur ne pointe pas vers la zone d’entrée, ignorez-le, car il doit pointer vers l’ancienne zone, et ce n’est pas notre objectif. Et si le pointeur pointe vers un objet dans la zone entrante, mais que nous ne l'avons pas copié (l'adresse de redirection n'a pas été définie), alors copiez cet objet dans la zone sortante, qui est ajouté à la fin de notre file d'attente, et en même temps, il est ajouté à l'incrément d'allocationPtr sortant. À ce stade, nous stockerons également une adresse de transfert vers le premier mot de l’objet hors zone et remplacerons le pointeur Map. Cette adresse de transfert est l'adresse où l'objet est stocké après avoir été copié. Le garbage collector peut facilement distinguer l'adresse de transfert du pointeur Map car le pointeur Map est marqué, mais cette adresse ne l'est pas. Si nous trouvons un pointeur et que l'objet vers lequel il pointe a été copié (l'adresse de transfert a été définie), nous mettons à jour le pointeur vers l'adresse de transfert et le marquons.
L'algorithme se termine lorsque tous les objets ont été traités (c'est-à-dire que scanPtr et allocationPtr se rencontrent). À ce stade, le contenu entré dans la zone peut être considéré comme un déchet et peut être publié ou réutilisé à l'avenir.
Arme secrète : Barrière d'écriture
Il y a un détail ignoré ci-dessus : si un objet dans la nouvelle zone n'a qu'un seul pointeur vers lui, et que ce pointeur arrive à Parmi les objets de l'ancien espace étudiant, comment savoir quel objet de l'espace nouvel étudiant est actif ? Évidemment, nous ne voulons pas traverser à nouveau l'ancienne zone brute, car il y a de nombreux objets dans l'ancienne zone brute, et le faire une seule fois consommerait trop.
Afin de résoudre ce problème, il existe en fait une liste dans le tampon d'écriture, qui enregistre tous les objets de l'ancienne zone pointant vers la nouvelle zone. Lorsqu'un nouvel objet naît, il n'y aura pas de pointeur vers lui. Lorsqu'un objet dans l'ancienne zone a un pointeur vers un objet dans la nouvelle zone, nous enregistrerons un tel pointeur inter-zone. Étant donné que ce comportement de journalisation se produit toujours lors d'une opération d'écriture, on l'appelle une barrière d'écriture, car chaque opération d'écriture doit passer par une telle barrière.
Vous pourriez être curieux, si chaque opération d'écriture doit passer par une barrière en écriture, n'y aurait-il pas beaucoup de code supplémentaire ? Oui, c'est l'un des coûts de notre mécanisme de collecte des déchets. Mais la situation n’est pas aussi grave qu’on le pense. Après tout, il y a moins d’opérations d’écriture que d’opérations de lecture. Certains algorithmes de garbage collection (pas V8) utiliseront des barrières de lecture, qui nécessitent une assistance matérielle pour garantir un coût inférieur. La V8 dispose également de quelques optimisations pour réduire la consommation causée par les barrières en écriture :
La majeure partie du temps d'exécution du script se produit dans Crankshaft, et Crankshaft peut souvent déterminer de manière statique si un objet se trouve dans la nouvelle zone. Les opérations d'écriture sur ces objets ne nécessitent pas de barrière en écriture.
Une nouvelle optimisation est apparue dans Crankshaft, c'est-à-dire que lorsqu'un objet n'a pas de référence non locale pointant vers lui, l'objet sera alloué sur la pile. L'opération d'écriture liée à un objet sur la pile ne nécessite évidemment pas de barrière en écriture. (Traduction : le nouveau quartier étudiant et l'ancien quartier étudiant sont sur le tas.)
La situation de « ancien → nouveau » est relativement rare, donc en combinant les deux situations courantes de « nouveau → nouveau » et "ancien → ancien" L'optimisation du code peut relativement améliorer les performances dans la plupart des situations. Chaque page est alignée sur 1 Mo, donc étant donné l'adresse mémoire d'un objet, la page où il se trouve peut être rapidement localisée en filtrant les 20 bits inférieurs et l'en-tête de la page possède des identifiants pertinents pour indiquer si elle appartient à la nouvelle zone ; ou l'ancienne zone, donc en déterminant la zone à laquelle appartiennent deux objets, on peut également déterminer rapidement s'ils sont "anciens → nouveaux".
Une fois que nous avons trouvé le pointeur "ancien → nouveau", nous pouvons l'enregistrer à la fin du tampon d'écriture. Après un certain temps (lorsque le tampon d'écriture est plein), nous le trions, fusionnons les éléments identiques, puis supprimons les pointeurs qui ne correspondent plus à la situation « ancien → nouveau ». (Annotation : De cette façon, le nombre de pointeurs sera réduit, et le temps d'écriture de la barrière sera raccourci en conséquence)
Algorithme "Mark-Sweep" et algorithme "Mark-Compact"
L'algorithme de récupération est destiné à un recyclage rapide. La compression de petits morceaux de mémoire fonctionne bien, mais elle consomme trop pour de gros morceaux de mémoire. Étant donné que l'algorithme Scavenge nécessite deux zones, la zone sortante et la zone entrante, cela est acceptable pour les petits morceaux de mémoire, mais devient peu pratique pour la mémoire dépassant quelques Mo. L'ancienne zone étudiant contient des centaines de Mo de données. Pour une zone aussi vaste, nous adoptons deux autres algorithmes relativement proches l'un de l'autre : l'algorithme « mark-clear » et l'algorithme « mark-compact ».
Les deux algorithmes se composent de deux phases : la phase de marquage et la phase de dégagement ou de compactage.
在标记阶段,所有堆上的活跃对象都会被标记。每个页都会包含一个用来标记的位图,位图中的每一位对应页中的一字(译注:一个指针就是一字大小)。这个标记非常有必要,因为指针可能会在任何字对齐的地方出现。显然,这样的位图要占据一定的空间(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引入了两项改进来减少垃圾回收所引起的停顿,并且效果显著:增量标记和惰性清理。
Le marquage incrémentiel permet de marquer le tas en plusieurs petites pauses de 5 à 10 ms (appareils mobiles). Le marquage incrémentiel est activé lorsque la taille du tas atteint un certain seuil. Après avoir été activé, chaque fois qu'une certaine quantité de mémoire est allouée, l'exécution du script sera suspendue et un marquage incrémentiel sera effectué. Tout comme le marquage ordinaire, le marquage incrémentiel est également une recherche en profondeur et utilise le même mécanisme blanc-gris-noir pour classer les objets.
Mais la différence entre les marqueurs incrémentaux et les marqueurs ordinaires est que la relation graphique de l'objet peut changer ! Ce à quoi nous devons prêter une attention particulière, ce sont les nouveaux pointeurs pointant de l’objet noir vers l’objet blanc. Rappelons qu'un objet noir signifie qu'il a été entièrement analysé par le garbage collector et qu'il ne sera plus analysé. Par conséquent, si un pointeur du type « noir → blanc » apparaît, nous risquons de manquer l'objet blanc et de le traiter par erreur comme un objet mort. (Annotation : les objets blancs restants après le processus de marquage sont considérés comme des objets morts.) Nous avons donc dû réactiver la barrière d'écriture. Désormais, la barrière d'écriture enregistre non seulement le pointeur « ancien → nouveau », mais enregistre également le pointeur « noir → blanc ». Une fois un tel pointeur trouvé, l'objet noir sera recoloré en objet gris et remis dans le deque. Lorsque l'algorithme retire l'objet, les pointeurs qu'il contient sont réanalysés afin que les objets blancs actifs ne soient pas manqués.
Une fois le marquage incrémentiel terminé, le nettoyage paresseux commence. Tous les objets ont été traités, ils sont donc soit morts, soit vivants, et il est acquis d'avance combien d'espace sur le tas peut devenir libre. Pour le moment, nous ne pouvons pas nous précipiter pour libérer ces espaces, et ce n’est pas grave de retarder le processus de nettoyage. Par conséquent, il n'est pas nécessaire de nettoyer toutes les pages en même temps. Le garbage collector les nettoiera une par une jusqu'à ce que toutes les pages soient nettoyées. À ce moment-là, la marque incrémentielle est prête à repartir.
Google a également récemment ajouté la prise en charge du nettoyage parallèle. Étant donné que le thread d'exécution du script ne touchera plus les objets morts, la tâche de nettoyage de page peut être effectuée dans un thread séparé avec un travail de synchronisation minimal. Le même support est en cours d'élaboration pour le marquage parallèle, mais il en est actuellement à un stade précoce d'expérimentation.
Résumé
La collecte des déchets est vraiment compliquée. J'ai sauté beaucoup de détails dans l'article et il est quand même très long. Un de mes collègues a dit qu'il pensait que travailler sur le garbage collector était plus effrayant que l'allocation des registres, et j'ai dit que c'était effectivement le cas. En d’autres termes, je préfère laisser ces détails fastidieux au runtime plutôt que de les laisser à tous les développeurs d’applications. Bien que le garbage collection présente des problèmes de performances et des bizarreries occasionnelles, il nous libère de nombreux détails afin que nous puissions nous concentrer sur des choses plus importantes.
Je pense que vous maîtrisez la méthode après avoir lu le cas dans cet article. Pour des informations plus intéressantes, veuillez prêter attention aux autres articles connexes sur le site Web chinois de php !
Lecture recommandée :
Comment utiliser le robot d'exploration nodeJs
Comment utiliser le rendu et l'envoi de node.js
Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!