为什么要关注内存
- 任何程序的运行都要分配运行空间。
- 如果不在使用的内容得不到释放,不会返回到操作系统或空闲内存池,会导致内存泄露。
- 程序运行所需的内存空间大于当前的可用内存空间会引发内存溢出。
JS数据类型与JS内存机制
数据类型
原始数据类型:
- 字符串 string
- 数字 number
- 布尔 boolean
- 空对象 null
- 未定义 undefined
引用数据类型:
- object
- function
- array
内置对象(实际上是内置函数,可以当做构造器使用)
- String
- Number
- Boolean
- Object
- Function
- Array
- Date
- RegExp
- Error
内存空间:
- 栈 stack 存放原始数据类型
- 堆 heap 存放引用数据类型( Array、Object、Function)
栈
栈,一种数据结构,限定在表尾进行插入和删除操作的线性表。
特点:后进先出(Last In First Out)–LIFO
特别的是,允许插入和删除的一端称为栈顶,另一端称为栈底。
栈的插入操作,叫进栈、入栈或压栈。
栈的删除操作,叫出栈、或弹栈。
可以想象成弹夹压子弹,1-2-3 入弹夹,3-2-1 出弹夹。
var a = 10;
var b;
b=a;
Javascript编译原理:
var a
,编译器判断当前作用域中是否已存在该变量,如果有,则忽略;否则在当前作用域中新声明一个变量,命名为a
a = 2
,引擎运行时,先判断作用域中是否存在 变量 a。如果存在变量 a,进行赋值操作,将2赋值给a;否则抛出异常。
当声明变量a并初始化值为10时
-
为变量a创建为标识符
-
在栈中分配地址,指向标识符
-
将值10存储在标识符对应的地址
也就是值传递。
声明变量b,然后赋值时:
- 为变量b创建标识符
- 将变量a在栈中的地址,指向b。
a==b
,结果是什么?true因为a,b均为原始数据引用,在比较值的时候,比较的是值的本身。
如果此时我们执行a=true
,栈中会发生什么变化呢
因为栈中存在的是原始数据类型,其不可变,当我们将赋值true时,将在栈中新分配地址,并指向a,同时b的值指向不变,仍为10.
1583420312259.png再次操作b=null
后,新分配内存空间值为null,由于,地址为0,值为10的内存未关联任何变量,会被垃圾回收释放此空间。
基本数据类型存在堆的情况
闭包:将内部函数传递到所在的词法作用域以外,都会持有对原始定义作用域的引用。
当一个基本类型被闭包引用之后,就可以长期存在于内存中,这个时候即使他是基本类型,也是会被存放在堆中的。
function foo(){
var name='bob';
return function (){
console.log(name)
}
}
var bar=foo();
bar();//bob
正常情况下,foo
在执行完成后,会被垃圾回收器掉,但是因为闭包的存在,内部函数仍保留着局部变量name
的引用,导致内存无法释放,所以不能滥用闭包。需要及时将退出函数前,将闭包内的变量引用删除。
堆
是存储引用类型的地方。跟调用堆栈主要的区别在于,堆可以存储无序的数据,这些数据可以动态地增长,非常适合数组和对象。在Javascript中我们无法直接操作堆,我们在操作对象时,实际是在操作对象的引用。
当如下声明时:
var a={
name:'Bob',
age:18
}
- 为变量创建标识符
a
- 在栈中分配地址,指向标识符
- 在堆内存中分配空间
- 在栈中存储堆内存的存储地址
那如果我将一个对象赋值给另一个变量呢?var b=a
,栈中会配一个新的值,来存放新的变量,但是这两个变量的地址是一样的,相当于指向的对象是一样的
a.name=‘Tom’
这里只是修改了堆内存地址0x1021
中的数据,并未修改变量a的指向的内存地址。又因为变量a和b指向了内存空间的同一个地址,所有b.name也等于Tom
对象属性的内存模型
不同于原始数据内存模型,一个对象可以包含多个属性,而对象的属性又可以分为原始数据和引用数据。
var obj = {
name:'Bob',
age:'18',
behaviour:{
fly:function(){
console.log("can fly")
},
eat:{
noodles:'大碗宽面'
}
}
}
并不是说obj变量为引用类型,在堆内存中直接存放了。
obj来说,变量obj指向了堆内存中分配给引用数据对象的地址。从obj的属性来看,属性只是指向了属性值的内存地址,并不指向实际的对象。也就是说对象的属性指向的也是引用,指向这些值真正存放的地方。
垃圾回收
Javascript在创建变量时(对象、字符串等)时会自动分配内存, 并且在不使用他们时释放 。
优势:由引擎跟踪内存的分配和使用,以便当分配的内存不再使用时,自动释放它,减少内存空间不足带来的内存泄露。
劣势:未提供相应的api,无法人为进行内存操作。
垃圾回收算法主要依赖 引用 (在内存管理的环境中,一个对象如果有访问另一个对象的权限(隐式或者显式),叫做一个对象引用另一个对象)
引用计数法
记录每个值被引用的次数,当引用数为0时,表示这个值不再使用了,判定可以进行释放。
var o = {
a: {
b:2
}
};
// 两个对象被创建,一个作为另一个的属性被引用,另一个被分配给变量o
// 很显然,没有一个可以被垃圾收集
var o2 = o; // o2变量是第二个对“这个对象”的引用
o = 1; // 现在,“这个对象”只有一个o2变量的引用了,“这个对象”的原始引用o已经没有
var oa = o2.a; // 引用“这个对象”的a属性
// 现在,“这个对象”有两个引用了,一个是o2,一个是oa
o2 = "yo"; // 虽然最初的对象现在已经是零引用了,可以被垃圾回收了
// 但是它的属性a的对象还在被oa引用,所以还不能回收
oa = null; // a属性的那个对象现在也是零引用了
// 它可以被垃圾回收了
弊端(IE8及以下)
我们来看个例子
function f(){
var o = {};
var o2 = {};
o.a = o2; // o 引用 o2
o2.a = o; // o2 引用 o
}
f();
这里创建了两个对象 o
和o2
并且相互引用,形成了一个循环。当函数f
执行完成后,内部作用域销毁,我们期待垃圾回收机制帮助我们销毁这两个对象并回收对应的空间,但是两个对象之间都保留有一次引用。
如果出现循环引用,那么值所占的空间将用永远得不到释放,运行时间越长,越容易引擎内存泄露。
小tip:可以使用JSON.stringfy(o)
来检测对象是否存在循环引用。
标记清除法(2012年起,所有浏览器均使用了此机制)
主要依赖与计算环境
执行环境:定义了变量或函数有权访问的其他数据,决定了他们各自的行为。每个执行环境都有一个与之相关联的变量对象(全局对象/局部对象),环境中定义的所有变量和函数都保存在这个对象中。
当变量进入执行环境时,就标记这个变量为“进入环境”。从逻辑上讲,永远不能释放进入环境的变量所占用的内存,因为只要执行流进入相应的环境,就可能会用到他们。当变量离开环境时,则将其标记为“离开环境”。
垃圾收集器在运行的时候会给存储在内存中的所有变量都加上标记。然后,它会去掉环境中的变量以及被环境中的变量引用的标记。而在此之后再被加上标记的变量将被视为准备删除的变量,原因是环境中的变量已经无法访问到这些变量了。最后。垃圾收集器完成内存清除工作,销毁那些带标记的值,并回收他们所占用的内存空间。
简单理解为:当每个变量或函数在作用域链中无法访问,那么就该收集了。
目前主流浏览器都是使用标记清除式的垃圾回收策略,只不过收集的间隔有所不同
V8内存管理
弊病
- 为浏览器设计,不太可能遇到大量内存的场景,64位下 新生代默认的最大内存空间为32MB,老生代默认的最大内存空间为1400MB。
- 垃圾回收会导致线程短暂停止线程从而引起性能问题。
回收策略:分代式垃圾回收机制
-
新生代:大多数对象被分配在这里。新生区是一个很小的区域,垃圾回收在这个区域非常频繁,与其他区域相独立
-
老生代:这里包含大多数可能存在指向其他对象的指针的对象。大多数在新生区存活一段时间之后的对象都会被挪到这里
回收算法
新生代
新生代中的对象主要通过Scavenge算法进行垃圾回收。
1583434994243.png内存分配空间时,分为两个区域:From空间和To空间。
- 当分配新的对象时,总是往From空间中分配。
- 在回收时,先扫描From空间,将From空间中存活的对象复制到To空间中,然后将From空间的内存全部释放,最后将From和To的角色交换
特点:
-
只能使用一半的内存,但由于只需要复制存活对象,因此该算法非常适合应用在新生代垃圾回收中,因为新生代中对象的生命周期较短,垃圾回收时多为未存活对象。
-
不会在内存中留下碎片
对象晋升
在执行Scavenge的存活对象复制操作时进行对象是否晋升的判断(新生代迁移至老生代)
晋升标准:
-
该对象已经进行过一次Scavenge回收;
-
To空间已使用了25%。
老生代
对于老生代中的对象,由于存活对象占较大比重,再采用Scavenge的方式会有两个问题:
一是存活对象较多,复制存活对象的效率将会很低;
另一个问题则是由于老生代空间较大,空闲一半空间的做法对内存是极大的浪费
主要采用了Mark-Sweep和Mark-Compact两种算法相结合的方式进行垃圾回收。
Mark-Sweep
分为标记阶段和清除阶段:
- 标记阶段会遍历老生代空间的所有对象,将其中非存活的对象标记出来;
- 清除阶段则会将标记的死亡对象一一清除,释放内存空间。
缺点:回收后会在内存中留下一些碎片,如果这时候需要分配大对象,不连续的内存可能无法满足需求
Mark-Compact
分为标记和合并阶段:
-
标记阶段会遍历老生代空间的所有对象,将其中非存活的对象标记出来;
-
合并阶段会将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存
算法对比
回收算法 | Mark-Sweep | Mark-Compact | Scavenge |
---|---|---|---|
速度 | 中等 | 最慢 | 最快 |
空间开销 | 少(有碎片) | 少(无碎片) | 双倍空间(无碎片) |
是否移动对象 | 否 | 是 | 是 |
主动启动时机 | 进程空闲时 | 进程空闲时 | 进程空闲时(频率低) |
被动启动时机 | 1.老生代空间中被分配了一定数量的对象的时候; 2.老生代空间里没有新生代空间大小相同的空间的时候 |
老生代空间的碎片到达一定数量的时候 | From空间没有足够的空间分配对象 |
网友评论