Skip to content

js没那么简单(3)--内存模型 #16

@wython

Description

@wython

前言

js的内存模型相对比较简单,可以简单的分为堆内存栈内存。本节主要讨论,js的内存模型,以及js如何做垃圾回收的。因为垃圾回收,其实对闭包有一定的思考意义。当然,我相信并不是所有人都能认识到这点。

内存模型

内存模型是代码中对硬件运行环境的一个抽象,其可以表示为执行过程中,变量和数据在实时内存中的一个表现。

在历史长河中,语言对内存对分配大体可分为三种,静态分配堆分配栈分配

静态分配

静态分配是最早出现的内存分配模式。我们可以简单理解为,代码需要在最开始时候就确定所使用的内存空间,并且所占用的空间是固定的。这样既不需要内存执行时创建,也不需要摧毁,效率更高。当然,灵活性方面也很差,对于我们现在高级的语言来说。这种方式显得笨拙。

栈分配

栈分配是后来演变出来的一种比较灵活的内存分配方式。如果你记得前面讲到的动态作用域语言,那么你可以理解到,对于一开始只有栈分配类型的语言,他的作用域是跟着其内存分配走的,意思是:这种类型但分配语言,每次运行时,都是以一种活动记录方式压入系统栈。活动记录在你可以看成类似js执行上下文的东西。也就是说,这种分配方式特点是:

  1. 程序所涉及到的内存需要提前预知,在运行时固定大小的内存作用类似栈结构的数据结构。
  2. 由于其作用域是跟着活动记录存活走,固栈分配的内存摧毁会根据活动记录的销毁而销毁
堆分配

堆分配相对栈分配则更加灵活,堆分配克服了栈分配内存无法动态分配的缺陷。同时,堆分配使得作用域灵活性更高,比如js的闭包,在栈结构的设计中是无法达成的,但是用堆内存分配之后,闭包具有更灵活的生命周期,而非跟着执行上下文周期去走。

这种动态内存分配的特性,必然导致了很多其他问题的出现,比如,计算机不能像栈内存那样去确定该内存的存活周期。你可以理解为:堆内存的变量我们无法知道其该在什么时候摧毁。栈内存之所以简单在于其变量是跟着活动记录走。所以堆内存的出现,在c++这种语言中,需要人工分配,人工去释放。这样的意思是:程序对于自己分配的内存具有自己把握的权利,但是对于程序员自己分配的内存,只有程序员知道什么时候可以摧毁。

后来,类似java这种,借助算法的实现,从而可以固定时间去确定某些变量已经无法继续使用。从而用算法方式去回收它们。这正是我们后面讨论的垃圾回收算法。

js内存模型

在c++中,c++的内存模型会相对更复杂,会有全局静态存储区,堆栈等等。对于js来说,其模型会相对简单很多。可以简单分为堆内存栈内存

我们可以简单看看这张图片:

栈空间(Stack)

在v8中,每一个js进程会有自己管辖的栈空间,内存中的栈空间可以抽象成类似数据结构的后进先出的结构。这样的内存空间,在物理上表现为连续固定的区域,在运行时表现为后进先出。比如我们之前提到执行上下文的概念,可以认为是对整个块环境的一个完整处理,那执行上下文中的数据以栈分配形式存储。

而在实际v8的运行过程中,栈空间的处理是类似于游标的指针决定。类似于下图的游标,很多时候我们对内存对处理不会像现实中那样,真正意义上对清理,游标下移,表示游标以上对空内存为空闲,这时候可以认为其已经出栈。在程序中,我们可以在用到时候覆盖这块内存,所以不需要显式处理

堆空间(Heap)

在v8中,对堆空间堆划分会比较详细很多,这也难怪,堆是具有灵活多变堆一种内存分配方式。在物理上表现为不一定连续不固定大小区域,在运行时,它可以根据代码上需要的空间,动态分配需要的内存。同时,最大的特点和问题,也是我们经常关注的内存回收问题。我们知道,在c++中,部分人为堆内存的分配是由人自己去释放。这是因为堆的灵活性,导致其回收也具有一定灵活性,无法像栈结构那样有确定的生命周期。

而在js中,语言的灵活性更高。好在js有灵活的算法去确定其是否需要回收,并且定时进行回收。

按v8的堆内存做划分:

  • 新生代(new space):新生代和老生代是我们的js代码所能分配的空间,是垃圾回收主要关注的区域,新生代主要存生命周期比较短的变量和内容。所以空间较小,但是变更频繁。
  • 老生代(old space):老生代是生命周期较长的分配区域,空间大,不过相对变化比较不频繁。新生代和老生代在后面垃圾回收会主要讲解,他们垃圾回收关注的主要区域。
  • 大对象空间(Large object space):超过内存限制的大对象会在这里开辟空间。
  • 代码空间(Code-space):在我们前面提到,执行上下文中,会有些编译后的二进制可执行代码,函数的过程代码,大部分会存储在这个区域。有一部分也会存储在大对象空间。
  • 细胞空间,属性细胞空间,map 空间(Cell space, property cell space, and map space):这些空间分别包含:Cells, PropertyCells, 和 Maps的数据结构。这个空间的大小结构是固定的。

js数据类型和内存模型的关系

通过上面的讨论,我们对内存对模型有个基本的认识。但是所有的知识,不能脱离js本身。那么在js中,我们对数据对使用,在内存中又是如何具体体现对呢?

首先,我们得知道js的数据类型有哪些,在js中,有八种基本的数据类型:

  1. Boolean: true或者false
  2. Number: 数字类型,固定64位空间。
  3. String: 字符串类型
  4. Undifined:一个没有值的空间,默认会是undifined。前面提到的变量提升之后,由于变量没有具体执行的内容,表现即为undifined。
  5. Null: 就是null
  6. Symbol: es6中新引入的一种类型
  7. BigInt: 具有大空间的整型
  8. Object: 引用类型基类,我们称它对象,函数,数组等特殊结构都是以它实现的。

在这八种类型中,Object我们叫引用类型,其他七种叫基本数据类型。这么区分的原因在于他们在内存分配上的区别导致的。

我们先简单的说,基本类型是存储在栈结构中,而引用类型存储在堆结构中。为什么要这么去划分,这是一个值得思考的问题。
这是因为,基本结构具有可提前预知,固定的数据大小,所以可以满足用栈存储的前提条件。而引用类型,他们的大小是会动态变化的,我们知道,堆的优点就是动态分配,所以引用类型需要借助堆动态分配的方式,灵活的变化空间。

这里我们用具体代码来看看:

var a = 100
a = a + 100

var b = []
b.push(1)

上面代码中,我们知道,对于number类型来说,64位固定大小空间,无论是100,还是200,都是用64位表示。所以当我们如果数字较大,就会溢出。这其实是固定位数大小的体现。而对于array类型来说,其存储的空间会不断真正意义上的变化。比如十个长度的数组和20个长度的数组其占用空间就不一样。

如此,array只能用堆动态分配才能满足其要求。

接下来,我们在看看两种不同类型如何用体现:

对于栈空间,变量a是基本数据类型,所以具有唯一内存标识,对应在栈里面的一个值。而对于变量b,执行上下文也会为其分配一定的栈内存,不过存储的是指向堆的一个内存区域,用于数组的存储,该数组是动态分配的。这样很好的看出,基本类型和引用类型在存储上的区别了。

包装类型

看过高级程序设计的书的同学应该知道,基本类型具有如引用类型一样特殊的方法,比如toString方法。相信大部分人,不会去过多思考这个东西,也许你们只是简单了解到它的特性,比如包装的周期很短,仅仅在于用到的语句间存活。但是其实包装类型体现了更多有趣的东西,正好验证了我们上面的说法。

这里先回顾下包装类型的特点

我们知道,基本类型都具有自己的构造函数,这个构造函数的原型是Object。所以他们的实例具有Object的方法,也具有一些自身的特殊方法。

但是我们前面又说到,基本类型是存储在栈内存,它的大小是固定的,它不应该有类型Object这种面向对象方式的灵活方法。这似乎是矛盾的,一方面它表现像实例,一方面它又是固定结构。

只有在这个层面上思考,你才真正能理解到包装类型的本质,包装类型的意思,真是如它名字表示的,包装。也就是说,在运行的时候,如果基本类型用了如对象一样的函数调用,那么会把基本类型做一个包装,让他表现的像一个对象。而与我们自己使用的对象类型不同,由于这个包装是编译器自己产生的,所以编译器会在其使用完之后,立即摧毁掉对应的对象。

如何理解这个行为,我们知道,垃圾回收是比较复杂的,对于这种编译器自己具有控制权,并且知道生命周期的堆内存空间,这种用完立即摧毁的方式是理所当然的。

我们可以用代码简单的去抽象这个行为:

var a = 1

console.log(a.toString())

a = a + 1

// 以上代码会转化称

var a = 1

var tem = a     // 包装
a = new Number(tem) // 包装
console.log(a.toString())  // 包装
a = tem         // 包装

a = a + 1

正如上面代码,a在用到toString的时候会进行包装,但是用完也会立即回收Number类型的包装。这样有利于更好利用堆空间。这里希望你在细细体会。

另外,在ts中,会对包装类型和基本类型做区别,体现为number和Number的类型。

最后,由于篇幅的原因,垃圾回收就放后面讲了,这里就先提出一个内存模型概念就好了。end

相关题目:

  1. 1 为什么没有toString方法
  2. 1 为什么能调用toString方法
  3. 1 和 new Number(1)在内存占用上有什么区别
  4. Number(1) 和 new Number(1) 的区别
  5. ts中Number和number类型有什么区别

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions