JavaScript红宝书第4章:变量、作用域与内存

简介: 变量、作用域与内存

变量


变量类型


变量一共有两种类型,分别是原始值和引用值。


原始值


它就是最简单的数据,而引用值就是由多个值组成的对象。


目前一共有7种基本原始值类型


undefined、Null、Boolean、Number、String、Symbol、BigInt


保存原始值的变量是按值访问的。因为操作的就是存储在变量的实际值。


引用值


引用值是保存在内存中的对象,Js不允许直接访问内存地址,所以不能直接操作对象所处的内存空间,在对object对象操作时,实际操作的是该对象的引用而非实际的对象本身。


所以它是按照引用访问的。


let obj={a:"1"}
obj.a=2
let str='1'
str.a=1
console.log(obj.a,str.a)//2 undefined


上述代码中,obj就是一个引用值,而str就是一个原始值。正常我们不可以对原始值增加属性和属性值,但是因为有ES5遗留问题,所以我们可以通过new一个String字符串的方式来新增一个属性值。

let str='1'
str.a=1
let strObj=new String()
strObj.a='1'
strObj.a=2
console.log(str.a,strObj.a)//undefined 2


new一个原始值,本质就是新建了一个包装对象,用typeof检测出来还是Object类型,但正常来说,原始值就是不允许加属性和属性值,new出来的是问题,而不是一个正常的现象,所以在ES6新出现的数据类型symbol中就不允许new Symbol。


复制值


原始值在互相复制的时候可以保证互不干扰,因为它们在复制的过程中相当于开辟了一个新的内存空间。


let a=1
let b=a
b=3
console.log(a,b)//1,3


如上述代码打印出来结果所示,两个变量本质它们在创建的时候,就是被开辟了一个内存空间。所以当它们修改的时候,不会对另一方造成影响。


引用值则不会,它们主要是以指针来指向堆内存的object变量和其中的属性,多次复制只会让多个变量对象指向同一个object堆,复制得本质是指针而非变量。那么就会出现,改变其中一个引用值,另一个引用值得属性也会发生改变。

let obj1={a:2}
let obj2=obj1
obj2.a=3
console.log(obj1.a,obj2.a)//3 3


这个就叫做浅拷贝,当我们用来拷贝,如何实现原始值复制得效果(深拷贝),我会再另一篇文章中深入讲解浅拷贝和深拷贝得问题以及解决方案。这里就不赘述了。可以通过JSON.parse(JSON.stringify(被引用值))得方法实现一个深拷贝。


传递参数

这里主要讲一个概念,就是局部变量和全局变量。还是举代码说明。

let count=1;
function demo(a){
  let count=a+1;
  return count;
}
let result=demo(count)
console.log(count,result)//1,2



在上述代码中,我们发现,有两个count,一个在function外面,一个在function里面,外面得count=1,里面得在调用函数得时候等于2,为什么最后打印得时候是1而不是2呢?


原因就是let在function定义得变量是一个块变量,而非全局变量,因为涉及到一个内存销毁的问题,我们在创建页面的同时会创建大量的变量,有些变量是某个函数甚至某个条件中需要用到的,那么在这个函数或条件开始的时候这个变量被创建,在函数结束的时候它就被销毁了,js中当我们没有用关键词直接定义变量并赋初值的情况下,那默认就是var变量,但是这个a=1和var a=1不同之处就在于,a=1是一个全局变量,这也是var的怪异特性之一。


下面举例说明。


function demo(){
  var a=1;
  b=2;
}
demo()
console.log(b);//2
console.log(a);//a is not defined


如果有大量的b=2这样定义的var变量,那么页面在创建这个变量之后是无法进行销毁的,那么就会出现内存冗余和内存泄漏的问题,所以说ES6新推出的let和const,目的就是想让开发者尽可能少的使用var变量,那么这就是传递参数比较重要的因素,就是考虑全局变量和局部变量的特性去针对性的编写代码。


判断类型


那么好,接下来我们讲解一下如何判断这个值是原始值还是引用值,进一步说,如何判断这个值的数据类型。比较常用的是typeof和instanceof两种方法。


typeof


前者主要用来判断简单还是复杂类型.


let str='1'
let num=2
let obj={}
let lll=setTimeout(()=>{
})
let isTrue=false
let nul=null
let und=undefined
let arr=[1,2]
console.log(typeof(str),typeof(num),typeof(obj),typeof(lll),typeof(isTrue),typeof(nul),typeof(und),typeof(arr))//string number object number boolean object undefined object


它会返回一个具体的值,比如str返回String,num返回number,arr返回object等,它在判断null类型时返回的是object,因为null类型的定义就是空指针对象,所以说它被判断成引用值,但本质它其实是一个基本数据类型,也就是原始值,另一种说法是它不严谨,另外判断数组对象和普通对象时候只能返回object,而不是返回更细致的Array和Object.而下面的instanceof方法主要是判断这个对象是否有原型链.用来检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上。


instanceof


语法格式:对象 instanceof 对象类型.


举例说明:


let str = '1'
let str1 = new String()
let dat = new Date()
let num = 2
let obj = {}
let isTrue = false
let nul = null
let und = undefined
let arr = [1, 2]
console.log(str instanceof String, num instanceof Number, obj instanceof Object, isTrue instanceof Boolean,
  nul instanceof Object,str1 instanceof String,dat instanceof Date,
  arr instanceof Array)//false false true false false true true true


在上述代码中,我们发现String变量在两种不同的定义方法中,有两种判断结果,这是因为第一种它没有原型就是单纯原始值,而第二种是通过new出来一个包装对象来定义String变量,所以它可以在原型链中找到prototype属性,那么它就是一种有原型链的原始值,那么日期变量也有相同的方法.同时我们发现instanceof在判断null时,返回的也是false,这说明它能简单区分一个变量是否是复杂数据类型,如果是则返回true,不是则返回false,但是它也有对应的缺陷,比如它不能区分包装对象的原始值和对象,它不把null和undefined作为一种数据类型可以放在右侧定义等等.


不常用的判断方法

constructor构造方法判断
let str = '1'
let str1 = new String()
let dat = new Date()
let num = 2
let obj = {}
let isTrue = false
let nul = null
let und = undefined
let arr = []
let fun=function(){}
let cla=class {}
console.log(str.constructor,str1.constructor,dat.constructor,num.constructor,obj.constructor,
isTrue.constructor,arr.constructor,fun.constructor,cla.constructor)//ƒ String() { [native code] } ƒ String() { [native code] } ƒ Date() { [native code] } ƒ Number() { [native code] } ƒ Object() { [native code] } ƒ Boolean() { [native code] } ƒ Array() { [native code] } ƒ Function() { [native code] } ƒ Function() { [native code] }


注:null, undefined 是无效的对象,所以没有 constructor ,这两种类型的数据需要通过其他方式来判断。

函数的 constructor 是不稳定的,这个主要体现在自定义对象上,当开发者重写 prototype 后,原有的 constructor 引用会丢失,constructor 会默认为 Object.


用 constructor 判断类型的限制过多且不准确,容易出错,少用,或者不用!


Object.getPrototypeOf()


这种方法返回值是调用对象的原型,可以通过返回值和要判断的数据类型的原型进行对比进行判断.


例:


let str = '1'
let str1 = new String()
let dat = new Date()
let num = 2
let obj = {}
let isTrue = false
let nul = null
let und = undefined
let arr = []
let fun = function() {}
let cla = class {}
console.log(Object.getPrototypeOf(obj) === Object.prototype) //true
console.log(Object.getPrototypeOf(arr) === Array.prototype); //true
console.log(Object.getPrototypeOf(dat) == Date.prototype); //true
console.log(Object.getPrototypeOf(str) === String.prototype); //true
console.log(Object.getPrototypeOf(num) === Number.prototype); //true
console.log(Object.getPrototypeOf(fun) === Function.prototype); //true
console.log(Object.getPrototypeOf(cla) === Function.prototype);//true


这个方法也无法判断null和undefined,因为它们两个没有原型.一个是空的另一个是未定义的,都没有原型.


最好用的判断数据类型的方法!toString.call


toString是Object.propetype上面的一个方法,它能判断基本所有非自定义的数据类型,返回结果是[Object 类型名]


例:


let str = '1'
let str1 = new String()
let dat = new Date()
let num = 2
let obj = {}
let isTrue = false
let nul = null
let und = undefined
let arr = []
let fun = function() {}
let cla = class {}
let gettype=target=>Object.prototype.toString.call(target)
console.log(gettype(str),gettype(str1),gettype(dat),gettype(num),gettype(obj)
,gettype(isTrue),gettype(nul),gettype(und),gettype(arr),gettype(fun),gettype(cla))
//[object String] [object String] [object Date] [object Number] [object Object] [object Boolean] [object Null] [object Undefined] [object Array] [object Function] [object Function]



小总结

1、typeof检测返回的是对应类型名称常用于检测基本数据类型,检测null和引用数据类型总是返回Object;


2、instanceof检测,返回的是布尔值,基本数据类型和引用数据类型分别是false和true,前提是不要使用new的方式创建基本数据类型.


3、constructor 返回的是构造函数,但是不可以检测null和undefined因为它俩没原型,且当调用对象原型被修改时,则只能返回默认的Object类型,慎用.


4、Object.getPrototypeof() 返回的是原型


5、 Object.prototype.toString.call() 检测 任何数据类型都可以检测,自定义数据类型除外.



作用域链


作用域是指一段函数所带来的。比如,function() {}大括号里面的内容就是作用域,当作用域被调用的时候,就是作用域里的方法和参数生成的时候,而调用结束,就是里面的参数和方法被释放的时候.同时还有一个上下文概念,上下文是指js是按序加载的,在调用变量或方法前必须要有这个变量的定义和方法的定义.才能成功调用使用,同时又有一个全局上下文的概念,全局是指window对象,所有通过var定义的全局变量也会放进window对象中,可以用window.属性名调用成功.with和trycatch可以增强作用域,但是不推荐使用with.因为涉及到数据泄露、性能问题以及严格模式下with被禁止使用.



垃圾回收


js对于垃圾回收最重要的就是一点:每隔一段时间回收一次使用完成的内存。类似于有个街道,每次用户用完之后会把垃圾都扔到街道上,每隔一段时间就会有人来清理一次地面上的垃圾。


例:

function demo(){
  let b=1;
  return b;
}
let a=demo()
console.log(a)//1


在上述代码中,demo函数在被调用后,先生成一个b块变量再返回b的值,在定义的时候被创建,在return 后被释放,释放的这个局部变量就类似于雪糕吃完剩下的皮,在调用结束后就扔掉了一个雪糕皮,js中每隔一段时间就会固定进行内存回收机制,把这个雪糕给清理掉。


上述说明的是较简单的内存回收过程,在有些时候,浏览器其实不知道这个内存是否还会被使用,所以还会又一个进一步的标记未被使用的变量,在js中,主要用到的是GC垃圾回收,主要用到的策略标记清理和引用计数方法。而js中也有一个垃圾回收策略叫做v8回收机制。


标记清理


js较常用的垃圾回收策略


主要核心就是三个阶段:


  1. 垃圾回收程序运行时,会把内存中所有变量打上标记。


  1. 把上下文中的以及在被上下文变量引用的变量取消标记,剩下的为待删除标记。


  1. 在一定时间后,清理还有标记的变量数据。

在代码执行阶段,为程序中所有的变量添加上一个二进制字符(二进制运算最快)并初始值置为0(默认全是垃圾),然后遍历所有的对象,被使用的变量标记置为1,在程序运行结束时回收掉所有标记为零的变量,回收结束之后将现存变量标记统一置为0,等待下一轮回收开启。

优点:标记清除算法思路清晰,实现比较简单。

缺点:由于系统分配的内存时间不同,回收的先后顺序也是不同的,这时就会导致剩余空闲空间并不是连续的,出现了内存碎片现象。

内存碎片化后,新分配空间在被分配的时候需要先计算是否有满足符合要求的空间,增加了计算负担,同时如果后续系统要分配的变量的占用空间较大,虽然系统总剩余内存满足需求,但是它是碎片化的,并不能连续的满足空间分配,就会出现分配失败问题。

所以虽然标记清除算法比较简单但是缺点也是很明显由于内存碎片的诞生导致的分配时间较长和空间浪费,所以只要解决掉内存碎片这个致命问题,这两个问题就会迎刃而解。



这时候 标记整理 (Mark-Compact)算法闪亮登场,他的清除逻辑和标记清除算法基本相似不过进行了优化,会在清除结束之后讲活着的空间进行整理向一端移动,同时清理掉内存的边界。


引用计数

某个值被引用的次数。

声明变量并赋初值时,被引用值=1,如果同一个值又被赋给另一个变量,那么引用+=1。那反过来说,如果这个变量,换值了,那么引用值-1,直到引用值=0,就说明没办法再访问到这个值了,就可以安全的进行回收了。

垃圾回收程序在下次运行的时候就会释放引用树为0的值的内存。举例:


let a=1;
let b=1;
a=2;b=2;



在上述代码中,先创建a=1;那么1这个值的引用次数+1,再创建b=1,那么1这个值的引用数再次+1,a=2 b=2时,就分别让值为1的引用次数-1 -1到0,而值为2的引用数+1 +1。当1这个值为0时,立即回收引用值为0的值。


优点:

  1. 实时回收,引用计数归零立即回收。


  1. 不会暂停执行栈,标记清除策略定时垃圾回收清楚时候会暂停程序执行,而引用计数实时回收不暂停程序执行。



缺点:


1.空间浪费,需要对引用数进行空间开辟,因为一个值可以无上限的被引用,那么引用无上限。所以占用空间也是无上限。


2.无法解决循环引用无法回收问题 ,举例:

function demo() {
  let Obj1 = new Object()
  let Obj2 = new Object()
  Obj2.b = Obj1
  Obj1.a = Obj2
}


JSON.decycle能解决这个问题。原理:当一个变量被赋予一个引用类型的值时,这个引用类型的值的引用计数加 1。


了解这个概念后,在上述代码中,在创建执行的时候obj1引用+1,那同时obj1赋值给obj2的属性时候,obj1引用再次+1,等于2,当我们尝试释放时,会将obj1=null,obj2=null,但是此时它们引用数会-1,但引用为1不为0所以并不会进行垃圾回收,但是这两个对象已经没有作用了,在函数外部也不可能使用到它门,所以就造成了内存泄漏。



v8回收

v8对GC的优化


之前GC的标记清除或引用标记,都需要检查内存中的所有对象,如果存在一些体积大,创建较早的内存检查相当于无用功,而新创建,体积小存活时间端的对象需要更加频繁多次检查,V8提出了新生代和老生代的概念,对于这两个部分分别采用不同的回收策略。


新生代

特征:存活时间较短的对象,通常只支持1~8M容量。回收频率较老生代快。


分为空闲区和使用区两部分,浏览器在内存申请时分配使用区空间,使用区快占满则进行垃圾回收,新生代的垃圾回收器会对使用区活动对象进行标记,标记后对活跃的对象赋值到空闲区并排序,然后垃圾清理,清理后使用区和空闲区转换,空闲变使用使用变空闲,循环往复。


当一个对象多次上述循环还没被清理,则认定生命周期较长,从新生代转到到老生代。


注:如果新生代的复制一个对象到空闲区,如果空闲区的使用空间超过25%之后这个对象会被立即复制到老生代,而25%的要求是为了保证空闲区和使用区翻转时对新对象分配空间操作不会被影响。


老生代


特征:生命周期较长,经过多次新生代垃圾回收还存在的对象。


相比新生代垃圾回收频率较低,且存储空间比新生代较大。回收算法为标记清除算法,在v8中解决了内存碎片问题,使用了标记整理算法进行空间优化提高了回收效率。


并行回收


JS是单线程语言,所以在进行GC回收时会阻塞js脚本导致系统停顿,回收结束时回复运行,又称为全停顿。


但是这样的话会存在极大的风险,如果GC的回收时间较长,就会导致系统停顿时间较长这事不可被接受的。所以V8引擎加入了并行回收的优化机制,在开启GC回收线程之后,会同时开启多个辅助线程进行处理,提高回收处理时间,虽然增加了一部分线程之间协调的时间,但是总时间比一个线程用时来讲大大缩短,避免系统卡顿时间过长。


增量标记


由于全停顿标记策略处理老生代回收时即使有并行处理优化但是也会消耗大量时间,所以又提出了增量标记策略来进行优化。


增量标记思想就是将一次GC标记过程进行拆分,一次执行一小部分,执行完毕后继续执行脚本,执行一段脚本后又继续执行刚刚拆份的GC标记任务,循环往复直至这次GC标记完成。


三色标记法(恢复和暂停)


GC标记最初是把活动变量标记为黑色,不活动的变量标记为白色,当GC标记过程结束之后,系统会回收掉所有的白色标记变量,但是这种非黑即白的中间应该还存在一个问题执行执行一段时间后不知道执行到了那里,不能进行暂停。所以v8又引入了一个灰色进行暂停和回复操作。


所以三色标记法可以渐进执行而不用每次执行都要全盘进行扫描整个内存空间,可以配合增量回收减少全停顿时间,提升体验。


写屏障


在一次完成GC标记暂停中,如果执行任务程序时内存存在的变量引用关系被改变了,这样会导致此次GC存在问题。所以提出了写屏障作为保护。强三色不变性(白色变量D被黑色变量B引用之后会被强制置灰保证程序运行正确性)。


惰性清理


在增量GC标记后下一步就是来真正回收内存空间,通过惰性清理来进行清除释放内存。惰性清理机制运行原理是在进行回收时如果内存足够就可以将这个回收清理时间稍微延迟一下,让JavaScript脚本脚本先执行,清理时也不会一下全部清理掉所有的垃圾,会根据按需进行清理直至所有垃圾都回收完毕,然后以及等待下一个GC标记阶段执行结束。


并发回收


虽然增量标记和惰性清理的出现让主线程停顿时间大大减少了,但是总体的停顿时间其实并未减少,如果真正细算起来甚至还增加了,应用程序的吞吐量也被降低,不过用户和浏览器的交互体验大大提升牺牲也是值得的。但是为了使得回收更加高效,又使用并发回收机制,它是在主线程在执行程序任务时,主动开启辅助线程进行GC回收/而主线程同时也可以自由执行而不会挂起。


总结


以上就是关于变量、作用域和内存的简单讲解。了解的越深入,才发现自己学的多么浅薄。



相关文章
|
1月前
|
Web App开发 监控 JavaScript
监控和分析 JavaScript 内存使用情况
【10月更文挑战第30天】通过使用上述的浏览器开发者工具、性能分析工具和内存泄漏检测工具,可以有效地监控和分析JavaScript内存使用情况,及时发现和解决内存泄漏、过度内存消耗等问题,从而提高JavaScript应用程序的性能和稳定性。在实际开发中,可以根据具体的需求和场景选择合适的工具和方法来进行内存监控和分析。
|
1月前
|
JavaScript 前端开发 Java
避免 JavaScript 中的内存泄漏
【10月更文挑战第30天】避免JavaScript中的内存泄漏问题需要开发者对变量引用、事件监听器管理、DOM元素操作以及异步操作等方面有深入的理解和注意。通过遵循良好的编程实践和及时清理不再使用的资源,可以有效地减少内存泄漏的风险,提高JavaScript应用程序的性能和稳定性。
|
1月前
|
JavaScript 前端开发
js的作用域作用域链
【10月更文挑战第29天】理解JavaScript的作用域和作用域链对于正确理解变量的访问和生命周期、避免变量命名冲突以及编写高质量的JavaScript代码都具有重要意义。在实际开发中,需要合理地利用作用域和作用域链来组织代码结构,提高代码的可读性和可维护性。
|
1月前
|
监控 JavaScript 算法
如何使用内存监控工具来定位和解决Node.js应用中的性能问题?
总之,利用内存监控工具结合代码分析和业务理解,能够逐步定位和解决 Node.js 应用中的性能问题,提高应用的运行效率和稳定性。需要耐心和细致地进行排查和优化,不断提升应用的性能表现。
181 77
|
1月前
|
监控 JavaScript
选择适合自己的Node.js内存监控工具
选择合适的内存监控工具是优化 Node.js 应用内存使用的重要一步,它可以帮助你更好地了解内存状况,及时发现问题并采取措施,提高应用的性能和稳定性。
117 76
|
1月前
|
监控 JavaScript 数据库连接
解读Node.js内存监控工具生成的报告
需要注意的是,不同的内存监控工具可能会有不同的报告格式和内容,具体的解读方法可能会有所差异。因此,在使用具体工具时,还需要参考其相关的文档和说明,以更好地理解和利用报告中的信息。通过深入解读内存监控报告,我们可以不断优化 Node.js 应用的内存使用,提高其性能和稳定性。
101 74
|
1月前
|
存储 缓存 JavaScript
如何优化Node.js应用的内存使用以提高性能?
通过以上多种方法的综合运用,可以有效地优化 Node.js 应用的内存使用,提高性能,提升用户体验。同时,不断关注内存管理的最新技术和最佳实践,持续改进应用的性能表现。
122 62
|
1月前
|
监控 JavaScript Java
Node.js中内存泄漏的检测方法
检测内存泄漏需要综合运用多种方法,并结合实际的应用场景和代码特点进行分析。及时发现和解决内存泄漏问题,可以提高应用的稳定性和性能,避免潜在的风险和故障。同时,不断学习和掌握内存管理的知识,也是有效预防内存泄漏的重要途径。
130 52
|
28天前
|
存储 缓存 监控
如何使用内存监控工具来优化 Node.js 应用的性能
需要注意的是,不同的内存监控工具可能具有不同的功能和特点,在使用时需要根据具体工具的要求和操作指南进行正确使用和分析。
68 31
|
1月前
|
自然语言处理 JavaScript 前端开发
[JS]作用域的“生产者”——词法作用域
本文介绍了JavaScript中的作用域模型与作用域,包括词法作用域和动态作用域的区别,以及全局作用域、函数作用域和块级作用域的特点。通过具体示例详细解析了变量提升、块级作用域中的暂时性死区等问题,并探讨了如何在循环中使用`var`和`let`的不同效果。最后,介绍了两种可以“欺骗”词法作用域的方法:`eval(str)`和`with(obj)`。文章结合了多位博主的总结,帮助读者更快速、便捷地掌握这些知识点。
36 2
[JS]作用域的“生产者”——词法作用域