js之内存机制

    数据结构是计算机存储,组织数据的方式。数据结构是指相互之间存在一种或多种特定关系的数据元素的集合。数据结构可以分为以下:数组、栈、堆、队列、链表、树、图、散列表类。本文主要讨论栈和堆。

一、堆栈

    栈(Stack)又名堆栈,它作为一种数据结构,是一种只能在一端进行插入和删除操作的特殊线性表。它按照先进后出的原则存储数据,先进入的数据被压入栈底,最后的数据在栈顶,需要读取数据时从栈顶取出。它具有记忆作用,对栈的插入与删除操作不需要改变栈底指针。它允许在同一端进行插入和删除,允许进行插入和删除操作的一端称为栈顶(top),另一端为栈底(bottom);栈底固定,而栈顶浮动;插入一般称为进栈(PUSH),删除则称为退栈(POP)。栈中元素个数为零时称为空栈。

    堆(Heap)通常是一个可以被看做一棵完全二叉树的数组对象。每个节点有一个值,堆中某个节点的值总是不大于或不小于其父节点的值。常用来实现优先队列,堆的存取方式跟顺序没有关系,无序存取,根据引用直接获取。

    内存(Memory)是计算机中用于存储程序和数据的重要部件。JS内存空间分为栈(stack)、堆(heap)、池(一般也会归类为栈中)。 其中栈存放变量,堆存放复杂对象,池存放常量,所以也叫常量池。

    JavaScript中的基本类型保存在栈内存中,因为这些类型在内存中分别占有固定大小的空间,通过按值来访问。基本类型:Undefined、Null、Boolean、Number 、String(在很多语言中,字符串是使用对象表示的,因此被认为是引用类型,但在ECMAScript中不是)。大致来说栈内存有如下特点:存储基本数据类型,按值访问,存储的值大小固定,系统自动分配和释放空间,主要是用来执行程序,空间小运行效率高,先进后出,后进先出。

    而引用类型如对象,数组,函数它们保存在堆内存中。因为这种值大小不固定,其实说存储于堆中不太准确,因为引用类型的数据的地址指针是存储于栈中的,当我们想要访问引用类型的值,需要先从栈中获得对象的地址指针然后在通过地址指针找到堆中的所需要的数据。堆内存的特点:存储引用数据类型,存储的值大小不定,可动态调整,手动分配和释放空间,主要用来存放对象,空间大,运行效率较低,是一种无序的存储,可根据引用直接获取。 

    既然基本数据类型的值保存在栈内存中,访问方式是按值访问,那从一个变量向一个变量复制时,会在栈中创建一个新值,然后把值复制到为新变量分配的位置上。

//基本数据类型的赋值就是把值复制一下
let n = 2;
let n1 = n;
console.log(n, n1);  //2 2

//复杂数据类型的赋值,它不光复制了值,还复制了内存中的引用地址
let arr1 = [10, true, null];
let arr2 = arr1;
let str1 = arr1[3];

arr2.push('amy');  //arr1与arr2的引用地址是相同,所以不论修改了哪个,两个都会一起改变
console.log(arr2); // [10,  true, null, "amy"]
console.log(arr1); // [10,  true, null, "amy"]

arr2 = [2, 3, 4];  //因为arr2又重新赋值开辟了一块内存,引用地址不一样了
console.log(arr2); // [2, 3, 4]
console.log(arr1); // [10,  true, null, "amy"]
复制代码

从上面代码可以看出,当改变arr2时,arr1中的数据也发生变化。这是因为arr1是数组属于引用类型,当它赋值给arr2时,传的是栈中的地址(相当于新建了一个不同名“指针”),而不是堆内存中对象的值。arr1和arr2都指向同一块堆内存,arr2修改堆内存时,也会影响到arr1。当arr2重新赋值时,又开辟了一块内存,arr2的引用指向这块新的内存,但arr1的指向并没有发生改变,所以这时arr2和arr1又不一样了。

二、浏览器中js代码运行

    浏览器想要代码执行就要提供一个供代码执行的环境。我们把这个环境叫做执行环境栈ECStack。浏览器还会把内置的一些属性和方法放到一个单独的堆内存中,这个堆内存叫做全局对象(Global Object) 简称GO,供以后我们调用。浏览器会让window指向GO,所以在浏览器端window代表的就是全局对象。

    浏览器环境提供后,我们开始让代码执行,代码执行会有一个自己的执行上下文,那什么是执行上下文呢?

执行上下文(Execution Context)简称EC。变量或者函数的上下文决定了他们有权访问的其它数据以及行为。简单来说EC指的是当前代码的执行环境。

    它可以分为全局执行上下文,我们在此简称为EC(G)、函数环境中的私有上下文和块级上下文。每一个上下文都有一个与之关联的变量对象,这个上下文中定义的所有变量和函数都保存在这个对象中, 这个变量对象称之为VO(Variable Object)。函数私有上下文中叫做AO(Activation Object)活动对象,但它也是变量对象,它是VO的一个分支。

    形成的全局执行上下文要进入到栈内存中执行,这个过程称为‘进栈’。执行完代码后它有可能会有一个出栈释放步骤,遵循先进后出原则。在全局执行上下文中会创建一些全局变量,这些全局变量还有全局变量存放的值放在变量对象VO(G)中。 

如下所示,代码自上而下执行:

  • 先创建一个值。在创建时,如果是基本数据类型值,它可以直接存在栈内存中,如果是引用数据类型值要重新开辟一个堆内存,把内容存入,最后把这个堆内存16进制地址放入栈内存中,供变量关联使用。
  • 然后创建相应的变量
  • 最后把值和变量进行关联(所有的指针赋值都是指针关联指向)
let a = 12;
let b = a;
b = 13;
console.log(a);
let n = {
    name: 'davina'
}
let m = n;
m.name = 'lisa';
console.log(n.name);
复制代码
var a = {n:12};
var b = a;
a.x = a = {n:13};
console.log(a.x);
console.log(a);
复制代码

image.png

三、生命周期

    不管什么程序,内存生命周期分为以下三步:1、分配所需内存 2、使用分配到的内存 3、不需要时将其释放(归放)。

JavaScript环境中分配的内存有如下生命周期:

  • a. 内存分配:声明变量、函数、对象时,系统会自动为他们分配内存
  • b. 内存使用: 即读写内存。也就是使用变量、函数
  • c. 内存回收: 由垃圾回收机制自动回收不再使用的内存 

JavaScript内存分配:在js中数据类型分为基本数据类型和复杂数据类型,对于基本数据类型它是存在于栈中,它由操作系统自动分配和自动释放,而复杂数据类型它是放在堆中,大小不固定,系统无法进行自动释放,需要js引擎来释放这些内存。

var n = 2;          //给数值变量分配内存
var o = {           //给对象及其包含的值分配内存
       a: 1,
       b: 2
   }                  
function fn(c) {
     return c;     //给函数分配内存
}

//有些函数调用结果是分配对象内存
var d = new Date() //  分配一个Date对象
复制代码

四、垃圾回收机制

4.1 概念

    JavaScript它是通过自动内存管理实现内存分配和闲置资源回收。它的基本思路很简单,确定某个变量不会再使用然后释放它占用的内存,这个过程是周期性的,垃圾回收(Garbage Collection,简称GC) 程序每隔一定时间就会自动运行。简单来说,当一个数据使用完后,垃圾回收机制会检测这个数据有没有在其它地方被引用,或者说是在其它地方有没有在使用,如果没有使用那么它就被回收并释放所占用的内存,如果在其它地方依然有使用,那就不会被回收。但某块内存是否还有用或者是还在使用是一个“不可判定”的问题,垃圾回收过程是一个近似但不完美方案。需要注意一点不是所有的语言都有GC,c语言就需要手动管理内存。

4.2 回收策略

4.2.1 标记清除

    JavaScript最常用的垃圾回收策略是标记清理 (mark-and-sweep)。垃圾回收程序运行时,会标记内存中存储的所有变量,然后它会将所有在上下文中的变量,以及被在上下文中的变量引用的变量的标记去掉。随后再给剩下的变量进行另外标记(任何在上下文中的变量都访问不到它们)。最后垃圾回收程序做内存清理,销毁带标记的所有值并收回它们的内存。

    在v8引擎浏览器环境下我们可以简单的理解标记清除就是从根对象(可以简单理解为全局上下文)出发定时扫描内存中的对象,所有roots被检查标记,所有的子对象也会被检查标记,从根开始的所有对象如果可以到达则它不会被当成垃圾会保留,从根部出发无法触及到的对象被标记为不在使用,后面进行回收。

function fn() {
     var a = 10; //进行标记
     a++;
     a = null;   //标记清除
}
复制代码

image.png

4.2.2 引用计数

    对每个值都记录它引用的次数。当声明一个变量并给它赋一个引用值这个值的引用次数为1,如果同一个值又赋值给另外一个变量,那么引用就加1。如果保存对该值引用的变量被其它值覆盖了,那引用数减1。当一个值的引用数为0时,就说明没办法再访问到这个值了,因此可以安全地收回其内存。垃圾回收程序下次运行的时候就会释放引用数为0的值的内存。

    低版本IE浏览器就是用引用计数策略来进行垃圾回收。在真实的项目中,某些情况会导致计数规则出现一些问题,造成很多内存不能被释放掉,产生“内存泄漏”。

 //引用计数
var person = {
    age: 18,        //标记1次
    name: 'amy'
    };
person.age = null; //虽然age设置了null,但是person对象还有指向name的引用,所以name不会被回收

 var p = person;
 person = 2;    //原来的person对象被赋值为2,但因为有新引用p指向person对象,所以它不会被回收

p = null       //person对象已经没有引用了,所以会被回收
复制代码

引用计数有一个很大的问题那就是循环引用,所谓的循环引用就是两个对象相互引用,即对象A有一个指针指向对象B,而对象B也引用了对象A。

function fn() {
     var a = {};  
     var b = {};
     a.age = b;
     b.age = a;
  // 由于a和b互相引用,计数永远不可能为0
   }
 fn();
复制代码

    总之,JavaScript是使用垃圾回收的编程语言,我们不需要担心内存的分配与回收,但了解其机制可以更好进行性能优化。一般来说主流浏览器都是使用标记清除即给当前不使用的值加上标记,一定时间后来清理。但也有另外一种引用计数策略,它在老版本IE中使用,也需要简单了解,主要记录值被引用了多少次,当引用为0时就可以进行清理。

五、内存管理

    程序运行是需要用到内存的,在日常的工作中我们需要将内存占用量保持在一个比较小的值让页面性能更加友好也需要及时释放内存保证系统正常运行。

5.1 优化性能

    我们可以通过解除引用来保证代码在执行时只保存必要数据,不再需要的数据将其设置为null,从而释放它的引用。它适用于全局变量和全局对象(局部变量在超出作用域后会被自动解除)。或者声明时使用let或者const尽量少用var,因为const或let它们都是以块作为作用域,相对于var来说,这两个关键字会让垃圾回收程序更早介入,这样也可以尽早回收内存。

function fn(name){
    let obj = new Object();
    obj.name = name;
    return obj;
}
let person = fn('amy');

person = null; //解除person对值的引用
复制代码

5.2 内存泄漏

    本质上来说,内存泄漏是由于疏忽或者是错误造成程序未能释放那些已经不在使用的内存,造成内存浪费。在内存有限的情况下,内存泄漏是一个大问题。

5.2.1 出现内存泄漏的情况

  • 意外的全局变量

意外声明的全局变量是最常见也是最容易修复的内存泄漏问题。如下所示:

function fn(){
    name = 'amy';
}
复制代码

这时name是window的属性,只要window本身不被清理那么name就不会消失。我们可以在变量声明前加上const,let或者var关键字,这样变量会在函数执行完后离开使用域。

  • 闭包

使用闭包很容易在不知不觉中就造成了内存泄漏。如下所示:

function fn(){
    let box = document.getElementById('xxx');
    box.onclick=function(){
        //do something
        return box;
    }
    // box = null;//解除引用
}
复制代码

闭包可以保护函数内部的变量,使其不被释放。在上面的例子中,定义fn函数时,因为它里面还有定义了其它函数即box点击事件,并且点击事件还引用了外面的变量,形成了闭包。这样也导致box很难被回收。如果box不是一个小字符串内容很大,这时就会导致内存占用这种大问题,所以我们要及时解除引用或者是将事件处理函数定义在外面。

  • 计时器或者回调函数

定时器也会导致内存的泄漏。如下所示:

let name = 'amy';
setInterval(()=>{
    console.log('*****' + name +'*******')
},1000);
复制代码

只要定时器一直运行。回调函数中引用的name就会一直占用内存。所以我们要习惯在用了定时器后及时清除定时器。

5.2.2 识别方法

我们可以在chrome中的performance中查看:

  • F12打开开发者工具performance
  • 勾选screenshots和memory
  • 开始录制
  • 停止录制

image.png
图中heap部分可以看到内存在周期性回落也可以看到垃圾回收的周期,如果垃圾回收的最低值在上涨,那就要内存泄漏问题。

避免内存泄漏的方法,主要是将不需要的引用及时归还:

  • 减少不必要的全局变量,尽量用const或者let声明,及时清除无用数据
  • 定时器使用后要及时清除定时器
  • 避免创建过多对象
  • 对闭包这种机制使用时要及时清除引用
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享