JavaScript のメモリ管理の詳細

青灯夜游
青灯夜游転載
2023-04-19 19:07:081531ブラウズ

JavaScript のメモリ管理の詳細

メモリ管理はプログラミング言語の基本機能であり、JavaScript でのメモリ管理は V8 によって完了します。 V8 の実装は ECMA-262 仕様に従っていますが、この仕様ではメモリ レイアウトとメモリ管理関連情報について詳しく説明されていないため、その原理は インタープリタ の実装に依存します。唯一確かなことは、どのプログラミング言語であっても、メモリのライフ サイクルは同じであるということです:

  1. 必要なメモリを割り当てます;
  2. 割り当てられたメモリを使用します (読み取り、 write);
  3. 不要になったら解放して返却してください。

この背景に基づいて、この記事では、メモリ ライフ サイクルを通じて JavaScript のメモリ レイアウトを拡張することを試みます。 [推奨学習: JavaScript ビデオ チュートリアル ]

メモリの割り当てを開始する前に、データ型とデータ構造を理解する必要があります。

データ型

JavaScript データ型は、基本型参照型#に分類されます。 ####。

JavaScript のメモリ管理の詳細

基本型

: 言語の最下位レベルにある不変の値は、プリミティブ値と呼ばれます。すべてのプリミティブ値は、typeof 演算子を使用して基本型かどうかをテストできます (typeof null === "object" であるため、null を除く)。すべてのプリミティブ値には、対応するオブジェクト ラッパー クラス (null と未定義を除く) があり、プリミティブ値に使用できるメソッドを提供します。オブジェクト ラッパー クラスの基本タイプには、Boolean、Number、String、Symbol などがあります。

参照型

: メモリ内の変更可能な値を表します。変更可能なのは JavaScript のオブジェクトのみです。オブジェクト、配列、関数などはすべてオブジェクトに属します。 Object.defineProperty() メソッドを通じてオブジェクトのプロパティを定義でき、Object.getOwnPropertyDescriptor() を通じてオブジェクトのプロパティ情報を読み取ることができます。 基本型と参照型は相互に変換できます。変換の動作は、

boxing および unboxing## と呼ばれます。 #。 Boxing

: Basic type=> 参照タイプ 例: new String('call_me')

Unboxing

: Reference type=> ; Basic型 例: new String('64').valueOf()、new String('64').toString()

開発プロセス中の一般的な型変換の一部を次に示します:

数値 -> 文字列: let a = 1 => a "" / String(a)

    文字列 -> 数値: let a = "1" => a / ~~ a / Number(a)
  • any -> boolean: let a = {} => !a / !!a / Boolean(a)
  • メモリの観点からの違い基本型とアプリケーション型の鍵は、値がメモリ内で変数であるかどうかです。基本型の更新では、領域が再度開かれ、ポインタ アドレスが変更されます。参照型の更新では、ポインタ アドレスは変更されませんが、ポインタが指すオブジェクトが変更されます。コードの観点から見ると、参照型は基本型と {} で構成されます。

データ構造

JavaScript プログラムの実行中、V8 はプログラムにメモリを割り当てます。このメモリは ## と呼ばれます。 JavaScript のメモリ管理の詳細 #Resident Set (常駐メモリ セット)

、V8 常駐メモリは、StackHeap# にさらに分割されます。 ##。 スタック(スタック) は、システムによって自動的に割り当てられ、自動的に解放される固定サイズのメモリ空間です。スタック データ構造は、先入れ後出しの原則、線形で順序付けされたストレージ、小容量、および高いシステム割り当て効率に従っています。

Heap (ヒープ) は可変サイズで動的に割り当てられるメモリ空間であり、自動的に解放されません (解放は GC に依存します)。ヒープのデータ構造はバイナリツリー構造であり、大容量ですが速度は遅いです。

スレッドにはスタック メモリ スペースが 1 つだけあり、プロセスにはヒープ スペースが 1 つだけあります。

スタック メモリ領域のデフォルトのサイズは 864KB

で、

node --v8-options | grep -B0 -A1 stack-size

を通じて確認することもできます。 。

実際、スタック構造をよく見ることができ、エラー報告コードを作成すると、コンソール上のエラー プロンプトがスタック構造になります。呼び出しパスを下から上に見ると、一番上がエラーの場所です。たとえば、上部に表示される「最大呼び出しスタック サイズを超えました」というエラーは、現在の呼び出しがスタック制限を超えていることを意味します。

ヒープ内の構造は New Space (New Space) Old Space (Old Space) 、# に分割されます。 ##ラージ オブジェクト スペース コード スペース セル スペース Property Cell Space (プロパティ セル スペース) Map Space (Map Space) 、新しいスペースと古いスペースについては後で詳しく紹介します。

ラージ オブジェクト スペース (ラージ オブジェクト スペース) : 他のスペース サイズ制限を超えるオブジェクトがここに格納されます。各オブジェクトには独自のメモリ領域があり、ここにあるオブジェクトはガベージ コレクターによって移動されません。

コードスペース: コンパイルされたコードブロックを保存し、唯一の実行可能なメモリスペースです。

セル スペース、プロパティ セル スペース、およびマップ スペース: これらのスペースには、それぞれ Cell、PropertyCell、および Map が格納されます。これらのスペースには同じサイズのオブジェクトが含まれており、リサイクルを簡素化するためにオブジェクトの種類にいくつかの制限があります。

各スペース (ラージ オブジェクト スペースを除く) は、複数の

Page で構成されます。ページは、オペレーティング システムによって割り当てられた連続したメモリ ブロックであり、メモリ ブロックのサイズは 1MB です。

メモリの観点からスタックとヒープを区別する鍵は、使用後すぐに解放されるかどうかにあります。

これを見た読者は間違いなくデータ型とスタックの関係を思い浮かべると思います。インターネットや一部の書籍からの結論は次のとおりです:

元の値はスタックに割り当てられ、オブジェクトはスタックに割り当てられますヒープ上に割り当てられます。 この発言は本当ですか?この質問を念頭に置いて、割り当てられたメモリを使用するという 2 番目のステップに進みます。

メモリ モデル

Node は、Node.js プロセスのメモリ使用量 (バイト単位) を記述する

process.memoryUsage() メソッドを提供します。単位)

$ node
> process.memoryUsage()

元の値がスタックに割り当てられ、オブジェクトがヒープに割り当てられると仮定すると、それは正しく、結合されたスタック領域はわずか

864KB です。 10MB 文字列を宣言した場合、ヒープ メモリが変化するかどうかを確認します。

const beforeMemeryUsed = process.memoryUsage().heapUsed / 1024 / 1024;

const bigString = 'x'.repeat(10*1024*1024) // 10 MB
console.log(bigString); // need to use the string otherwise the compiler would just optimize it into nothingness

const afterMemeryUsed = process.memoryUsage().heapUsed / 1024 / 1024;

console.log(`Before memory used: ${beforeMemeryUsed} MB`); // Before memory used: 3.7668304443359375 MB
console.log(`After memory used: ${afterMemeryUsed} MB`); // After memory used: 13.8348388671875 MB

ヒープ メモリ消費量は 10 MB に近く、

文字列がヒープ に格納されていることを示しています。

そこで、小さな文字列やその他の基本的な型もヒープに保存されているかどうかを、Google Chrome の

Memery Heap スナップショット を使用して分析します。

Google Chrome シークレット モード コンソールを開いて次のコードを入力し、実行前と実行後の変数の変化を分析します。

function testHeap() {
    const smi = 18;
    const heapNumber = 18.18;
    const nextHeapNumber = 18.18;
    const boolean = true;
    const muNull = null;
    const myUndefined = undefined;
    const symbol = Symbol("my-symbol");
    const emptyString = "";
    const string = "my-string";
    const nextString = "my-string";
}
testHeap()

JavaScript のメモリ管理の詳細

#図から、関数の実行後のヒープ内の変数の割り当てがわかります。

10 進数、文字列、および記号はすべてヒープ領域を解放し、それらがヒープ内に割り当てられていることを示します。 2 つの同一の「my-string」文字列がありますが、2 つの文字列スペースは繰り返し開かれません。これは、V8 がコードを作成して AST に変換すると、検出された各文字列がハッシュ値に変換され、ハッシュマップに挿入されます。したがって、文字列を作成するとき、V8 はまずメモリ ハッシュ テーブルを検索して、すでに作成されている同一の文字列が存在するかどうかを確認し、存在する場合は直接再利用します。存在しない場合は、新しいメモリ空間が保存用に開かれます。文字列を変更する場合は、新しいスペースを開く必要があり、元のスペースを変更することはできません。

小さな整数、ブール値、未定義、null、および空の文字列には追加のスペースがありません。これらのデータ型には 2 つの推測があります:

はスタック スペースに格納されます。

これはヒープに格納されていますが、システムの起動時に開かれています。

JavaScript のメモリ管理の詳細

実際、V8 には

## と呼ばれるプリミティブ値の特別なサブセットがあります。 #奇妙ですJavaScript のメモリ管理の詳細#。これらは、JavaScript プログラムが実際に使用するかどうかに関係なく、実行前に V8 によってヒープ上に事前に割り当てられます。ヒープ領域全体からこれらの型の割り当てを表示します。boolean、unknown、null、空の文字列はヒープ メモリに割り当てられ、奇数型

に属します。スペースが割り当てられると、対応するメモリ アドレスは常に固定されます (空の文字列

@77、null@71、未定義@67、true @73) )。しかし、小さい整数は見つかりませんでした。これは、 関数のローカル変数の小さい整数はスタック上に存在しますが、グローバルで定義された小さい整数はヒープ に割り当てられていることがわかります。

同样都是表示 Number 类型,小整数和小数在存储上有什么区别呢?

一般编程语言在区分 Number 类型时需要关心 Int、Float、32、64。在 JavaScript 中统称为 Number,但 v8 内部对 Number 类型的实现可没看起来这么简单,在 V8 内部 Number 分为 smiheapNumber,分别用于存储小整数与小数(包括大整数)。ECMAScript 标准约定 Number 需要被当成 64 位双精度浮点数处理,但事实上一直使用 64 位去存储任何数字在时间和空间上非常低效的,并且 smi 大量使用位运算,所以为了提高性能 JavaScript 引擎在存储 smi 的时候使用 32 位去存储数字而 heapNumber 使用 32 位或 64 位存储数字

以上是局部变量在函数中的内存分布,接下来验证对象的内存分布。谷歌浏览器无痕模式 Console 中输入以下代码,并在 Class filter 中输入 TestClass 查看其内存分布情况。

function TestClass() {
    this.number = 123;
    this.number2 = 123;
    this.heapNumebr = 123.18;
    this.heapNumber2 = 123.18;
    this.string = "abc";
    this.string2 = "abc";
    this.boolean = true;
    this.symbol = Symbol('test')
    this.undefined = undefined;
    this.null = null
    this.object = { name: 'pp' }
    this.array = [1, 2, 3];
}
let testobject = new TestClass()

JavaScript のメモリ管理の詳細

和上一个案例不同的是内存中多了 smi number 类型。由于对象本身就存储在堆中,所以小整数也存储在堆中。shallow size 大小为 0,证明了小整数虽在堆中却不占内存空间。是什么原因导致小整数不占内存空间?

这和 V8 中使用 指针标记技术 有关,指针标记技术使得指针标记位可以存储地址或者标记值。整数的值直接存储在指针中,而不必为其分配额外的存储空间;对象的值需要开辟额外内存空间,指针中存放其地址。这也导致了对象中的小整数数值相同地址也相同。

|------ 32位架构 -----|
|_____address_____ w1| 指针
|___int31_value____ 0| Smi

|---------------- 64位架构 ----------------|
|________base________|_____offset______ w1| 指针
|--------------------|___int31_value____ 0| Smi

V8 使用最低有效位来区分 Smi 和对象指针。对于对象指针,它使用第二个最低有效位来区分强引用弱引用

在 32 位架构中 Smi 值只能携带 31 位有效载荷。包括符号位,Int32类型的范围是 -(2^31) ~ 2^31 - 1, 所以Smi的范围实际上是Int31类型的范围(-(2^30) ~ 2^30 - 1)。对象指针有 30 位可用作堆对象地址有效负载。

由于单线程和 v8 垃圾回收机制的限制,内存越大回收的过程中 JavaScript 线程会阻塞且严重影响程序的性能和响应能力,出于性能以及避免空间浪费的考虑,大部分浏览器以及 Node15+ 的内存上限为 4G(4G 刚好是 2^32 byte)。以内存上限为 4G 为例,V8 中的堆布局需要保证无论是 64 位系统还是 32 位系统都只使用32位的空间来储存。在 64 位架构中 Smi 同样使用 31 位有效负载,与 32 位架构保持一致;对象指针使用 62 位有效负载,其中前 32 位表示 base(基址),其值指向 4G 内存中间段的地址。后 32 位的前 30 位表示 offset,指前后 2G 内存空间的偏移量。

v8 可以通过以下代码查看内存上限。

const v8 = require('v8')
console.log('heap_size_limit:',v8.getHeapStatistics().heap_size_limit) // 查询堆内存上限设置,不同 node 版本默认设置是不一样

通过设置环境 export NODE_OPTIONS=--max_old_space_size=8192 或者启动时传递 --max-old-space-size(或 --max-new-space-size)参数修改内存上限。

通过以上两个案例,细心的读者可能已经发现 heap number 作为函数私有变量时存在复用但作为对象的属性时不存在复用(地址不相同)。作者猜测函数中的私有变量做了类似字符串的 hashmap 优化,而作为对象属性时为了避免每次修改变量重新开辟空间而导致内存消耗大,无论数值是否相同都会重新开辟空间,修改时直接修改指针所指向的具体值。

以执行函数为例简单概括 JavaScript 的内存模型

JavaScript のメモリ管理の詳細

垃圾回收机制及策略

使用完内存我们需要对内存进行释放以及归还,像 C 语言这样的底层语言一般都有底层的堆内存管理接口,比如 malloc() 和 free()。相反,JavaScript 是在创建变量(对象,字符串等)时自动进行了分配内存,并且在不使用它们时"自动"释放。释放的过程称为 垃圾回收。释放过程不是实时的,因为其开销比较大,所以垃圾回收器会按照固定的时间间隔周期性的执行,这让 JavaScript 开发者错误的认为可以不关心垃圾回收机制及策略。

引用计数法

这是最初级的垃圾收集算法。此算法把"对象是否不再需要"简化定义为"对象有没有其他对象引用到它"。假设有一个对象A,任何一个对象对A的引用,那么对象A的引用计数器+1,当引用清除时,对象A的引用计数器就-1,如果对象A的计算器的值为 0,就说明对象A没有引用了,可以被回收。

但该算法有个限制:无法处理循环引用问题。在下面的例子中,两个对象被创建,并互相引用,形成了一个循环。它们被调用之后会离开函数作用域,所以它们已经没有用了,可以被回收了。然而,引用计数算法考虑到它们互相都有至少一次引用,所以它们不会被回收。

function f(){
  var o = {};
  var o2 = {};
  o.a = o2; // o 引用 o2
  o2.a = o; // o2 引用 o
  return "";
}
f();

标记清除法

这个算法把"对象是否不再需要"简化定义为"对象是否可达",解决了循环引用的问题。这个算法假定设置一个叫做根(root)的对象(在 Javascript 里,根是全局对象)。垃圾回收器将定期从根开始,不具备可达性的元素将被回收。可达性指的是一个变量是否能够直接或间接通过全局对象访问到,如果可以那么该变量就是可达的,否则就是不可达。

但标记清除法对比引用计数法 缺乏时效性,只有在有效内存空间耗尽了,V8引擎将会停止应用程序的运行并开启 GC 线程,然后开始进行标记工作。所以这种方式效率低,标记和清除都需要遍历所有对象,并且在 GC 时,需要停止应用程序,对于交互性要求比较高的应用而言这个体验是非常差的;通过标记清除算法清理出来的内容碎片化较为严重,因为被回收的对象可能存在于内存的各个角落,所以清理出来的内存是不连贯的。

标记压缩算法

标记压缩算法是在标记清除算法的基础之上,做了优化改进的算法。和标记清除算法一样,也是从根节点开始,对对象的引用进行标记,在清理阶段,并不是简单的清理未标记的对象,而是将存活的对象压缩到内存的一端,然后清理边界以外的垃圾,从而解决了碎片化的问题

标记压缩算法解决了标记清除算法的碎片化的问题,同时,标记压缩算法多了一步,对象移动内存位置的步骤,其效率也有一定的影响。

增量标记法

标记压缩算法只解决了标记清除法的内存碎片化问题,但是没有解决停顿问题。为了减少全停顿的时间,V8 使用了如下优化,改进后,最大停顿时间减少到原来的1/6。

  1. 增量 GC:GC 是在多个增量步骤中完成,而不是一步完成。
  2. 并发标记: 标记空间的对象哪些是活的哪些是死的是使用多个辅助线程并发进行,不影响 JavaScript 的主线程。
  3. 并发清扫/压缩:清扫和压缩也是在辅助线程中并发进行,不影响 JavaScript 的主线程。
  4. 延迟清扫:延迟删除垃圾,直到有内存需求或者主线程空闲时再删除。

V8引擎垃圾回收策略

JavaScript 中的 垃圾回收策略采用分代回收的思想。Heap(堆)内存中只有新空间(New Space)和旧空间(Old Space)由 GC 管理。

新空间(New Space):新对象存活的地方,驻留在此处的对象称为New Generation(新生代)。Minor GC 作为该空间的回收机制,该空间采用 Scavenge 算法 + 标记清除法

  • Minor GC 保持新空间的紧凑和干净,其中有一个分配指针,每当我们想为新的对象分配内存空间时,就会递增这个指针。当该指针达到新空间的末端时,就会触发一次 Minor GC。这个过程也被称为 Scavenger,它实现了 Cheney 算法。由于空间很小(1-8MB 之间)导致 Minor GC 经常被触发,所以这些对象的生命周期都很短,而且 Minor GC 过程使用并行的辅助线程,速度非常快,内存分配的成本很低。
  • 新空间由两个大小 Semi-Space 组成,为了区分二者 Minor GC 将二者命名为 from-spaceto-space。内存分配发生在 from-space 空间,当 from-space 空间被填满时,就会触发 Minor GC。将还存活着的对象迁移到 to-space 空间,并将 from-space 和 to-space 的名字交换一下,交换后所有的对象都在 from-space 空间,to-space 空间是空的。一段时间后 from-space 又被填满时再次触发 Minor GC,第二次存活的对象将会被迁移到旧空间(Old Space),第一次存活下来的新对象被迁移到 to-space 空间,如此周而复始操作就形成了 Minor GC 的过程。

旧空间(Old Space):在新空间(New Space)被两次 Minor GC 后依旧存活的对象会被迁移到这里,驻留在此处的对象称为Old Generation(老生代)。 Major GC 作为该空间的回收机制,该空间采用标记清除、标记压缩、增量标记算法

  • V8 根据某种算法计算,确定没有足够的旧空间就会触发 Major GC。Cheney 算法对于小数据量来说是完美的,但对于 Heap 中的旧空间来说是不切实际的,因为算法本身有内存开销,所以 Major GC 使用标记清除、标记压缩、增量标记算法。
  • 旧空间分为旧址针空间和旧数据空间:旧指针空间包含具有指向其他对象的指针的对象;旧数据空间包含数据的对象(没有指向其他对象的指针)。

内存泄漏

并不是所有内存都会被回收,当程序运行时由于某种原因未能被 GC 而造成内存空间的浪费称为 内存泄漏。轻微的内存泄漏或许不太会对程序造成什么影响,严重的内存泄漏则会影响程序的性能,甚至导致程序的崩溃。

以下是一些导致内存泄漏的场景

闭包

var theThing = null;
const replaceThing = function () { 
  var originalThing = theThing; 
  
  var unused = function () { 
    if (originalThing) 
      console.log("hi"); 
  }; 
  
  theThing = { 
    longStr: new Array(1000000).join('*'), 
    someMethod: function () { 
      console.log("someMessage"); 
    } 
  };
  // 如果在此处添加 `originalThing = null`,则不会导致内存泄漏。
};
setInterval(replaceThing, 1000);

这是一个非常经典的闭包内存泄漏案例,unused 中引用了 originalThing,所以强制它保持活动状态,阻止了它的回收。unused 本身并未被使用所以函数执行结束后会被 gc 回收。但 somemethod 与 unused 在同一个上下文,共享闭包范围。每次执行 replaceThing 时闭包函数 someMethod 中都会引用上一个 theThing 对象。

意外的全局变量

function foo(arg) { 
    bar = "隐式全局变量"; 
}
// 等同于:
function foo(arg) { 
    window.bar = "显式全局变量"; 
}

定义大量的全局变量会导致内存泄漏。在浏览器中全局对象是“ window”。在 NodeJs 中全局对象是“global”或“process”。此处变量 bar 永远无法被收集。

还有一种情况是使用 this 生成全局变量。

function fn () {
    this.bar = "全局变量"; // 这里的  this 的指向 window, 因此 bar 同样会被挂载到 window 对象下
}
fn();

避免此问题的办法是在文件头部或者函数的顶部加上 'use strict', 开启严格模式使得 this 的指向为 undefined。

若必须使用全局变量存储大量数据时,确保用完后设置为 null 即可。

忘记清除定时器

setInterval/setTimeout 未被清除会导致内存泄漏。在执行 clearInterval/clearTimeout 之前,系统不会释放 setInterval/setTimeout 回调函数中的变量,及时释放内存就需要手动执行clearInterval/clearTimeout。

若 setTimeout 执行完成则没有内存泄漏的问题,因为执行结束后就会立即释放内存。

忘记清除事件监听器

当组件挂载事件处理函数后,在组件销毁时不主动将其清除,事件处理函数被认为是需要的而不会被 GC。如果内部引用的变量存储了大量数据,可能会引起页面占用内存过高,造成内存泄漏。

忘记清除 DOM 引用

把 DOM 存储在字典(JSON 键值对)或者数组中,当元素从 DOM 中删除时,而 DOM 的引用还是存于内存中,则 DOM 的引用不会被 GC 回收而需要手动清除,所以存储 DOM 通常使用弱引用的方式。

旧版浏览器和错误扩展

旧版浏览器 (IE6–7) 因无法处理 DOM 对象和 JavaScript 对象之间的循环引用而导致内存泄漏。

有时错误的浏览器扩展可能会导致内存泄漏。

メモリ リークのトラブルシューティング

一定期間実行した後にプログラムが徐々にフリーズしたりクラッシュしたりする場合は、トラブルシューティングを開始し、メモリ リークを特定して修復する必要があります。一般的に使用される 4 つのメモリ リークのトラブルシューティング方法。次のように入力します。

  1. Chrome ブラウザの Performance を使用してメモリ リークがあるかどうかを確認し、Memory を使用してメモリ リークを見つけます。漏れの原因。
  2. Node.js が提供する process.memoryUsage メソッドを使用して、heap Used の傾向を確認します;
  3. Use node --inspect xxx.jsサービスを開始し、chrome://inspect にアクセスしてメモリを開いてリークの原因を特定します。
  4. アプリケーションが grafana に接続されている場合は、ab pressure を通じて grafana のメモリ傾向を観察できます。テスト中。

メモリ配分は、ほとんどの開発者にとってブラック ボックスです。v8 で実装された JavaScript メモリ モデルは非常に複雑です。開発者の 99% は気にする必要はありません。これは ECMAScript にも含まれていませんメモリ レイアウトに関する情報を検索します。ご興味がございましたら、v8 エンジンのソース コードをご覧ください。仕事で JavaScript のメモリ配分の問題を専門的に扱い始めたということは、低レベル言語を書き始める能力があることを意味します。

この記事はソースコードを読んでの結論ではなく、メモリ解析ツールと既存の理論を組み合わせた結論ですので、不備があれば修正してください。

プログラミング関連の知識について詳しくは、プログラミング教育をご覧ください。 !

以上がJavaScript のメモリ管理の詳細の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。

声明:
この記事はjuejin.cnで複製されています。侵害がある場合は、admin@php.cn までご連絡ください。