内存分配以及垃圾回收机制

内存分配以及垃圾回收机制

本文参考:

  1. 可视化分析js的内存分配与回收

本文面向面试,只罗列些概念

内存分配或者有的文章里叫内存模型,关于这类的文章,网上是真的多,你抄我,我抄你,不同文章里可能讲的还不一样,真令人头大

没办法,还没有能力能直接去阅读源码,直接去看规范,所以我这篇,也是抄来抄去的

顶多,我这是面向面试准备,所以只会罗列些概念,以及看了这么多篇文章后的一些疑惑、思考

疑惑还不是一点两点!

疑惑

Q:都说基本类型存栈,引用类型存堆,那么,基本类型是指什么,某个对象里的属性值也是基本类型,这个算吗?

对于这点,我个人看法是,这句话是针对变量而已,那么变量是什么东西,就是通过 var function let const 关键词声明的标识符就叫变量,其他的比如对象里某个属性值类型也是基本类型的不算

而根据变量声明位置不同,可分为全局变量(函数外声明)和局部变量(函数内声明),所以不管是在全局声明的变量,还是函数内声明的变量,都遵循这点:

基本类型的变量值存储在栈中,引用类型的变量值存储在堆中

记住是变量值,所有的变量都会指向栈内一块地址,只是该内存地址上存的是具体基本类型值,还是一个引用(堆内存地址)

Q:有人说,基本类型的变量直接指向栈内地址,所以对基本类型变量的赋值等操作都是直接修改内存空间,拷贝时也才会是值拷贝现象,而引用类型的变量在栈内存的只是堆内存地址,因此对引用类型的拷贝也就只是引用拷贝;但又有人说,基本类型的数据都是不可变的,对基本类型数据的操作其实是会重新生成一份基本类型数据后,再将原来变量指向新的内存地址;那这两种说法不是就相互矛盾了吗?

有这个疑惑是因为看到了这篇文章:JavaScript的内存模型

截个里面的图给你们看看:

他这篇文章里的说法,跟通常的理解不大一样啊

不都说,对一个基本类型变量的拷贝,是直接将值拷贝一份到新变量指向的内存地址吗?那照他的说法,其实并没有拷贝值,而是两个变量都指向了同一个内存地址,而且修改某个变量值时,也不是直接在内存地址上修改值,而是重新指向一个新的内存地址

看完这篇后,就有点懵,你要说他的说法是错的吧,那基本类型数据都是不可变的这点该怎么解释

你要说他的说法才是对的吧,那基本类型的拷贝还能算是值拷贝吗,值拷贝这种说法不是很流行了吗

对于这点,我个人啊,个人的理解:

其实这两种说法都没错的,只是作者的角度不同吧,值拷贝、引用拷贝这种说法,可能是基于结果的一个角度,也就是程序运行之后,从结果来看,对于基本类型确实是值拷贝的现象,因为两个变量互不影响,相互独立,而对于引用类型的两个变量,修改对象某个属性会影响到另外一个,这是因为其实指向同个堆内存,所以现象是引用拷贝

但上面这篇的作者,他可能是基于机器在处理这些程序的方式上的角度来写的吧,可能对于机器来说,它处理基本类型数据确实是让其不可变,那这样一来,对基本类型变量的赋值操作,其实是会改变变量指向的内存地址,但这个行为对于开发者来说是隐藏的,无感知的,开发者只能感知到结果现象

我只能这么理解了,毕竟栈内的数据好像没办法调试、抓取,开发者工具里能抓取的关于内存的数据全是堆内存数据

Q:有些文章里经常说的函数调用栈、执行环境栈、基本类型变量的入栈出栈,这些栈都是同一个栈吗?

很多人在分析内存分配时,总会随便定义些变量,然后画张图,图里有个栈存储着基本类型的变量,有个堆存储着对象,由栈内存储的引用类型变量会指向这个堆,如(图片盗自 https://juejin.im/post/5c8a065c6fb9a049de6e3f2f ,侵删):

我总感觉这种图,尤其是里面的栈画得无头无尾的,这个栈是哪里来的栈,栈里就没有其他数据了吗

假如我们这张图画的对应代码是这样的吧:

1
2
3
4
5
6
7
8
9
10
11
var a = 1;
var b = 2;
function a() {
var a1 = 0; // 变量对象
var a2 = 'this is string'; // 变量对象
var a3 = null; // 变量对象

var b = { m: 20 }; // 变量b存在于变量对象中,{m: 20} 作为对象存在于堆内存中
var c = [1, 2, 3]; // 变量c存在于变量对象中,[1, 2, 3] 作为对象存在于堆内存中
}
a();

画的是 a 函数执行时,对于函数 a 内部变量的处理吧,那这个时候,函数外部不还有一些全局变量吗,这些呢?

正疑惑时,还看到有人这么画(盗自 https://juejin.im/post/5dd8b3a851882572f56b578f ,侵删):

图中出现函数执行上下文,那这个栈应该就是所谓的执行上下文栈 ECS 了吧,应该也俗称叫函数调用栈吧

那这图意思是执行上下文栈跟变量存储的栈其实是同一个?

还有的文章里虽然不画图,但至少有描述了一下,说:

每个执行上下文内的变量都会存储在一个单独的栈内空间里,生命周期跟随执行上下文在 ECS 中的生命周期

忘记原话是什么了,大概是这么个意思,所以我想,上图想表达应该也是这么个意思

每个执行上下文都会作为一个类似内存基址一样,这个执行上下文内的变量都会根据这个基址的偏移量计算出它们的栈内地址,所以每个函数内的变量都只跟该函数绑定、生命周期由自己决定

Q:对于基本类型变量值存栈、引用类型变量值存堆的说法,有人说,除了闭包除外,因为闭包不管基本类型还是引用类型都存在堆里,这是什么意思?

先看下偶然间在其他文章里看到的这么一句描述:

基本类型的变量存在栈里,引用类型的变量存在堆里,但除了闭包,闭包里不管基本类型还是引用类型都存在堆里

我觉得这种说法不对,会有误导的意思

这种说法,好像会让人理解成,有闭包的时候,函数内的变量就都存在堆里了

个人看法,闭包是闭包,不影响内存的分配

闭包里的变量之所以存在堆里,是因为,闭包本来就是因为函数会有一个持有外部词法环境的特性:

函数本身就是个对象,在定义时,这个函数对象就会被存在堆内存中里了,然后它有一个内部属性 [[scope]],用来存储需要使用到的外部词法环境里的变量

我们经常会说当函数返回一个函数时,返回的函数称为闭包,其实想想我们通常是怎么做的,不都是外部会有个变量指向返回的这个函数吗

那对于外部这个变量来说,它的变量值就是函数对象,这个函数对象存储在堆中,对象里还有个内部属性 [[scope]],存储着需要使用到的这个函数定义时所在的词法环境中的变量

所以闭包里的变量存在堆中不是很正常的行为吗,因为这些变量挂靠在函数这个对象上面啊,对象不存堆里存哪

Q:有人说闭包是在外部函数执行结束后,才将闭包捕获的外部函数里的变量转存到堆里,这种说法对吗?

这个问题其实就是问,函数的内部属性 [[scope]] 是什么时候,如何存储外部函数里的变量的

函数定义完就会创建内部属性 [[scope]] 来存储了,至于它怎么知道要捕获哪些变量来存,可能会对函数内的代码做扫描吧,把使用到的外部变量都存进来了吧

至于如何存储我也不知道,因为没想通,它是不是就是简单的将外部变量拷贝一份过来,如果是这样,外部变量值变化时,岂不是还要再拷贝一遍?

所以对于这道题,我也不知道这种说法对不对,因为关键取决于函数的内部属性 [[scope]] 是如何存的那些外部变量?

我说说我的个人理解啊,不知道对不对,但感觉有些说得通:

函数本身是个对象是不是,而 [[scope]] 只是它的一个内部属性,需要用来存储使用到的外部变量,那么它的属性值应该也还是对象类型,是吧。既然是用对象来存储,那其实就是在这个对象上添加一些跟外部变量同名的属性,然后拷贝过来存储,同时想办法解决修改的同步就好了,所以,并不是外部函数执行结束时,才将栈内变量拷贝到堆里,而是一开始,堆里就有一份栈内变量的副本了

Q:有人说闭包是会让内部函数持有它外部函数的词法环境,那么是持有所有外部函数的变量吗,还是只是有使用到的才捕获?

上面那题里也提到了,只会捕获使用到的变量,没有使用的变量不会存储

但有一个特性:一个函数里面定义的所有函数共享一个闭包,也就是捕获的是内部所有函数需要使用的变量的并集,而不是简单我这个函数没使用,我就不捕获,即使我不使用,但其他函数里使用了,我用样需要捕获,因为所有内部函数的 [[scope]] 属性其实存储的是同一个对象

内存分配

内存分配指的是如何对变量进行内存的分配存储,注意是变量,也只有对变量,也只有变量才需要存储

当全局代码执行时,其实会有一个全局执行环境入栈,这个栈叫执行环境栈,也可以通俗理解成函数调用栈,反正栈顶一直表示当前正在运行的代码环境

内存的分配行为,只有代码运行时才会进行,也就是说,只有当程序运行到变量赋值那行代码,才会进行一个内存分配

分配的时候,如果变量类型是基本类型,那就直接将基本类型值存储在栈中,如果是引用类型也就是对象的话,那么这个对象会被存在堆中,而栈里存的只是引用地址,也就是这个对象的堆内存地址

简单来说,所有变量标识符都会指向栈,但栈内存的是具体值,还是一个引用地址,取决于变量类型

当调用一个函数时,此时会有一个函数执行上下文入栈,之后,函数内代码执行中,对于变量赋值的内存分配行为也还是一样的处理

每当一个执行上下文出栈时,就会把该环境里存在栈上的所有变量标识符都清空掉

对于变量类型是函数对象时,会有特殊的处理,因为函数会有一个内部属性 [[scope]] 存储着函数定义时所在的词法环境的副本

闭包里的变量,之所以能够在函数执行结束后,仍旧可以使用,就是因为此时虽然函数执行上下文的栈被清空了,但函数的内部属性 [[scope]] 里还有一份副本,保存在堆中

这份副本并不会保存所有的外部变量,而是只捕获那些需要使用到的变量,需要使用到的是指,所有内部函数需要使用到的

垃圾回收机制

垃圾回收机制针对的是堆内存中的数据,因为栈内的数据清理是由执行上下文出栈时会顺带处理

垃圾回收机制其实有两个核心点:

  • 如何识别目标内存是否可被回收
  • 效率问题

现在的垃圾回收策略都是标记-清除法,简单说就是标记那些需要被清除的对象,一一清除掉

标记-清除策略的思路,用的是从根节点出发,目标对象是否可达来判定是否需要被清除,可达表示还需要使用,不能清除,不可达就认为是垃圾,需要回收,这就是简单的思路

但具体的做法,考虑到性能、效率等问题,设计成了新生代、老生代,以及解决内存碎片化所进行的移动处理

新生代:存储那些存活时间短的对象,所以分配给新生代的内存也比较小,因为经常换来换去,总会有空位空出来

老生代:存储那些存活时间长、常驻内存的对象,所以分配给新生代的内存也比较大,需要在新生代中经受次多次筛选,才会晋升到老生代

对于新生代的处理,通常默认大小是 32M/16M,对应 64/32 位系统。为了解决内存碎片化,将新生代划分成两个区域 from(正在使用中) 和 to(闲置中),每个新生成的对象都先放入 from 区,每次处理时,都会遍历 from 区的对象,检查对象是否可达,不可达直接清除,可达则将其放入 to 区,全部处理完毕后,to 区就空出来,from 区就按顺序重新存放了,这样就解决了内存碎片化,然后 from 和 to 身份交换,循环处理

当在新生代中经历了:

  • 经历一次 scavenge 回收(也就是 from 到 to 交换)
  • to (闲置)空间的内存占用超过 25%

当有以上两种情况任意一种出现时,就把 to 区的对象晋升到老生代

对于老生代的处理,就不再是 scavenge 算法了,而是利用标记-清除:第一步先把所有对象做上可清除标记,然后遍历一次,检测对象是否可达,可达的表示还需使用,则去掉标记,遍历结束后,把所有带有标记的都清理掉;然后再整理内存碎片即可。

大伙基本都清楚,不管什么语言,讲到内存模型时,总会说到这么经典的一句:

基本类型数据保存在栈中,对象类型保存在堆中

请叫我大苏 wechat
您的支持将鼓励我继续创作!