In Bezug auf die Funktionsweise von JavaScript konzentrieren wir uns in diesem Artikel auf die Engine, die Laufzeit und den Aufrufstapel. Der zweite Artikel enthüllt die Interna der V8-JavaScript-Engine von Google und gibt einige Ratschläge, wie man besseren JavaScript-Code schreibt. Außerdem geben wir einige Tipps zum Umgang mit JavaScript-Speicherlecks. Bei SessionStack müssen wir sicherstellen, dass wir keine Speicherlecks verursachen oder den Speicherverbrauch unserer integrierten Webanwendungen erhöhen.
Einige Sprachen, wie z. B. C, verfügen über native Speicherverwaltungsprimitive auf niedriger Ebene, wie malloc() und free(). Entwickler verwenden diese Grundelemente, um Betriebssystemspeicher explizit zuzuweisen und freizugeben.
Im Gegensatz dazu weist JavaScript automatisch Speicher zu, wenn Variablen (Objekte, Zeichenfolgen) erstellt werden, und gibt automatisch Speicher frei, wenn diese Variablen nicht verwendet werden. Dieser Vorgang wird als Garbage Collection bezeichnet. Diese Funktion der „automatischen“ Freigabe von Ressourcen hat viel Verwirrung gestiftet und Entwickler von JavaScript (und anderen Hochsprachen) fälschlicherweise glauben lassen, dass ihnen die Speicherverwaltung egal ist. Das ist ein großer Fehler
Auch wenn Entwickler eine Hochsprache verwenden, sollten sie über ein gewisses Verständnis (zumindest ein Grundverständnis) der Speicherverwaltung verfügen. Manchmal gibt es Probleme mit der automatischen Speicherverwaltung (z. B. ist die Implementierung der Garbage Collection möglicherweise fehlerhaft oder unzureichend), und Entwickler müssen diese Probleme verstehen, um eine geeignete Lösung zu finden.
Egal welche Programmiersprache Sie verwenden, der Speicherlebenszyklus ist fast immer derselbe:
Hier eine Übersicht was in jedem Schritt des Zyklus passiert:
Dies ist eine Übersicht über jeden Schritt im Lebenszyklus:
Speicher zuweisen – Speicher wird vom Betriebssystem zugewiesen, sodass Programme ihn verwenden können. In einer Low-Level-Sprache (z. B. C) ist dies eine explizite Operation, mit der Sie als Entwickler umgehen müssen. In Hochsprachen werden diese Vorgänge jedoch im Auftrag des Entwicklers abgewickelt.
Speicher nutzen. Nutzen Sie tatsächlich den zuvor zugewiesenen Speicher, indem Sie Variablen im Code manipulieren, um intern zu lesen und zu schreiben.
Speicher freigeben. Bei Nichtgebrauch kann der Speicher zur Neuzuweisung freigegeben werden. Ebenso wie die Zuweisung von Speicheroperationen erfordert die Freigabe von Speicher auch explizite Operationen in Low-Level-Sprachen.
Wenn Sie die Konzepte von Stack und Speicher schnell verstehen möchten, können Sie den ersten Artikel dieser Reihe lesen.
Was ist Gedächtnis?
Bevor wir Gedächtnis in Javascript direkt besprechen, wollen wir kurz besprechen, was Gedächtnis ist und wie Gedächtnis funktioniert.
In der Hardware enthält der Speicher des Computers eine große Anzahl von Triggerschaltungen, und jede Triggerschaltung enthält einige span>Transistor. Trigger werden durch eine eindeutige Kennung angesprochen, sodass sie gelesen und überschrieben werden können. Daher kann man sich den Computerspeicher konzeptionell als ein riesiges Lese-/Schreib-Array vorstellen.
Menschen sind nicht gut darin, alle unsere Gedanken und Arithmetik mithilfe von Bitoperationen auszudrücken. Wir organisieren diese kleinen Dinge zu großen Dingen, und diese großen Dinge können zur Darstellung von Zahlen verwendet werden: 8 Bits sind ein Byte. Die oben genannten Bytes sind Wörter (16-Bit, 32-Bit).
Viele Dinge werden im Speicher gespeichert:
Alle im Programm verwendeten Variablen und Daten
Programmcode, einschließlich Betriebssystemcode.
Der Compiler und das Betriebssystem arbeiten zusammen, um Entwicklern dabei zu helfen, den Großteil der Speicherverwaltung zu übernehmen. Wir empfehlen Ihnen jedoch, zu verstehen, was unter der Haube vor sich geht.
Beim Kompilieren von Code analysiert der Compiler die primitiven Datentypen und berechnet im Voraus, wie viel Speicherplatz sie benötigen. Weisen Sie dann die erforderliche Menge an Stapelplatz zu. Er wird als Stapelspeicher bezeichnet, da beim Aufrufen von Funktionen deren Speicher zum vorhandenen Speicher hinzugefügt wird (d. h., oben im Stapel wird ein Stapelrahmen hinzugefügt, der auf den Speicherplatz zeigt, in dem die internen Variablen der Funktion gespeichert sind). Bei der Beendigung werden diese Anrufe in der LIFO-Reihenfolge (Last In, First Out) entfernt. Zum Beispiel:
int n; // 4字节 int x[4]; // 4个元素的数组,每个元素4字节 double m; // 8字节
Der Compiler weiß sofort, dass er Speicher benötigt
4 + 4 × 4 + 8 = 28 Bytes.
Dies ist die aktuelle Größe von Ganzzahlen und Doppelzahlen. Vor etwa 20 Jahren benötigten Ganzzahlen normalerweise nur 2 Bytes, Doppelte 4 Bytes, und Ihr Code war nicht durch die Größe des zugrunde liegenden Datentyps beschränkt.
Der Compiler fügt Code ein, der mit dem Betriebssystem interagiert, um die erforderliche Bytegröße auf dem Stapel zum Speichern von Variablen anzufordern.
Im obigen Beispiel kennt der Editor die genaue Adresse jeder Variablen. Tatsächlich wird jedes Mal, wenn wir in die Variable n schreiben, diese intern in etwas wie „Speicheradresse 4127963“ übersetzt.
注意,如果我们尝试访问 x[4] 的内存(开始声明的x[4]是长度为4的数组, x[4] 表示第五个元素),我们会访问m的数据。那是因为我们正在访问一个数组里不存在的元素,m比数组中实际分配内存的最后一个元素 x[3] 要远4个字节,可能最后的结果是读取(或者覆盖)了 m 的一些位。这肯定会对其他程序产生不希望产生的结果。
当函数调用其他函数的时候,每一个函数被调用的时候都会获得自己的栈块。在自己的栈块里会保存函数内所有的变量,还有一个程序计数器会记录变量执行时所在的位置。当函数执行完之后,会释放它的内存以作他用。
动态分配
不幸的是,事情并不是那么简单,因为在编译的时候我们并不知道一个变量将会需要多少内存。假设我们做了下面这样的事:
int n = readInput(); //读取用户的输入 ... //创建一个有n个元素的数组
编译器不知道这个数组需要多少内存,因为数组大小取决于用户提供的值。
因此,此时不能在栈上分配空间。程序必须在运行时向操作系统请求够用的空间。此时内存从 堆空间 中被分配。静态与动态分配内存之间的不同在下面的表格中被总结出来:
静态分配内存与动态分配内存的区别。
为了完全理解动态内存是如何分配的,我们需要花更多的时间在 指针 上,这个可能很大程度上偏离了这篇文章的主题。如果你有兴趣学习更多的知识,那就在评论中让我知道,我就可以在之后的文章中写更多关于指针的细节。
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中,window对象是一个可以作为根的全局变量。
所有的根都被检查和标记成活跃的(不是垃圾),所有的子变量也被递归检查。所有可能从根元素到达的都不被认为是垃圾。
所有没有被标记成活跃的内存都被认为是垃圾。垃圾回收器就可以释放内存并且把内存还给操作系统。
上图就是标记清除示意。
这个算法就比之前的(引用计算)要好些,因为“一个对象没有被引用”导致这个对象不能被访问。相反,正如我们在循环引用的示例中看到的,对象不能被访问到,不一定不存在引用。
2012年起,所有浏览器都内置了标记清除垃圾回收器。在过去几年中,JavaScript垃圾回收领域中的所有改进(代/增量/并行/并行垃圾收集)都是由这个算法(标记清除法)改进实现的,但并不是对垃圾收集算法本身的改进,也没有改变它确定对象是否可达这个目标。
推荐 一篇文章 ,其中有关于跟踪垃圾回收的细节,包括了标记清除法和它的优化算法。
循环引用不再是问题
在上面的例子中(循环引用的那个),在函数执行完之后,这个2个对象没有被任何可以到达的全局对象所引用。因此,他们将会被垃圾回收器发现为不可到达的。
尽管在这两个对象之间有相互引用,但是他们不能从全局对象上到达。
垃圾回收器的反常行为
尽管垃圾回收器很方便,但是他们有一套自己的方案。其中之一就是不确定性。换句话说,GC是不可预测的。你不可能知道一个回收器什么时候会被执行。这意味着程序在某些情况下会使用比实际需求还要多的内存。在其他情况下,在特别敏感的应用程序中,可能会出现短停顿。尽管不确定意味着不能确定回收工作何时执行,但大多数GC实现都会在分配内存的期间启动收集例程。如果没有内存分配,大部分垃圾回收就保持空闲。参考下面的情况。
执行相当大的一组分配。
这些元素中的大部分(或者所有的)都被标记为不可到达的(假设我们清空了一个指向我们不再需要的缓存的引用。)
没有更多的分配被执行。
在这种情况下,大多数垃圾回收实现都不会做进一步的回收。换句话说,尽管这里有不可达的引用变量可供回收,回收器也不会管。严格讲,这不是泄露,但结果却会占用比通常情况下更多的内存。
什么是内存泄漏
内存泄漏基本上就是不再被应用需要的内存,由于某种原因,没有被归还给操作系统或者进入可用内存池。
编程语言喜欢不同的管理内存方式。然而,一段确定的内存是否被使用是一个不可判断的问题。换句话说,只有开发者才能弄清楚,是否一段内存可以被还给操作系统。
某些编程语言为开发者提供了释放内存功能。另一些则期待开发者清楚的知道一段内存什么时候是没用的。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努力遵循这些最佳实践,因为:
Sobald Sie sessionStack in Ihre Produktionsanwendung integrieren, beginnt es, alles zu protokollieren: DOM-Änderungen, Benutzerinteraktionen, JS-Ausnahmen, Stack-Traces, fehlgeschlagene Netzwerkanforderungen, Debugging-Informationen und mehr.
Mit SessionStack können Sie Probleme in Ihrer Anwendung wiedergeben und deren Auswirkungen auf Benutzer sehen. All dies hat keine Auswirkungen auf die Leistung Ihrer Anwendung. Da der Benutzer die Seite neu laden oder innerhalb der Anwendung springen kann, müssen alle Beobachter, Interceptoren und Variablenzuweisungen entsprechend behandelt werden. Dies vermeidet Speicherlecks und erhöht die Speichernutzung der gesamten Anwendung.
Dies ist ein kostenloser Plan, den Sie jetzt ausprobieren können.
Der obige Inhalt ist ein Austausch darüber, wie JavaScript funktioniert. Ich hoffe, dass er allen helfen kann.
Verwandte Empfehlungen:
Detaillierte Erläuterung verschiedener JavaScript-Traversalmethoden
Umfassende Analyse dazu in JavaScript
Das obige ist der detaillierte Inhalt vonSo funktioniert JavaScript. Für weitere Informationen folgen Sie bitte anderen verwandten Artikeln auf der PHP chinesischen Website!