前言
在现代 web 开发中,JavaScript 作为一种广泛使用的编程语言,其内存机制对开发者的编程效率和程序性能至关重要。通过理解 JavaScript 内存管理的原理,不仅有助于优化应用程序的性能,还能避免常见的内存泄漏和资源浪费问题,下面呢我们来简单的认识一下js内存机制。
1. 语言类型
在了解js的内存机制之前,我们先来了解一下语言的类型这样有助于我们后面的理解。程序员之间的交流方式大部分都是通过代码来完成的,而代码就是程序员的语言,比如c、c++、Java等而这些语言呢都有各自不同的特点,下面通过几段代码来展示一下不同语言的区别。
先来看一段js的代码:
let a = 1
a = 'hello'
a = true
a = undefined
a = null
a = Symbol(1)
a = 123n
a = []
a = {}
a = function () {}
在js这门语言当中,我们定义变量的时候并不需要自己为变量加上类型,V8引擎会自动帮我们识别类型,而这种在运行的过程中检查数据的类型的语言我们通常称之为动态语言。
下面我们来看一段大家可能已经有点忘记的C语言的代码:
#include <stdbool.h>
#include <stdio.h>
int main()
{
int a = 1;
char* b = "hello";
bool c = false;
c = a; // 隐式类型转换
}
我们对比上面的js代码可以发现,在c语言当中,我们在定义变量的时候需要我们去手动的定义变量的类型,而这种在使用前就需要确定其变量的数据类型的语言我们称之为静态语言
除了这两个之外我们还有两个概念分别是弱类型语言和强类型语言,而什么是弱类型语言什么是强类型语言呢?我们通过观察上面c语言的代码,发现在最后面有个c = a; // 隐式类型转换
,而这种支持隐式类型转换的语言就是弱类型语言,反之不支持隐式类型转换的则是强类型语言(例如Java)。
语言类型
- 在使用前就需要确定其变量的数据类型 —— 静态语言
- 在运行的过程中检查数据的类型 —— 动态语言
- 支持隐式类型转换的语言 —— 弱类型语言
- 不支持隐式类型转换的语言 —— 强类型语言
在了解完了语言类型之后,我们来送大家一个小bug
。
在js这门语言中我们如果想检查一个变量的类型通常会使用
typeof
来实现,当变量定义为null
的时候大家可能猜测输出的是null,实则不然它输出的是object
。而这与typeof的执行机制有关了,typeof
会先将变量转换为二进制,然后根据二进制来判断变量类型。object类型的变量转换为二进制前三位都是0,而null转化为二进制所有位数都是0,这就刚好符合object类型,而这是一个小bug,大家在平常写代码的时候记得考虑一下即可。
2. 内存机制
在js的世界中,它的内存空间主要是由三个部分所组成:代码空间、栈空间、堆空间。而这三个空间分别用来储存不同类型的变量或者函数等。代码空间顾名思义是用来储存代码的,下面我们先来看一段代码,然后再告诉大家栈空间和堆空间分别是用来储存什么的。
function foo() {
var a = 1
var b = a
var c = {name: '牛哥'}
var d = c
}
foo()
大家可以根据之前的文章js的执行机制(忘记的可以先回顾一下:js的执行机制)可以先自己画一下调用栈中的函数调用以及变量储存,下面我们来看看实际的调用栈储存的是什么:
在之前js的运行机制中我们只讲到基础类型和let、const定义的变量分别存放在变量环境和词法环境中,根据上图可以看到旁边有个名字叫堆的存储空间,而之前没讲到的引用类型正是存放在堆中。引用类型在堆中的存放和数组中的元素存放差不多,可以看成键值对的样子,每个地址用来储存不同的数据。
在了解完了堆的一些基础认识后,我们来看看代码是如何执行的:我们直接从foo部分来看,在编译完成后,foo中四个变量都是un
,然后我们执行foo,a、b这个大家肯定都知道,那么当执行到var c = {name: '牛哥'}
的时候,c会被赋值成堆中的一个地址,可以用来访问该地址中存放的数据,就类似于c语言中的指针一样。然后var d = c
会将d的值同样赋值成一个地址,由于c
已经被赋值成了#001
,那么d
也会被赋值成#001
用来储存地址以此用来访问数据。
tips:原始类型的赋值是值的赋值,引用类型的赋值是引用地址的赋值
在看完了上面代码的运行过程之后,我们可以知道根绝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);
在结果出来之后,大家可能会有些疑惑,为什么p1不是庆玲呢?下面我们来看看根据js运行机制调用栈中的过程:
根据调用栈中的运行过程我们可以看到
person
首先被赋值成了#001
,然后随着fn的执行person中的age:18
被修改成了age:19
,从而导致堆中#001的地址中的age被修改成了19,然后person又被赋值成了一个新对象,而新对象在堆中存放的地址是#002
,此时person存放的是地址#002,所以最后p1输出的是age被修改之后#001的值,而p2则是获得了函数返回的person = #002
。
小测验
在了解完了js内存机制堆中的数据是如何赋值以及修改之后,下面我们来检测一下自己对堆、调用栈和闭包的掌握,下面来看一段代码看看输出的是什么,并且画一下调用栈和堆:
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());
下面我们来看一下在调用栈和堆中数据的存放以及闭包中数据的变化:
下面我们来简单解析一下上面的过程
首先我们对全局进行编译,发现了变量bar和函数foo,将其分别定义为un和函数,然后执行代码,首先进行foo的调用,将foo赋值成具体函数体。
执行foo,先对foo进行编译,将其中var
定义的变量myname
和innerBar
放入变量环境中定义为un
,将let
和const
定义的变量test1
和test2
放入词法环境中定义为un
。然后执行函数foo将变量myname,test1和test2分别进行赋值,在对innerBar
进行赋值的时候要注意,是对其赋值一个地址#001
,而这个地址是在堆中用来存放setName
和getName
两个函数的,然后将innerBar返回给bar,再将foo弹出栈进行销毁。
在将foo进行销毁的时候,由于innerBar中的两个函数会对变量
myname
和test1
进行调用,根据闭包的的定义从而会产生一个闭包用来储存这两个变量,方便后面函数对其的调用。
在调用完函数foo后,此时bar = #001
,接下来我们对bar中函数setName
进行调用,先将函数setName压入调用栈内,然后执行setName。setName是将myname的值修改成传入的参数name的值,此时我们传入了'陈总',所以我们将foo闭包中的myname的值修改为'陈总',然后函数执行完毕将setName弹出销毁。
然后我们对bar中的函数getName
进行调用,先将getName压入栈中进行编译,然后执行函数体内的内容,发现其要找到变量myname和test1,而这两个变量储存在foo的闭包中。然后将闭包中的myname和test1取出并且赋值,然后打印test1返回myname,执行完毕后将getName弹出销毁。
最后再打印由函数getName返回的myname,此时test1 = 1,myname = '陈总'
。
3. 为什么栈的空间设置很小
在经过前面对堆的学习之后,我们发现在堆中可以存放很多很大且复杂的数据,那为什么栈中不可以呢?
因为如果栈设计的很大的话,那么栈中函数的上下文切换效率(例如作用域链,函数中的outer)就会大大降低
在js官方进行设定的时候,把栈的空间定义的很小这并不是说很小气,而是为了提高执行效率,因为大家想想,如果把栈设计的很大可以存放很多数据时,如果你有个函数里面嵌套了很多个函数,在进行上下文切换的时候,是不是要耗费很长的时间,此时还会产生很长的一条作用域链,这样大大降低了运行效率。
上面讲的可能有点复杂,下面我们来举个例子,我们平常裤子都有口袋是吧,如果把口袋的深度设计到裤脚,那么我们如果就是拿个钥匙,那不是得一直往下伸,那会非常的麻烦。但是我们把口袋深度设置的浅一点的话,要拿东西不就是非常的方便吗。
最后感谢各位的观看,喜欢的话点个三连吧