Simon Logo
WritingMonthlyProjects
Simon Logo

Thanks for reading!

CC BY-NC-SA 4.0© 2019-2026 Simon Wong.

Contact

GitHubGithubXTwitterZhihu知乎WeChat微信公众号RSSRSS

Legal

隐私策略服务条款
CC BY-NC-SA 4.0© 2019-2026 Simon Wong.

On this page

On this page

JavaScript内存管理垃圾回收V8引擎性能优化

内存管理-堆栈和GC

Dec 05, 2023

前言

所有的程序都必须和计算机内存打交道,从计算机申请内存,在不需要的时候释放内存,这是各个编程语言的设计难点。

目前有三种流派的内存管理:

  • 垃圾回收机制(GC):在运行时不断的寻找需要释放的内存。JavaScript、Java 等等各大主流语言都用的 GC
  • 手动管理内存的分配和释放:所有堆对象的创建和销毁都要由程序员负责。C++
  • 通过所有权进行管理内存:在编译时根据一系列规则进行检查。Rust

为什么要学内存管理?

帮助开发同学更好的理解各种变量创建的过程,应用的性能情况有哪些优化空间,帮助我们开发高性能的应用,尤其是 Node 应用。

内存管理的知识是通用的,你在开发 JS、JAVA、C++、GO 等等各种语言都绕不开它。

栈(Stack)和堆(Heap)

数据结构中的栈和堆

一般我们看到栈和堆就会以为是两个数据结构:

栈是一个后进先出的数据结构,比如 JS 中的 函数调用栈、浏览器的网页历史记录

堆 是一种特别的完全二叉树的数据结构,还分为最大堆、最小堆,一般适用于优先级队列,比如 React 的 Scheduler

内存中的栈和堆

我们主要是讲 C/C++ 的内存分配。( V8 引擎是 C++ 实现的,Java 中的 JVM 是 汇编、C、C++ 实现的,比较有代表性)

我们这里所说的堆栈是两种内存管理的形式,所以也会叫栈内存(栈区)和堆内存(堆区)。

在 C/C++ 中内存分为 栈区、堆区、全局/静态存储区、常量存储区、代码区。

**静态内存分配:**编译时分配。包括:全局、静态全局、静态局部三种变量。

**动态内存分配:**运行时分配。包括:栈、堆。

栈内存

  • 是由编译器管理的

  • 是线程私有的。

  • 栈中的数据占有大小是已知的。

  • 每个方法在执行时都会创建一个栈帧用于存储局部变量、操作数栈、方法出口等等信息。

哪些是存到栈内存的?:

字符(char)、数值(int)、布尔值、引用指针等等,可以看出来,这些值一旦声明了,它的大小是确定的,编译期就能知道的。

堆内存

  • 是由程序员管理的
  • 所有线程共享的
  • 可动态分配和释放的内存
  • 堆中的数据都是大小不可知,也可能会发生变化。

由此可以看出,我们一般说的 内存溢出、GC 等都是指的是堆内存。

哪些是存到堆内存的?:

容器:动态数组(C++ 种的 Vector,JS 中的 Array)、哈希表、JS 中的对象。

从这些可以看出来,这些变量声明后,大小是不确定的,编译器是不可知的。运行时会动态的变大变小。

垃圾回收机制

垃圾回收机制的实现有两种:

  • 引用计数收集器:最早的也是最简单的垃圾回收实现方法。会将存到堆空间的对象

    当引用一个对象,计数器就加一,不引用计数器就减一,为 0 时释放空间

    • 优点:可以尽快的回收不适用的对象
    • 缺点:频繁更新计数会降低效率、循环引用问题等等
0
0
0
8
4
8
Views
Previous

commonjs与es模块的互操作

Next

状态管理之 Signal

You Might Also Like

  • AI 与前端月刊Jan 31, 2026
  • 前端月刊Oct 31, 2025
  • 前端月刊Aug 31, 2025
  • 跟踪收集器

  • 可达性

    内存管理概念是 可达性。“可达”值是那些以某种方式可访问或可用的值。它们一定是存储在内存中的。

    这里有个很简单的例子:

    js
    // user 具有对这个对象的引用
    let user = {
      name: 'John',
    };
    

    user 引用了 对象 { name: "John" }。随后 user 的值被重写了

    js
    user = null;
    

    此时 { name: "John" } “断开了连接”,没有了引用,不可达了,那么就会被当作垃圾数据回收掉。

    还有更加复杂的例子,下面这种情况,即使这个对象还引用了别的对象,也会被回收掉。

    还有一个是无法到达的岛屿,尽管他们内部有各自的引用,但是始终无法到达,也会被回收。

    垃圾回收算法:标记-清除(mark-and-sweep)& 标记-紧缩(mark-and-compact)

    V8 将标记-清除和标记-紧缩(标记-整理)两种算法结合使用。

    这两个回收算法分为两个阶段,第一阶段的算法都是一样的:

    标记(marking) 在托管堆(managed heap)中扫描后的活动对象(live objects)的位置

    清除(sweeping) 在托管堆中被回收死亡对象(dead objects)的位置。

    但是清除会带来一个问题就是空间不连续,所以需要优化,*紧缩(compacting)*就是用于优化不连续的空间整合到一片连续的空间。

    可能有点抽象,我们直接看图说话:

    比如现在有这样一个结构的引用关系:

    • 当开始准备垃圾回收时,会暂停整个程序的全部运行的线程(Stop The World, STW)

    • 找到所有的引用的根,并标记,并继续遍历,有点像有向图的广度优先遍历

    • 全部标记完成后,开始清除没有被标记的(不可达的)

    • 然后所有线程恢复运行

    这种算法有一个必然的结果,就是每次运行 GC,都会导致整个应用卡顿,那体验也太差了。浏览器引擎对此做了很多优化,使得 GC 速度非常快,感受不到延迟:

    • 分代收集(Generational collection)—— 对象被分成两组:“新的”和“旧的”。在典型的代码中,许多对象的生命周期都很短:它们出现、完成它们的工作并很快死去,因此在这种情况下跟踪新对象并将其从内存中清除是有意义的。那些长期存活的对象会变得“老旧”,并且被检查的频次也会降低。
    • 增量收集(Incremental collection)—— 如果有许多对象,并且我们试图一次遍历并标记整个对象集,则可能需要一些时间,并在执行过程中带来明显的延迟。因此,引擎将现有的整个对象集拆分为多个部分,然后将这些部分逐一清除。这样就会有很多小型的垃圾收集,而不是一个大型的。这需要它们之间有额外的标记来追踪变化,但是这样会带来许多微小的延迟而不是一个大的延迟。
    • 闲时收集(Idle-time collection)—— 垃圾收集器只会在 CPU 空闲时尝试运行,以减少可能对代码执行的影响。

    分代收集

    V8 将堆分了很多个片区:

    • 新生代:大多数对象被分配在这里,这是一个很小的片区。垃圾回收非常频繁。
    • 老生代:大多数在新生区存活一段时间之后的对象都会被挪到这里。会有一个大周期进行垃圾回收。
    • 大对象区:
    • ... 等等

    我们主要理解新老分代。

    新生代

    这里需要再引出一个算法:Scavenge 算法。

    大概是将新生代区分为出区、入区,内存分配发生在出区,出区将要耗尽时(小周期),交换出区入区,将入区的活跃对象复制到出区或者老生代区。

    这个算法的速度是比标记-清除和标记-紧缩快的,但是占用的空间比较大(直接需要一半的空间)。所以只适用于区域小的新生代区。

    c
    def scavenge():
      swap(fromSpace, toSpace)
      allocationPtr = toSpace.bottom
      scanPtr = toSpace.bottom
    
      for i = 0..len(roots):
        root = roots[i]
        if inFromSpace(root):
          rootCopy = copyObject(&allocationPtr, root)
          setForwardingAddress(root, rootCopy)
          roots[i] = rootCopy
    
      while scanPtr < allocationPtr:
        obj = object at scanPtr
        scanPtr += size(obj)
        n = sizeInWords(obj)
        for i = 0..n:
          if isPointer(obj[i]) and not inOldSpace(obj[i]):
            fromNeighbor = obj[i]
            if hasForwardingAddress(fromNeighbor):
              toNeighbor = getForwardingAddress(fromNeighbor)
            else:
              toNeighbor = copyObject(&allocationPtr, fromNeighbor)
              setForwardingAddress(fromNeighbor, toNeighbor)
            obj[i] = toNeighbor
    
    def copyObject(*allocationPtr, object):
      copy = *allocationPtr
      *allocationPtr += size(object)
      memcpy(copy, object, size(object))
      return copy
    

    老生代

    老生区在标记-清除或标记-紧缩的过程中(大周期)进行回收。大周期进行的并不频繁。一次大周期通常是在移动足够多的对象至老生区后才会发生。

    思考

    物理世界的内存

    操作系统中的内存管理包含了物理内存管理&虚拟内存管理,他们各自有各自的管理内容,有着复杂的存储体系。

    栈内存、堆内存是如何存储的?在真实的机器中,是存储在什么位置的?

    PS:栈内存、堆内存是 OS 抽象出来的。

    垃圾回收为什么慢

    假设 V8 的堆内存为1.5G,那么V8做一次小的垃圾回收需要 50ms 以上,而做一次非增量式回收甚至需要1s以上,可见其耗时之久,而在这 1s 的时间内,浏览器一直处于等待的状态。

    参考

    C++

    C++ 的高性能垃圾回收(GC)

    TechCPP C++ 内存分配

    JS、V8

    JavaScript.INFO - 垃圾回收

    A tour of V8: Garbage Collection

    一文搞懂 V8 引擎的垃圾回收

    操作系统

    十年大佬讲述,操作系统内存管理

    Rust

    Rust 语言圣经 - 所有权