JavaScript学习系列之内存模型

简介: JavaScript学习系列之内存模型

1、基本的数据类型的内存结构

首先粗略的介绍一下JavaScript中$\color{#FF0000}{五种基本的数据类型Undefined、Null、Boolean、Number、String}$;其中对于Undefined与Null的区别,网上有很多大牛都有介绍,在此本文暂不涉及,如有疑虑之处,请移步相关博客。

或许有看官会问为什么在介绍JavaScript内存模型之前要先介绍JavaScript的基本数据类型的内存结构呢?这是因为JavaScript内存模型与基本数据类型的内存结构的关系就好比数学与实数的关系,基本数据类型的内存结构是整个JavaScript内存模型的基础。

那么接下来就让我以最简短的方式来阐述一下基本数据类型的内存结构吧.
基本数据类型的内存结构:在JavaScript中基本的数据类型都是以值的形式保存在内存中的。举个例子:

var inta = 10;
var strb = 'Hello';

那么在执行完这段JavaScript代码之后,内存中会有两个区域分别表示为inta,strb;其中表示inta区域的值为‘10’,表示strb区域的值为‘Hello’,也即表示inta与strb的内存区域保存的均为实际的真值;

2、引用数据类型的内存结构

在JavaScript中除了基本数据类型,那就剩下引用数据类型了,所以在介绍玩基本数据类型内存结构之后,就很有必要再介绍一下引用数据类型内存结构。$\color{#FF0000}{基本的数据类型保存在栈中,引用数据类型的真实对象是保存在堆内存中的}$ ,而JavaScript与Java相似,均不可以直接访问堆内存,所以都是使用“引用”这个东西来访问处于堆中的对象,引用与对象的关系可以描述成遥控器与电视机之间的关系,我们可以持有遥控器来操控电视机。所谓的引用其实就是一块内存的地址,即在表示引用的区域上保存的是内存中对象的内存地址值,如图所示:

其中假设对象处于内存中一个位置叫做0x23215的区域,那么椭圆的区域表示这个对象的引用,椭圆区域中存的就是0x23125这个值,在实际的操作中执行环境会通过引用中存的0x23125,去找到内存中的这个对象。

3、内存模型

3.1 内存分类

  在JavaScript执行时期,可以将内存从逻辑上划分为两部分:栈与堆。 $\color{#FF0000}{其中栈是在JavaScript执行时,用于储存执行上下文的,而堆是存储对象的区域。}$ 在执行上下文生成之后,会创建一个变量对象(后续文章会介绍),变量对象是一个特殊的对象,它也会存储在堆。基本数据类型往往都会直接保存在变量对象中,而引用数据类型实际上是在变量对象中保存一个引用指向对象的地址(也即引用本身)。
  
  

  

学习完JavaScript中的内存模型之后,请各位看官看看下面这段代码,并且猜猜它的输出结果,以验证上述知识的理解程度:

var inta = 10;
var stra = 'Hello';
 
var obja = {a: 10, b: 'Hello'};
 
var intb = inta;
var strb = stra;
var objb = obja;
 
intb = 20;
strb = 'World';
objb.a = 20;
objb.b = 'World';
 
console.log(inta, intb);
console.log(stra, strb);
console.log(obja, objb);

运行结果:



10 20
Hello World
{ a: 20, b: 'World' } { a: 20, b: 'World' }

这其中会涉及到对象的赋值问题:

  • 在对基本数据类型赋值的时候,都是将原值赋值到新的对象上,所以改变新的对象的值,并不会影响到原值(因为它们本质上保存的是两个值);
  • 而对引用数据类型赋值则是将引用所指向对象的地址赋值给另一个引用,而在后续操作中,如果通过新的引用去改变对象中内部的值的话,还是会影响原来的引用所指向的对象(因为它们本质上保存的是同一个对象)

3.2 变量声明与赋值

$\color{#FF0000}{核心点总结}$

  1. 变量声明的本质是变量名与栈内存地址进行绑定,不直接与堆内存进行绑定。
  2. 声明的基本数据类型会将值存储在栈内存中,声明的复杂数据类型会将值存储在堆内存中并将其在堆中的内存地址作为值存到栈内存中。
  3. const声明常量本质是指的是声明的变量名所指向的栈内存地址不可改变,但是栈中对应的值可以改变。
  4. 基本数据类型赋值是在栈内存中申请新的内存区域保存值并将其指向的内存地址绑定到原有变量上。
  5. 复杂数据类型在声明时是在堆内存上分配内存空间存储其值,将分配的堆内存空间地址作为值存储在栈内存上,变量直接绑定的是栈上内存地址。
  6. 复杂数据类型赋值是在堆内存中申请新的内存区域保存值,并将其指向的内存地址作为值在栈内存中申请新的内存区域保存将其在栈中的内存地址绑定到变量上。
  7. 赋值语句只是将两个变量指向同一个栈内存地址

3.3 深复制与浅复制

上面说的复杂数据类型通过指针指向了同一块堆内存空间, $\color{#FF0000}{深、浅复制主要区别就在于复制值的时候是否新分配堆内存空间来保存原值的拷贝。}$

  • 对于对象或数组类型,当我们将 a 赋值给 b,然后更改 b 中的属性,a 也会随着变化。也就是说 a 和 b 指向了同一块内存,所以修改其中任意的值,另一个值都会随之变化,这就是浅复制(拷贝)。
  • 深复制(拷贝)则是在上述 a 赋值给 b 过程分配了新堆内存空间来存储拷贝的值,同时在存在复杂数据类型的嵌套属性(递归遍历)也要用同样方式处理,最后复制出来的新数据对象下的任意层级的复杂对象都有新的堆内存存储相应的值。

4、内存回收机制

有内存就必然有垃圾回收(GC),JS中栈内存多数是在函数执行时使用(根据函数调用顺序也叫做调用栈),函数执行完后即开始栈内存的垃圾回收。堆内存由于存在多个栈内存中的指针指向它以及堆内存较大等原因,需要采用特定的垃圾回收算法处理。

$\color{#FF0000}{垃圾回收的关键在于如何判断内存已经不再使用然后将其释放掉.}$
$\color{#FF0000}{那么垃圾回收器是如何检测变量是否需要的呢,大体上分为两种检测手段,引用计数与标记清除。}$

4.1 垃圾回收的两种模式

4.1.1引用计数算法

主要是IE等旧浏览器在采用,通过计数器分析变量的引用次数,清除没有引用到的变量。

// 创建一个对象,由变量user指向这个对象的两个属性
var user = {
    name: 'liuyunshengsir',
    handsome: true
};


// name虽然设置为了null,但user依旧有name属性的引用
user.name = null;

var s = user;
// 我们修改并释放了user对于对象的引用,但变量s依旧存在引用


user = null;
// 变量s也不再引用,对象很快会被垃圾回收器释放
s = null;

对于存在循环引用的情况则无法处理,比如:

function cycle() {
    var o1 = {}
    var o2 = {}
    o1.a = o2
    o2.a = o1
    return "Cycle reference!"
}
cycle()

其中 o1 引用了 o2,o2 引用了 o1,在cycle函数执行完 o1,o2 都没有再次引用到,但是引用计数算法判断两者都存在引用。,即便函数执行完毕,垃圾回收器通过引用计数也无法释放它们。

4.1.2 标记清除算法(可达性)

标记清除的概念也好理解,从根部出发看是否能达到某个对象,如果能达到则认定这个对象还被需要,如果无法达到,则释放它,这个过程大致分为三步:

  1. 垃圾回收器创建roots列表,roots通常是代码中保留引用的全局变量,在js中,我们一般认定全局对象window作为root,也就是所谓的根部。
  2. 从根部出发检查所有 的roots,所有的children也会被递归检查,能从root到达的都会被标记为active。
  3. 未被标记为active的数据被认定为不再需要,垃圾回收器开始释放它们。

当一个对象零引用时,我们从根部一定无法到达;但反过来,从根部无法到达的不一定是严格意义上的零引用,比如循环引用,所以标记清除要更优于引用计数。

从2012年起,所有现代浏览器都使用了标记清除垃圾回收算法,但老版本的IE6除外。

JavaScript 引擎在垃圾回收方面的优化:

  • 分代回收:对象分为“新对象”和“旧对象”,新对象出现,工作结束后就会被清理干净,存在时间长的对象,就会很少接受检查
  • 增量回收:将垃圾回收分解为多个部分,分别执行
  • 空闲时间收集:垃圾回收器只在CPU空闲时运行

4.2 垃圾回收的缺陷

javascript的GC策略也无法避免一个问题: GC 时, 停止响应其他的操作, 这是为了安全考虑.

5.如何避免内存泄漏

我们已经知道了垃圾回收的原理,那么我们如何避免创建无法回收的对象,以至造成内存泄漏的尴尬呢?下面说说常见的四种js内存泄漏。

5.1 全局变量

尽可能少的去创建全局变量是js开发者的常识,但如下两种方式还是会意外的创建全局变量, $\color{#FF0000}{第一是在函数中声明变量未使用var}$ :

function fn() {
    a = 1;
};
fn();
window.a //1

上述代码中我们在函数体内声明了一个变量a,由于未使用var声明,即便在函数体内,但它依旧是一个全局变量。我们知道全局变量等同于在window上添加属性,所以在函数执行完毕,我们依旧可以访问到它。

$\color{#FF0000}{第二种是在函数体内通过this来创建变量}$:


function fn() {
    this.a = 1;
};
fn();
window.a //1

我们知道,当直接调用函数fn时,等同于window.fn(),所以函数体内的this会指向window,所以本质上还是创建了一个全局变量。

当然上述问题也不是无法解决,我们可以使用严格模式来避免这个问题,试着在代码头部添加‘use strict’,你会发现a就无法访问了,因为严格模式下,全局对象指向undefined。

有时候我们无法避免使用全局变量,那么记得在使用完毕后手动释放它们,例如让变量指向null。

5.2 被遗忘的定时器

var serverData = loadData();
setInterval(function () {
    var renderer = document.getElementById('renderer');
    if (renderer) {
        renderer.innerHTML = JSON.stringify(serverData);
    }
}, 3000);

在上述代码中,当dom元素renderer被移除时,由于是周期定时器的缘故,定时器回调函数始终无法被回收,这也导致了定时器会一直对数据serverData保持引用,好的做法是在不需要时停止定时器。

5.3 被遗忘的监听事件

在例如我们在使用事件监听时,如果不再需要监听记得移除监听事件。

var element = document.getElementById('button');

function onclick(event) {
    element.innerHTML = 'text';
};

element.addEventListener('click', onclick);
// 移除监听
element.removeEventListener('click', onclick);

5.4 闭包

var theThing = null;
var replaceThing = function () {
    var originalThing = theThing;
    var unused = function () {
        //unused未执行,但一直保持对theThing的引用
        if (originalThing)
            console.log("hi");
    };
    //创建一个新对象
    theThing = {
        longStr: new Array(1000000).join('*'),
        someMethod: function () {
            console.log("message");
        }
    };
};

setInterval(replaceThing, 1000);

定时器每次调用replaceThing,theThing都会获得一个包含数组longStr与闭包someMethod的新对象。

闭包unused保持着对象originalThing的引用,因为theThing赋值的缘故,也保持了对theThing的引用。虽然unused没执行,但引用关系会导致originalThing一直无法被回收,那么theThing也一样。正确做法是在replaceThing 最后添加originalThing = null;

所以我们常说,对于闭包中的变量,在不需要时一定记得手动释放。

5.5 DOM的引用

操作dom总是被认为是不好的,但一定得操作,我们的习惯是通过一个变量来存储它,这样就可以反复使用了,但这也会造成一个问题,dom会被引用2次。

var elements = document.getElementById('button')

function doStuff() {
    elements.innerHTML = '听风是风';
};
// 清除引用
elements = null;
document.body.removeChild(document.getElementById('button'));

在上述代码中,一次引用是基于dom树的引用,第二是变量elements的引用,当我们不需要这个dom时,都做两次清除操作。

5.6 循环引用

循环引用导致不能被标记清除

思考

所有局部变量都应该用const或let声明。默认用const,除非变量需要被重新赋值。不能使用var 关键字。

虽然没有明确说明为什么,据我所知,应该有以下几个原因:

  • 提前减少 bug。
  • 使用 const 声明的变量必须在声明时进行初始化,这就迫使程序员在声明变量时多考虑作用域的问题,最终有利于内存管理和性能。
  • 跟接触代码的其他人交流时,可用代码说话:哪些变量是不可变的(就 JS 而言),哪些变量是可以重新赋值的。

$\color{#FF0000}{红色字}$

相关文章
|
2月前
|
存储 缓存 JavaScript
请描述一种JavaScript内存泄漏的情况,并说明如何避免这种情况的发生。
JavaScript内存泄漏常由闭包引起,导致无用对象滞留内存,影响性能。例如,当一个函数返回访问大型对象的闭包,即使函数执行完,对象仍被闭包引用,无法被垃圾回收。防止泄漏需及时解除引用,注意事件监听器清理,使用WeakMap或WeakSet,定期清理缓存,以及利用性能分析工具检测。
13 2
|
2天前
|
JavaScript 前端开发 算法
JavaScript的垃圾回收机制通过标记-清除算法自动管理内存
JavaScript的垃圾回收机制通过标记-清除算法自动管理内存,免除开发者处理内存泄漏问题。它从根对象开始遍历,标记活动对象,未标记的对象被视为垃圾并释放内存。优化技术包括分代收集和增量收集,以提升性能。然而,开发者仍需谨慎处理全局变量、闭包、定时器和DOM引用,防止内存泄漏,保证程序稳定性和性能。
7 0
|
14天前
|
存储 缓存 JavaScript
【Web 前端】JS哪些操作会造成内存泄露?
【4月更文挑战第22天】【Web 前端】JS哪些操作会造成内存泄露?
|
14天前
|
存储 移动开发 JavaScript
学习javascript,前端知识精讲,助力你轻松掌握
学习javascript,前端知识精讲,助力你轻松掌握
|
20天前
|
JavaScript 前端开发 测试技术
学习JavaScript
【4月更文挑战第23天】学习JavaScript
14 1
|
27天前
|
JavaScript 前端开发
FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory 内存溢出问题
FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory 内存溢出问题
18 1
|
28天前
|
JavaScript 前端开发 应用服务中间件
node.js之第一天学习
node.js之第一天学习
|
1月前
|
JavaScript 前端开发 Java
JavaScript中的内存泄露:如何避免及修复
JavaScript中的内存泄露:如何避免及修复
31 3
|
2月前
|
运维 JavaScript 前端开发
发现了一款宝藏学习项目,包含了Web全栈的知识体系,JS、Vue、React知识就靠它了!
发现了一款宝藏学习项目,包含了Web全栈的知识体系,JS、Vue、React知识就靠它了!
|
2月前
|
JavaScript
Vue.js学习详细课程系列--共32节(6 / 6)
Vue.js学习详细课程系列--共32节(6 / 6)
28 0