摘要:眾所周知,應用程序在運行過程中需要占用一定的內存空間,且在運行過后就必須將不再用到的內存釋放掉,否則就會出現下圖中內存的占用持續升高的情況,一方面會影響程序的運行速度,另一方面嚴重的話則會導致整個程序的崩潰。
眾所周知,應用程序在運行過程中需要占用一定的內存空間,且在運行過后就必須將不再用到的內存釋放掉,否則就會出現下圖中內存的占用持續升高的情況,一方面會影響程序的運行速度,另一方面嚴重的話則會導致整個程序的崩潰。
JavaScript中的內存管理
- 內存:由可讀寫單元組成,表示一片可操作空間;
- 管理:人為的去操作一片空間的申請、使用和釋放;
- 內存管理:開發者主動申請空間、使用空間、釋放空間;
- 管理流程:申請-使用-釋放
部分語言需要(例如C
語言)需要手動去釋放內存,但是會很麻煩,所以很多語言,例如JAVA
都會提供自動的內存管理機制,稱為“垃圾回收機制”,JavaScript
語言中也提供了垃圾回收機制(Garbage Collecation
),簡稱GC機制。
全停頓(Stop The World )
在介紹垃圾回收算法之前,我們先了解一下「全停頓」。垃圾回收算法在執行前,需要將應用邏輯暫停,執行完垃圾回收后再執行應用邏輯,這種行為稱為 「全停頓」(Stop The World
)。例如,如果一次GC需要50ms,應用邏輯就會暫停50ms。
全停頓的目的,是為了解決應用邏輯與垃圾回收器看到的情況不一致的問題。
舉個例子,在自助餐廳吃飯,高高興興地取完食物回來時,結果發現自己餐具被服務員收走了。這里,服務員好比垃圾回收器,餐具就像是分配的對象,我們就是應用邏輯。在我們看來,只是將餐具臨時放在桌上,但是服務員看來覺得你已經不需要使用了,因此就收走了。你與服務員對於同一個事物看到的情況是不一致,導致服務員做了與我們不期望的事情。因此,為避免應用邏輯與垃圾回收器看到的情況不一致,垃圾回收算法在執行時,需要停止應用邏輯。
JavaScript
中的垃圾回收
JavaScript中會被判定為垃圾的情形如下:
- 對象不再被引用;
- 對象不能從根上訪問到;
GC
算法
常見的GC
算法如下:
- 引用計數
- 標記清除
- 標記整理
- 分代回收
引用計數
早期的瀏覽器最常使用的垃圾回收方法叫做"引用計數"(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通過各自的屬性相互引用,所有它們的引用計數都不為零,這樣就不會被垃圾回收機制回收,造成內存浪費。
引用計數算法其實還有一個比較大的缺點,就是我們需要單獨拿出一片空間去維護每個變量的引用計數,這對於比較大的程序,空間開銷還是比較大的。
引用計數算法優點:
- 引用計數為零時,發現垃圾立即回收;
- 最大限度減少程序暫停;
引用計數算法缺點:
- 無法回收循環引用的對象;
- 空間開銷比較大;
標記清除(Mark-Sweep)
核心思想:分標記和清除兩個階段完成。
- 遍歷所有對象找標記活動對象;
- 遍歷所有對象清除沒有標記對象;
- 回收相應的空間。
標記清除算法的優點是:對比引用計數算法,標記清除算法最大的優點是能夠回收循環引用的對象,它也是v8引擎使用最多的算法。
標記清除算法的缺點是:
上圖我們可以看到,紅色區域是一個根對象,就是一個全局變量,會被標記;而藍色區域就是沒有被標記的對象,會被回收機制回收。這時就會出現一個問題,表面上藍色區域被回收了三個空間,但是這三個空間是不連續的,當我們有一個需要三個空間的對象,那么我們剛剛被回收的空間是不能被分配的,這就是“空間碎片化”。
標記整理(Mark-Compact)
為了解決內存碎片化的問題,提高對內存的利用,引入了標記整理算法。
標記整理可以看做是標記清除的增強。標記階段的操作和標記清除一致。
清除階段會先執行整理,移動對象位置,將存活的對象移動到一邊,然后再清理端邊界外的內存。
標記整理的缺點是:移動對象位置,不會立即回收對象,回收的效率比較慢。
增量標記(Incremental Marking)
為了減少全停頓的時間,V8
對標記進行了優化,將一次停頓進行的標記過程,分成了很多小步。每執行完一小步就讓應用邏輯執行一會兒,這樣交替多次后完成標記。
長時間的GC
,會導致應用暫停和無響應,將會導致糟糕的用戶體驗。從2011年起,v8就將「全暫停」標記換成了增量標記。改進后的標記方式,最大停頓時間減少到原來的1/6。
v8引擎垃圾回收策略
- 采用分代回收的思想;
- 內存分為新生代、老生代;
針對不同對象采用不同算法:
(1)新生代:對象的存活時間較短。新生對象或只經過一次垃圾回收的對象。
(2)老生代:對象存活時間較長。經歷過一次或多次垃圾回收的對象。
V8
堆的空間等於新生代空間加上老生代空間。且針對不同的操作系統對空間做了內存的限制。
針對瀏覽器來說,這樣的內存是足夠使用的。限制內存的原因:
針對瀏覽器的GC機制,經過不斷的測試,如果內存再設置大一點,GC
回收的時間就會達到用戶的感知,會造成感知上的卡頓。
回收新生代對象
回收新生代對象主要采用復制算法(Scavenge 算法
)加標記整理算法。而Scavenge 算法
的具體實現,主要采用了Cheney算法
。
Cheney算法
將內存分為兩個等大空間,使用空間為From
,空閑空間為To
。
檢查From
空間內的存活對象,若對象存活,檢查對象是否符合晉升條件,若符合條件則晉升到老生代,否則將對象從 From
空間復制到 To
空間。若對象不存活,則釋放不存活對象的空間。完成復制后,將 From
空間與 To
空間進行角色翻轉。
對象晉升機制
一輪GC
還存活的新生代需要晉升。
當對象從From
空間復制到 To
空間時,若 To
空間使用超過 25%,則對象直接晉升到老生代中。設置為25%的比例的原因是,當完成 Scavenge
回收后,To
空間將翻轉成From
空間,繼續進行對象內存的分配。若占比過大,將影響后續內存分配。
回收老生代對象
回收老生代對象主要采用標記清除、標記整理、增量標記算法,主要使用標記清除算法,只有在內存分配不足時,采用標記整理算法。
- 首先使用標記清除完成垃圾空間的回收;
- 采用標記整理進行空間優化;
- 采用增量標記進行效率優化;
新生代和老生代回收對比
新生代由於占用空間比較少,采用空間換時間機制。
老生代區域空間比較大,不太適合大量的復制算法和標記整理,所以最常用的是標記清除算法,為了就是讓全停頓的時間盡量減少。
內存泄漏識別方法
我們先寫一段比較消耗內存的代碼:
<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 = ['Hello World!', 25, '豈曰無衣,與子同袍']
for(i = 0; i < arr.length; i++) {
console.log(arr[i])
}
}
// 優化后,將arr.length單獨提出,防止每次循環都獲取一次
var test = () => {
var i
var arr = ['Hello World!', 25, '豈曰無衣,與子同袍']
var len = arr.length
for(i = 0; i < len; i++) {
console.log(arr[i])
}
}
5.事件綁定優化
<ul class="ul">
<li>Hello World!</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>
本文分享自華為雲社區《Vue進階(幺陸玖):JS垃圾回收機制》,原文作者:SHQ5785 。