【JavaScript】垃圾回收与内存管理(内存优化)

2023-03-14 09:41:49 浏览数 (1)

由于JavaScript借鉴了Java的内存管理方案,因而JavaScript与Java的垃圾回收策略是一样的。

1. 垃圾回收原理

Java和JavaScript都是是使用垃圾回收的语言,也就是说执行环境负责在代码执行时管理内存,通过自动内存分配管理实现内存分配和闲置资源回收。

基本思路很简单:确定哪个变量不再使用,然后释放它占用的内存。这个过程是周期性的,即垃圾回收程序每个一段时间(或者说在代码执行过程中某个预定的收集时间)就会自动运行。

垃圾回收是一个近似且不完美的方案,因为某块内存是否还有用,属于“不可判定的”问题,意味着靠算法无法解决。我们以函数正常生命周期为例,函数中的变量会在函数执行时存在,当函数执行完毕时,就不再需要哪些局部变量了,它占用的内存就可以释放掉,供后面的使用。但不是所有情况下都这么明显,垃圾回收程序必须跟踪记录哪个变量还会使用,以及哪个变量不会再使用,以便内存回收。如何标记未使用的变量,在浏览器发展史上有两种标记策略:标记清理、引用计数。

2. 标记清理(主要使用)

Java和JavaScript最常用的垃圾回收策略就是标记清理。当变量进入上下文,比如在函数内部声明一个变量,这个变量会被加上存在于上下文中的标记,当变量离开上下文时会被加上一个离开上下文的标记。

原理很简单:垃圾回收程序在运行的时候会标记内存中存储的所有变量。然后,它会将所有在上下文中的变量,以及被上下文中变量引用的变量的标记去掉。在此之后,在被添加上标记的变量就是待删除的了,原因 是任何在上下文中的变量都访问不到它们了。随后垃圾回收程序做一次内存清理,销毁带有标记的所有值并回收它们的内存。简单来说就是垃圾回收器会给所有变量加上标记,然后删除上下文中用到的变量的标记,剩下的没有标记的变量都会被删掉并回收内存

Java和JavaScript最常用的变量标记策略是基于词法作用域的静态标记策略 。也就是说,在JavaScript代码编写阶段,变量的作用域就已经确定了,不会受到代码执行时的上下文影响。这种策略也被称为词法作用域,因为变量的作用域是由代码中变量声明的位置决定的,而不是有代码执行时的上下文决定的。在JavaScript中,变量的作用域可以是全局作用域、函数作用域或块级作用域,但无论哪种作用域,都是在代码编写阶段就已经确定了。

何时清理呢?在不同的引擎中表现不一样,但总体思路就是当内存占用达到限制,就会自动回收垃圾变量(未使用的变量)。

离开作用域的值都会被标记为可回收,然后被垃圾回收器删除。

3. 引用计数(了解)

对于Java和JavaScript常用的变量标记策略都是标记-清理 策略,这里简单介绍以下引用计数的原理。

原理很简单:其思路是对每个值都记录它被使用的次数。声明变量并赋予它一个引用值时,这个值的引用数为1,如果同一个值又被赋值给另一个变量,那么引用数 1。类似的,如果保存对该值引用的变量被其他值覆盖了,那么引用数 -1。当一值个引用数为 0 时,就说明没办法再访问到这个值了,垃圾回收程序就会释放这个引用数为0的值所占用的内存空间。

引用计数存在一个最严重的问题就是循环引用,即对象A有一个指针指向对象B,而对象B也引用了对象A,比如:

代码语言:javascript复制
function problem(){
	let objA = new Object();
	let objB = new Object();

	objA.xxx = objB;
	objB.xxx = objA;
}

在这个例子中,objA和objB都通过各自的属性相互引用,意为着它们的引计数都是2。在标记清理策略下,这不是问题,因为在函数执行完毕后,这两个对象都不在作用域中。而在引用计数策略下,objA和objB在函数执行结束后依然存在,因为它们的引用数永远不会变成0.如果函数被多次调用,则会导致大量的内存永远得不到释放,为此引用计数就被弃用掉了,转用标记清理策略,事实上引用计数的问题还不于此,但这里就不作介绍了。

4. 内存管理

在使用垃圾回收的编程环境中,开发者无序关心内存管理。不过JavaScript运行在一个内存管理与垃圾回收都很特殊的环境。分配给浏览器的内存往往比分配给桌面软件的要少很多,分配给移动浏览器的就更少了。这更多是出自于安全的考虑,为了避免运行大量的JavaScript的网页耗尽系统内存,导致操作系统崩溃。这个内存分配不仅影响变量分配,也影响调用栈以及能同时在一个线程中执行的语句的数量。因而我们需要让内存占用保持在一个较小的值可任意通过以下方案来优化内存。

4.1 解除引用

将内存占用保持在一个较小的值可以让页面性能更好。优化内存占用的最佳手段就是保证在执行代码时只保存必要的数据,如果数据不再必要,那么把它设置为null,从而释放其引用。这也可以叫做解除引用。这个建议最适合全局变量和全局对象的属性(显示设置为null)。局部变量在超出作用域后会被自动解除引用。

不过要注意,解除对一个值的引用并不会自动导致相关内存被回收。解除引用的关键在于确保相关的值已经不再上下文里了,因此它在下次垃圾回收时会被回收。

4.2 通过const和let声明提升性能

ES6新增的这两个关键字不仅有助于改善代码风格,而且同样有助于垃圾回收过程。因为const和let都是以块(非函数)为作用域,所以相比于使用car,使用这两个新增的关键字会更早的让垃圾回收器介入,尽早回收该回收的内存。在块作用域比函数作用域更早终止的情况下,这就有可能发生。

4.3 隐藏类和删除操作

V8引擎在将解释后的JavaScript代码编译为实际的机器码时会利用“隐藏类”。如果你的代码非常注重性能,那么这一点对你可能很重要。(这里涉及V8引擎原理,不了解的可以看一下我的另一篇文章:V8引擎解析JavaScript代码原理)

运行期间,V8会给两个相同的实例创建一个隐藏类,让这两个对象来共享这个隐藏类以节省内存占用。比如:

代码语言:javascript复制
let a1 = new Article();
let a2 = new Article();

V8会在后台配置,让这两个类共享相同的隐藏类,因为这两个实例共享同一个相同的构造函数和原型。假设之后又添加了以下代码:

代码语言:javascript复制
a2.author = "CODER-V";

此时两个Article就会对应两个不同的隐藏类。根据这种操作频率和隐藏类的大小,这有可能会对性能产生明显的影响。当然解决方案就是避免JavaScript的“先创建再补充”式的动态属性赋值,并在构造器中一次性声明所有属性。这样就可以保证两个实例的一致性,从而带来潜在的性能提升。

不过要记住,使用delete关键字也会导致两个实例不再共享同一个隐藏类,比如:

代码语言:javascript复制
delete a1.author;

再代码结束后,即使两个实例共享了同一个构造函数,它们也不再共享同一个隐藏类。动态删除属性或添加属性都会导致一样的结果。最佳实践是把不想要的属性设置为null,这样可以保持隐藏类不变和继续共享,同时也能达到删除引用值供垃圾回收器回收的效果,比如

代码语言:javascript复制
a1.author = null;

4.4 内存泄漏

JavaScript中的内存泄漏大多是由不合理的引用导致的。意外声明全局变量是最常见也是最难以修复的内存泄漏问题,比如未经声明就是用的变量会被自动添加到全局上下文中(即作为window对象的属性存在,在严格模式下会报错),只要window对象不被清理,这些变量就不会被回收。解决这个问题也很简单,只需加上关键字声明即可,这样变量就会在函数执行完毕后离开作用域。

定时器也可能会悄悄的导致内存泄漏。如下面代码,定时器的回调函数通过闭包访问了外部变量:

代码语言:javascript复制
let name = "CODER-V";
setInterVal(
	()=>{
		console.log(name);
	},
100)

只要定时器一直在运行,回调函数中的name就会一直占用内存,导致垃圾回收器不能清除外部变量。

4.5 静态分配与对象池

为了提升JavaScript的性能,最后要考虑的一点往往就是压榨浏览器了。此时,一个关键的问题就是如何减少浏览器垃圾回收的次数。开发者无法直接控制什么时候开始收集垃圾,但是可以间接控制触发垃圾回收的条件。理论上,如果能够合理使用分配的内存,同时避免多余的垃圾回收,那就可以保住因内存释放而损失的性能。

浏览器决定何时运行垃圾回收器的一个标准就是对象更替的速度,如果很多对象被频繁的被初始化,然后又超出了作用域,就会频繁的调用垃圾回收器影响性能。那么如何才能让不被垃圾回收器盯上呢?

一种有效的策略就是使用对象池,在初始化的某一时刻可以创建一个对象池,用来管理一组可回收的对象。应用程序可以向这个对象池请求一个对象、设置其属性、使用它,然后在操作完成后再把他归还给对象池。由于没有发生对象初始化,垃圾回收探测就不会发现又对象更替,因此垃圾回收程序就不会频繁的运行。

如果对象池在对象不存在时创建新的,存在则复用存在的,那么这个实现本质上是一种贪婪算法,有单调增长但为静态的内存。这个对象池必须使用某种结构维护所有对象,数组是比较好的选择。不过,使用数组也要留意不要招致额外的垃圾回收,比如JavaScript数组的大小是动态可变的,当容量溢出时,会创建新的数组。要避免这种扩容操作,事先一定要想好这个数组有多大。

大多情况下,这都属于过早优化,除非你的程序被垃圾回收严重拖了后腿,否则不必考虑。

单调增长但为静态的内存:

  • 通常指的是程序在运行过程中需要占用的一段连续的内存空间,在程序运行前就已经确定了其大小,且在程序运行过程中不会发生变化。这种内存通常被称为静态内存,因为它的大小在程序运行前就已经确定了,与程序的运行状态无关,不会发生动态变化。
  • 例如,在C或C 程序中,可以使用静态变量或全局变量来分配静态内存。这些变量在程序运行前就已经分配了一段固定大小的内存空间,且在程序运行过程中不会发生变化。因此,这些变量所占用的内存空间被称为静态内存。
  • 需要注意的是,虽然静态内存的大小是固定的,但程序在运行过程中也可以动态地申请和释放内存空间,这些内存空间通常被称为动态内存。动态内存的大小可以在程序运行过程中动态变化,与静态内存不同。

0 人点赞