這是《圖解 Google V8》第三篇/共三篇:事件迴圈和垃圾回收
這裡主要講了 2 點:
事件迴圈:宏任務和微任務
- 什麼是微任務
- 微任務的執行時機
垃圾回收
- 垃圾回收執行過程
- 垃圾回收演算法
透過這個專欄的學習,V8
不在是個陌生的黑盒了,變成了一個熟悉的黑盒,因為這個專欄讓你瞭解了 V8
的大致原理,面試時吹吹牛皮還是可以的,不過也就僅此而已,細節方面還需要自己去深入
17 | 訊息佇列:V8 是怎麼實現回撥函式的?
- 同步回撥函式是在執行函式內部被執行的
- 非同步回撥函式是在執行函式外部被執行的
UI
執行緒是執行視窗的執行緒,也叫主執行緒
當滑鼠點選了頁面,系統會將該事件交給 UI
執行緒來處理,但是 UI
執行緒不能立即響應來處理
針對這種情況,瀏覽器為 UI
執行緒提供了訊息佇列,然後 UI
執行緒會不斷的從訊息佇列中取出事件和執行事件,如果當前沒有任何訊息等待被處理,那麼這個迴圈就會被掛起
setTimeout
在執行 setTimeout
,瀏覽器會將回撥函式封裝成一個事件,新增到訊息佇列中,然後 UI
執行緒會不間斷的從訊息佇列中取出任務,執行任務,在合適的時機取出 setTimeout
的回撥函式
XMLHttpRequest
在 UI
執行緒執行 XMLHttpRequest
,會阻塞 UI
執行緒,所以 UI
執行緒會將它分配給網路執行緒(是網路程式中的一個執行緒):
UI
執行緒從訊息佇列中取出任務,分析- 發現是一個下載任務,就會交給網路執行緒去執行
- 網路執行緒接到下載請求後,會和伺服器建立聯絡,發出下載請求
- 網路執行緒不斷從伺服器接收資料
- 網路請求在收到資料後,會將返回的資料和回撥函式封裝成一個事件,放在訊息佇列中
UI
執行緒迴圈讀取訊息佇列,如果是下載狀態的事件,UI
執行緒就會執行回撥函式- 直到下載事件結束,頁面顯示下載完成
18 | 非同步程式設計(一):V8 是如何實現微任務的?
宏任務是訊息佇列中等待被主執行緒執行的事件,每個宏任務在執行的時候都會建立棧,宏任務結束,棧也會被清空
微任務是一個需要非同步執行的函式,執行時機是在主函式執行結束之後,當前宏任務結束之前
微任務執行的時機:
- 如果當前任務中產生了一個微任務,不會再當前的函式中被執行,所以執行微任務時,不會導致棧的無限擴張
- 微任務會在當前任務執行結束之前被執行
- 微任務結束執行之前,不會執行其他的任務
參考資料
19 |非同步程式設計(二):V8 是如何實現 async/await 的?
生成器 Generator
帶星號的函式配合 yield
可以實現函式的暫停和恢復,這個叫生成器
function* getResult() {
console.log("getUserID before");
yield "getUserID";
console.log("getUserName before");
yield "getUserName";
console.log("name before");
return "name";
}
let result = getResult();
console.log(result.next().value);
console.log(result.next().value);
console.log(result.next().value);
在生成器內部,如果遇到 yield
關鍵詞,那麼 V8
將 yield
後面的內容返回給外部,並暫停函式的執行
生成器暫停後,外面程式碼開始執行,如果想要繼續恢復生成器的執行,就可以使用 result.next()
方法
在暫停和恢復之間切換,這背後的原理是協程,協程是比執行緒更加輕量級,它是跑線上程上的任務
一個執行緒有多個協程,但只能執行一個協程,如果 A
協程啟動 B
協程,那 A
協程就是 B
協程的父協程
使用生成器編寫同步程式碼
function* getResult() {
let id_res = yield fetch(id_url);
console.log(id_res);
let id_text = yield id_res.text();
console.log(id_text);
let new_name_url = name_url + "?id=" + id_text;
console.log(new_name_url);
let name_res = yield fetch(new_name_url);
console.log(name_res);
let name_text = yield name_res.text();
console.log(name_text);
}
let result = getResult();
result
.next()
.value.then((response) => {
return result.next(response).value;
})
.then((response) => {
return result.next(response).value;
})
.then((response) => {
return result.next(response).value;
})
.then((response) => {
return result.next(response).value;
});
把執行生成器程式碼的函式稱為執行器(可參考著名的 co
框架)
function* getResult() {
let id_res = yield fetch(id_url);
console.log(id_res);
let id_text = yield id_res.text();
console.log(id_text);
let new_name_url = name_url + "?id=" + id_text;
console.log(new_name_url);
let name_res = yield fetch(new_name_url);
console.log(name_res);
let name_text = yield name_res.text();
console.log(name_text);
}
co(getResult());
async/await
async
是非同步執行並隱式返回 Promise
作為結果的函式。
await
後面可以接兩種型別的表示式:
- 任何普通表示式
Promise
物件的表示式
如果 await
等待的是一個 Promise
物件,它會暫停執行生成器函式,直到 Promise
物件變成 resolve
才會恢復執行,然後 resolve
的值作為 await
表示式的運算結果
function NeverResolvePromise() {
return new Promise((resolve, reject) => {});
}
function ResolvePromise() {
return new Promise((resolve, reject) => resolve("resolve"));
}
async function getResult() {
let a = await NeverResolvePromise();
console.log(a); // 不會輸出
}
async function getResult2() {
let b = await ResolvePromise();
console.log(b); // "resolve"
}
getResult();
getResult2();
console.log(0);
async
是一個非同步執行的函式,不會阻塞主執行緒的執行
async
函式在執行時,是一個單獨的協程,可以用 await
來暫停,由於等待的是一個 Promise
物件,就可以用 resolve
來恢復該協程
參考資料
20 | 垃圾回收(一):V8 的兩個垃圾回收器是如何工作的?
透過
GC Root
標記記憶體中活動物件和非活動物件。V8
採用可訪問性(reachability)演算法判斷堆中的物件是是否為活動物件GC Root
能夠遍歷到的物件,是可訪問的(reachable),稱為活動物件GC Root
不能遍歷到的物件,認為是不可訪問的(unreachable),稱為非活動物件
瀏覽器環境中有很多
GC Root
window
物件(位於每個iframe
中)DOM
,由可以透過遍歷文件到達的所有原生 DOM 節點組成- 存放棧上變數
- 回收非活動物件所佔用的記憶體
回收後,做記憶體整理(可選,有些垃圾回收器不會產生記憶體碎片,比如副垃圾回收器)
- 回收結束後,記憶體中會出現大量不連續的空間,這空間被稱為記憶體碎片
- 如果記憶體碎片太多的話,當需要較大連續的記憶體時,就會出現記憶體不足
V8
受代際假說影響,使用了兩個垃圾回收器:主垃圾回收器(Major GC
),副垃圾回收器(Minor GC
)
代際假說:
- 大部分物件都是“朝生夕死”的,也就是說大部分物件在記憶體中存活的時間很短,比如函式內部宣告的變數,或者塊級作用域中的變數,當函式或者程式碼塊執行結束時,作用域中定義的變數就會被銷燬。因此這一類物件一經分配記憶體,很快就變得不可訪問
- 不死的物件,會活得更久,比如:
window
、DOM
、Web API
等
V8
把堆分為兩個區域:
新生代:存放生存時間短的物件
- 容量小,
1~8M
- 使用副垃圾回收器(
Minor GC
) 使用
Scavenge
演算法,將新生代區域分成兩部分- 物件區域 (
from-space
) 空閒區域 (
to-space
)- 物件區域放新加入的物件
- 物件區域快滿的時候,執行垃圾清理(先標記,再清理)
- 清理的把活動物件複製到空閒區域,並且排序(空閒區域就沒有記憶體碎片了)
- 複製完之後,把物件區域和空閒區域進行翻轉
- 重複執行上面的步驟
- 經過兩次垃圾回收後還存在的物件,移動到老生代中
- 物件區域 (
- 容量小,
老生代:存放生存時間久的物件
容量大
- 物件佔用空間大
- 物件存活時間長
- 使用主垃圾回收器(
Major GC
) 使用標記 - 清除演算法(
Mark-Sweep
)- 標記:從根元素開始,找到活動物件,找不到的就是垃圾
- 清理:直接清理垃圾(會產生垃圾碎片)
或者使用標記 - 整理演算法(
Mark-Compact
)- 標記:從根元素開始,找到活動物件,找不到的就是垃圾
- 整理:把活動物件向同一端移動,另一端直接清理(不會產生垃圾碎片)
參考資料
- Understanding Garbage Collection and hunting Memory Leaks in Node.js
- 深入理解 Node.js:核心思想與原始碼分析
- When and How JavaScript garbage collector works
- V8 引擎的記憶體管理
- Trash talk: the Orinoco garbage collector
21 | 垃圾回收(二):V8 是如何最佳化垃圾回收器執行效率的?
JavaScript 是執行在主執行緒上的,一旦執行垃圾回收演算法,JavaScript 會暫停執行,等垃圾回收完畢後再恢復執行,這種行為成為全停頓(Stop-The-World
)
V8 團隊向現有的垃圾回收器新增並行、併發、增量等垃圾回收技術
這些技術主要從兩方面解決垃圾回收效率的問題:
- 將一個完整的垃圾回收任務拆分成多個小的任務
- 將標記物件、移動物件等任務轉移到後端執行緒進行
並行回收(在主執行緒執行,全停頓)
在主執行緒執行垃圾回收的任務時,開啟多個協助執行緒,同時執行回收工作
採用並行回收,垃圾回收所消耗的時間 = 輔助執行緒數 * 單個執行緒所消耗的時間
在執行垃圾標記的過程中,主執行緒不會同時執行 JavaScript 程式碼,因此程式碼不會改變回收過程
假設記憶體狀態是靜態的,因此只要確保同時只有一個輔助執行緒在訪問物件就好了
這是 V8 副垃圾回收器採用的策略
它在執行垃圾回收的過程中,啟動多個執行緒來負責新生代中的垃圾清理,同時將物件空間中的資料移動到空閒區域,這操作會導致資料地址變了,所以還需要同步更新這些物件的指標
增量回收(在主執行緒執行,穿插在各個任務之間)
將標記工作分解為更小的塊,穿插在主執行緒不同的任務之間執行
採用增量回收,垃圾回收器沒必要一次執行完成的垃圾回收流程,每次執行的只是一小部分工作
增量回收是併發的,需要滿足兩點要求:
- 垃圾回收可以隨時被暫停和重啟,暫停的時候需要保留掃描結果,等待下一次回收
- 在暫停期間,被標記好的垃圾資料,如果被修改了,垃圾回收器需要正確的處理
在垃圾回收的時候,V8 使用三色標記法:
- 黑色:表示所有能訪問到的資料(活動資料),且子節點已經都標記完成
- 白色:表示還沒有被訪問到,如果在一輪遍歷結束還是白色,這個資料就會被回收
- 灰色:表示正在處理這個節點,且子節點還沒被處理
垃圾回收器會根據有沒有灰色的節點來判斷這一輪遍歷有沒有結束
- 沒有灰色:一輪遍歷結束,可以清理垃圾
- 有灰色:一輪遍歷還沒結束,從灰色的節點繼續執行
標記為黑色的資料被修改了,也就是說黑色的節點引用了一個白色的節點,但是黑色的節點是已經完成標記的,這是它後面還有一個白色的節點是不會被標記為黑色的
這就需要一個約束條件:不能讓黑色節點指向白色節點
這個約束條件是:寫屏障機制(Write-barrier):
- 當發生黑色節點引用白色節點,寫屏障機制會強制將這個白色節點變為灰色的,從而保證黑色節點不能指向白色節點
這種方法被稱為強三色不變性
併發回收(不在主執行緒執行)
在主執行緒執行 JavaScript 時,輔助執行緒在後臺執行垃圾回收操作
優點:主執行緒不會被掛起(JavaScript 可以自由執行,同時輔助執行緒可以執行垃圾回收)
但有兩點導致它很難實現:
- 主執行緒執行 JavaScript 時,堆中的內容隨時會變化,就會使得輔助執行緒之前的工作白做
- 主執行緒和輔助執行緒可能會在同一時間去修改同一個物件,這就需要額外實現讀寫鎖的功能