手记

JavaScript编译原理与内存管理

编译原理

编译还是解释?

编程语言分为编译型语言和解释型语言两种,编译型语言的源代码在执行之前要进行完全编译,例如 Java ,如果要运行,就需要 Java 虚拟机( JVM )把源代码转换为具体平台上的机器指令去执行。而解释型语言,一边解释一边执行,很明显执行速度会慢于编译型语言。 鉴于 JavaScript 在前端执行,并且只是做一些简单的表单验证等工作,所以,长期以来,市面上的浏览器引擎都是将 JavaScript 作为解释型语言去运行。然而,在过去的几年,JavaScript 受到了更加广泛的应用,引擎对 JavaScript 的处理速度也变得更加重要。谷歌的 V8 引擎就是为解决这一问题而诞生的。

V8 引擎引入了 Java 虚拟机技术,在代码执行前,将源代码编译生成机器码,并使用隐藏类、内联缓存等技术来提高性能,从而让 JavaScript 在 V8 引擎下的运行速度大幅度提高。与传统编译型语言不同的是,JavaScript 的编译工作发生在运行时,也就是代码执行前的几微妙时间内,而且代码并非一次性完全编译,而是在某些代码需要执行时,才会进行编译。

我写的整个" 深入挖掘系列 “手记,都是基于 V8 引擎的,因此很多理论与其他引擎的” 运行时 "理论会略有不同。希望此系列手记,可以起到抛砖引玉的作用。

隐藏类和内联缓存

根据 V8 引擎的运行时理论,代码在编译阶段,最主要的工作就是确定标识符的位置( 作用域链 )和类型( 原型链 ),等到代码执行时,不再需要进行额外的查找,几个机器指令即可完成,节省了大量时间。但与 Java 这种静态类型语言稍有不同,JavaScript 是一种动态类型语言,即标识符的数据类型在编译阶段无法确定。大多数 JavaScript 引擎选择通过字符串匹配来查找属性值,每一次访问某个属性,都需要重新找到其在内存中的位置。如果属性值存取十分频繁,会严重影响性能。为了突破 JavaScript 这种天生的缺陷,V8 引擎采用了隐藏类和内联缓存相结合的机制。这种机制将那些具有相同属性名的对象划归为一类,并把初次查找的属性值缓存起来,当下次查找的时候,优先比较当前对象是否是之前的隐藏类,如果是,就直接使用,如果不是,就再创建一个隐藏类,并缓存起来。

内存管理

内存分配

*以下内容,对象默认包括 Number、Boolean 和 String 这三种包装对象。

首先,V8 引擎在内存使用上,针对不同的操作系统做了相应的限制:32位操作系统约为700M,64为操作系统约为1.4G。 其次,V8 引擎在内存划分上大致可以分为栈内存和堆内存:栈的优势是,存取速度比堆要快,但缺点是,存在栈中的数据大小与生命周期必须是确定的;堆的优势是,可以动态分配内存,数据的生命周期也没有限制,但缺点是,由于要在运行时动态分配内存,存取速度较慢。堆是垃圾收集器活动的区域。

栈内存

在 V8 引擎中,大部分的对象都是直接创建在堆上。但是,如果确定一个对象不会逃逸出方法外( 数据大小与生命周期确定 ),那就让这个对象在栈上分配内存,对象占用的内存也会随着方法的销毁而销毁。保存在栈上,从而减少了临时对象在堆上的分配数量。

堆内存

V8 引擎将堆内存细分为几个不同功能的空间:

  1. 新生代内存区:新创建的对象被分配到这里,属于临时区域。
  2. 老生代内存区:临时区域的对象达到一定的生存率之后,就会被分配到这个区域,主要包括引用值和仍然在使用的基本值( 闭包或全局 )。
  3. 大对象内存区:当数据需要 1M 以上的空间,体积较大,就会被分配到这个区域。

如果再细分,新生代内存区又被平分为两部分,From 区和 To 区,任意时刻只有一个区域的内存被使用。下面" 内存回收 "会详细讲解。其实,无论如何细分,目的只有一个:更快的分配内存和回收内存。

内存回收

回收范围

栈内存不需要进行内存回收,因为存在栈中的数据会随着局部环境的销毁而自动清除,因此只有堆需要内存回收。

如何判断对象不再使用

某个对象已经离开执行环境,并且不再被任何变量引用,就可将其占用的空间释放。

内存回收常用算法

1、标记清除

标记清除是目前主流的内存回收的算法,主要分为两个阶段:标记阶段和清除阶段。这种算法的思想是标记不再使用的对象,然后在清除阶段回收其内存。

2、标记整理

标记整理首先对所有对象进行一次标记,将还在使用的对象压缩到内存的一端,之后,直接清理掉不再使用的内存。

3、复制算法

复制算法将现有的内存空间平分为两部分,每次只使用其中一部分,假设为 From 区和 To 区。创建对象时,会在 From 区分配内存,当垃圾收集器运行时,如果 From 区的对象还在使用,就会被复制到 To 区( 数据体积超过 1M 以上,会直接进入大对象内存区 ),之后, From 区的内存可以直接释放。这一系列完成后,From 区和 To 区会进行角色互换,继续下一次内存回收。

垃圾收集器

JavaScript 是具有自动垃圾收集机制的编程语言,内存分配与内存回收完全是自动化管理。自动垃圾收集机制的最大特点是按固定时间间隔,周期性的执行内存回收。但 JavaScript 的垃圾收集器也有自己的特点:首先,JavaScript 是单线程语言,因此垃圾收集器是串行内存回收,而多线程语言大多是并行内存回收;其次,垃圾收集器在运行时,会独占 CPU,主线程暂停执行。长时间暂停必然影响用户体验,因此 V8 引擎引入了" 标记增量 "的概念,即,将原本一口气完成的内存回收,分步完成,与主线程程序交替进行。

V8 引擎的分代式垃圾回收机制

这种机制将堆内存分为新生代和老生代,并针对两种内存执行不同的算法。

  1. 新生代内存:串行 - 复制算法,并独占 CPU,垃圾收集器运行最为频繁。
  2. 老生代内存:串行 - 标记清除 - 标记整理,并独占 CPU。
  3. 大对象内存区也属于老生代,因此同样适用老生代内存回收的算法,垃圾收集器运行最为不频繁。

内存泄漏

不再使用的内存,没有得到及时释放,就叫做内存泄漏。

常见的内存泄漏

1、意外的全局变量

在函数内部没有使用 var 关键字声明变量或者 this 意外绑定 window ,会创建全局变量,造成内存泄漏。

例子:

function fn() {
    this.name = "Tom";
    age = 20;
}
fn();
console.log(name); //输出:Tom
console.log(age); //输出:20

*全局变量一直处于全局环境,内存不会释放,建议不要储存过大的数据。

2、闭包

闭包会使一些变量无法被及时销毁,造成内存泄漏。

例子:

function fn() {
    var n = 999;
    return function() {
        console.log(n);
    }
}
var f = fn();
f(); //输出:999

*关于闭包是否会造成内存泄漏,一直有争议。个人认为,内存泄露并非闭包的问题。我们会选择主动把一些变量封闭在闭包中,主要考虑的是在不污染全局环境的前提下,以后还会需要使用这些变量,这些变量占用的内存并非是没有用的。所以,闭包并不符合内存泄漏的定义。

3、引用计数垃圾收集机制

引用计数的主要思想是跟踪对象被引用的次数,目前主流引擎都不再使用这种机制。它最大的缺陷是容易造成循环引用,导致内存得不到回收。

*由于 IE9 之前的浏览器采用引用计数垃圾收集机制,所以非常容易出现内存泄漏的问题,但目前的浏览器早已抛弃这种过时的算法,那些早期 IE 浏览器特有的内存泄漏的例子,这里就不再叙述了。


如有错误,欢迎指正,本人不胜感激。

3人推荐
随时随地看视频
慕课网APP