JavaScript の仕組み

小云云
リリース: 2017-12-05 10:30:42
オリジナル
1955 人が閲覧しました

JavaScript の仕組みについて、この記事ではエンジン、ランタイム、呼び出しスタックに焦点を当てます。 2 番目の記事では、Google の V8 JavaScript エンジンの内部を明らかにし、より良い JavaScript コードを記述する方法についてのアドバイスを提供します。 JavaScript のメモリ リークに対処する方法についてのヒントも提供します。 SessionStack では、メモリ リークを引き起こしたり、統合 Web アプリケーションのメモリ消費量を増加させたりしないようにする必要があります。

概要

C などの一部の言語には、 malloc() や free() などの低レベルのネイティブ メモリ管理プリミティブがあります。開発者はこれらのプリミティブを使用して、オペレーティング システムのメモリを明示的に割り当てたり解放したりできます。

対照的に、JavaScript は変数 (オブジェクト、文字列) が作成されると自動的にメモリを割り当て、これらの変数が使用されない場合は自動的にメモリを解放します。このプロセスは ガベージ コレクション と呼ばれます。リソースを「自動的に」解放するこの機能は多くの混乱を引き起こし、JavaScript (およびその他の高級言語) 開発者はメモリ管理を気にする必要がないと誤解しています。 これは大きな間違いです

高級言語を使用する場合でも、開発者はメモリ管理についてある程度の理解 (少なくとも基本的な理解) を持っている必要があります。場合によっては、自動メモリ管理に何らかの問題が発生することがあります (たとえば、ガベージ コレクションの実装に欠陥があるか不十分である可能性があります)。開発者は、適切な解決策を見つけるためにこれらの問題を理解する必要があります。

メモリのライフ サイクル

どのプログラミング言語を使用しても、メモリのライフ サイクルはほぼ常に同じです:

JavaScript の仕組み

サイクルの各ステップで何が起こるかの概要を次に示します:

概要は次のとおりです:サイクルの各ステップの大まかな説明:

  • メモリの割り当て — メモリはオペレーティング システムによって割り当てられ、プログラムが使用できるようになります。低レベル言語 (C など) では、これは開発者として対処する必要がある明示的な操作です。ただし、高級言語では、これらの操作は開発者に代わって処理されます。

  • メモリを使用します。 以前に割り当てられたメモリの実際の使用は、コード内の変数を操作して内部的に読み書きすることによって実行されます。

  • 記憶を解放。使用されていないときは、メモリを解放して再割り当てできます。メモリの割り当て操作と同様に、メモリの解放にも低レベル言語での明示的な操作が必要です。

スタックとメモリの概念をすぐに理解したい場合は、このシリーズの最初の記事を読むことができます。

メモリとは何ですか

Javascript のメモリについて直接議論する前に、メモリとは何か、メモリがどのように機能するかについて簡単に説明しましょう。

ハードウェアでは、コンピューターのメモリには多数のトリガー回路が含まれており、各トリガー回路には 1 ビットのデータを保存できるいくつかの トランジスタが含まれています。トリガーは一意の識別子によってアドレス指定され、読み取りと上書きが可能になります。したがって、概念的には、コンピューター メモリは巨大な読み取り/書き込みアレイとして考えることができます。

人間は、ビット演算を使用してすべての思考や算術を表現するのが苦手です。これらの小さなものを大きなものに組織し、これらの大きなものを使用して数値を表すことができます。8 ビットは 1 バイトです。上記のバイトはワード (16 ビット、32 ビット) です。

多くのものがメモリに保存されます:

  1. プログラムで使用されるすべての変数とデータ、

  2. オペレーティングシステムのコードを含むプログラムのコード。

コンパイラとオペレーティング システムは連携して、開発者がメモリ管理のほとんどを行うのを支援しますが、内部で何が起こっているのかを理解しておくことをお勧めします。

コードをコンパイルするとき、コンパイラーは元のデータ型を解析し、必要なメモリ容量を事前に計算します。次に、必要な量をスタックスペースに割り当てます。関数が呼び出されるときに、関数のメモリが既存のメモリに追加されるため、これはスタック スペースと呼ばれます (つまり、関数の内部変数が格納される領域を指すスタック フレームがスタックの最上部に追加されます)。終了時には、これらの呼び出しは LIFO (後入れ先出し) の順序で削除されます。例:

int n; // 4字节
int x[4]; // 4个元素的数组,每个元素4字节
double m; // 8字节
ログイン後にコピー

コンパイラは、メモリ

4 + 4 × 4 + 8 = 28 バイトが必要であることをすぐに認識します。

これは、整数と double の現在のサイズです。約 20 年前、整数は通常 2 バイト、2 倍の 4 バイトしか必要とせず、コードは基礎となるデータ型のサイズによって制限されませんでした。

コンパイラは、変数を格納するためにスタック上に必要なバイト サイズを要求するために、オペレーティング システムと対話するコードを挿入します。

上記の例では、エディターは各変数の正確なアドレスを知っています。実際、変数 n に書き込むときは常に、内部で「メモリ アドレス 4127963」のようなものに変換されます。

注意,如果我们尝试访问 x[4] 的内存(开始声明的x[4]是长度为4的数组, x[4] 表示第五个元素),我们会访问m的数据。那是因为我们正在访问一个数组里不存在的元素,m比数组中实际分配内存的最后一个元素 x[3] 要远4个字节,可能最后的结果是读取(或者覆盖)了 m 的一些位。这肯定会对其他程序产生不希望产生的结果。

JavaScript の仕組み

当函数调用其他函数的时候,每一个函数被调用的时候都会获得自己的栈块。在自己的栈块里会保存函数内所有的变量,还有一个程序计数器会记录变量执行时所在的位置。当函数执行完之后,会释放它的内存以作他用。

动态分配

不幸的是,事情并不是那么简单,因为在编译的时候我们并不知道一个变量将会需要多少内存。假设我们做了下面这样的事:

int n = readInput(); //读取用户的输入

...

//创建一个有n个元素的数组
ログイン後にコピー

 

编译器不知道这个数组需要多少内存,因为数组大小取决于用户提供的值。

因此,此时不能在栈上分配空间。程序必须在运行时向操作系统请求够用的空间。此时内存从 堆空间 中被分配。静态与动态分配内存之间的不同在下面的表格中被总结出来:

JavaScript の仕組み

静态分配内存与动态分配内存的区别。

为了完全理解动态内存是如何分配的,我们需要花更多的时间在 指针 上,这个可能很大程度上偏离了这篇文章的主题。如果你有兴趣学习更多的知识,那就在评论中让我知道,我就可以在之后的文章中写更多关于指针的细节。

JavaScript中的内存分配

现在我们来解释JavaScript中的第一步( 分配内存 )是如何工作的。

JavaScript在开发者声明值的时候自动分配内存。

var n = 374; // 为数值分配内存
var s = 'sessionstack'; //为字符串分配内存

var o = {
  a: 1,
  b: null
};  //为对象和它包含的值分配内存

var a = [1, null, 'str']; //为数组和它包含的值分配内存

function f(a) {
  return a + 3;
} //为函数(可调用的对象)分配内存

//函数表达式也会分配一个对象
someElement.addEventListener('click', function() {
  someElement.style.backgroundColor = 'blue';
}, false);

 //一些函数调用也会导致对象分配
`var d = new Date(); // allocates a Date object`   //分配一个Date对象的内存

`var e = document.createElement('p');  //分配一个DOM元素的内存

//方法可以分配新的值或者对象

var s1 = 'sessionstack';
var s2 = s1.substr(0, 3);  //s2是一个新的字符串
// 因为字符串是不可变的
// JavaScript可能决定不分配内存
// 而仅仅存储 0-3的范围

var a1 = ['str1', 'str2'];
var a2 = ['str3', 'str4'];
var a3 = a1.concat(a2); 
//新的数组有4个元素是a1和a2连接起来的。
ログイン後にコピー

 

在JavaScript中使用内存

在JavaScript中使用被分配的内存,本质上就是对内在的读和写。

比如,读、写变量的值或者对象的属性,抑或向一个函数传递参数。

内存不在被需要时释放内存

大部分的内存管理问题都在这个阶段出现。

这里最难的任务是找出这些被分配的内存什么时候不再被需要。这常常要求开发者去决定程序中的一段内存不在被需要而且释放它。

高级语言嵌入了一个叫 垃圾回收 的软件,它的工作是跟踪内存的分配和使用,以便于发现一些内存在一些情况下不再被需要,它将会自动地释放这些内存。

不幸的是,这个过程是一个近似的过程,因为一般关于知道内存是否是被需要的问题是不可判断的(不能用一个算法解决)。

大部分的垃圾回收器会收集不再被访问的内存,例如指向它的所有变量都在作用域之外。然而,这是一组可以收集的内存空间的近似值。因为在任何时候,一个内存地址可能还有一个在作用域里的变量指向它,但是它将不会被再次访问。

垃圾收集

由于找到一些内存是否是“不再被需要的”这个事实是不可判定的,垃圾回收的实现存在局限性。本节解释必要的概念去理解主要的垃圾回收算法和它们的局限性。

内存引用

垃圾回收算法依赖的主要概念是 引用。

在内存管理的语境下,一个对象只要显式或隐式访问另一个对象,就可以说它引用了另一个对象。例如,JavaScript对象引用其Prototype( 隐式引用 ),或者引用prototype对象的属性值( 显式引用 )。

在这种情况下,“对象”的概念扩展到比普通JavaScript对象更广的范围,并且还包含函数作用域。(或者global 词法作用域

词法作用域定义变量的名字在嵌套的函数中如何被解析:内部的函数包含了父级函数的作用域,即使父级函数已经返回。

引用计数垃圾回收

这是最简单的垃圾回收算法。 一个对象在没有其他的引用指向它的时候就被认为“可被回收的”。

看一下下面的代码:

var o1 = {
  o2: {
    x: 1
  }
};

//2个对象被创建
/'o2'被'o1'作为属性引用
//谁也不能被回收

var o3 = o1; //'o3'是第二个引用'o1'指向对象的变量

o1 = 1;      //现在,'o1'只有一个引用了,就是'o3'
var o4 = o3.o2; // 引用'o3'对象的'o2'属性
                //'o2'对象这时有2个引用: 一个是作为对象的属性
                //另一个是'o4'

o3 = '374'; //'o1'原来的对象现在有0个对它的引用
             //'o1'可以被垃圾回收了。
            //然而它的'o2'属性依然被'o4'变量引用,所以'o2'不能被释放。

o4 = null;  //最初'o1'中的'o2'属性没有被其他的引用了
           //'o2'可以被垃圾回收了
ログイン後にコピー

 

循环引用创造麻烦

在涉及循环引用的时候有一个限制。在下面的例子中,两个对象被创建了,而且相互引用,这样创建了一个循环引用。它们会在函数调用后超出作用域,应该可以释放。然而引用计数算法考虑到2个对象中的每一个至少被引用了一次,因此都不可以被回收。

function f() {
  var o1 = {};
  var o2 = {};
  o1.p = o2; // o1 引用 o2
  o2.p = o1; // o2 引用 o1\. 形成循环引用
}

f();
ログイン後にコピー

 

JavaScript の仕組み

标记清除算法

为了决定一个对象是否被需要,这个算法用于确定是否可以找到某个对象。

这个算法包含以下步骤。

  1. 垃圾回收器生成一个根列表。根通常是将引用保存在代码中的全局变量。在JavaScript中,window对象是一个可以作为根的全局变量。

  2. 所有的根都被检查和标记成活跃的(不是垃圾),所有的子变量也被递归检查。所有可能从根元素到达的都不被认为是垃圾。

  3. 所有没有被标记成活跃的内存都被认为是垃圾。垃圾回收器就可以释放内存并且把内存还给操作系统。

JavaScript の仕組み

上图就是标记清除示意。

这个算法就比之前的(引用计算)要好些,因为“一个对象没有被引用”导致这个对象不能被访问。相反,正如我们在循环引用的示例中看到的,对象不能被访问到,不一定不存在引用。

2012年起,所有浏览器都内置了标记清除垃圾回收器。在过去几年中,JavaScript垃圾回收领域中的所有改进(代/增量/并行/并行垃圾收集)都是由这个算法(标记清除法)改进实现的,但并不是对垃圾收集算法本身的改进,也没有改变它确定对象是否可达这个目标。

推荐 一篇文章 ,其中有关于跟踪垃圾回收的细节,包括了标记清除法和它的优化算法。

循环引用不再是问题

在上面的例子中(循环引用的那个),在函数执行完之后,这个2个对象没有被任何可以到达的全局对象所引用。因此,他们将会被垃圾回收器发现为不可到达的。

JavaScript の仕組み

尽管在这两个对象之间有相互引用,但是他们不能从全局对象上到达。

垃圾回收器的反常行为

尽管垃圾回收器很方便,但是他们有一套自己的方案。其中之一就是不确定性。换句话说,GC是不可预测的。你不可能知道一个回收器什么时候会被执行。这意味着程序在某些情况下会使用比实际需求还要多的内存。在其他情况下,在特别敏感的应用程序中,可能会出现短停顿。尽管不确定意味着不能确定回收工作何时执行,但大多数GC实现都会在分配内存的期间启动收集例程。如果没有内存分配,大部分垃圾回收就保持空闲。参考下面的情况。

  1. 执行相当大的一组分配。

  2. 这些元素中的大部分(或者所有的)都被标记为不可到达的(假设我们清空了一个指向我们不再需要的缓存的引用。)

  3. 没有更多的分配被执行。

在这种情况下,大多数垃圾回收实现都不会做进一步的回收。换句话说,尽管这里有不可达的引用变量可供回收,回收器也不会管。严格讲,这不是泄露,但结果却会占用比通常情况下更多的内存。

什么是内存泄漏

内存泄漏基本上就是不再被应用需要的内存,由于某种原因,没有被归还给操作系统或者进入可用内存池。

JavaScript の仕組み

编程语言喜欢不同的管理内存方式。然而,一段确定的内存是否被使用是一个不可判断的问题。换句话说,只有开发者才能弄清楚,是否一段内存可以被还给操作系统。

某些编程语言为开发者提供了释放内存功能。另一些则期待开发者清楚的知道一段内存什么时候是没用的。Wikipedia有一篇非常好的关于内存管理的文章。

4种常见的JavaScript内存泄漏

1:全局变量

JavaScript用一个有趣的方式管理未被声明的变量:对未声明的变量的引用在全局对象里创建一个新的变量。在浏览器的情况下,这个全局对象是 window 。换句话说:

function foo(arg) {
    bar = "some text";
}
ログイン後にコピー

 

等同于

function foo(arg) {
    window.bar = "some text";
}
ログイン後にコピー

 

如果 bar 被假定只在 foo 函数的作用域里引用变量,但是你忘记了使用 var 去声明它,一个意外的全局变量就被声明了。

在这个例子里,泄漏一个简单的字符串不会造成很大的伤害,但是它确实有可能变得更糟。

另外一个意外创建全局变量的方法是通过 this :

function foo() {
    this.var1 = "potential accidental global";
}

// Foo作为函数调用,this指向全局变量(window)
// 而不是undefined
foo();
ログイン後にコピー

 

为了防止这些问题发生,可以在你的JaveScript文件开头使用 'use strict'; 。这个可以使用一种严格的模式解析JavaScript来阻止意外的全局变量。

除了意外创建的全局变量,明确创建的全局变量同样也很多。这些当然属于不能被回收的(除非被指定为null或者重新分配)。特别那些用于暂时存储数据的全局变量,是非常重要的。如果你必须要使用全局变量来存储大量数据,确保在是使用完成之后为其赋值 null或者重新赋其他值。

2: 被遗忘的定时器或者回调

在JavaScript中使用 setInterval 是十分常见的。

大多数库,特别是提供观察器或其他接收回调的实用函数的,都会在自己的实例无法访问前把这些回调也设置为无法访问。但涉及 setInterval 时,下面这样的代码十分常见:

var serverData = loadData();
setInterval(function() {
    var renderer = document.getElementById('renderer');
    if(renderer) {
        renderer.innerHTML = JSON.stringify(serverData);
    }
}, 5000); //每5秒执行一次
ログイン後にコピー

 

定时器可能会导致对不需要的节点或者数据的引用。

renderer 对象在将来有可能被移除,让interval处理器内部的整个块都变得没有用。但由于interval仍然起作用,处理程序并不能被回收(除非interval停止)。如果interval不能被回收,它的依赖也不可能被回收。这就意味着 serverData ,大概保存了大量的数据,也不可能被回收。

在观察者的情况下,在他们不再被需要(或相关对象需要设置成不能到达)的时候明确的调用移除是非常重要的。

在过去,这一点尤其重要,因为某些浏览器(旧的IE6)不能很好的管理循环引用(更多信息见下文)。如今,大部分的浏览器都能而且会在对象变得不可到达的时候回收观察处理器,即使监听器没有被明确的移除掉。然而,在对象被处理之前,要显式地删除这些观察者仍然是值得提倡的做法。例如:

var element = document.getElementById('launch-button');
var counter = 0;

function onClick(event) {
   counter++;
   element.innerHtml = 'text ' + counter;
}

element.addEventListener('click', onClick);

// 做点事

element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);

// 当元素被销毁
//元素和事件都会即使在老的浏览器里也会被回收
ログイン後にコピー

 

如今的浏览器(包括IE和Edge)使用现代的垃圾回收算法,可以立即发现并处理这些循环引用。换句话说,先调用 removeEventListener 再删节点并非严格必要。

jQuery等框架和插件会在丢弃节点前删除监听器。这都是它们内部处理,以保证不会产生内存泄漏,甚至是在有问题的浏览器(没错,IE6)上也不会。

3: 闭包

闭包是JavaScript开发的一个关键方面:一个内部函数使用了外部(封闭)函数的变量。由于JavaScript运行时实现的不同,它可能以下面的方式造成内存泄漏:

var theThing = null;

var replaceThing = function () {

  var originalThing = theThing;
  var unused = function () {
    if (originalThing) // 引用'originalThing'
      console.log("hi");
  };

  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log("message");
    }
  };
};

setInterval(replaceThing, 1000);
ログイン後にコピー

 

这段代码做了一件事:每次 ReplaceThing 被调用, theThing 获得一个包含大数组和新的闭包( someMethod )的对象。同时,变量 unused 保持了一个引用 originalThing ( theThing 是上次调用 replaceThing 生成的值)的闭包。已经有点困惑了吧?最重要的事情是 一旦为同一父域中的作用域产生闭包,则该作用域是共享的。

这里,作用域产生了闭包, someMethod 和 unused 共享这个闭包中的内存。 unused 引用了 originalThing 。尽管 unused 不会被使用, someMethod 可以通过 theThing 来使用 replaceThing 作用域外的变量(例如某些全局的)。而且 someMethod 和 unused 有共同的闭包作用域, unused 对 originalThing 的引用强制 oriiginalThing 保持激活状态(两个闭包共享整个作用域)。这阻止了它的回收。

当这段代码重复执行,可以观察到被使用的内存在持续增加。垃圾回收运行的时候也不会变小。从本质上来说,闭包的连接列表已经创建了(以 theThing 变量为根),这些闭包每个作用域都间接引用了大数组,导致大量的内存泄漏。

这个问题被Meteor团队发现,他们有描述了闭包大量的细节。

4: DOM外引用

有的时候在数据结构里存储DOM节点是非常有用的,比如你想要快速更新一个表格几行的内容。此时存储每一行的DOM节点的引用在一个字典或者数组里是有意义的。此时一个DOM节点有两个引用:一个在dom树中,另外一个在字典中。如果在未来的某个时候你想要去移除这些排,你需要确保两个引用都不可到达。

var elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image')
};

function doStuff() {
    image.src = 'http://example.com/image_name.png';
}

function removeImage() {
    //image是body元素的子节点
    document.body.removeChild(document.getElementById('image'));

    //这个时候我们在全局的elements对象里仍然有一个对#button的引用。
    //换句话说,buttom元素仍然在内存中而且不能被回收。
}
ログイン後にコピー

 

当涉及DOM树内部或子节点时,需要考虑额外的考虑因素。例如,你在JavaScript中保持对某个表的特定单元格的引用。有一天你决定从DOM中移除表格但是保留了对单元格的引用。人们也许会认为除了单元格其他的都会被回收。实际并不是这样的:单元格是表格的一个子节点,子节点保持了对父节点的引用。确切的说,JS代码中对单元格的引用造成了 整个表格被留在内存中了 ,所以在移除有被引用的节点时候要当心。

我们在SessionStack努力遵循这些最佳实践,因为:

sessionStack を運用アプリケーションに統合すると、DOM の変更、ユーザー操作、JS 例外、スタック トレース、失敗したネットワーク リクエスト、デバッグ情報など、すべてのログが記録されます。

SessionStack を使用すると、アプリケーションの問題を再現し、問題がユーザーに与える影響を確認できます。これらすべてがアプリケーションのパフォーマンスに影響を与えることはありません。ユーザーはアプリケーション内でページをリロードしたりジャンプしたりできるため、すべてのオブザーバー、インターセプター、変数の割り当てを適切に処理する必要があります。これにより、メモリ リークが回避され、アプリケーション全体のメモリ使用量が増加します。

これは今すぐお試しいただける無料プランです。

JavaScript の仕組み


上記の内容は、JavaScript の仕組みを共有するものであり、皆様のお役に立てれば幸いです。

関連する推奨事項:

JavaScript 非同期とは何ですか

JavaScript のさまざまなトラバーサル メソッドの詳細な説明

JavaScript での包括的な分析

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

関連ラベル:
ソース:php.cn
このウェブサイトの声明
この記事の内容はネチズンが自主的に寄稿したものであり、著作権は原著者に帰属します。このサイトは、それに相当する法的責任を負いません。盗作または侵害の疑いのあるコンテンツを見つけた場合は、admin@php.cn までご連絡ください。
最新の問題
人気のチュートリアル
詳細>
最新のダウンロード
詳細>
ウェブエフェクト
公式サイト
サイト素材
フロントエンドテンプレート