JavaScript。内存。架构和生命周期。
这篇文章的开头,我想引用一句改变了我对内存看法的名言。这句话改变了我对主流现代语言(那些具有自动内存释放机制,也就是垃圾回收机制的语言)内存生命周期的看法。
垃圾收集正在模拟一台拥有无限内存的计算机。——
Raymond Chen
这正是我们在 JavaScript 中对内存的理解。我们不……
确实,自从我不再写 C++ 代码后,我就忘了内存管理的事儿了。我懒得去管它。我干嘛要管呢?我的意思是,它就是管用。这里有个变量,那里有个,完全不用担心……现在内存泄漏已经不是什么问题了。大多数时候,你得费点劲儿才能创建一个……
但如果这个区域背后没有隐藏有趣的怪癖和特性,它就不是 JavaScript……
此外,我们将探索 JavaScript 内存架构、主要概念和组织方式,以及内存生命周期(从分配到释放)。
此外,我们还将探讨一些常见的内存泄漏以及如何避免它们。
记忆
在编程中,一切都需要空间。数字、字符串、对象、函数。即使在抽象的计算机科学算法领域,也有一个衡量空间复杂度的指标。
记忆不同
在 JavaScript 中(与许多其他语言类似),主要有两种类型的内存:栈 (Stack)和堆 (Heap) 。两者都由JavaScript 引擎管理,用于存储运行时数据。
区别在于速度和大小。堆 (Heap) 较大且较慢,而栈 (Stack) 较小且较快。
引擎如何知道该使用哪一个?经验法则是:如果引擎不确定大小,则使用堆 (Heap)。如果引擎可以预先计算大小,则使用栈 (Stack)。
所有原语,例如number
、boolean
、string
、Symbol
、和BigInt
,都会进入栈。引用也存储在那里,我们稍后会讨论引用。 剩余的内容最终会进入堆。这包括任意对象和函数。null
undefined
💡栈中的数据通常被称为静态数据,因为它的大小是固定的,不会改变,因此是在编译时分配的。
堆中的数据通常被称为动态数据,因为它的大小不可预测(并且在整个程序执行过程中可能会发生变化),并且在运行时动态分配。
ℹ️你听说过“提升”这个术语吗?
栈中的内存分配(又称静态内存分配)发生在代码(下一个词法作用域)执行之前。引用存储在栈中,因此它们在代码执行之前就已分配。因此,如果我们声明变量,它甚至在代码中实际声明之前就可用。尽管值不可用,undefined
因为它还没有值指向……
console.log(yolo); // undefined
var yolo = "hello!";
let
用、var
、声明的变量const
会被提升,尽管let
和const
不会返回undefined
。
参考
引用概念是 JavaScript 内存组织的重要支柱。它间接影响着大多数关键操作(例如赋值和等式)的运作方式。
然而,它常常被人们误解,从而偶尔导致一些意外和困惑。
想象一个大书架,里面有多个隔间。每个隔间都有一个标签,上面有一个唯一的编号。每次你往隔间里放东西时,你都会拿一张纸,写下隔间的编号以及里面存放物品的简短描述。
这就是引用工作原理的要点。简短的描述是变量名,架子号是内存地址。地址存储在变量中,而变量存储在栈中。架子上的实际对象是存储在堆中的对象,由变量引用……
每次我们使用赋值(=)运算符时,我们都不会分配值……我们正在创建一个指向存储该值的内存的指针。您的变量存储地址,该地址指向存储实际值的内存。
这里有一些个人观点...🤪
我认为我们使用的语言很重要。因此,我认为“赋值”和运算=
符
邪恶的这会造成误导,造成认知混乱和不必要的简化。我认为大量的 bug 就是由这种混乱造成的。
我个人更愿意更明确地说明正在发生的事情,并建议使用“指向”或“引用”之类的术语而不是“分配”,使用运算符->
而不是=
。
但我们拥有的都有了🤷
现在我们已经对内存组织有了一定的了解,接下来我们用一些例子来巩固一下。我们将从原始值开始,逐渐过渡到对象……
let answer = 42;
正如我们之前所想的,我们没有设置值,而是指向它......到目前为止相当简单,让我们让它变得更复杂一些......
let answer = 42;
let true_answer = answer;
answer = 43;
console.log(answer); // 43
console.log(true_answer); // 42
原理相同。首先answer
,和trueAnswer
指向存储值的同一个地址42
。一旦这样做,answer = 43
我们改变的不是值,而是指向的内存……
原始类型是不可变的。如果我们仔细讨论一下,就会发现这一点显而易见,甚至多余。如果我们尝试改变它42
(例如,1
对它进行加法运算),我们只会得到另一个数字,而这个数字并非42
……我们不会改变它42
(42
它仍然存在)……因此它是不可变的。
我们也无法扩展它。例如,它42.value = 22
无法工作,尽管如果它42
是一个对象,它就可以……
希望这一切都有意义哈哈😅
我们再举一个原语的例子……null
并且undefined
是原语。这是什么意思?它们的行为和所有原语一样……
const null1 = null;
const null2 = null;
console.log(null1 === null2); // true
let undefined1;
let undefined2;
console.log(undefined1 === undefined2); // true
现在我们明白了为什么两个值严格相等,指向同一个值。
有趣的事实
console.log(typeof null); // object
这不真实,null
也不是一个对象。这是一个无法修复的bug ……
让我们做最后一个关于原语的事情……
const a = true;
const b = false;
const c = true;
const d = false;
const e = true;
一切看上去都很熟悉。
现在我们来尝试一些新东西。对象。对象有所不同,它们代表着更复杂的树形结构🌳。而且与原始类型不同,对象是可变的。这个属性会产生一些有趣的效果。这正是运算符展现其邪恶之处的
地方😈。=
const catzilla = { name: "Catzilla", breed: "Bengal Cat" };
const peanut = catzilla;
peanut.name = "Peanut";
console.log(catzilla); // { name: "Peanut", breed: "Bengal Cat" }
console.log(peanut); // { name: "Peanut", breed: "Bengal Cat" }
可能不是我们想要的……
记住,=
实际上指向的是数据。我们这里只是路由指针。
幸运的是我们可以轻松修复它......
const catzilla = { name: "Catzilla", breed: "Bengal Cat" };
const peanut = { ...catzilla };
peanut.name = "Peanut";
console.log(catzilla); // { name: "Catzilla", breed: "Bengal Cat" }
console.log(peanut); // { name: "Peanut", breed: "Bengal Cat" }
借助...
扩展运算符,我们成功地克隆了catzilla
新地址中指向的内容,并使其peanut
指向该地址。这并非该运算符的初衷。但是(就像 JavaScript 中常见的情况一样),这种副作用被 JavaScript 社区广泛接受,被认为是一种执行浅克隆的方法。
随着物体变得越来越复杂,事情开始变得非常混乱......
const breed = {
name: "Bengal Cat",
origin: "United States",
color: { pattern: "spotted", name: "brown" },
};
const catzilla = { name: "Catzilla", breed: breed };
const peanut = { ...catzilla };
peanut.name = "Peanut";
peanut.breed.color.name = "marble";
console.log(catzilla);
/*
{
name: "Catzilla",
breed: {
name: "Bengal Cat",
origin: "United States,
color: {
pattern: "spotted",
name: "marble"
}
}
}
*/
console.log(peanut);
/*
{
name: "Peanut",
breed: {
name: "Bengal Cat",
origin: "United States,
color: {
pattern: "spotted",
name: "marble"
}
}
}
*/
又发生了……两只猫的颜色相同,尽管这不是我们的本意……
我们执行的只是顶层(树的第一层)的浅克隆,为了使其正常工作,我们需要执行所谓的深克隆。最简单的方法是执行以下操作……
// ...
const peanut = JSON.parse(JSON.stringify(catzilla));
// ...
虽然丑陋,但能完成任务。它强制引擎分配一块新的内存,并用对象数据填充。
可惜的是,JavaScript 并没有提供良好的开箱即用的克隆机制。因此,这是一种无需额外工具即可克隆对象的方法。
如果您追求更优雅、更高效的解决方案,我推荐使用类似underscore.js的工具。
好吧,这是一个卷曲的⚾...你能猜出为什么会发生这种情况吗?
console.log({} === {}); // false
惊讶吗?
让我们尝试重写一下这个例子……
const value1 = {};
const value2 = {};
console.log(value1 === value2); // false
这更有意义吗?
要完全理解它,我们需要了解等号运算符==
和严格等号===
运算符的工作原理,遗憾的是,这并不容易理解。不过,为了避免本文冗长,我们假设比较是通过变量的实际值进行的。正如我们现在所知,它是对象的地址,而不是值。因为我们指向的是两个不同的对象,它们位于不同的地址。值是不相等的……
垃圾收集
引用的概念(我们刚刚讨论过)是内存释放/清理(也就是垃圾回收)过程的基础。垃圾回收器可以通过引用判断哪些是“垃圾”,需要回收,哪些还不需要回收。
目前主要有两种算法。
一种是“新”算法:它的变体在所有现代浏览器中使用
;另一种是“旧”算法:由于其固有缺陷(我们将在后面讨论),如今它的变体已很少在任何地方使用。
新功能:标记与清除
原理在于找到不可达的对象……
不可达的对象是指任何无法通过从所谓的根 (root)开始遍历引用到达的对象。在浏览器世界中,根window
(root) 由对象 (又称全局作用域)表示。
📝顺便提一下,JavaScript 中的所有全局变量都不是悬在空中的,而是附加在window
对象的引用上……
垃圾收集器时不时地启动。并遵循以下阶段
- 启动阶段:一旦启动,它假定所有对象都是不可达的。
- 标记阶段:然后,从根节点(通过引用)开始实际的树遍历。沿途找到的每个对象都被标记为可到达。
- 清除阶段:遍历完成后,所有无法访问的对象都将被消除。
优化
标记-清除算法属于跟踪垃圾收集家族。该算法有一些专门针对该家族的优化(例如三色标记)。这些都是唾手可得的成果🍐。
尽管如此,大多数 JavaScript 引擎都会执行一些额外的优化,这些优化通常借鉴自其他垃圾收集语言。
一种经典的优化方法就是所谓的基于代的垃圾收集。
其原理基于一项观察:老旧对象不太可能被垃圾收集。他们通过多次垃圾收集证明了这一点。因此,从统计学上讲,我们可以假设这些对象将继续被使用。
有了这些知识,我们只需很少干扰老旧对象,就能显著缩短垃圾收集时间👴。
它的工作原理如下:每个对象都会被分配到某一代。所有对象都从零代开始。如果一个对象在垃圾回收中幸存下来,它就会向上移动到下一代。年轻代的垃圾回收比老年代的垃圾回收更频繁。
被分配到的老年代中幸存下来的对象越多,它被回收的可能性就越小。
最终,这种方法减少了对统计上“低回收概率”候选对象的遍历,并专注于那些统计上更有可能被回收的对象……
旧:引用计数
该算法最后在 IE 7 中使用,自 2012 年起已弃用。因此本节纯粹用于历史目的。
与标记-清除算法不同,该算法会尝试查找未引用的对象,而不是不可达的对象……
该算法不会尝试确定该对象是否仍然需要(在上例中,从根节点可到达)。相反,它只检查是否有任何对象引用了该对象。
这看起来可能没什么区别,但这种方法限制较少。因此,它存在一个重大缺陷。
重大缺陷
最大的缺陷是循环引用。两个对象可能无法访问,但只要它们相互引用,就不会被回收。
让我们看下面的例子...
function catdog() {
let cat = {};
let dog = {};
cat.dog = dog;
dog.cat = cat;
return "hello";
}
catdog();
上述代码(如果使用当前算法)会造成内存泄漏。因为分配给变量的内存cat
永远dog
不会被回收,即使它从未在外部作用域中使用过……🐱🐶
内存泄漏
🤔为什么内存泄漏仍然存在?
因为判断某块内存是否被使用的过程是一个所谓的“不可判定问题”。听起来很吓人,但这意味着没有好的编程方法可以让机器判断内存是否可以被安全释放。因此,只有人类才能对此做出真正完整的判断。
在探索旧的垃圾回收算法时,我们看到了一个内存泄漏的例子。内存泄漏似乎只是意外遗忘了对某个对象的引用。这个对象永远不会被垃圾回收,并且只要应用程序运行,它就会一直无用地占用内存。造成内存泄漏的方式有很多种。
既然我们已经了解了内存的分配和垃圾回收机制,我们可以看看几个最常见的例子。
全局变量
如今,使用全局变量是一种糟糕的做法。如果发生这种情况,通常是意外造成的。这个问题很容易被 linter 👮 发现。或者,也可以通过use strict
在文件开头添加 来防止这种情况发生。
泄漏就是这样发生的。
- 我们创建一个全局变量(它被自动引用
window
)。 - 它永远留在那里……
修复
不要使用全局变量。
它被认为是一种不好的做法,这是有原因的。所以避免这个问题的最好方法就是避免使用全局变量。
观察者或被遗忘的间隔计时器
这个更难追踪,一旦我们不需要计时器,我们就会忘记释放它们。
这次泄漏是这样的。
- 我们创建一个带有回调的间隔计时器,例如
setInterval(() => {}, 1000);
- 我们确保引用了外部范围的内容
- 我们引用的东西永远不会被垃圾收集
const memoryLeak = {
counter: 0,
massiveData: new Array(100).join('I am your memory leak!');
};
setInterval(() => memoryLeak.counter++, 1000);
memoryLeak
即使我们可能不再需要整个对象,该对象也永远不会被释放。
修复
防止这种情况发生的最佳方法是
// ...
const timerId = setInterval(() => memoryLeak.counter++, 1000);
// do stuff
clearInterval(timerId);
全局变量或分离 DOM 元素的伪装版本
又一个经典的例子。如果你正在使用 React 或 Angular 之类的代码,那就不用担心了。然而,这是一种丢失内存的有趣方式🧠……
它是全局变量内存泄漏的伪装版本。即使在今天,这种情况也经常发生,通常发生在script
标签之间。
这次泄漏是这样的。
- 我们在代码中引用任意 DOM 元素(例如通过调用
document.getElementById('i-will-leak')
) - 即使我们从 DOM 中删除该元素,它仍然挂在词法范围或全局范围内(例如通过调用
document.body.removeChild(document.getElementById('i-will-leak'))
)
<script>
var memoryLeak = document.getElementById('i-will-leak');
document.body.removeChild(memoryLeak);
</script>
永远memoryLeak
不会被垃圾收集,removeChild
这里非常具有误导性,它似乎会从各处删除元素,但它只针对 DOM 树执行此操作。
修复
修复方法与全局变量泄漏相同。不要使用全局变量 😀 相反,我们可以使用子词法作用域,例如函数
<script>
function doStuff() {
var memoryLeak = document.getElementById('i-will-leak');
document.body.removeChild(memoryLeak);
}();
</script>
这是一个可自执行的函数,它将创建一个本地词法范围,并且在它完成执行后,所有本地变量将被垃圾收集。
聚苯乙烯
如果您读过我之前写过的 JavaScript 运行时相关文章,您就会知道 JavaScript 运行时在不同浏览器之间存在差异。因此,不同浏览器的内存管理方式也可能不同。不过,如果不提及过去十年中出现的越来越多的共性,那就太不公平了。这确实减轻了我们的负担……
此外,鉴于 JavaScript 基础设施的不断发展,包括各种 linter、模块打包器以及成熟的 DOM 交互框架,内存泄漏问题已被降至最低。
但是……垃圾回收仍然被列为一个不可判定的问题,因此总有办法制造错误。了解 JavaScript 的内存组织方式以及引用的管理方式,或许能帮你节省大量的调试时间。
无论如何,希望你喜欢阅读并发现一些新的东西😀
文章来源:https://dev.to/vudodov/javascript-memory-architecture-and-lifecycle-ae9