目录
1.JS内存管理
1.1 内存生命周期
分配内存
=> 内存读写
=> 释放内存
在JS语言中,内存分配是由JS引擎自动完成的,释放内存也是如此,由JS引擎根据垃圾回收机制进行回收,开发者能参与的是内存读写这一环节
1.2 两种内存存储类型
基本类型
:内存是固定大小
的,其值存储在栈(stack)
空间中
引用类型
:内存是非固定
的,其值存储在堆(heap)
空间中,指向堆空间中的对象的指针
存储在栈空间中
不多bb,来看代码:
let n = 7 // 在栈中给数值分配内存
let s = 'NO x ONE' // 在栈中给字符串分配内存
let b = true // ...
let a = null // ...
let obj = { // 在堆中存储新的对象,在栈中存储指向该对象的指针并返回给obj,构成引用关系
id: 1
}
let obj2 = obj // 共享同一个指针
obj2 = 2 // 在下一轮垃圾回收中,将堆中不再被引用的对象从内存中释放,原先栈空间的指针变为了2
// 由于obj2与obj共享同一个指针,故obj=obj2= 1
let arr = [1,2] // ...
let fn = function(a){ alert(a)} // ...
2.垃圾回收机制
2.1 核心原理
GC
即Garbage Collection,垃圾回收机制。JS引擎定期找出不再用到的内存,然后释放,至于如何实现有不同的策略,常见的有标记清除算法
和引用计数算法
2.2 标记清除算法
标记清除(Mark-Sweep),大多数浏览器的JS引擎都在采用这种算法,只是在此基础之上又进行各自的优化加工
该策略分为Mark
和Sweep
两个阶段
过程如下:
Mark阶段
- 运行时在内存中所有变量标记0(即垃圾)
- 从各个根对象遍历,将非垃圾变量标记变为1
Sweep阶段
- 将所有标记0的变量内存释放,GC
来看看代码:
function fn(a){ // 开始执行此函数时,将其作用域中a、B以及匿名函数标记为0
alert(a) // 0
let B = new Object() // 0
return function (){ // 由于这里return出去会被其他变量引用,故标记变为1
altert(B) // 由于这里的闭包,B的标记变为1
}
... // 执行函数完毕,销毁作用域,在某个GC回收循环时会清理标记为0的变量a,B和匿名函数被保留了下来即非垃圾变量
}
let fn2 = fn(new Object())
// 补充一下:fn和fn2作为window.fn和window.fn2,标记一直为1,仅仅当手动设置fn=null和fn2=null才会标记为0
但是这种策略存在内存碎片化
缺陷,即释放的内存空间往往是不连续的,如下图所示:
这样不利于内存的回收利用,即空闲内存与非空闲内存是相交错的,不利于存储
故考虑优化在Mark
和Sweep
阶段之间再补充上整理Compact
阶段
- 在
Mark
阶段结束后,将标记1的变量内存往内存一段移动,标记0的变量内存往另一端移动 - 开启
Sweep
阶段
优化后的策略即标记整理清除算法(Mark-Compact-Sweep),入下图所示:
2.3 引用计数算法
JS引擎很早之前使用过这种策略回收内存,其核心思想为:将不再被引用的对象(零引用)
作为垃圾回收,需要提醒的是,这种策略由于存在很多问题,目前逐渐被弃用了
过程如下:
- 当声明一个引用类型并赋值给变量时,这个值的引用次数初始为1
- 若该值又被赋值给另一个变量,引用次数+1
- 若该变量的被其他值覆盖了,引用次数-1
- 当这个值引用次数变为0时,说明该值不再被引用,垃圾回收器会在运行时清理释放其内存
代码如下:
let a = new Object() // 引用次数初始化为1
let b = a // 引用次数2,即obj被a和b引用
a=null // 引用次数1
b=null // 引用次数0,
... // GC回收此引用类型在堆空间中所占的内存
但是存在一些问题,例如最常见的是循环引用现象
function fn(){ // fn引用次数为1,因为window.fn = fn,会在window=null即浏览器关闭时回收
let A = new Object() // A: 1
let B = new Object() // B: 1
A.b = B // B: 2
B.a = a // A: 2
... // 执行完fn函数,作用域销毁时,A和B引用次数-1,但是还不为0,在某个GC回收循环执行时不会释放其内存,存在了内存泄漏
}
fn() // 若执行无限多次fn,那么内存将会被占满,程序宕机
若是采用标记清除
策略则会在fn
执行完毕后,作用域销毁,将域中的A
和B
变量标记为0以便GC回收内存,不会存在这种问题。
3.V8对GC的优化
前面提过,现在大部分浏览器JS引擎都采用标记清理
策略来实现垃圾回收机制,但是又各自基于此策略又进行了不同的优化,这里主要来看Chrome的JS引擎V8对此进行的优化
3.1 分代式回收
标记清理
策略在每次垃圾回收前都要检测内存中所有的对象标记是否为0来作为是否回收的依据,若一些大、老、存活长的对象(老生代
)与小、新、存活时间短的对象(新生代
)采用一个频率检查的话将会消耗很大的性能,故要区别对待,所以V8采用分代
的方式进行垃圾回收,对前者使用老生代GC
(清理频率低),后者采用新生代GC
(清理频率高)
3.1.1 内存存储分代
由于V8的GC策略主要是基于分代,故V8存储变量的方式也是分代的,将堆空间开辟为新生代和老生代
新生代:堆空间内存空间小(1~8MB),对应GC算法Scavenge
效率高
老生代:堆空间内存较大,对应GC算法Mark-Compact-Sweep
效率低些
这种分配是非常合理的,对于频繁回收操作,牺牲空间换取时间
;对于低频回收操作,牺牲时间换取空间
。此思想可以类比计算机存储分层结构,将需要读取频率高的存储在小容量、高处理的cache中,将读取频率低的存储在大容量、低处理的内存中
3.1.2 新生代GC
对于新生代采用Scavenge
算法进行垃圾回收,将新生代存储区一分为二:使用区
、空闲区
程序运行时,作用域中所有变量
都会存入使用区
,当该区域内存写满之后就会进行一次GC。在GC开始前,先将使用区里的活动对象
移动到空闲区
,非活动对象
则保留,随后进行GC,清除保留在使用区里的对象,最后将空闲区
与使用区
进行角色互换,如下图所示
3.1.3老生代GC
首先,老生代都是从新生代转变来的,但要经过考核,满足以下任意一种条件即可:
- 当一个新生代对象经过多次
Scavenge GC
仍然是存活,那么就判定它是生命周期长的对象(老油条),会将其移动到老生代内存中,作为老生代对象 - 当一个变量刚进入使用区时就已经占了
25%
,那么为了性能考虑,直接将其移动到老生代内存中
随后采用前面所说的Mark-Compact-Sweep
即标记整理清除策略来进行垃圾回收即可
3.2 并行回收
V8主要是采用分代式回收
,但是对于老生代使用Mark-Compact-Sweep
性能还是有提升空间,故又对老生代垃圾回收机制采用并行回收
进行优化
首先为什么要并行回收
呢?这是由于JS是单线程
的,运行在主线程上,在GC回收也是运行在主线程中,这会造成JS脚本暂时堵塞,在GC回收完毕才会恢复脚本运行,这种现象叫作全停顿(Stop-To-World)
,所以为了加快GC回收,V8引擎引入了并行回收
,即并行开启多个辅助线程,协同完成GC回收工作(人多好干活,社会主义好啊~)
3.3并发回收
采用并行回收
还是存在一个问题,那就是它还是多多少少造成JS脚本堵塞,并未从根本上解决问题,所以又提出了并发回收
机制,如下图所示:
GC回收完全在辅助线程中进行,不占用主线程,丝毫不会导致JS脚本挂起,这就是并发
的好处
但是要实现很难,因为主线程在执行 JavaScript
时,堆中的对象引用关系随时可能变化,这时辅助线程之前做的一些标记或者正在进行的标记就会改变,所以需要额外实现一些读写锁
机制来控制,具体怎么搞又是一个深入的话题
3.4增量标记、惰性清理
这也是对并行回收
存在的全停顿
现象进行的优化,在Mark
阶段采用增量标记
代替全停顿标记
,在Sweep
阶段采用惰性清理
来代替,尽最大的可能减少全停顿
时间,具体怎么搞也是一个深入的话题