JS 内存理解总结

JS内存分配管理

ECMAScript 变量可能包含两种不同数据类型的值,即基本类型值和引用类型值。

栈、堆、池
  • 栈:LIFO,后进先出的原则,属于一级缓存,相当于CPU的寄存器,由编译器自动分配释放,读写速度快,存储的是固定值(基本类型值)
  • 堆: 属于二级缓存,由开发人员分配释放, 要是没有手动的释放,在调用结束后可能由GC回收,其生命周期由虚拟机的垃圾回收算法来决定。存储的是引用类型
  • 池:存放常量,一般归类在栈中
基础类型值和栈

JavaScript中的基础类型值都有固定的大小,往往保存在栈内存中(闭包除外),由系统自动分配存储空间。基本类型值按值访问,因为可以直接操作保存在栈内存中的实际的值。

引用类型值和堆

JavaScript中,引用数据类型值的大小是不固定的。引用类型的值是保存在堆内存中的对象,而由于JavaScript不允许直接访问堆内存中的位置,或者说不能直接操作对象的堆内存空间,所以,在操作对象时,实际是在操作对实际对象的引用,而不是实际的对象。因此,引用类型的值是按引用访问的。(即在栈内存中存储的值是实际对象在对内存中的地址)

举个例子,对于如下代码的内存分配:

	var a = 1;    //栈
	var b = 'hhh';    //栈
	var c = null;    //栈
	var d = {name: 'xiaoming'};    //堆
	var e = [1,2,3];    //堆

在这里插入图片描述

那为什么引用值要放在堆中,而原始值要放在栈中?

堆比栈大,栈比堆的运算速度快,对象是一个复杂的结构,并且可以自由扩展,例如:数组可以无限扩充,对象可以自由添加属性。将他们放在堆中是为了不影响栈的效率,而是通过引用的方式查找到堆中的实际对象再进行操作。相对于简单数据类型而言,简单数据类型就比较稳定,并且它只占据很小的内存。不将简单数据类型放在堆是因为通过引用到堆中查找实际对象是要花费时间的,而这个综合成本远大于直接从栈中取得实际值的成本。所以简单数据类型的值直接存放在栈中。

复制变量值

复制变量的值时,实际上就是复制该变量在栈中存储的值,但由于基本变量值存储的就是它的实际值,而引用类型值存储的则是目标对象在堆内存中的地址,对复制后的变量进行操作的结果会不一样。例如,复制一个基本变量值就是创建了一个和被复制值相同的新值,并把这个新值分配到了目标位置,这两个值之后的操作互不影响。而复制一个引用类型值,就是把被复制引用类型的引用(地址)复制到一个新变量中,等于又多了一个指针指向该引用类型,所以这操作两个变量时,影响的是同一个对象。
例如:

	var a = 1;
	var b = a;
	b = 3;
	console.log(a,b);  //1,3

在这里插入图片描述
步骤如下:

  1. 申请一个内存,并分配给变量a,存储值为1
  2. 申请一个内存,分配给变量b,并复制一个a的值副本保存到b;
  3. 把b的值改为3;
    因为基本类型按值访问,所以操作b就是操作栈中b存储的值,而和a没有半毛钱关系,自然是互不影响的关系。

而如果是复制引用类型值的话:

	var a = {name:'xiaoming'};
	var b = a;
	b.name = 'honghong';
	console.log(a.name);    //'honghong'

在这里插入图片描述
可以这样理解:

  1. 申请一个内存,并分配给变量a
  2. 由于要存储的是一个对象obj,所以在堆中申请一块内存存储该obj,并将obj的位置返回给a中存储。
  3. 申请一个内存,分配给变量b,并复制一个a的值副本(即obj的位置)保存到b;
  4. 由于a和b指向的是都是obj,所以操作 b.name = ‘honghong’,就等于将obj的name属性改了,所以在a,b上都会反映出来这种变化。
函数传参
  • 传值:传内存拷贝
  • 传引用:传内存指针

JS中函数传参其实是按值传递(传值)的,即给函数中的形参分配了独立的内存空间,按照复制一样,给形参分配了空间,并将栈内存中的值复制给了它。

(引用类型值我是这样理解的,存储引用类型的变量在栈内存中存储的是引用类型值在堆中的地址,所以在传参的时候,复制的就是它的地址,也就是该变量在栈内存中的存储的值)

对于,基本类型值,可以理解如下,change函数中的形参a的值为实参a的复制值,且有自己的内存(这里将change中的参数标记为了a’),所以在函数内部操作a’自然是不会影响到a的。

	var a = 1;
	function change(a) {
		a = 3;
		console.log(a);    //3
	}
	change();
	console.log(a);    //1

在这里插入图片描述

对于引用类型值,可以理解如下,change函数中的形参obj的值为传入参数per的复制值,即对象tar(取个名字叫tar)在堆内存中的地址。所以函数内部改变属性和方法就是在改变对象tar,自然会反映出来,而直接改变obj,就等于断了obj与tar的关系,而此时的obj中存的是另一个对象的地址,它此时的改变属性和方法都是在改变这个对象,而不是之前的tar。

	var per = {age:10};
	function change(obj) {
		console.log(obj);    //{age:10}
		obj.age = 100;
		console.log(obj);    //{age:100}
		obj = {name: 'hhh'};
		console.log(obj);	//{name: 'hhh'}
	}
	console.log(per);    //{age:10}
	change(per);
	console.log(per);    //{age:100}

在这里插入图片描述


#### 内存的生命周期 JavaScript中,内存一般有如下生命周期: 1. 内存分配:申明变量、函数、对象的时候,系统自动分配内存 2. 内存使用:读写内存时(即使用变量、函数、对象时) 3. 内存回收:使用完毕,不需要时将其释放、归还
内存回收
垃圾回收机制

JavaScript具有自动垃圾收集机制,这种垃圾收集机制的原理很简单:找出那些不再继续使用的变量,然后释放其占用的内存。为此,垃圾收集器会按照固定的事间间隔(周期性)来执行这一操作。

局部变量只有在函数执行的过程中存在,然后到函数结束之后,局部变量就没有存在的变量了,因此这个时候就可以释放它们的内存以便将来使用。在这种情况下,很容易判断变量是否还有存在的必要,但并非所有情况下都这么容易,所以垃圾收集器必须跟踪哪个变量有用,哪个没用。由于事实上发现内存“不再被需要”是不可判定的,因此垃圾收集的通常解决方案都存在局限性

标记清除

js中最常用的垃圾回收方式就是标记清除。当变量进入环境时,例如,在函数中声明一个变量,就将这个变量标记为“进入环境”。从逻辑上讲,永远不能释放进入环境的变量所占用的内存,因为只要执行流进入相应的环境,就可能会用到它们。而当变量离开环境时,则将其标记为“离开环境”。

function test(){
 var a = 10 ; //被标记 ,进入环境 
 var b = 20 ; //被标记 ,进入环境
}
test(); //执行完毕 之后 a、b又被标离开环境,被回收。

垃圾回收器在运行的时候会给存储在内存中的所有变量都加上标记(当然,可以使用任何标记方式)。然后,它会去掉环境中的变量以及被环境中的变量引用的变量的标记(闭包)。而在此之后再被加上标记的变量将被视为准备删除的变量,原因是环境中的变量已经无法访问到这些变量了。最后,垃圾回收器完成内存清除工作,销毁那些带标记的值并回收它们所占用的内存空间。

到目前为止,IE、Firefox、Opera、Chrome、Safari的js实现使用的都是标记清除的垃圾回收策略或类似的策略,只不过垃圾收集的时间间隔互不相同。

引用计数

这种方法把“可以回收”的标准定义为“没有其他人引用这个对象”(原文:This algorithm reduces the definition of “an object is not needed anymore” to “an object has no other object referencing to it”)。也就是说,只有当对象没有被引用的时候,才会被当作垃圾回收掉。

引用计数的含义是跟踪记录每个值被引用的次数。当声明了一个变量并将一个引用类型值赋给该变量时,则这个值的引用次数就是1。如果同一个值又被赋给另一个变量,则该值的引用次数加1。相反,如果包含对这个值引用的变量又取得了另外一个值,则这个值的引用次数减1。当这个值的引用次数变成0时,则说明没有办法再访问这个值了,因而就可以将其占用的内存空间回收回来。这样,当垃圾回收器下次再运行时,它就会释放那些引用次数为0的值所占用的内存。

function test(){
 var a = {} ; //a的引用次数为0 
 var b = a ; //a的引用次数加1,为1 
 var c =a; //a的引用次数再加1,为2
 var b ={}; //a的引用次数减1,为1
}

Netscape Navigator3是最早使用引用计数策略的浏览器,但很快它就遇到一个严重的问题:循环引用。循环引用指的是对象A中包含一个指向对象B的指针,而对象B中也包含一个指向对象A的引用。

function fn() {
 var a = {};   //a的引用次数为0;
 var b = {};   //b的引用次数为0;
 a.pro = b;    //b的引用次数为1;
 b.pro = a;    //a的引用次数为1;
}
 
fn();

在上面例子中,执行fn之后,即使函数结束了,a和b的引用次数也不为0,所以引用计数的机制就不会把它们释放,但是如果是标记清除的话,在fn执行完之后,a和b都会被标记为离开环境,就会被清除。

内存回收的必要性

在不需要字符串、对象的时候,需要释放其所占使用的内存,否则将会耗费完系统中所有可使用的内存,造成系统崩溃,这就是垃圾回收机制所存在的意义。

内存泄漏

因为疏忽或者错误造成程序未可以释放那些已经不再用的内存,造成内存的白费。

JavaScript的内存泄露

JS内存泄漏的主要原因是不需要的引用,要了解不需要的引用,首先需要了解垃圾回收器是怎么确定一块内存是否“可达”的。大多数垃圾回收器使用的是标记清除法,具体步骤如下:

  1. 垃圾回收器构建一个“根”列表。根通常是在代码中保存引用的全局变量。在JavaScript中,“window”对象是可以充当根的全局变量的示例。窗口对象总是存在的,所以垃圾回收器可以考虑它和它的所有的孩子总是存在(即不是垃圾)。
  2. 所有根被检查并标记为活动(即不是垃圾)。所有孩子也被递归检查。从根可以到达的一切都不被认为是垃圾。
  3. 所有未标记为活动的内存块现在可以被认为是垃圾。回收器现在可以释放该内存并将其返回到操作系统。

现代垃圾回收器以不同的方式改进了该算法,但本质是相同的:可访问的内存段被标记,其余被垃圾器回收不需要的引用是开发者知道它不再需要,但由于某种原因,保存在活动根的树内部的内存段的引用。 在JavaScript的上下文中,不需要的引用是保存在代码中某处的变量,它不再被使用,并指向可以被释放的一块内存。 有些人会认为这些都是开发者的错误。所以要了解哪些是JavaScript中最常见的漏洞,我们需要知道在哪些方式引用通常被忽略。

导致JavaScript内存泄漏的常见场景
1.意外的全局变量

JavaScript在进行LRH(左查询)时,如果没有找到变量,就会在全局对象内创建一个新的变量。在浏览器的情况下,全局对象就是窗口。意外生成一个全局变量时(即未通过var声明一个全局变量),例如:

/*1.第一种情况*/
	function foo(){
		a = 1;
	}
	foo();
	
/*2.第二种情况*/
	function foo() {
		this.a = 1;
	}
	foo();

这个变量会被当做全局对象的一个属性处理,而导致不可回收(除非被取消或者重新分配)。**如果必须使用全局变量来存储大量数据,请确保将其置空或在完成后重新分配它。**与全局变量有关的增加的内存消耗的一个常见原因是高速缓存。缓存存储重复使用的数据。 为了有效率,高速缓存必须具有其大小的上限。 无限增长的缓存可能会导致高内存消耗,因为缓存内容无法被回收。

2.被遗忘的计时器或回调函数

setInterval的使用在JavaScript中是很常见的。大多数这些库在它们自己的实例变得不可达之后,使得对回调的任何引用也不可达。在setInterval的情况下,像这样的代码是很常见的:

var someResource = getData();
setInterval(function() {
  var node = document.getElementById('Node');
  if(node) {
    // Do stuff with node and someResource.
    node.innerHTML = JSON.stringify(someResource));
  }
}, 1000);

例子中挂起的计时器可能发生以下的情况:引用不再需要的节点或数据的计时器。 由节点表示的对象可能回在将来被移除,这样就使得区间处理器内部的整个块不需要了。 但是,处理程序(因为时间间隔仍处于活动状态)无法回收(需要clear停止时间间隔才能发生)。 如果无法回收间隔处理程序,则也无法回收其依赖项。 这也就意味着由于对someResource引用,它可能也不会需要被用到,但也不能被回收。

对于观察者的情况,重要的是进行显式调用,以便在不再需要它们时删除它们(或者相关对象即将无法访问)。 在过去特别重要,因为某些浏览器(IE6)不能管理循环引用。 现在,一旦观察到的对象变得不可达,即使没有明确删除监听器,大多数浏览器也可以回收观察者处理程序。 然而,在对象被处理之前显式地删除这些观察者仍然是良好的做法。 例如:

var element = document.getElementById('button');
function onClick(event) {
  element.innerHtml = 'text';
}
element.addEventListener('click', onClick);
//执行一些操作后,手动删除事件
element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);
// 当element被移除之后,element和它的绑定事件都会被回收

关于对象观察者和循环引用:

观察者和循环引用曾经是JavaScript开发者的祸根。 这是由于Internet Explorer的垃圾回收器中的错误(或设计决策)。旧版本的Internet Explorer无法检测DOM节点和JavaScript代码之间的循环引用。这是一个典型的观察者,通常保持对可观察者的引用(如上例所示)。换句话说,每当观察者被添加到Internet Explorer中的一个节点时,它就会导致泄漏。这是开发人员在节点或在观察者中引用之前明确删除处理程序的原因。 现在,现代浏览器(包括Internet Explorer和Microsoft Edge)使用现代垃圾回收算法,可以检测这些周期并正确处理它们。 换句话说,在使节点不可达之前,不必严格地调用removeEventListener。框架和库(jQuery)在处理节点之前删除侦听器(当为其使用特定的API时)。这是由库内部处理,并确保不产生泄漏,即使运行在有问题的浏览器,如旧的Internet Explorer。

3、脱离 DOM 的引用

有时,将DOM节点存储在数据结构中可能很有用。 假设要快速更新表中多行的内容。 在字典或数组中存储对每个DOM行的引用可能是有意义的。 当发生这种情况时,会保留对同一个DOM元素的两个引用:一个在DOM树中,另一个在字典中。 如果在将来的某个时候,你要删除这些行,则需要使这两个引用不可访问。

  //在字典中存入队button\image\text对象的引用
  var elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image'),
  };

  function doStuff() {
    image.src = 'http://some.url/image';
    button.click();
    // 一些操作
  }
  
  function removeButton() {
    // 假设button是body上的子元素
    document.body.removeChild(document.getElementById('button'));
    // 在body上移除了button对象,但是在全局elements中还存在着对button的引用,所以它还不能被GC回收
  }

当涉及DOM树内部或子节点时,需要考虑额外的考虑因素。例如,你在JavaScript中保持对某个表的特定单元格的引用。有一天你决定从DOM中移除表格但是保留了对单元格的引用。人们也许会认为除了单元格其他的都会被回收。实际并不是这样的:单元格是表格的一个子节点,子节点保持了对父节点的引用。确切的说,JS代码中对单元格的引用造成了整个表格被留在内存中了,所以在移除有被引用的节点时候要当心。

4、闭包

JavaScript开发的一个关键方面是闭包:从父作用域捕获变量的匿名函数。 Meteor开发人员发现了一个特定的情况,由于JavaScript运行时的实现细节,可能会以下面的方式泄漏内存:

  var theThing = null;
  var replaceThing = function () {
    var originalThing = theThing;
    var unused = function () {
      if (originalThing)    //引用originalThing;
        console.log("hi");
      };
      theThing = {
        longStr: new Array(1000000).join('*'),
        someMethod: function () {
          console.log(111);
        }
      };
    };
  setInterval(replaceThing, 1000);

这个片段做了一件事:每次replaceThing被调用,theThing获取一个新的对象,其中包含一个大数组和一个新的闭包(someMethod)。同时,unused变量保持一个闭包,该闭包具有对originalThing的引用(来自之前对replaceThing的调用的theThing)。一旦为同一父作用域中的闭包创建了作用域,则该作用域是共享的。

在这种情况下,为闭包someMethod创建的作用域由unused共享。unused引用了originalThing。即使unused未使用,可以通过theThing使用someMethod。由于someMethod与unused共享闭包范围,即使未使用,someMethod可以通过theThing来使用replaceThing作用域外的变量(例如某些全局的)。而且someMethod和unused有共同的闭包作用域,unused对originalThing的引用强制oriiginalThing保持激活状态(两个闭包共享整个作用域)。这阻止了它的回收。

解决办法:

  1. replaceThing中加入originalThing = null
  2. unused以参数形式,而非闭包使用originalThing
  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值