1

内存管理

内存由可读写单元组成,表示一片连续可操作的空间。在编程时,可以通过主动操作来申请,使用和释放可操作空间。内存管理指的就是主动操作过程,也就是申请内存,使用内存和释放内存。

// 申请内存
let str
// 使用内存
str = 'foo'
// 释放内存
str = null // 不再引用,垃圾回收会自动回收内存

垃圾回收

当内存不再被使用时,其会被视为垃圾,然后被释放(回收)。

在JavaScript中,垃圾回收是自动进行的。

如何判断垃圾内存?

  1. 对象不再被引用。
  2. 对象不能从根上访问到。
“根”在js中,可以将根看作全局对象。不能从根上访问到指的就是不能从全局对象上通过某条路径找到,可以是直接挂载在全局对象上,也可以是间接挂载在全局对象上。
function fn(obj1, obj2) {
    obj1['next'] = obj2
    obj2['pre'] = obj1
    return {
        o1: obj1,
        o2: obj2
    }
}
const obj = fn()

上述代码的关系如下图所示,此时obj,obj1,obj2都可以从全局对象上找到,因此不能当作垃圾被回收。
微信图片_20200725155844.png
如下图所示,如果通过delete将obj的o1属性和obj2的prev属性删除,那么obj1就无法从全局对象上找到,此时obj1将会被当作垃圾回收。
微信图片_20200725155951.png

可达对象

可到对象指的是能访问到的对象,访问的方式可以是引用,也可以是通过作用域链查找到。
判断一个对象是否是可达对象的标准就是从根出发是否可以被找到。

GC算法

GC可以理解为是垃圾回收机制的简写。算法也就指的是查找垃圾,回收垃圾的规则。
常用的GC算法包含以下几个:

  1. 引用计数
  2. 标记清除
  3. 标记整理
  4. 分代回收

引用计数算法

通过引用计数器设置内存的引用数,当内存的引用关系发生改变的时候修改引用数,当引用数为0的时候内存立即被回收。

// {name: 'zs'}所在的空间是一块内存
// 此时obj1引用这块内存,所以引用计数器上记为1
let obj1= {name: 'zs'}
// obj2 同样引用了这块内存,所以引用计数器为2
let obj2 = obj1
// obj1 不再引用这块内存,所以计数器变为1
obj1 = null
// obj2也不再引用这块内存,此时计数器为0.这块内存会被当作垃圾回收
obj2 = null

算法优点:

  • 发现垃圾时立即回收。
  • 最大程度减少程序暂停(垃圾回收时程序会被暂停,如果回收的速度快,那么暂停的时间也就越少)。

算法缺点:

  • 无法回收循环引用的对象。
function fn() {
    const obj1 = { name: 'zs' }
    const obj2 = { name: 'ls' }
    // 在方法执行完毕以后,obj1和obj2应该被当作垃圾被回收,但是由于其相互引用,此时引用计数器上不为0, 所以无法回收
    obj1['friend'] = obj2
    obj2['friend'] = obj1
}
fn()
  • 时间开销大(由于需要引用计数器,当引用计数器对象越大,每次修改引用数的时间越长)。

标记清除算法

标记清除算法将垃圾回收分为标记和删除阶段,其算法步骤如下:

  1. 遍历所有对象,找到活动对象进行标记。
  2. 遍历所有对象,找到所有没有标记的对象并清除。

如下图所示,第一不找到所有活动对象,由于ABCDE可以通过全局对象找到,所以被标记,a1和b1不能通过全局对象找到,所以不会被标记。第二步,找到没有被标记的a1和b1,将其当作垃圾回收。
微信图片_20200725155851.png
与引用计数算法相比。
优点:

  • 可以回收循环引用的对象

缺点:

  • 回收后内存地址可能不再连续,造成碎片化。

假设内存中有一段连续的内存空间ABCDEF,如果BCDE被标记为活动对象,AB和F没有被标记,那么AB,F会被当作垃圾回收。回收完成后,造成存在AB和F两个碎片内存可以被使用,其只能放入对应长度的数据。

标记整理算法

标记整理算法和标记清除算法类似,只是多了整理内存步骤。

  1. 遍历所有对象,找到活动对象进行标记。
  2. 遍历所有对象,整理标记的内存,然后找到所有没有标记的对象并清除。

通过整理,可以解决标记清除算法造成内存碎片化的问题。

V8引擎

V8是一款主流的JavaScript执行引擎,采用即时编译,内存有限制(64位1.5G,32位800M)。

垃圾回收策略

js中的数据分为原始数据和对象引用数据两种,其中原始数据是由语言本身去处理,所以此处的垃圾回收策略主要针对栈上的对象引用数据。
V8采取分代回收的策略,由于v8对内存大小有限制,所以其将内存分成新生代和老生代两种,不同的生代采取不同的垃圾回收策略。
V8主要采取的GC算法有如下:

  1. 分代回收
  2. 空间复制
  3. 标记清除
  4. 标记整理
  5. 标记增量

新生代

V8将内存分为两块,其中小的空间称为新生代(64位32M/32位16M),其主要存储存活时间较短的对象。新生代内部同样分为两个等大小的空间From和To,通过空间复制和标记整理两个算法完成垃圾回收。

  1. From为使用空间,To为空闲空间,活动对象存储在From。
  2. 标记整理后从From拷贝到To。
  3. 清理From,将From和To交换空间。

From到To的拷贝过程可能出发晋升,也就是从新生代拷贝到老生代,下面两种情况将出发晋升。

  1. 一轮GC之后还存活的新生代。
  2. To空间的使用率超过25%。

老生代

老生代指的是空间较大的内存块(64位1.4G,32位700M),其内部存储存活时间长的对象,采用标记清除,标记整理和增量标记三种算法实现垃圾回收。

  1. 首先采用标记清除进行垃圾回收(会遗留空间碎片)。
  2. 新生代向老生代拷贝并且老生代存储区不足的时候进行空间优化(标记整理)。
  3. 采用增量标记进行效率优化(js代码执行和垃圾回收互斥,执行垃圾回收时无法执行js代码,增量标记指的时将遍历对象进行标记的过程拆分成多个小的执行段,这样js代码执行和标记过程可交叉进行)。

内存问题

js代码在浏览器中执行的时候,可能出现的和内存相关的问题如下:

  • 内存泄露: 内存使用持续增加。
  • 内存膨胀: 内存使用短时间内暴涨,超过内存限制。
  • 分离Dom: Dom节点没有在Dom树上,被变量引用导致无法回收。
  • 频繁GC: GC操作会暂停代码执行,频繁GC会使得页面卡顿。

代码优化

慎用全局变量

全局变量会导致的问题如下:

  • 全局变量存在于全局上下文,全局上下文是作用域链的顶端,当通过作用域链进行变量查找的时候,会延长查找时间。
  • 全局执行上下文会一直存在于上下文执行栈,直到程序推出,这样会影响GC垃圾回收。
  • 如果局部作用域中定义了同名变量,会遮蔽或者污染全局。

缓存全局变量

将不可避免的全局变量缓存到局部作用域中,减少查找时间,优化性能。适用于在局部作用域中频繁使用某个全局变量。

function query() {
    // 在局部作用域中直接使用全局的document变量,在执行时,局部作用域找不到该变量,会沿着作用域链向上查找直到在全局中找到
    return document.getElementsByTagName('input')
}

function query1() {
    // 通过将全局变量赋值给局部变量,那么查找时直接在局部作用域找到,不用再向上查找
    let dom = document
    return dom.getElementsByTagName('input')
}

通过原型新增方法

在为所有的实例对象添加共享方法的时候,通过原型定义比在构造函数中通过this定义性能更好。这是由于构造函数中this定义的方法在每个实例中都会保存一份单独的引用,而通过原型定义,所有的实例会指向同一个引用。

function Person() {
    // 每个实例对象都会保存一份say的引用,10个就会有10个内存引用
    this.say = function () {
        console.log(1)
    }
}
const zs = new Person()

function Person1() { }
// 所有实例的原型都指向一个内存引用,减少内存开销
Person1.prototype.say = function () {
    console.log(1)
}
const ls = new Person1()

避开闭包陷井

闭包是指在外部作用域中可以使用内部作用域中的变量。

function foo() {
    let str = 'foo'
    return function () {
        console.log(str)
    }
}

let f = foo()
// f在外部执行的时候依然能够访问foo作用域中的str变量
f()

闭包是一种常见写法,可以解决js编程中的很多问题,但是由于内部作用域中的变量被外部引用,所以此变量不能被垃圾回收,如果使用不当很容易造成内存泄露,因此在编程中不能为了闭包而闭包

避免属性访问方法使用

js在编写类的时候,很容易的出现在类上提供一个方法,该方法用于访问类内部的一个属性。

function Person() {
    this.name = 'foo'
    // 为了便于控制,在属性的访问上添加了一层
    this.getName = function () {
        return this.name
    }
}
const zs = new Person()
console.log(zs.getName)

function Person1() {
    this.name = 'foo'
}
const ls = new Person1()
// 直接访问属性
console.log(ls.name)

通过jsperf测试,发现直接访问会比包装访问要快的多。因此抛开代码编写规范,单从执行速度上来讲,直接访问更快。

for循环优化

let arr = Array(100).fill('foo')
// 每次循环都要获取数组长度
for (let i = 0; i < arr.length; i++) {
    console.log(i)
}
// 缓存数组长度,
for (let i = 0, len = arr.length; i < len; i++) {
    console.log(i)
}

缓存数组长度for循环执行速度要更快,特别适合非常大或者非常复杂的数组遍历。

选择最优的循环方式

let arr = Array(100).fill('foo')
arr.forEach(function (item) {
    console.log(item)
})

for (let i = 0, len = arr.length; i < len; i++) {
    console.log(i)
}

for (let i in arr) {
    console.log(arr[i])
}

通过jsperf工具发现,forEach的执行速度最快,因此在不影响功能的前提下,尽量使用forEach可加快代码的执行速度。

节点添加优化

在平常的js代码编写过程中,常常伴有Dom节点的添加,由于Dom节点添加操作常常伴有回流和重绘,这两个操作比较耗时,可以使用文档碎片优化这种耗时操作。

for (let i = 0; i < 10; i++) {
    let p = document.createElement('p')
    document.body.append(p)
}

let fraEls = document.createDocumentFragment()
for (let i = 0; i < 10; i++) {
    let p = document.createElement('p')
    fraEls.append(p)
}
document.body.append(fraEls)

carry
58 声望7 粉丝

学无止境