深入理解js内存机制

3,960 阅读8分钟
原文链接: www.oecom.cn

js的内存机制在很多前端开发者看来并不是那么重要,但是如果你想深入学习js,并将它利用好,打造高质量高性能的前端应用,就必须要了解js的内存机制。对于内存机制理解了以后,一些基本的问题比如最基本的引用数据类型和引用传递到底是怎么回事儿?比如浅复制与深复制有什么不同?还有闭包,原型等等就迎刃而解了。

js类型

在js中,js的类型分为两个大类,分别是基本数据类型引用数据类型。我们暂时先抛开ES6不说,先只说在ES5中的类型。在ES5中有5中简单数据类型(也就是上面说的基本数据类型):Undefined、Null、Boolean、Number和String。还有1种复杂的数据类型————Object,Object本质上是由一组无序的名值对组成的。其中可以算在object中的还有Array和Function。

在内存当中,基本数据类型存放在栈中,引用数据类型存放在堆中。说到这里就要说一下内存空间了,一般来说,js的内存空间分为栈(stack)、堆(heap)、池(一般也会归类栈中)。其中栈存放变量,堆存放复杂对象,池存放常量,所以也叫常量池。

但是有一个变量是特殊的,那就是闭包中的变量,闭包中的变量并不保存中栈内存中,而是保存在堆内存中,这也就解释了函数之后之后为什么闭包还能引用到函数内的变量。

function A() {
  let a = 1
  function B() {
      console.log(a)
  }
  return B
}

闭包的简单定义是:函数 A 返回了一个函数 B,并且函数 B 中使用了函数 A 的变量,函数 B 就被称为闭包。

函数 A 弹出调用栈后,函数 A 中的变量这时候是存储在堆上的,所以函数B依旧能引用到函数A中的变量。现在的 JS 引擎可以通过逃逸分析辨别出哪些变量需要存储在堆上,哪些需要存储在栈上。

引用数据类型与堆内存

与其他语言不同,JS的引用数据类型,比如数组Array,它们值的大小是不固定的。引用数据类型的值是保存在堆内存中的对象。JavaScript不允许直接访问堆内存中的位置,因此我们不能直接操作对象的堆内存空间。在操作对象时,实际上是在操作对象的引用而不是实际的对象。因此,引用类型的值都是按引用访问的。这里的引用,我们可以粗浅地理解为保存在变量对象中的一个地址,该地址与堆内存的实际值相关联。

为了更好的搞懂变量对象与堆内存,我们可以结合以下例子与图解进行理解.

var b = { m: 20 }; // 变量b存在栈中,对应的值就是一个索引指向对象{m: 20},{m:20}作为对象存在于堆内存中.
深入理解js内存机制

因此当我们要访问堆内存中的引用数据类型时,实际上我们首先是从变量对象中获取了该对象的地址引用(或者地址指针),然后再从堆内存中取得我们需要的数据。

如此,就会出现我们经常被问到的关于引用数据类型的值问题了

var x =30;
var b = x;
x+=10;
console.log(b);

上面的问题我们很容易解答,答案就是会输出30,x的操作不会对b有什么影响,因为在变量对象中的数据发生复制行为时,系统会自动为新的变量分配一个新值。var b = a执行之后,a与b虽然值都等于20,但是他们其实已经是相互独立互不影响的值了。但是下面这个问题就有意思了

var x={m:1}
var y = x;
x.m++;
console.log(y.m);

通过输出我们发现答案是2。这是因为我们通过var y = x是执行一次复制引用类型的操作。引用类型的复制同样也会为新的变量自动分配一个新的值保存在变量对象中,但不同的是,这个新的值,仅仅只是引用类型的一个地址指针。当地址指针相同时,尽管他们相互独立,但是在变量对象中访问到的具体对象实际上是同一个。

垃圾回收

在js中有垃圾回收机制,其作用是回收过期无效的变量,以防止内存泄漏。这些工作不需要我们去管理什么时候进行垃圾回收,js会自动进行,这让我们写起代码来感觉超级爽,哈哈。

下面来看一下js垃圾回收机制什么时候会回收变量。我们写代码的时候是区分全局变量和局部变量的,在此,我们看一下局部变量和全局变量的销毁。

  • 局部变量:局部作用域中,当函数执行完毕,局部变量也就没有存在的必要了,因此垃圾收集器很容易做出判断并回收。
  • 全局变量:全局变量什么时候需要自动释放内存空间则很难判断,所以在开发中尽量避免使用全局变量,以提高内存有效使用率

垃圾回收算法

现在各大浏览器通常用采用的垃圾回收有两种方法:标记清除、引用计数。

引用计数

现代浏览器基本上已经不再使用了,在这里我们做一下简单的介绍。引用计数的含义是跟踪记录每个值被引用的次数。当声明了一个变量并将一个引用类型赋值给该变量时,则这个值的引用次数就是1。相反,如果包含对这个值引用的变量又取得了另外一个值,则这个值的引用次数就减1。当这个引用次数变成0时,则说明没有办法再访问这个值了,因而就可以将其所占的内存空间给收回来。这样,垃圾收集器下次再运行时,它就会释放那些引用次数为0的值所占的内存。简单来说就是看一个对象是否有指向它的引用。如果没有其他对象指向它了,说明该对象已经不再需要了。

// 创建一个对象person,他有两个指向属性age和name的引用
var person = {
    age: 12,
    name: 'aaaa'
};
person.name = null; // 虽然name设置为null,但因为person对象还有指向name的引用,因此name不会回收
var p = person;
person = 1;         //原来的person对象被赋值为1,但因为有新引用p指向原person对象,因此它不会被回收
p = null;           //原person对象已经没有引用,很快会被回收

但是,如果出现了循环引用,那么这种方式就会存在一个大bug,看一下下面这个例子

function bigBug(){
    var objA = new Object();
    var objB = new Object();
    objA.bug1 = objB;
    objB.bug2 = objA;
}

在上面这个例子中,ab两个对象是互相引用的,也就是说他们的引用次数永远为2,如果不进行其他操作的话,这样的互相引用如果大量使用的话,就会造成内存泄漏问题。虽然说现在主流的浏览器都已经不在使用了,但是之前的IE版本还是那样,所以在写代码时我们应该尽量避免。避免的方法就是在不使用的时候进行手动解除循环绑定

objA.bug1 = null;
objB.bug2 = null;

标记清除

标记清除算法将“不再使用的对象”定义为“无法到达的对象”。即从根部(在JS中就是全局对象)出发定时扫描内存中的对象,凡是能从根部到达的对象,保留。那些从根部出发无法触及到的对象被标记为不再使用,稍后进行回收。每一个变量都有自己的使用环境,当进入环境以后,垃圾回收机制就会给他打上一个“进入环境”的标签,从逻辑上来将,系统不能清除处于环境中的变量,因为只要是在环境中就有可能会使用到。当其离开环境时,会给其打上“离开环境”标签,这时候便可以进行回收了。

内存泄漏

内存泄漏可能对于前端开发着来说比较陌生,但是你肯定遇到过浏览器卡死的现象,卡死的原因就可能是因为一个死循环导致的内存爆满泄漏。所以对于持续运行的服务进程(daemon),必须及时释放不再用到的内存。否则,内存占用越来越高,轻则影响系统性能,重则导致进程崩溃。 对于不再用到的内存,没有及时释放,就叫做内存泄漏(memory leak)
如果想模拟的话,可以按下面的步骤来进行操作:

  • 打开开发者工具,选择 Memory
  • 在右侧的Select profiling type字段里面勾选 timeline
  • 点击左上角的录制按钮。
  • 在页面上进行各种操作,模拟用户的使用情况。
  • 一段时间后,点击左上角的 stop 按钮,面板上就会显示这段时间的内存占用情况。

深入理解js内存机制