JavaScript 对象变量内存分配规则及其优化方法
JavaScript 是一种动态类型的编程语言,它会在运行时自动为变量分配内存,并在不需要时自动释放内存。这个过程称为垃圾回收(Garbage Collection),它可以简化开发者的工作,但也会带来一些潜在的问题和挑战。本文将介绍 JavaScript 中对象变量的内存分配规则,以及垃圾回收的原理和算法。我们将了解如何区分基本类型和对象类型,如何判断对象是否可达,以及如何避免内存泄漏和优化内存使用。
基本类型和对象类型
JavaScript 中的变量可以分为两种类型:基本类型(Primitive Type)和对象类型(Object Type)。基本类型包括以下六种:
数字(Number)
字符串(String)
布尔值(Boolean)
空值(Null)
未定义(Undefined)
符号(Symbol)
对象类型则包括以下几种:
普通对象(Object)
数组(Array)
函数(Function)
日期(Date)
正则表达式(RegExp)
Map、Set、WeakMap、WeakSet 等
基本类型和对象类型在内存分配上有一个重要的区别:基本类型的值存储在栈内存中,而对象类型的值存储在堆内存中。栈内存是一种后进先出(LIFO)的数据结构,它有着固定的大小和快速的访问速度,但也有着有限的空间。堆内存是一种无序的数据结构,它有着灵活的大小和动态的分配方式,但
也有着较慢的访问速度和复杂的管理方式。
当我们声明一个基本类型的变量时,JavaScript 会在栈内存中为该变量分配一个固定大小的空间,并将变量的值直接存储在该空间中。例如:
var n =123; // 在栈内存中为 n 分配空间,并存储 123
var s ="azerty"; // 在栈内存中为 s 分配空间,并存储 "azerty"
当我们声明一个对象类型的变量时,JavaScript 会同时在栈内存和堆内存中为该变量分配空间。首先,在栈内存中为该变量分配一个固定大小的空间,并将该空间中存储一个指向堆内存中实际对象的地址。然后,在堆内存中为该对象分配一个动态大小的空间,并将对象的属性和方法存储在该空间中。例如:
var o = { a:1, b:null }; // 在栈内存中为 o 分配空间,并存储一个指向堆内存中 { a: 1, b: null } 的地址
var a = [1, null, "abra"]; // 在栈内存中为 a 分配空间,并存储一个指向堆内存中 [1, null, "abra"] 的地址
由于基本类型的值是直接存储在栈内存中的,所以我们访问或修改基本类型变量时,是按值操作的。也就是说,我们操作的是变量的实际值,而不是变量的引用。例如:
var x =10; // 在栈内存中为 x 分配空间,并存储 10
var y = x; // 在栈内存中为 y 分配空间,并存储 10
x =20; // 修改 x 的值为 20
console.log(x); // 输出 20
console.log(y); // 输出 10,y 的值不受 x 的影响
由于对象类型的值是存储在堆内存中的,所以我们访问或修改对象类型变量时,是按引用操作的。也就是说,我们操作的是变量的地址,而不是变量的实际值。例如:
var o1 = { a:1, b:null }; // 在栈内存中为 o1 分配空间,并存储一个指向堆内存中 { a: 1, b: null } 的地址
var o2 = o1; // 在栈内存中为 o2 分配空间,并存储 o1 的地址
o1.a=2; // 修改 o1.a 的值为 2
console.log(o1.a); // 输出 2
console.log(o2.a); // 输出 2,o2.a 的值受 o1.a 的影响
对象的可达性
JavaScript 中的垃圾回收机制是基于对象的可达性(Reachability)来判断对象是否需要回收的。一个对象如果可以通过某种方式访问到,那么它就是可达的,反之,它就是不可达的。一个不可达的对象就意味着它不再被使用,它占用的内存就可以被释放。
JavaScript 中有一个特殊的对象,叫做全局对象(Global Object)。在浏览器环境中,全局对象就是 window 对象,在 Node.js 环境中,全局对象就是 global 对象。全局对象是 JavaScript 程序运行时的最顶层的对象,它包含了一些预定义的属性和方法,例如 Math、Date、console 等。全局对象本身是永远可达的,它也可以作为其他对象可达性判断的起点。
当我们在全局作用域中声明一个变量时,该变量实际上就成为了全局对象的一个属性。例如:
var n =123; // 在栈内存中为 n 分配空间,并存储 123
console.log(window.n); // 输出 123,n 是 window 的一个属性
当我们在函数作用域中声明一个变量时,该变量实际上就成为了函数对象的一个属性。函数对象也是一种特殊的对象,它除了有普通对象的属性和方法外,还有一个Scope属性,用来保存函数执行时的作用域链。例如:
function f() {
var m =456; // 在栈内存中为 m 分配空间,并存储 456
console.log(f.m); // 输出 undefined,m 不是 f 的一个属性
}
f(); // 调用 f 函数
console.log(f.[[Scope]]); // 输出 f 函数的作用域链,包含了 m 变量
由于函数对象也是一种对象类型,所以它也遵循对象类型变量的内存分配规则。也就是说,在栈内存中存储函数对象的地址,在堆内存中存储函数对象的属性和方法。当我们调用一个函数时,JavaScript 引擎会创建一个执行上下文(Execution Context),用来保存函数执行时的相关信息,例如 this 指向、参数列表、变量对象等。执行上下文也会被分配在堆内存中,并通过作用域链与其他执行上下文相连。例如:
function f(a) {
var b = a +1;
function g(c) {
var d = c + b +1;
console.log(d);
}
g(b);
}
f(1); // 调用 f 函数
在上面的代码中,当我们调用 f 函数时,JavaScript 引擎会在堆内存中创建一个 f 的执行上下文,并将其压入执行上下文栈中。
f 的执行上下文包含了以下信息:
this 指向:指向全局对象 window
参数列表:a 的值为 1
变量对象:包含了 b 和 g 两个变量,b 的值为 2,g 的值为一个指向堆内存中 g 函数对象的地址
作用域链:包含了 f 的变量对象和全局对象
当我们在 f 函数中调用 g 函数时,JavaScript 引擎会在堆内存中创建一个 g 的执行上下文,并将其压入执行上下文栈中。g 的执行上下文包含了以下信息:
this 指向:指向全局对象 window
参数列表:c 的值为 2
变量对象:包含了 d 一个变量,d 的值为 4
作用域链:包含了 g 的变量对象、f 的变量对象和全局对象
当 g 函数执行完毕后,JavaScript 引擎会将 g 的执行上下文从执行上下文栈中弹出,并释放其占用的内存。同理,当 f 函数执行完毕后,JavaScript 引擎也会将 f 的执行上下文从执行上下文栈中弹出,并释放其占用的内存。
通过这个例子,我们可以看到,JavaScript 中的对象可达性是通过一系列的引用链来判断的。只要一
个对象可以从全局对象或者当前执行上下文中通过某种方式访问到,那么它就是可达的。如果一个对象没有任何引用指向它,或者它的所有引用都被删除或者覆盖了,那么它就是不可达的。例如:
var o = { a:1, b:null }; // 在栈内存中为 o 分配空间,并存储一个指向堆内存中 { a: 1, b: null } 的地址javascript全局数组
o =null; // 将 o 的值修改为 null,原来的地址被覆盖了
// 此时,堆内存中的 { a: 1, b: null } 对象没有任何引用指向它,它就是不可达的,可以被垃圾回收器回收
垃圾回收的原理和算法
JavaScript 中的垃圾回收器(Garbage Collector)是一种自动运行的程序,它负责监控和管理内存的使用情况,并定期释放不可达的对象占用的内存。垃圾回收器的运行时机和频率是由 JavaScript 引擎决定的,开发者无法直接控制或干预。但是,开发者可以通过了解垃圾回收器的原理和算法,来编写更高效和优化的代码。
垃圾回收器的基本工作流程是这样的:
遍历所有可达的对象,并将它们标记为活动对象(Active Object)
清除所有未被标记的对象,并将它们占用的内存释放
对剩余的活动对象进行整理和优化,以减少内存碎片(Memory Fragmentation)
垃圾回收器实现这个工作流程的具体算法有多种,不同的 JavaScript 引擎可能采用不同的算法或者组合多种算法。这里我们介绍两种常见且经典的算法:标记清除(Mark and Sweep)算法和引用计数(Reference Counting)算法。
标记清除算法
标记清除算法是一种基于对象可达性的垃圾回收算法,它的核心思想是从全局对象和当前执行上下文作为根节点,遍历所有可达的对象,并将它们标记为活动对象,然后清除所有未被标记的对象,并将它们占用的内存释放。例如:
var o1 = { a:1, b:null }; // 在栈内存中为 o1 分配空间,并存储一个指向堆内存中 { a: 1, b: null } 的地址
var o2 = { c:2, d: o1 }; // 在栈内存中为 o2 分配空间,并存储一个指向堆内存中 { c: 2, d: o1 } 的地址
var o3 = { e:3, f: o2 }; // 在栈内存中为 o3 分配空间,并存储一个指向堆内存中 { e: 3, f: o2 } 的地址
var o4 = { g:4, h:null }; // 在栈内存中为 o4 分配空间,并存储一个指向堆内存中 { g: 4, h: null } 的地址
o4 =null; // 将 o4 的值修改为 null,原来的地址被覆盖了
// 此时,垃圾回收器开始运行,它会从全局对象 window 作为根节点,遍历所有可达的对象,并将它们标记为活动对象
// 堆内存中的 { a: 1, b: null }、{ c: 2, d: o1 }、{ e: 3, f: o2 } 对象都是可达的,因为它们可以通过 window.o1、window.o2、window.o3 的引用链访问到,所以它们都被标记为活动对象
// 堆内存中的 { g: 4, h: null } 对象是不可达的,因为它没有任何引用指向它,所以它没有被标记为活动对象
// 垃圾回收器会清除所有未被标记的对象,并将它们占用的内存释放
// 此时,堆内存中的 { g: 4, h: null } 对象被清除了,它占用的内存被释放了
标记清除算法的优点是可以有效地识别和回收不可达的对象,避免了内存泄漏(Memory Leak)的问题。内存泄漏是指一些不再使用的对象占用的内存没有被及时释放,导致程序运行缓慢或者崩溃的现象。例如:
function f() {
var o = { a:1, b:null }; // 在栈内存中为 o 分配空间,并存储一个指向堆内存中 { a: 1, b: null } 的地址
return function() {
console.log(o.a); // 访问 o.a 的值
};
}
var g =f(); // 在栈内存中为 g 分配空间,并存储一个指向堆内存中 f 返回的函数对象的地址
g(); // 调用 g 函数,输出 1
g =null; // 将 g 的值修改为 null,原来的地址被覆盖了
// 此时,堆内存中的 f 返回的函数对象和 { a: 1, b: null } 对象都是不可达的,因为它们没有任何引用指向它们,所以它们可以被垃圾回收器回收
标记清除算法的缺点是需要遍历所有可达的对象,这个过程可能会消耗较多的时间和资源,影响应用户的性能和体验。为了解决这个问题,一些 JavaScript 引擎采用了增量标记(Incremental Marking)
和延迟清除(Lazy Sweeping)的技术,将标记和
清除的过程分成多个小步骤,交替执行,以减少每次垃圾回收的时间和开销。
另一个标记清除算法的缺点是可能会导致内存碎片(Memory Fragmentation)的问题。内存碎片是指由于频繁地分配和释放内存,导致内存空间被分割成许多不连续的小块,这些小块可能无法满足新的内存请求,从而造成内存浪费和效率降低的现象。例如:
var o1 = { a:1, b:null }; // 在堆内存中为 o1 分配一个大小为 16 字节的空间,并存储 { a: 1, b: null }
var o2 = { c:2, d: o1 }; // 在堆内存中为 o2 分配一个大小为 16 字节的空间,并存储 { c: 2, d: o1 }
var o3 = { e:3, f: o2 }; // 在堆内存中为 o3 分配一个大小为 16 字节的空间,并存储 { e: 3, f: o2 }
o1 =null; // 将 o1 的值修改为 null,原来的地址被覆盖了
o3 =null; // 将 o3 的值修改为 null,原来的地址被覆盖了
// 此时,堆内存中的 { a: 1, b: null } 和 { e: 3, f: o2 } 对象都是不可达的,可以被垃圾回收器回收
// 垃圾回收器会清除这两个对象,并将它们占用的内存释放
// 此时,堆内存中会出现两个大小为 16 字节的空闲空间,但是它们不是连续的,而是被 { c: 2, d: o1 } 对象隔开了
// 如果我们现在要创建一个大小为 32 字节的对象,那么这两个空闲空间就无法满足需求,只能再分配一个新的空间,这就造成了内存碎片
为了解决这个问题,一些 JavaScript 引擎采用了压缩(Compaction)的技术,将活动对象移动到一起,消除空闲空间之间的间隙,从而减少内存碎片。但是,这个过程也会消耗一定的时间和资源,并且可能会改变对象的地址,影响程序的执行。
引用计数算法
引用计数算法是一种基于对象引用数量的垃圾回收算法,它的核心思想是为每个对象维护一个引用计数器(Reference Counter),记录该对象被引用的次数。当一个对象被创建时,其引用计数器初始化为 0。当一个对象被引用时,其引用计数器加 1。当一个对象被取消引用时,其引用计数器减 1。当一个对象的引用计数器变为 0 时,说明该对象没有任何引用指向它,它就是不可达的,可以被垃圾回收器回收。例如:
var o1 = { a:1, b:null }; // 在堆内存中为 o1 分配一个大小为 16 字节的空间,并存储 { a: 1, b: null },其引用计数器初始化为 0
o1 =new Object(); // 将 o1 的值修改为一个新创建的对象,并存储一个指向堆内存中该对象的地址,其引用计数器初始化为 0 // 此时,原来的 { a: 1, b: null } 对象的引用计数器减 1,变为 0,说明该对象没有任何引用指向它,它就是不可达的,可以被垃圾回收器回收
// 新创建的对象的引用计数器加 1,变为 1,说明该对象有一个引用指向它,它就是可达的,不会被垃圾回收器回收
引用计数算法的优点是可以实时地监控和回收不可达的对象,避免了内存泄漏的问题。而且,由于不需要遍历所有可达的对象,也不需要移动活动对象,所以也避免了标记清除算法的缺点。
引用计数算法的缺点是需要为每个对象维护一个引用计数器,这会增加内存的开销和管理的复杂度。而且,引用计数算法无法处理循环引用(Circular Reference)的情况。循环引用是指两个或多个对象相互引用,形成一个闭环,导致它们的引用计数器永远不为 0,即使它们已经不可达了。例如:
var o1 = { a:1, b:null }; // 在堆内存中为 o1 分配一个大小为 16 字节的空间,并存储 { a: 1, b: null },其引用计数器初始化为 0
var o2 = { c:2, d:null }; // 在堆内存中为 o2 分配一个大小为 16 字节的空间,并存储 { c: 2, d: null },其引用计数器初始化为 0
o1.b= o2; // 将 o1.b 的值修改为 o2,并存储一个指向堆内存中 { c: 2, d: null } 的地址
o2.d= o1; // 将 o2.d 的值修改为 o1,并存储一个指向堆内存中 { a: 1, b: null } 的地址
// 此时,{ a: 1, b: null } 和 { c: 2, d: null } 对象形成了一个循环引用,它们的引用计数器都加 1,变为 1
o1 =null; // 将 o1 的值修改为 null,原来的地址被覆盖了
o2 =null; // 将 o2 的值修改为 null,原来的地址被覆盖了
// 此时,{ a: 1, b: null } 和 { c: 2, d: null } 对象都是不可达的,因为它们没有任何外部引用指向它们
// 但是,它们的引用计数器都减 1,变为 0,并不是 0,所以垃圾回收器无法识别和回收它们,导致内存泄漏
为了解决这个问题,一些 JavaScript 引擎采用了弱引用(Weak Reference)和弱集合(Weak Set、Weak Map)等技术,来避免或者减少循环引用的产生。弱引用是一种特殊的引用,它不会增加对象的引用计数器,也不会阻止对象被垃圾回收器回收。弱集合是一种特殊的集合,它只能存储弱引用的对象,并且会自动删除被垃圾回收器回收的对象。例如:
var o1 = { a:1, b:null }; // 在堆内存中为 o1 分配一个大小为 16 字节的空间,并存储 { a: 1, b: null },其引用计数器初始化为 0
var o2 = { c:2, d:null }; // 在堆内存中为 o2 分配一个大小为 16 字节的空间,并存储 { c: 2, d: null },其引用计数器初始化为 0
var ws =new WeakSet(); // 创建一个弱集合 ws
ws.add(o1); // 将 o1 添加到 ws 中,这是一个弱引用,不会增加 o1 的引用计数器
ws.add(o2); // 将 o2 添加到 ws 中,这是一个弱引用,不会增加 o2 的引用计数器
o1.b= o2; // 将 o1.b 的值修改为 o2,并存储一个指向堆内存中 { c: 2, d: null } 的地址,这是一个强引用,会增加 o2 的引用计数器
o2.d= o1; // 将 o2.d 的值修改为 o1,并存储一个指向堆内存中 { a: 1, b: null } 的地址,这是一个强引用,会增加 o1 的引用计数器
// 此时,{ a: 1, b: null } 和 { c: 2, d: null } 对象形成了一个循环引用,它们的引用计数器都加 1,变为 1
o1 =null; // 将 o1 的值修改为 null,原来的地址被覆盖了
o2 =null; // 将 o2 的值修改为 null,原来的地址被覆盖了
// 此时,{ a: 1, b: null } 和 { c: 2, d: null } 对象都是不可达的,因为它们没有任何外部引用指向它们
// 但是,由于它们在 ws 中有弱引用,所以垃圾回收器可以识别和回收它们,不会导致内存泄漏
// 垃圾回收器会清除这两个对象,并将它们占用的内存释放
// ws 中的弱引用也会自动删除,避免了循环引用的问题
内存泄漏和优化
虽然 JavaScript 中有垃圾回收机制来自动管理内存的使用和释放,但是开发者仍然需要注意一些可能导致内存泄漏或者浪费的情况,并采取一些措施来优化内存的使用。以下是一些常见的内存泄漏和优化的方法和建议:
避免使用全局变量或者长期占用全局变量。全局变量是永远可达的,它们占用的内存不会被垃圾回收器回收。如果必须使用全局变量,那么在不需要时应该及时将其值设为 null 或者 undefined,以减少内存的占用。
避免使用闭包或者长期占用闭包。闭包是指一个函数可以访问其外部函数的变量和参数的特性。闭包可以实现一些高级的功能和效果,但是也会导致内存泄漏。因为闭包会保持对外部函数变量对象的引用,即使外部函数已经执行完毕,它们占用的内存也不会被垃圾回收器回收。如果必须使用闭包,那么在不需要时应该及时删除对外部函数变量对象的引用,以减少内存的占用。
避免使用循环引用或者长期占用循环引用。循环引用是指两个或多个对象相互引用,形成一个闭环,导致它们的引用计数器永远不为 0,即使它们已经不可达了。循环引用会导致内存泄漏,因为垃圾回收器无法识别和回收它们。如果必须使用循环引用,那么在不需要时应该及时删除或者断开循环引用,以减少内存的占用。
使用弱引用和弱集合来避免或者减少循环引用的产生。弱引用是一种特殊的引用,它不会增加对象的引用计数器,也不会阻止对象被垃圾回收器回收。弱集合是一种特殊的集合,它只能存储弱引用的对象,并且会自动删除被垃圾回收器回收的对象。使用弱引用和弱集合可以避免或者减少循环引用的产生,从而避免或者减少内存泄漏。
使用缓存或者池化技术来复用已经分配的内存空间。缓存是指将一些经常使用或者重要的数据或者对象存储在内存中,以提高访问的速度和效率。池化是指将一些相同类型或者大小的数据或者对象存储在一个集合中,以便于分配和回收。使用缓存或者池化技术可以复用已经分配的内存空间,从而避免或者减少频繁地分配和释放内存,提高内存的利用率和性能。
使用工具或者方法来监控和分析内存的使用情况。有一些工具或者方法可以帮助开发者监控和分析内存的使用情况,例如浏览器的开发者工具、Node.js 的 Usage() 方法、JavaScript 的 对象等。使用这些工具或者方法可以帮助开发者发现和定位内存泄漏或者浪费的问题,并采取相应的优化措施。
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论