JavaScript垃圾回收機制和效能優化
JavaScript垃圾回收機制和效能優化
前言
我們都知道程式的執行需要一定的記憶體空間,且在執行過後就必須將不再用到的記憶體釋放掉,否則就會出現下圖中記憶體的佔用持續升高的情況,一方面會影響程式的執行速度,另一方面嚴重的話則會導致整個程式的崩潰。
JavaScript中的記憶體管理
- 記憶體:由可讀寫單元組成,表示一片可操作空間
- 管理:人為的去操作一片空間的申請、使用和釋放
- 記憶體管理:開發者主動申請空間、使用空間、釋放空間
- 管理流程:申請-使用-釋放
部分語言需要(例如C語言)需要手動去釋放記憶體,但是會很麻煩,所以很多語言都會提供自動的記憶體管理機制,稱為“垃圾回收機制”,JavaScript語言中也提供了垃圾回收機制(Garbage Collecation),簡稱GC機制
全停頓(Stop The World )
在介紹垃圾回收演算法之前,我們先了解一下「全停頓」。垃圾回收演算法在執行前,需要將應用邏輯暫停,執行完垃圾回收後再執行應用邏輯,這種行為稱為 「全停頓」(Stop The World)。例如,如果一次GC需要50ms,應用邏輯就會暫停50ms。
全停頓的目的,是為了解決應用邏輯與垃圾回收器看到的情況不一致的問題。舉個例子,在自助餐廳吃飯,高高興興地取完食物回來時,結果發現自己餐具被服務員收走了。這裡,服務員好比垃圾回收器,餐具就像是分配的物件,我們就是應用邏輯。在我們看來,只是將餐具臨時放在桌上,但是服務員看來覺得你已經不需要使用了,因此就收走了。你與服務員對於同一個事物看到的情況是不一致,導致服務員做了與我們不期望的事情。因此,為避免應用邏輯與垃圾回收器看到的情況不一致,垃圾回收演算法在執行時,需要停止應用邏輯。
JavaScript中的垃圾回收
JavaScript中會被判定為垃圾:
- 物件不再被引用是垃圾
- 物件不能從根上訪問到時垃圾
常見的GC演算法:
- 引用計數
- 標記清除
- 標記整理
- 分代回收
(1). 引用計數
早期的瀏覽器最常使用的垃圾回收方法叫做"引用計數"(reference counting):語言引擎有一張"引用表",儲存了記憶體裡面所有的資源(通常是各種值)的引用次數。如果一個值的引用次數是0,就表示這個值不再用到了,因此可以將這塊記憶體釋放。
const user1 = {age: 11} const user2 = {age: 22} const user3 = {age: 33} const userList = [user1.age, user2.age, user3.age]
上面這段程式碼,當執行過一遍過後,user1、user2、user3都是被userList引用的,所以它們的引用計數不為零,就不會被回收
function fn() {
const num1 = 1
const num2 = 2
}
fn()
上面程式碼中fn函式執行完畢,num1、num2都是區域性變數,執行過後,它們的引用計數就都為零,所有這樣的程式碼就會被當做“垃圾”,進行回收
引用計數演算法有一個比較大的問題: 迴圈引用
function objGroup(obj1, obj2) {
obj1.next = obj2
obj2.prev = obj1
return {
o1: obj1,
o2: obj2,
}
}
let obj = objGroup({name: 'obj1'}, {name: 'obj2'})
console.log(obj)
上面的這個例子中,obj1和obj2通過各自的屬性相互引用,所有它們的引用計數都不為零,這樣就不會被垃圾回收機制回收,造成記憶體浪費。
引用計數演算法其實還有一個比較大的缺點,就是我們需要單獨拿出一片空間去維護每個變數的引用計數,這對於比較大的程式,在空間開銷還是比較大的。
- 引用計數演算法優點:
- 引用計數為零時,發現垃圾立即回收
- 最大限度減少程式暫停
- 引用計數演算法缺點:
- 無法回收迴圈引用的物件
- 空間開銷比較大
(2). 標記清除(Mark-Sweep)
- 核心思想:分標記和清除兩個階段完成
- 遍歷所有物件找標記活動物件
- 遍歷所有物件清除沒有標記物件
- 回收相應的空間
標記清除演算法的優點是:對比引用計數演算法,標記清除演算法最大的優點是能夠回收迴圈引用的物件,它也是v8引擎使用最多的演算法。
標記清除演算法的缺點是:
上圖我們可以看到,紅色區域是一個根物件,就是一個全域性變數,會被標記;而藍色區域就是沒有被標記的物件,會被回收機制回收。這時就會出現一個問題,表面上藍色區域被回收了三個空間,但是這三個空間是不連續的,當我們有一個需要三個空間的物件,那麼我們剛剛被回收的空間是不能被分配的,這就是“空間碎片化”。
3. 標記整理(Mark-Compact)
為了解決記憶體碎片化的問題,提高對記憶體的利用,引入了標記整理演算法。
- 標記整理可以看做是標記清除的增強
- 標記階段的操作和標記清除一致
- 清除階段會先執行整理,移動物件位置,將存活的物件移動到一邊,然後再清理端邊界外的記憶體。
標記整理的缺點是:移動物件位置,不會立即回收物件,回收的效率比較慢。
增量標記(Incremental Marking)
為了減少全停頓的時間,V8對標記進行了優化,將一次停頓進行的標記過程,分成了很多小步。每執行完一小步就讓應用邏輯執行一會兒,這樣交替多次後完成標記。
長時間的GC,會導致應用暫停和無響應,將會導致糟糕的使用者體驗。從2011年起,v8就將「全暫停」標記換成了增量標記。改進後的標記方式,最大停頓時間減少到原來的1/6。
v8引擎垃圾回收策略
- 採用分代回收的思想
- 記憶體分為新生代、老生代
- 針對不同物件採用不同演算法
(1)新生代:物件的存活時間較短。新生物件或只經過一次垃圾回收的物件。
(2)老生代:物件存活時間較長。經歷過一次或多次垃圾回收的物件。
V8堆的空間等於新生代空間加上老生代空間。且針對不同的作業系統對空間做了記憶體的限制。
型別 \ 系統位數 | 64位 | 32位 |
---|---|---|
老生代 | 1400MB | 700MB |
新生代 | 32MB | 16MB |
限制記憶體的原因:
- 針對瀏覽器來說,這樣的記憶體是足夠使用的
- 針對瀏覽器的GC機制,經過不斷的測試,如果記憶體再設定大一點,GC回收的時間就會達到使用者的感知,會造成感知上的卡頓。
(1). 回收新生代物件
回收新生代物件主要採用複製演算法(Scavenge 演算法)加標記整理演算法。而Scavenge 演算法的具體實現,主要採用了Cheney演算法。
Cheney演算法將記憶體分為兩個等大空間,使用空間為From,空閒空間為To。
檢查From空間內的存活物件,若物件存活,檢查物件是否符合晉升條件,若符合條件則晉升到老生代,否則將物件從 From 空間複製到 To 空間。若物件不存活,則釋放不存活物件的空間。完成複製後,將 From 空間與 To 空間進行角色翻轉。
物件晉升機制
- 一輪GC還存活的新生代需要晉升
- 當物件從From 空間複製到 To 空間時,若 To 空間使用超過 25%,則物件直接晉升到老生代中。設定為25%的比例的原因是,當完成 Scavenge 回收後,To 空間將翻轉成From 空間,繼續進行物件記憶體的分配。若佔比過大,將影響後續記憶體分配。
(2). 回收老生代物件
- 回收老生代物件主要採用標記清除、標記整理、增量標記演算法,主要使用標記清除演算法,只有在記憶體分配不足時,採用標記整理演算法
- 首先使用標記清除完成垃圾空間的回收
- 採用標記整理進行空間優化
- 採用增量標記進行效率優化
新生代和老生代回收對比
- 新生代由於佔用空間比較少,採用空間換時間機制
- 老生代區域空間比較大,不太適合大量的複製演算法和標記整理,所以最常用的是標記清除演算法,為了就是讓全停頓的時間儘量減少
記憶體洩漏識別方法
我們先寫一段比較消耗記憶體的程式碼
<button class="btn">點選</button>
<script>
const btn = document.querySelector('.btn')
const arrList = []
btn.onclick = function() {
for(let i = 0; i < 100000; i++) {
const p = document.createElement('p')
// p.innerHTML = '我是一個p元素'
document.body.appendChild(p)
}
arrList.push(new Array(1000000).join('x'))
}
</script>
使用瀏覽器的Performance來監控記憶體變化
點選錄製,然後我們操作們感覺消耗效能的操作,操作完成之後,點選stop停止錄製
然後我們看一看是那些地方引起了記憶體的洩漏,我們只需要關注記憶體即可
可以看到記憶體在短時間消耗的比較快,下降的小凹槽,就是瀏覽器在進行垃圾回收
效能優化
-
1.避免使用全域性變數
- 全域性變數會掛載在window下
- 全域性變數至少有一個引用計數
- 全域性變數存活更久,但是持續佔用記憶體
在明確資料作用域的情況下,儘量使用區域性變數
-
2.減少判斷層級
function doSomething(part, chapter) {
const parts = ['ES2016', '工程化', 'Vue', 'React', 'Node']
if (part) {
if (parts.includes(part)) {
console.log('屬於當前課程')
if (chapter > 5) {
console.log('您需要提供 VIP 身份')
}
}
} else {
console.log('請確認模組資訊')
}
}
doSomething('Vue', 6)
// 減少判斷層級
function doSomething(part, chapter) {
const parts = ['ES2016', '工程化', 'Vue', 'React', 'Node']
if (!part) {
console.log('請確認模組資訊')
return
}
if (!parts.includes(part)) return
console.log('屬於當前課程')
if (chapter > 5) {
console.log('您需要提供 VIP 身份')
}
}
doSomething('Vue', 6)
- 3.減少資料讀取次數
對於頻繁使用的資料,我們要對資料進行快取
<div id="skip" class="skip"></div>
<script>
var oBox = document.getElementById('skip')
// function hasEle (ele, cls) {
// return ele.className === cls
// }
function hasEle (ele, cls) {
const className = ele.className
return className === cls
}
console.log(hasEle(oBox, 'skip'))
</script>
- 4.減少迴圈體中的活動
var test = () => {
var i
var arr = ['maoxiaoxing', 25, '能被看見的努力,都是膚淺的努力']
for(i = 0; i < arr.length; i++) {
console.log(arr[i])
}
}
// 優化後,將arr.length單獨提出,防止每次迴圈都獲取一次
var test = () => {
var i
var arr = ['maoxiaoxing', 25, '能被看見的努力,都是膚淺的努力']
var len = arr.length
for(i = 0; i < len; i++) {
console.log(arr[i])
}
}
- 5.事件繫結優化
<ul class="ul">
<li>毛小星</li>
<li>25</li>
<li>能看見的努力,都是膚淺的努力</li>
</ul>
<script>
var list = document.querySelectorAll('li')
function showTxt(ev) {
console.log(ev.target.innerHTML)
}
for (item of list) {
item.onclick = showTxt
}
// 優化後
function showTxt(ev) {
var target = ev.target
if (target.nodeName.toLowerCase() === 'li') {
console.log(ev.target.innerHTML)
}
}
var ul = document.querySelector('.ul')
ul.addEventListener('click', showTxt)
</script>
- 6.避開閉包陷阱
<button class="btn">點選</button>
<script>
function foo() {
let el = document.querySelector('.btn')
el.onclick = function() {
console.log(el.className)
}
}
foo()
// 優化後
function foo1() {
let el = document.querySelector('.btn')
el.onclick = function() {
console.log(el.className)
}
el = null // 將el置為 null 防止閉包中的引用使得不能被回收
}
foo1()
</script>