深入剖析JS的内存机制

219 阅读13分钟

我们已经学过了JS的执行机制、作用域链和闭包。今天我们来好好聊聊JS的内存机制。当我们写下一段JS代码,它是怎么在内存中存储的。

1. 语言的类型

首先,我们来了解一下编程语言的类型,为接下来的内存机制做铺垫。

对于C语言,我们在C语言定义变量前得先确定变量的类型,如:

int main(){
    int a = 1
    char* b = 'hello'
    bool c = false
    c=a  
}

使用int去定义数字类型;使用char去定义字符串类型;使用bool去定义布尔类型等。不能随意去声明,否则会报错。我们把这种在使用前需要确认变量类型的语言叫做静态语言

而对于像JS这样,用var关键字可以声明任何类型,根本不需要事先知道变量类型的语言叫做动态语言

还有,在C语言里我们可以给一种类型赋值为不是这种类型的值。如上面那段代码,我们将数字类型的a赋值给了布尔类型的c,这样是不会报错的。这种转换就叫隐式类型转换。我们把这种支持隐式类型转换的语言称为弱类型语言。而对于Java,它就不支持隐式类型转换,我们就把这种语言称为强类型语言

所以JavaScript是动态弱类型语言,而Java就是静态强类型语言

所以对于语言的类型,我们有这几个结论:

1. 在使用前就需要确定其变量的数据类型--静态语言

2. 在运行的过程中检查数据的类型--动态语言

3. 支持隐式类型转换的语言--弱类型语言

4. 不支持隐式类型转换的语言--强类型语言

2. JS的数据类型

了解完语言的类型,我们再来好好聊聊JS中的数据类型。

在JS中有这么几种类型:

let a = 1
// 数字
a = 'hello'
// 字符串
a = true
// 布尔类型
a = null
// null类型
a = undefined
// undefined类型
a = Symbol(1)
// Symbol类型
a = 123n
// bigint类型
a = []
// 数组类型
a = {}
// 对象类型
a = function () {
}
// 函数类型

而在JS中有一个方法可以查看JS中数据的类型,typeof。当我们用这个方法去一一查看a的类型时,它都能给我们返回正确的类型。而对于null和数组,它就出了问题。

let a = null
console.log(typeof a);
let a = []
console.log(typeof a);

image.png

对于这两种类型,它返回的是object类型。这是什么原因呢?

我们知道,在计算机中,数据都是以二进制来进行存储的。而所有的引用类型在转换成二进制的时候,前三位一定是0,而typeof的判断原理是如果把这个数据转换成二进制时它的前三位是0的话它就会直接判断它为object。所以,数组是引用类型,当它被转换成二进制的时候它的前三位也是0,typeof就会判断它为object了。而对于null类型,它的二进制就是64位的0,所以它也会被判断为object。而函数虽然也是引用类型,但它额外做了一个操作,所以它会被正确的识别出来。

所以null被识别为object算是JS的一个bug。所以在写代码的时候我们得知道它并且去规避它。

3. JS的内存空间

了解完上述前置知识,现在我们就能来聊聊JS的内存空间了。我们先通过一个例子来引出内存空间。

let a = 1
let b = a
a = 2
console.log(b);

我们定义了一个a为1,然后给b赋值为a,再修改a的值为2,然后输出b。请问输出结果是什么?它是会输出1呢,还是跟着a的值变为2呢?

有朋友说,啊这也太简单了,这不一眼1吗。你修改了a的值和我b的值有什么关系呢?

确实如此。我们再来看一个例子:

 let a = { name: '庆玲', age: 18 }
 let b = a
 a.age = 19
 console.log(b.age);

我们定义了一个a为对象,然后把对象a赋值给b,然后更改a中属性age的值为19,然后输出对象b中age的值。这时,它还会像上面那个例子一一样保持不变吗?

image.png

我们发现,b中属性age的值改变了。这是为什么呢?明明两段函数都差不多,为什么输出结果却大相径庭呢?

想要弄清楚这个,我们就得从JS的内存空间出发了。

在JS的运行内存中,大概长这样。

image.png

当你写下一段JS代码时,它就被存放在这三个空间里。代码空间就是用来存放你写的代码,栈空间就是我们讲过的调用栈,它是用来维护函数间的调用关系,而这个堆空间是用来干什么的呢?

我们通过一个例子来认识一下堆空间。

function foo() {
    var a = 1
    var b = a
    var c = { name: '熊总' }
    var d = c
}
foo()

我们定义了一个函数foo,定义了一个a为1,定义了一个b为a,定义了一个c为对象,定义了一个d为c。我们来简单画一下它在内存空间中的存储方式。

首先在调用栈里,有一个全局执行上下文,里面有一个函数foo。然后调用了这个函数,于是生成一个foo执行上下文入栈。

image.png

于是开始对foo中的代码开始编译。有一个a=undefined,b=undefined,然后注意,c是一个对象,对象是引用类型。它就不会存放在调用栈里,而是存放在里,c存放的是对象在堆里的地址。

image.png

于是开始执行foo中的代码,给a赋值为1,给b赋值为1。因为c是一个引用类型,所以c中存放的是对象在堆中的引用地址,假设为#001,所以c为#001,而d=c,所以d中存放的也是引用地址#001。

这就是当碰到引用类型时JS代码在运行空间中的存储模式。

所以当我们回到这段代码时,我们就能分析为什么age的值改变了。

 let a = { name: '庆玲', age: 18 }
 let b = a
 a.age = 19
 console.log(b.age);

我们定义了一个对象a,于是它存放在里,a中存放的是对象在堆里的引用地址,假设是#001。然后给b赋值为a于是b存放的也是引用地址#001。然后执行a.age = 19,于是修改了存放在调用栈里这个对象的age值为19。此时我们要输出b.age,而b存放的依然是这个引用地址#001,于是它访问的就是这个被修改了的对象,所以输出19。

let a = 1
let b = a
a = 2
console.log(b);

而上面这段代码,a、b都是原始类型,它们都是存放在上下文里的。当我们给b赋值为a时,b存放的就是1。所以当你a之后不管变成什么值都都与我b无关了。所以输出b时还是为1.

此时,应该有朋友会有疑问了。为什么又要将引用类型存放在堆里呢?直接都存放在调用栈里不好吗?

其实,这个问题很简单,因为调用栈的空间很小,它只适合存放一些占用空间很小的原始类型。而引用类型,它可以占用空间很大,我们就不能把它存放在调用栈里,而是需要再开辟一个堆空间单独存放。

而有朋友又有疑问了。那为什么不把调用栈的空间设置的大一些呢?我们说过,调用栈是维护函数之间的调用关系的。而当调用栈的空间设置的很大时,程序员们就会理所当然的去随意写函数调用,可能会导致函数之间层层调用,作用域链拉的非常长,上下文之间的切换效率会大大降低,于是最后这段代码的执行时间可能会很长,这是不会被允许的。所以我们不能把调用栈的空间设置的很大,程序员们需要自己约束自己,不要把代码写的层层包裹。

这就好比我们裤子的口袋,设计师们不会将口袋设计的很深,而是放在我们手最后能伸到的地方。如果设计师们为了口袋能存放更多东西而将口袋底部设计到脚边,我们拿东西时就会苦恼了。这就和调用栈是同一个道理。所以不能将调用栈设计得很大,而是再需要一个堆空间去存放引用类型。

对于内存空间,我们有这几个结论:

1. 栈: 原始类型(原始类型的值一般都很小)

2. 堆: 引用类型(引用类型要占据的内存很大)

  • 栈的设计本身就很小:因为如果栈设计的很大的话,那么栈中函数的上下文切换效率就会大大降低

  • 原始类型的赋值是值的赋值,引用类型的赋值是引用地址的复制

4. 闭包与调用栈、堆的结合

这就是JS的内存机制。至此,对于一段JS代码,它是怎么编译、怎么存储、怎么执行的,我们都已经非常明白了。

温故而知新,可以为师矣。让我们来结合之前所学的的,看几个例子,加深一下对内存空间的认识。

function fn(person) {
    person.age = 19
    person = {
        name: '庆玲',
        age: 19
    }
    return person
}
const p1 = {
    name: '凤如',
    age: 18
}
const p2 = fn(p1)

console.log(p1);
console.log(p2);

我们来画图分析一下这段代码的输出结果。

首先,在全局,我们定义了一个函数fn,一个对象p1,一个变量p2。p1和p2是用const声明的,所以存放在词法环境中。先对全局的代码进行编译,有一个全局执行上下文入栈。

image.png

编译完毕,开始执行全局中的代码。给p1赋值为一个对象,此时会生成一个堆空间,存放p1的对象。堆中的对象有一个引用地址,于是p1存放的是引用地址#001。

image.png

然后给p2赋值为fn的fn的返回结果,调用了fn,于是生成一个fn执行上下文入栈。

image.png

开始对fn中的代码开始编译。先找形参声明和函数声明。只有一个形参person,然后统一形参与实参的值,传了一个实参p1进来,所以person此时存放的也是引用地址#001。

image.png

编译结束,开始执行foo中的代码。先执行person.age = 19,person指向#001,于是它会去修改引用地址为#001的对象的age属性。然后又给person赋值为一个对象,于是一个新的对象入栈,引用地址为#002。此时person就为#002。

image.png

然后返回person的值,于是全局指向上下文中的p2就为#002。foo执行上下文执行完毕销毁。

image.png

然后输出p1和p2,所以执行结果为#001和#002中的对象。

image.png

这就是这段代码一整个完整的执行过程。

我们再来看一个与闭包相结合的。

function foo() {
    var myname = '子俊哥哥'
    let test1 = 1
    const test2 = 2
    var innerBar = {
        setName: function (name) {
            myname = name
        },
        getName: function () {
            console.log(test1);
            return myname
        }
    }
    return innerBar
}
var bar = foo()
bar.setName('陈总')
console.log(bar.getName());

我们先来分析一下这段代码。首先有一个函数foo,里面我们定义了一个对象innerBar,里面有一个key为setName值为函数体,一个key为getName值为函数体。函数setName是将myname的值赋值为name,getName是将test1输出并返回myname的值。最后返回对象innerBar。

代码开始编译,全局上下文入栈,在变量环境里,有foo=function(){},bar=undefined。

image.png

编译结束开始执行。给bar赋值为foo()的返回结果。调用了foo,于是foo执行上下文入栈。开始对foo中的代码进行编译。先找形参声明和变量声明。

image.png

编译结束开始执行foo中的代码, var myname = '子俊哥哥'、let test1 = 1、const test2 = 2,执行到innerBar时发现是一个对象,于是存放到堆空间。

image.png

然后返回innerBar,foo执行上下文执行完毕销毁。但此时因为对象里的两个函数需要访问foo中的myname和test1。所以会生成一个闭包。然后bar的就为引用地址#001。

image.png

然后调用了setName函数,于是setName执行上下文入栈。对setName中的代码进行编译,有一个形参name,统一形参与实参的量,所以name='陈总'。它将myName的值赋值为name,自己的作用域中没有myName,于是去所在的词法作用域中找,而foo已被销毁,于是去闭包中找,找到了myname修改值为'陈总'。

image.png

执行完毕后setName执行上下文出栈。又调用了函数getName,于是getName执行上下文入栈。

image.png

它要输出test1的值,于是它也会去闭包中找,找到test1,输出为1,然后返回myname1的值为'陈总'。执行完毕全部出栈。

于是结果就为:

image.png

5. 总结

至此,关于JS的执行机制、作用域链、闭包和内存机制我们都已经学完了。当我们碰到一段很复杂的代码时,应该就能从容应对了。一步步来。在脑海中构建代码的编译和执行过程,回忆我们学过的知识。

在编译时应该怎么做,var声明的量应该放在变量环境中,let、const声明的量应该放在词法环境中,引用类型应该存放在堆中,当在自己的上下文没找到应该去所在的词法作用域中找,上下文执行完毕会被销毁等等。

如有遗漏可以去回顾我们所学的知识。

[](浅谈一下JS中的作用域关于JS的作用域,其实还有不少容易出错的地方。在今天仔细学习了一下作用域之后,发现有不少可以拿出来 - 掘金)

[](一篇文章彻底搞懂JS的执行机制 在上一篇文章中,我们学习了JS的作用域,其中,我们提到了一点:就是用关键字var声明的变 - 掘金)

[](手把手带你拆解JS中的作用域链和闭包在上一篇文章中,我们详细分析了JS的执行机制:JS是先编译再执行的;并且解决了用va - 掘金)