Memory management: V8 GC

在这里我们会通过多个部分来揭开内存管理背后的概念,并更深入地解释现代编程语言中的内存管理。希望本文能够让你深入了解这些语言在内存管理方面所发生的情况。

了解内存管理还有助于我们编写性能更高的代码,因为无论该语言使用什么自动内存管理技术,我们编写代码的方式也会对内存管理产生影响。

本文在我对所引用的一些文章内容的基础上添加了自己的理解,大家可以在阅读本文后去阅读原文,链接会在文章底部贴出,相信你会有更多不一样的收获。

Introduction to Memory management

内存管理是控制和协调软件应用程序访问计算机内存的方式的过程。

What is it ?

当应用在计算机上的目标操作系统上运行时,它需要访问计算机 RAM(随机存取存储器)以:

  1. 加载自己需要执行的字节码;
  2. 存储所执行的程序使用的数据值和数据结构;
  3. 加载程序执行所需的任何运行时系统;

应用程序在内存的使用上,除了用于加载字节码的内存空间外,还使用两个内存区域:堆栈内存和堆内存。

Stack

堆栈 (我们常说的栈) 用于静态内存分配,顾名思义:它是后进先出(LIFO)堆栈(可以将其视为一堆盒子):

  1. 由于这种性质,从堆栈存储和检索数据的过程非常快,因为不需要查找,只需从堆栈的最顶层块存储和检索数据即可;
  2. 但这意味着存储在堆栈上的任何数据都必须是有限的和静态的(数据的大小在编译时已知);
  3. 这是函数的执行数据作为堆栈帧存储的位置(因此这是实际的执行堆栈)。每个帧都是一个空间块,存储该功能所需的数据。例如,每次函数声明一个新变量时,它都会被“推”到堆栈中最顶层的块上。然后,每次函数退出时,最顶层的块都会被清除,因此该函数压入堆栈的所有变量都会被清除。由于此处存储的数据的静态性质,这些可以在编译时确定;
  4. 堆栈的内存管理由操作系统完成。存储在堆栈上的典型数据是局部变量(原始值、原始值常量)、指针和函数帧。这里也是通常导致堆栈溢出错误的地方,因为与堆相比,堆栈的大小是有限的(对于大多数语言来说,堆栈上可以存储的值的大小是有限制的)。

Memory management: V8 GC

堆栈在 JavaScript 中的使用,通常是堆中对象、数据存储的引用。

Heap

堆用于动态内存分配,与堆栈不同,程序需要使用指针查找堆中的数据(可以将其视为一个大型多级库)。

  1. 执行速率要比堆栈慢,因为查找数据的过程更复杂,但它可以存储比堆栈更多的数据;
  2. 意味着可以在这里存储具有动态大小的数据;
  3. 堆是由应用程序的线程进行共享;
  4. 由于堆具有动态特性,因此管理起来比较麻烦,这也是大多数内存管理问题产生的原因,而这正是语言的自动内存管理解决方案发挥作用的地方;
  5. 堆上存储的典型数据包括全局变量、对象等引用类型、字符串、map和其他复杂的数据结构;
  6. 如果应用程序试图使用超过已分配堆的内存,就会出现内存不足的错误(尽管还有许多其他因素在起作用,如 GC、压缩);
  7. 通常,堆上可以存储的值的大小是没有限制的。当然,分配给应用程序的内存量是有限的。

Why is it important ?

与硬盘驱动器不同,RAM不是无限的。如果一个程序在不释放内存的情况下继续消耗内存,最终它将耗尽内存并自行崩溃,甚至更糟的是:操作系统崩溃。

因此,应用不能随心所欲地持续使用 RAM,因为它会导致其他程序和进程内存不足。

因此,大多数编程语言都提供了进行自动内存管理的方法,而不是让软件开发人员弄清楚这一点。当我们谈论内存管理时,我们主要谈论的是管理堆内存。

Different approaches?

由于现代编程语言不想给最终开发人员带来管理其应用程序内存的负担,因此大多数编程语言都设计了一种进行自动内存管理的方法。一些较旧的语言仍然需要手动内存处理,但许多语言确实提供了巧妙的方法来做到这一点。

同时,有些语言使用多种内存管理方法,有些甚至让开发人员选择最适合他/她的方法( C++ )。这些方法可分为以下几类:

手动内存管理

默认情况下,该语言不会为您管理内存,而是由您为您创建的对象分配和释放内存。例如,C 和 C++。它们提供 mallocrealloccalloc 和 free 方法来管理内存,由开发人员分配和释放堆程序中的内存并有效地利用指针来管理内存。这并不适合所有人。

GC

通过释放未使用的内存分配来自动管理堆内存。 GC 是现代语言中最常见的内存管理之一,该进程通常以一定的时间间隔运行,因此可能会导致称为暂停时间的微小开销。

JVM(Java/Scala/Groovy/Kotlin)、JavaScript、C#、Golang、OCaml 和 Ruby 是默认使用垃圾收集进行内存管理的一些语言。

Memory management: V8 GC

  1. 标记和清除 GC:也称为 Tracing GC。它通常是一个两阶段算法,首先将仍然被引用的对象标记为 “alive”,然后在下一阶段释放不活动对象的内存。例如,JVM、C#、Ruby、JavaScript 和 Golang 都采用这种方法。在 JVM 中,有不同的 GC 算法可供选择,像 V8 这样的 JavaScript 引擎则使用标记和清除 GC 以及引用计数 GC 。这种 GC 也可作为外部库用于 C 和 C++;
  2. 引用计数 GC:在这种方法中,每个对象都会获得一个引用计数,该计数随着对其引用的更改而增加或减少,并且当计数变为零时完成垃圾收集。引用计数 GC 并不是很受欢迎,我们知道它是无法处理循环引用的。例如,PHP、Perl 和 Python 使用这种类型的 GC 并通过解决方法来克服循环引用。这种类型的 GC 也可以为 C++ 启用;

RALL: Resource Acquisition is Initialization

这种类型的内存管理中,对象的内存分配与其生命周期相关,即从构造到销毁。它是在 C++ 中引入的,也被 Ada 和 Rust 使用。

ARC: Automatic Rference Coounting

类似于引用计数 GC,但不是以特定时间间隔运行运行时进程,而是在编译时以及当对象引用变为零它作为执行的一部分自动清除,无需任何程序暂停。同样的,它也无法处理循环引用,并依赖开发人员使用某些关键字来处理它。

它是 Clang 编译器的一项功能,并为 Objective C 和 Swift 提供 ARC。

Ownership

它将 RAII 与所有权模型结合起来,它基于对象所有权的概念而不是引用计数。在Ownership GC中,每个对象都有一个所有者,当所有者离开作用域或者手动释放所有权时,对象就会被回收(无论它是在堆栈内存还是堆内存中)。

Memory management: V8 GC

这种机制可以避免循环引用和内存泄漏问题。并没发现有任何其他语言使用这种确切的机制,虽然在 Rust 中使用了它。

V8 内存管理

由于JavaScript是一种解释性语言,因此它需要一个引擎来解释和执行代码。 V8 引擎解释 JavaScript 并将其编译为本机机器代码。 V8 用 C++ 编写,可以嵌入到任何 C++ 应用程序中。

V8 内存结构

首先我们来看看V8引擎的内存结构是怎样的。由于 JavaScript 是单线程的,V8 会为每个 JavaScript content 提供一个进程,因此如果你使用了 processper woker,它将为每个工作线程生成一个新的 V8 进程。

正在运行的程序会由 V8 进程中分配的一些内存来表示,通常称为:常驻集。进一步可以分为不同的部分,如下所示:

Memory management: V8 GC

堆内存

堆内存是 V8 存储对象或动态数据的地方。是最大的内存区域块,同时也是垃圾回收(GC)发生的地方。整个堆内存不进行垃圾回收,只有 Young 和 Old space 由垃圾回收管理。堆进一步分为以下几部分:

  1. New Space:New Space 或新生代是新数据对象活动的地方,这些数据对象大多数都是临时的。新生代内存分配的空间很小,该内存空间由 “Scavenger(Minor GC” 管理,下午会介绍。可以使用 --min_semi_space_size(Initial) 和 --max_semi_space_size(Max) V8 标志来控制 New Space 的大小;

  2. Old Space: Old Space或老生代是在“新生代”中存活了两个 GC 周期的对象被移动到的地方。由 Major GC (Mark-Sweep & Mark-Compact)管理,我们将在后面讨论它。可以使用 -- initialold _ space _ size (Initial)和 -- Max _ old _ space _ size (Max) V8标志来控制旧空间的大小。这个空间分为两部分:

    • Old pointer space: 存放经历 GC 依旧存活的指针对象;
    • Old data space: 存放经历 GC 依旧存活的数据对象;
  3. Large object sapce: 大于其他空间大小限制的对象所在的空间。每个对象都有自己的[mmap](https://en.wikipedia.org/wiki/Mmap)'d内存区域。垃圾回收器永远不会移动大对象;

  4. Code-space: 即时 (JIT) 编译器存储已编译代码块的位置。这是唯一具有可执行内存的空间(尽管 Codes 可能分配在“大对象空间”中,但它们依旧是可执行的);

  5. Cell space, property cell space, and map space: 这些空间分别包含 CellsPropertyCells 和 Maps。每个空间都包含大小相同的对象,并且对它们指向的对象类型有一些限制,这简化了收集。

Memory management: V8 GC

每个空间都由一组页面组成。页是从操作系统(或在 Windows 上)分配的连续内存块。除大对象空间外,每个页面的大小为 1MB。

Stack 是堆栈内存区域,每个 V8 进程有一个堆栈。这是存储静态数据的地方,包括方法/函数框架、原始值和对象指针。可以使用 --stack_size V8 标志设置堆栈内存限制。

V8 内存使用率:栈与堆

我们已经清楚了内存是怎样组成的,接下来我们来看在执行程序时如何使用内存中最重要的部分。

class Employee {
  constructor(name, salary, sales) {
    this.name = name;
    this.salary = salary;
    this.sales = sales;
  }
}

const BONUS_PERCENTAGE = 10;

function getBonusPercentage(salary) {
  const percentage = (salary * BONUS_PERCENTAGE) / 100;
  return percentage;
}

function findEmployeeBonus(salary, noOfSales) {
  const bonusPercentage = getBonusPercentage(salary);
  const bonus = bonusPercentage * noOfSales;
  return bonus;
}

let john = new Employee("John", 5000, 5);
john.bonus = findEmployeeBonus(john.salary, john.sales);
console.log(john.bonus);

如这里查看程序执行堆栈使用方式所见:

  1. Global scope 保存在 Stack’s Global frame 中;
  2. 函数的每一调用都会以 frame-block 的方式添加到执行堆栈中;
  3. 所有的局部变量都会保存到堆栈中的函数 frame-block 中;
  4. 原始值类型,会直接存储到堆栈中;
  5. 所有对象数据类型都在堆上创建,通过指针的方式在栈中被引用;
  6. 当当前函数调用的函数被推到执行堆栈顶端时,函数返回将被从执行堆栈中删除;
  7. 一旦主进程结束,堆上的对象将不存在来自栈的引用指针,成为一个独立的个体;
  8. 除非数据是通过明确复制的方式存在,否则其他对象中的所有对象引用都是通过指针来完成的。

如此所见,堆栈是由操作系统而不是 V8 本身自动管理的。

因此,我们通常不会太担心堆栈。另一方面,堆并不是由操作系统自动管理的,由于它是 V8 内存结构中最大的内存空间且保存着动态数据,内存的占用会因为一些因素导致呈指数级增长,导致我们的程序随着时间的推移而耗尽内存。随着时间的推移,内存也会变得支离破碎,从而降低应用程序的运行速度。这也是垃圾回收的作用所在。

区分堆上的指针和数据对于垃圾回收非常重要,V8 针对其使用了 “标记指针 “方法 — 在这种方法中,会在每个字的末尾保留一个位,以指示它是指针还是数据。这种方法需要有限的编译器支持,但相对来说实现起来非常简单,而且效率相当高(大家可以在 yt 自行搜索)。

V8 内存管理:垃圾回收

现在我们已经知道 V8 是如何分配内存的,接下来我们来看它是如何自动管理堆内存的,这对应用程序的性能非常重要。

当程序试图在堆上分配的内存超过可自由使用的内存时(取决于 V8 falg 的设置),我们就会遇到内存不足的错误。堆管理不正确也会导致内存泄漏。

V8 通过垃圾回收来管理堆内存。简单来说,它会释放独立对象(即不再直接或间接从堆栈中引用的对象)使用的内存,为新对象的创建腾出空间。

Orinoco 是 V8 GC 项目的代号,它利用并行、增量和并发技术进行垃圾回收,释放主线程。

V8 中的垃圾回收器负责回收未使用的内存,供 V8 进程重新使用。

V8 垃圾回收器是分代式的,意味着它根据对象的存活时间将内存分为不同的代,以便更有效地进行垃圾回收。(堆中的对象按其生存周期分组,并在不同阶段清除)。V8 的垃圾回收有两个阶段和三种不同的算法:

Minor GC (Scaventer)

这种类型的 GC 可以保持新生代内存空间的紧凑和整洁。对象在新生代内存空间中分配。在 “new space” 中分配对象的成本很低:每当我们要为新对象保留空间时,都会递增一个分配指针。当分配指针到达 “new space” 的尽头时,就会触发一次 minor GC。这个过程也被称为 Scavenger,它实现了 Cheney 算法。它频繁发生,使用并行辅助线程,速度非常快。

我们来看一下 minor GC 流程:

新生代内存空间分为两个大小相等的 semi-space:to-space 和 from-space。大多数分配都在 from-space 中进行(某些类型的对象除外,如可执行代码,它们总是在 old-space 中分配)。当 from-space 填满时,就会触发 minor GC。

点击 here,查看内存如何分配,以下各步骤会基于这里的导航分享:

  1. 假设开始时 “from-space” 中已经有对象(01 至 06 块标记为已用内存);
  2. 进程会创建一个新 object(07);
  3. V8 尝试从 from-space 获取所需的内存,但那里没有空闲空间来容纳新的对象,因此 V8 触发了 minor GC;
  4. Minor GC 会从堆栈指针(GC roots)开始递归遍历 “from-space” 中的对象图,以查找已使用或存活(已使用内存)的对象。这些对象会被移动到 “to-space” 中。这些对象所引用的任何对象也会被移动到 “to-space”中,并更新它们的指针。如此反复,直到扫描完 “from-space “中的所有对象。扫描结束后,”待扫描空间 “会自动压缩,以减少碎片;
  5. minor GC 现在清空了 “from-space”,这里剩余的任何对象都是垃圾对象,minor GC 交换 “to-space “和 “from-space”,现在所有对象都在 “from-space “中,而 “to-space” 是空的;
  6. 在 “from-space” 中为新对象分配内存,假设时间已经过去了一段时间,”从空间 “上现在有了更多的对象(07 至 09 块标记为已用内存);
  7. 应用程序创建一个新对象(10),V8 尝试从 “from-space” 中获取所需的内存,但那里没有空闲空间来容纳新的的对象,因此 V8 触发了第二次 minor GC;
  8. 重复上述过程,任何在第二次 minor GC 中存活的对象都会被移到 “Old space”。首次存活的对象会被移到 “to-space”,剩余的垃圾会从 “from-space” 中清除;
  9. Minor GC 交换 “to space” 和 “from space”,现在所有对象都在 “from-space “中,而 “to-space “是空的,最后在 “from-space” 中为新对象分配内存。

现在,我们看到了 minor GC 如何从新生代手中回收空间,并使其保持紧凑。这是一个 “srop-the-world” 的过程,但它非常快速高效,大多数时候都可以忽略不计。

由于该过程不会扫描 “old space” 中的对象以获取 “new space” 中的任何引用,因此它使用的是从 “old space” 到 “new space” 的所有指针的寄存器。这个寄存器由一个称为 write barriers 的过程记录到存储缓冲区中。

Major GC

Major GC 可以保持 “old generation space” 的紧凑和整洁。当 V8基于一个动态计算的限制决定没有足够的 “old space” 时,就会触发这个问题,它会经历 minor GC 周期来填充。

Scavenget algorithm 非常适合数据量较小的情况,但对于大型堆(如 old space)来说并不实用,它会产生内存开销,因此 major GC 采用 Mark-Sweep-Compact 算法。它使用三色(白-灰-黑)标记系统。So,major GC 是一个分三步走的过程,第三步的执行取决于碎片启发式。

Memory management: V8 GC

  1. Marking:第一步,两种算法通用,垃圾回收器识别哪些对象在使用中,哪些不在使用中。正在使用或可从 GC root(堆栈指针)递归到达的对象会被标记为 “活跃对象”。从技术上讲,这是对堆的深度优先搜索;
  2. Sweeping:垃圾回收器会遍历堆,并记下任何未标记为 “活跃对象” 的内存地址。现在,该空间在空闲列表中被标记为空闲,可用于存储其他对象;
  3. Sompacting:清扫后,如果有需要,所有活跃的对象将被移动到一起。这将减少碎片,并提高为较新对象分配内存的性能。

这种类型的 GC 也被称为 stop-the-world GC,因为它们会在执行 GC 的过程中引入暂停时间。为了避免这种情况,V8 使用了以下技术:

  1. Incremental GC: 以多个增量步骤完成,而不是一个步骤;
  2. Concurrent marking: 在不影响 JavaScript 主线程的情况下使用多个辅助线程并发完成的。Write barriers (在上文有提及) 用于跟踪 JavaScript 在辅助线程并发标记时创建的对象之间的新引用;
  3. Concurrent sweeping/compacting: Sweeping and compacting 在辅助线程中同时进行,不会影响 JavaScript 主线程;
  4. lazy sweeping: 指延迟删除页面中的垃圾,直到需要内存时才删除。

接下来让我们来看下 GC 的主要流程:

  1. 假设许多 minor GC 循环已经过去,old space 几乎已满,V8 决定触发一次 “major GC”。
  2. major GC 从堆栈指针开始递归遍历对象图,在 old space 中将使用的对象标记为存活对象(已用内存),将剩余对象标记为垃圾对象(独立对象)。这需要使用多个并发辅助线程来完成,每个辅助线程跟踪一个指针。当然这不会影响 JS 主线程。
  3. 当并发标记完成或达到内存限制时,GC 会使用主线程执行标记最终化步骤。这会带来一小段暂停时间。
  4. major GC 现在会使用并发扫描线程将所有垃圾对象的内存标记为空闲内存。还会触发并行压缩任务,将相关的内存块移动到同一页面,以避免碎片化。在这些步骤中,指针会被更新。

避免 Node.js 中的内存泄漏

内存泄漏是每个开发人员最终都必须面对的问题。它们在大多数语言中都很常见,即使该语言会自动为您管理内存。内存泄漏可能会导致应用程序速度减慢、崩溃、高延迟等问题。

JavaScript 中的内存管理

要了解内存泄漏,我们首先需要了解 NodeJS 中的内存是如何管理的。

我们通过上文知道,V8通过垃圾回收来管理堆内存。简而言之,它释放独立对象(即不再直接或间接从堆栈引用的对象)使用的内存,为新对象的创建腾出空间。

V8 中的垃圾回收器负责回收未使用的内存以供 V8 进程重用。

什么是内存泄漏

简单来说,内存泄漏只不过是堆上的独立内存块,不再被应用程序使用,并且垃圾回收器尚未将其返回给操作系统。

所以实际上,它是一个无用的内存块。随着时间的推移,此类块的积累可能会导致应用程序没有足够的内存来使用,甚至您的操作系统没有足够的内存来分配,从而导致应用程序甚至操作系统的速度减慢和/或崩溃。

Js 内存泄漏的原因

V8 中的垃圾收集等自动内存管理旨在避免此类内存泄漏,例如,循环引用不再是问题,但由于堆中不需要的引用仍然可能发生,并且可能由不同原因引起。

下面介绍了一些最常见的原因:

  1. Global variables: 由于JavaScript中的全局变量是由根节点(窗口或全局this)引用的,因此它们在应用程序的整个生命周期中永远不会被垃圾回收,并且只要应用程序正在运行就会占用内存。这适用于全局变量及其所有子变量引用的任何对象。从根引用大量对象图可能会导致内存泄漏。
  2. Multiple references: 当同一对象被多个对象引用时,如果其中一个引用悬空,可能会导致内存泄漏。
  3. Closures: JavaScript 闭包具有记忆周围上下文的超酷特性。当闭包持有堆中一个大型对象的引用时,只要闭包在使用,它就会将该对象保留在内存中。这就意味着,持有此类引用的闭包很容易因使用不当而导致内存泄漏。
  4. Timers & Events: 当大量对象引用保留在其回调中而没有正确处理时,使用 setTimeout、setInterval、观察者和事件侦听器可能会导致内存泄漏。

避免内存泄漏

在我们了解了导致内存泄漏的原因,我们要如何避免它们以及确保高效内存使用。

监控内存泄漏

可以通过 AppSignal 附带的堆内存使用反馈机制。

可以在任何图表中的任何阈值上设置警报。同样的,我们可以设置一个当堆大小变化很大时,通过 Slack 或电子邮件发出警告。

减少全局变量的使用

由于全局变量永远不会被垃圾回收,因此最好确保不要过度使用它们。以下是确保这一点的一些方法。

避免意外的全局变量

虽然下面的各种情况在现代开发中已经很少遇见,且完备的工程化配置杜绝了以下错误的出现,但是我们在这里依旧做为案例来一一举例。

当我们为未声明的变量赋值时,JavaScript 会在默认模式下自动将其提升为全局变量。另一种情况是可能将变量分配给 this 而导致的泄漏。

// This will be hoisted as a global variable
function hello() {
  foo = "Message";
}

// This will also become a global variable as global functions have
// global `this` as the contextual `this` in non strict mode
function hello() {
  this.foo = "Message";
}

为了避免此类意外情况,我们应该始终在 JS 文件顶部使用 'use strict'; 注释以严格模式编写 JavaScript。

当我们使用 ES 模块或转译器(如 TypeScript 或 Babel)时,严格模式会自动启用。在 NodeJS 的最新版本中,也可以通过在运行 node 命令时传递 --use_strict 标志来全局启用严格模式。

"use strict";

// This will not be hoisted as global variable
function hello() {
  foo = "Message"; // will throw runtime error
}

// This will not become global variable as global functions
// have their own `this` in strict mode
function hello() {
  this.foo = "Message";
}

当在使用箭头函数时,还需要注意不要创建意外的全局变量,对于这种情况,严格模式无法捕获到这个错误。当然我们可以使用 ESLint 中的 no-invalid-this 规则来避免此类情况。如果你的项目中没有使用 ESLint,那么需要确保不要从全局箭头函数分配 this 。

// This will also become a global variable as arrow functions
// do not have a contextual `this` and instead use a lexical `this`
const hello = () => {
    this.foo = 'Message";
}

最后,应该没有人会使用 bind 或 call 方法将全局 this 绑定到任何函数上。

谨慎使用 Global scope

通常情况下,要避免使用全局作用域并尽可能避免使用全局变量。

  1. 尽可能不要使用 global scope。相反,应该在函数内部使用局部作用域,此时这些函数将被垃圾收集并释放内存。如果由于某些限制而必须使用全局变量,也要在不需要使用时将其值设置为 null
  2. 只对常量、缓存和可重用单例(一种设计模式)使用全局变量。不因为为了避免传递值而使用全局变量。为了在函数和类之间共享数据,最好的是将值作为参数或对象属性传递。
  3. 不在全局范围内存储大对象。如果必须存储,要确保在不需要使用时将其销毁。对于缓存对象,设置一个处理程序来偶尔清理它们,并且切忌让这类对象无限期地增长。

有效使用 Stack

尽可能多地使用堆栈变量有助于提高内存效率和性能,通过前文我们知道堆栈访问比堆访问快得多。这也确保了我们不会因为意外而造成内存泄漏。

在实际应用中,我们通常不可避免地使用了大量对象和动态数据。但我们可以通过一些方式来利用堆栈。

  1. 避免从堆栈变量引用堆对象。另外将未使用的变量置空。
  2. 解构和使用对象或数组所需的字段,而不是将整个对象/数组传递给函数、闭包、计时器和事件处理程序。这样可以避免在闭包内保留对对象的引用。传递的字段可能大部分是原始值类型,这类型值将会保存在堆栈中。
function outer() {
    const obj = {
        foo: 1,
        bar: "hello",
    };

    const closure = () {
        const { foo } = obj;
        myFunc(foo);
    }
}

function myFunc(foo) {}

有效地使用 heap

在实际应用程序中,我们都不可能避免使用堆内存,同样地我们可以通过利用一些技巧来提高堆内存的效率:

  1. 尽可能复制对象而不是传递引用。仅在对象很大且复制操作成本极高时传递引用。
  2. 避免对象数据突然发生大量变更,可以使用对象扩展或 Object.assign 来进行复制。
  3. 避免创建对同一对象的多个引用。相反可以通过拷贝该对象产出一对象副本。 
  4. 避免创建巨大的对象树。如果不可避免,也要避免对象资源长时间的内存占用。

合理使用闭包、计时器、事件处理程序

正如我们之前看到的,闭包、计时器和事件处理程序是可能发生内存泄漏的其他区域。看下面一段代码:

var theThing = null;
var replaceThing = function () {
  var originalThing = theThing;
  var unused = function () {
    if (originalThing) console.log("hi");
  };
  theThing = {
    longStr: new Array(1000000).join("*"),
    someMethod: function () {
      console.log(someMessage);
    },
  };
};
setInterval(replaceThing, 1000);

上面的代码创建了多个闭包,并且这些闭包保留了对象引用。在这种情况下,可以通过在 replaceThing 函数末尾取消 originalThing 来修复内存泄漏。也可以通过创建对象的副本并遵循前面提到的不可变方法来避免这种情况。

当涉及到计时器时,完成后要通过 clearTimeout 和 clearInterval 清除计时器。

事件监听者和观察者也是如此。工作完成后清除它们,不要让事件侦听器永远运行,特别是如果还需要保留来自父作用域的任何对象引用。

由于 JS 引擎的发展和语言的改进,JavaScript 中的内存泄漏不再像以前一样严重,但依然会因为内存泄漏而导致性能问题甚至应用程序问题/操作系统崩溃。

本文参考

如果你对一个语言如何实现浏览器从请求开始到页面渲染是如何进行处理的很感兴趣,那么你可以点击进行查阅。

最后,本文从内存的管理到 V8 中对内存的划分,再到最后的内存泄露问题展开了详细的探讨,希望大家可以有所收获。

原文链接:https://juejin.cn/post/7314559238721978408 作者:inblossoms

(0)
上一篇 2023年12月21日 上午10:11
下一篇 2023年12月21日 上午10:21

相关推荐

发表回复

登录后才能评论