V8引擎的內存管理

本文首發於公衆號:符合預期的CoyPan
這是一篇譯文,有部分刪減

原文地址:https://deepu.tech/memory-man...javascript

原文標題:Visualizing memory management in V8 Engine (JavaScript, NodeJS, Deno, WebAssembly)java

在本章中,咱們將介紹用於ECMAScript和WebAssembly的V8引擎的內存管理,這些引擎用於NodeJS、Deno&Electron等運行時,以及Chrome、Chromium、Brave、Opera和Microsoft Edge等web瀏覽器。因爲JavaScript是一種解釋性語言,它須要一個引擎來解釋和執行代碼。V8引擎解釋JavaScript並將其編譯爲機器代碼。V8是用C++編寫的,能夠嵌入任何C++應用程序中。web

首先,咱們來看看V8引擎的內存結構。因爲JavaScript是單線程語言,因此V8爲每個JavaScript上下文使用一個進程。若是你使用service worker,V8會爲每一個service worker開啓一個新的進程。在V8進程中,一個正在運行的程序老是由一些分配的內存來表示,這稱爲常駐集Resident Set)。能夠進一步劃分如下不一樣的部分:算法

0.png

這和咱們在上一篇文章中提到的JVM有些類似。咱們來看一看每個部分都是作什麼的:windows

堆內存(Heap memory)

這是V8存儲對象和動態數據的地方。這是內存中區域中最大的塊,也是垃圾回收(GC)發生的地方。整個堆內存不是垃圾回收的,只有新舊空間(New space、Old space)是垃圾回收管理的。堆內存能夠進一步劃分爲如下幾部分:api

  1. 新空間(New space)

新空間(或者說叫:新生代),是存儲新對象的地方,而且大部分對象的聲明週期都很短。這個空間很小,有兩個半空間,相似於JVM中的S0,S1。這片空間是由Scavenger(Minor GC)來管理的,稍後會介紹。新生代空間的大小能夠由--min_semi_space_size(初始值) 和 --max_semi_space_size(最大值) 兩個V8標誌來控制。數組

  1. 老空間(Old space)

老空間(或者說叫:老生代),存儲的是在新生代空間中通過了兩次Minor GC後存活下來的數據。這片空間是由Major GC(Mark-Sweep & Mark-Compact)」管理的,稍後會介紹。老生代空間的大小能夠--initial_old_space_size(初始值) and --max_old_space_size(最大值) 兩個V8標誌來控制。這片空間被分紅了兩個部分:瀏覽器

  • 老指針空間(Old pointer space):包含了存活下來的包含指向其餘對象指針的對象。
  • 老數據空間(Old data space):包含了僅保存數據的對象(沒有指向其餘對象的指針)。字符串,已裝箱的數字,未裝箱的雙精度數組,在新生代空間通過兩輪Minor GC後存活下來的,會被移到老數據空間。
  1. 大對象空間(Large object space)

這是大於其餘空間大小限制的對象存儲的地方。每一個對象都有本身的內存區域。大對象是不會被垃圾回收的。併發

  1. 代碼空間(Code-space)

這就是即時(JIT)編譯器存儲編譯代碼塊的地方。這是惟一有可執行內存的空間(儘管代碼可能被分配在「大對象空間」中,它們也是可執行的)。框架

  1. 單元空間、屬性單元空間、映射空間(Cell space, property cell space, and map space)

這些空間分別包含Cell,PropertyCell 和 Map. 這些空間中的每個都包含相同大小的對象,而且對它們指向的對象類型有一些限制,這簡化了收集。

每一個空間都由一組頁組成。頁是使用 mmap從操做系統分配的連續內存塊。每頁大小爲1MB,但大對象空間較大。

棧(Stack)

這是棧內存區域,每一個V8進程有一個棧。這裏存儲靜態數據,包括方法/函數框架、原語值和指向對象的指針。棧內存限制可使用--stack_size V8標誌設置。

V8的內存使用(棧 VS 堆)

既然咱們已經清楚了內存是如何組織的,讓咱們看看在執行程序時如何使用其中最重要的部分。

讓咱們使用下面的JavaScript程序,代碼沒有針對正確性進行優化,所以忽略了沒必要要的中間變量等問題,重點是可視化棧和堆內存的使用狀況。

class Employee {
    constructor(name, salary, sales) {
        this.name = name;
        this.salary = salary;
        this.sales = sales;
    }
}

const BONUS_PERCENTAGE = 10;

function getBonusPercentage(salary) {
    const percentage = (salary * BONUS_PERCENTAGE) / 100;
    return percentage;
}

function findEmployeeBonus(salary, noOfSales) {
    const bonusPercentage = getBonusPercentage(salary);
    const bonus = bonusPercentage * noOfSales;
    return bonus;
}

let john = new Employee("John", 5000, 5);
john.bonus = findEmployeeBonus(john.salary, john.sales);
console.log(john.bonus);

能夠經過下面的ppt看一下在上面的代碼執行的過程當中,棧內存和堆內存是如何使用的。

ppt-1.png
ppt-2.png
ppt-3.png

如你所見:

  1. 全局做用域保存在棧上的全局框架(Global frame)中。
  2. 每一個函數調用都做爲幀塊添加到堆棧內存中。
  3. 全部局部變量(包括參數和返回值)都保存在棧的函數框塊中。
  4. 像int&string這樣的全部基元類型都直接存儲在棧上。這一樣適用於全局做用域。
  5. 當前函數調用的函數將被推到棧的頂部。
  6. 當函數返回時,它的框架幀塊將被移除。
  7. 一旦主進程完成,堆上的對象就再也不有來自棧的指針,成爲孤立的對象。
  8. 除非顯式複製,不然其餘對象中的全部對象引用都是使用引用指針完成的。

如你所見,棧是由操做系統自動管理的,而不是V8。所以,咱們沒必要太擔憂棧。另外一方面,堆並非由操做系統自動管理的,由於堆是最大的內存空間,並保存動態數據,它可能會隨着時間的推移呈指數增加,致使咱們的程序內存耗盡。隨着時間的推移,它也變得支離破碎,減慢了應用程序的速度。這就是爲何須要垃圾回收。

區分堆上的指針和數據對於垃圾收集很重要,V8使用「標記指針」方法來實現這一點。在這種方法中,它在每一個單詞的末尾保留一個位,以指示它是指針仍是數據。這種方法須要有限的編譯器支持,但實現起來很簡單,同時效率也至關高。

V8內存管理 - 垃圾回收(GC)

如今咱們知道了V8如何分配內存,讓咱們看看它如何自動管理堆內存,這對應用程序的性能很是重要。當一個程序試圖在堆上分配比自由可用的更多的內存(取決於V8標誌集)時,咱們會遇到內存不足的錯誤。錯誤管理的堆也可能致使內存泄漏。

V8經過垃圾收集來管理堆內存。簡單地說,它釋放孤立對象(即再也不直接或間接從堆棧中引用的對象(經過另外一個對象中的引用)使用的內存,以便爲建立新對象騰出空間。

Orinoco是V8 GC項目的代碼名,用於使用並行、增量和併發的垃圾回收技術來釋放主線程。

V8中的垃圾回收器負責回收未使用的內存,供V8進程重用。

V8垃圾回收器是分代的(堆中的對象按其年齡分組並在不一樣階段清除)。V8有兩個階段和三種不一樣的垃圾收集算法:

Minor GC (Scavenger)

這種類型的GC保持新生代空間的緊湊和清潔。對象被分配到至關小的空間(1到8MB之間,取決於行爲啓發)。新生代空間的分配成本很低:有一個分配指針,每當咱們想爲新對象保留空間時,它都會遞增。當分配指針到達新生代空間的末尾時,將觸發次Minor GC。這個過程被稱爲Scavenger,實現了「切尼算法」。Minor GC常常出現並使用並行的輔助線程,並且速度很是快。

讓咱們來看一看Minor GC的過程:

新生代空間被分紅兩個大小相等的半空間:from-space和to-space。大多數分配都是在to-space中進行的(除了某些類型的對象,例如老是在老生代空間中分配的可執行代碼)。當to-space填滿時,將觸發Minor GC。完成過程以下:

  1. 當咱們開始時,假設to-space裏已經有對象了。
  2. 進程建立了一個新的對象。
  3. V8試圖從to-space獲取所需的內存,但其中沒有可用空間來容納咱們的對象,所以V8觸發了Minor GC。
  4. Minor GC交換to-space和from-space,全部對象如今都在from-space中,to space爲空。
  5. Minor GC遞歸地從堆棧指針(GC根)開始遍歷from-space中的對象圖,以查找已使用或活動的對象(已用內存)。這些對象將移動到to-space的頁中。由這些對象引用的任何對象也會在to-space中移動到此頁,而且它們的指針會更新。重複此操做,直到from-space中的對象都被掃描一次。最終,to-space被自動壓縮以減小碎片。
  6. Minor GC如今清空from-space,由於這裏的任何剩餘對象都是垃圾。
  7. 新對象被分配到to-space的內存空間中。
  8. 讓咱們假設過了一段時間,to-space中的對象更多了。
  9. 應用又新建了一個對象。
  10. V8試圖從to-space獲取所需的內存,但其中沒有可用空間來容納咱們的對象,所以V8觸發了第二次Minor GC。
  11. 重複上述過程,並將第二個Minor GC中倖存的任何活動對象移動到老生代空間。第一次Minor GC的倖存者被轉移到to-space,剩餘的垃圾從from-space中被清除。
  12. 新對象被分配到to-space的內存空間中。

咱們看到了Minor GC如何重新生代內存空間那裏回收空間並使其保持緊湊的。這個過程雖然會中止其餘操做,可是這個過程是十分迅速而有效的,大部分時候都微不足道。因爲此進程不掃描老生代空間中的對象以獲取新生代空間中的任何引用,所以它使用從老生代空間到新生代空間的全部指針的寄存器。這將由一個名爲write barriers的進程記錄到存儲緩衝區。

Major GC

這種類型的GC保持了老生代空間的緊湊和乾淨。當V8根據動態計算的限制肯定沒有足夠的老生代空間時,就會觸發此操做,由於它是從Minor GC週期中填充的。

Scavenger算法很是適合於較小的數據量,但對於較大的老生代空間來講是不實際的,由於它有內存開銷,所以主要的GC是使用Mark-Sweep-Compact算法完成的。它使用三色(白灰黑)標記系統。所以,Major GC是一個三步過程,第三步是根據分段啓發執行的。

15.gif

  • 標記:第一步,兩種算法都通用,其中垃圾回收器標識哪些對象正在使用,哪些對象未在使用。遞歸地從GC根(棧指針)中使用中或可訪問的對象被標記爲活動的。從技術上講,這是對堆的深度優先搜索,能夠看做是有向圖。
  • 清理:垃圾回收器遍歷堆並記錄任何未標記爲活動的對象的內存地址。這些空間如今在空閒列表中被標記爲空閒,可用於存儲其餘對象。
  • 壓縮:清理後,若是須要,將全部剩下的對象移動到一塊兒。這將減小碎片並提升向較新對象分配內存的性能。

這種類型的GC也稱爲stop-the-world GC,由於它們在執行GC的過程當中引入了暫停時間。爲了不這個V8使用了以下技術:

16.png

  • 增量GC:GC是以多個增量步驟而不是一個增量步驟完成的。
  • 併發標記:標記是在不影響主JavaScript線程的狀況下使用多個輔助線程併發完成的。Write barriers用於跟蹤JavaScript在幫助程序併發標記時建立的對象之間的新引用。
  • 併發掃描/壓縮:掃描和壓縮在助手線程中同時完成,而不影響主JavaScript線程。
  • 延遲清理:延遲清理,包括延遲刪除頁中的垃圾,直到須要內存爲止。

讓咱們來看一下 major GC的過程:

  1. 讓咱們假設許多Minor GC週期已通過去,舊空間幾乎滿了,V8決定觸發一個Major GC
  2. Major GC從棧指針開始遞歸地遍歷對象圖,以標記在老生代空間中用做活動(已用內存)和剩餘對象做爲垃圾(孤立)的對象。這是使用多個併發助手線程完成的,每一個助手都跟隨一個指針。這不會影響主JS線程。
  3. 當併發標記完成或達到內存限制時,GC使用主線程執行標記終結步驟。這將引入一個小的暫停時間。
  4. Major GC如今使用併發掃描線程將全部孤立對象的內存標記爲空閒。並行壓縮任務也會被觸發,以將相關內存塊移動到同一頁以免碎片化。在這些步驟中會更新指針。

結論

本文將爲您提供V8內存結構和內存管理的概述。這裏沒有作到面面俱到的,還有不少更高級的概念,您能夠從v8.dev中瞭解它們。可是對於大多數JS/WebAssembly開發人員來講,這一級別的信息就足夠了,我但願它能幫助您編寫更好的代碼,考慮到這些因素,對於更高性能的應用程序,記住這些能夠幫助您避免下一個可能遇到的內存泄漏問題。

最新的.png

相關文章
相關標籤/搜索