开发者社区 > 博文 > V8引擎的内存管理分析
分享
  • 打开微信扫码分享

  • 点击前往QQ分享

  • 点击前往微博分享

  • 点击复制链接

V8引擎的内存管理分析

  • jd****
  • 2021-02-05
  • IP归属:北京
  • 54160浏览

    概述

    作为目前最受欢迎的JavaScript引擎,V8引擎从一出现就因其出色的性能而备受关注,更是帮助Chrome确立了浏览器霸主的地位。从浏览器结构来看,V8引擎位于Chrome最底层的webkit中(后来Chrome对webkit进行了再封装,称为blink,关于浏览器结构,请参考之前的一篇文章:浏览器(基于Chromium)运行机制剖析),用于替换webkit默认的JavaScript引擎 - JavaScriptCore。下面我们先从JavaScript的变量定义着手,来理解V8是如何进行内存管理的。

    JavaScript的变量类型(以ES5为例)

    在ES5规范中,JavaScript总共包含5种基本数据类型:Number、Boolean、String、null、undefined(ES6新增了Symbol);其余的类型统称为引用类型,比如Function、Object、Array、RegExp(正则表达式)等。引用类型中除Object以外的所有类型均继承自Object(所以我们一般把它们都称为对象)。

    另外,对于Number、String和Boolean三种数据类型。js都为他们定义了对应的包装类型,用于将其封装成引用类型,用法如下:

    var a = new String("你好");
    var b = new Number(123);
    var c = new Boolean(true);
    

    这样a、b、c三个变量保存的都不再是基本数据类型,而是一个对象,因此我们可以直接调用一些对象的基本方法,如toString()。实际上每当JavaScript引擎读取一个基本数据类型时,它都会在后台创建对应的引用类型,这也是我们能在一个基本数据类型上调用对象方法的原因。如下面的代码在JavaScript中都可以正常运行:

    var a = 123;
    var b = new Number(123);
    a.toString();   //输出“123”
    b.toString();   //同样输出“123”
    

    此外这三个构造函数还可以直接作为转型函数使用,用于将基本数据类型转为对象,如:

    var a = 123;
    var b = Number(a);  //现在b是一个Number类型的对象
    

    实际上对于JavaScript来说,我们声明的所有变量都只是保存了内存中某块区域的地址而已(当变量未初始化或被赋值为undefined时是个例外,因为它表示该变量不指向任何内存地址),因此我们后面也按照C语言的习惯把变量称为指针。比如:

    var a = {};
    var b = 123;
    var c = null;
    

    这里的a保存的是新创建的空对象 {} 在内存中的地址,b保存的是数字 123 在内存中的地址,而c保存的是null在内存中的地址(没错,null在JavaScript中是实际存在的,并且有自己的地址,因此将变量初始化为null和不初始化是不一样的)。

    理解上面这段话对我们学习V8的内存模型至关重要,现在我们可以开始探索V8的内存模型了。

    V8的内存模型

    我们先来看一张内存模型图(图片来自网易杨钰老师):

    在这里插入图片描述

    从图片中可以看出,内存区主要可以分为以下几类:栈区、堆区、常量区、函数定义区、函数缓存区,而后三者也可以认为是有特殊用途的堆区,这三块内存区完全由引擎控制,我们无法直接操作。

    下面我们分别来看一下各个内存区的用途。

    1. 栈区

    从上面对JavaScript变量的介绍中我们知道,JavaScript的变量名是用来保存内存中某块内存区的地址的,而栈区就是用来保存变量名和内存地址的键值对的。举个例子:

    var a = {};
    

    对于该语句,V8会在堆区中开辟一块内存,然后在栈区添加一个键值对,键名是我们声明的变量名a,键值是堆区中开辟出的内存的地址,图示如下:

    在这里插入图片描述

    现在我们就可以通过变量名a来操作堆区中的这块内存了。如果将变量赋值为undefined或者未进行初始化,如:

    var a;
    var b = undefined;
    

    这两种情况下,栈区中的a和b对应的内存地址都是空的,也就是不指向任何内存地址。因此为变量赋值undefined是没有任何意义的。

    2. 堆区

    堆区是V8引擎为开发者提供的可直接操作的内存区。比如上面我们通过 var a = {}; 语句获得了堆区中的一块内存,那我们就可以通过存在栈区的对象名a直接操作该内存,比如:

    a.name = "夕山雨";  //向该对象添加一个name属性
    delete a.name;    //删除对象的name属性
    

    注意,在堆区中存在一个特殊的预置对象null,它在堆区中有固定的内存地址,并且是唯一的。也就是说所有被赋值为null的变量指向的都是这同一块内存地址(因此被赋值为null的变量也是有内存地址的)。

    如果要手动释放一块内存,我们可以通过让变量名指向null来实现,如对于上面的例子,如果我们写:

    a = null;
    

    现在我们就手动切断了变量a与刚才那块内存之间的联系,而让a指向了null,在之后的某个时间点(取决于浏览器何时进行垃圾回收),浏览器的垃圾回收机制就会通过标记清除法释放那块未被任何变量引用的内存。

    3. 常量区

    顾名思义,常量区就是用来存储常量的。在JavaScript中(基于ES5),总共存在三类常量,分别是基本数据类型中的Number、String和Boolean。也就是说这三类值都是存储在常量区中的。如:

    var a = 123;
    var b = "你好";
    var c = true;
    

    现在常量区中就新增了三个常量:123、“你好”和true,而a、b、c分别存储了这三个值在常量区的地址,我们可以通过变量名来引用这几个常量。

    注意,常量区有两个重要特点:

    1. 所有的值都是不可变的
    2. 所有相同的常量值在常量区都是惟一的。

    对于特点1我们很容易理解,这也是常量区之所以称为常量区的原因。那有人可能会问,如果我写了下面的代码:

    b = "这是一个新字符串";
    

    不就是修改了原来那个字符串b的值了吗?

    实际情况并不是这样的!当你为b重新赋值的时候,V8会在常量区新开辟一块内存来保存这个新的字符串,然后将b指向这块新的内存,随后之前的字符串所在的内存就会被垃圾回收机制回收。Number和Boolean也是同样的道理。

    对于特点2,我们可以理解为,V8只会为同一个常量值开辟一块内存。比如:

    var a = 123;
    var b = 123;
    

    实际上在常量区只存在一个数值 123,而a和b都是指向这个地址的(这也是为什么我们写 a === b会返回true,因为两者指向的地址是完全一致的)。

    4. 函数定义区

    显然,函数定义区就是用来存储我们定义的函数的,包括普通函数和构造函数(实际上两者的定义没有本质差别,只是调用方式不同)。比如我们通过以下两种方式定义的函数:

    //函数声明
    function f(){
      ...
    }
    //函数引用
    var f = function(){
     ...
    }
    

    这两个函数都会被存储到函数定义区,用于之后的调用。但是这两种函数定义方式的原理是不同的。

    第一种我们称为函数声明,这种定义方式不会在栈区生成对应的函数名(因为它不是一个变量)。引擎会直接在函数定义区定义这个函数,我们在调用这个函数的时候,引擎会去函数定义区搜索这个函数名进行调用。

    第二种我们称为函数引用,这种方式会在栈区生成一个变量来保存这个函数的地址。由于function后面没有跟任何字符串,我们称这个函数为匿名函数(没有名字的函数),我们需要通过栈区的变量f来引用它。

    两种定义方式在调用的时候会表现出一些不同。首先,对于第一种方式,V8引擎会在预扫描阶段进行函数提升,也就是说,你可以在函数定义之前调用该函数;但是对于第二种方式,尽管引擎也会进行变量提升,但是并不会在提升的时候对变量赋值,因此不可以在定义之前调用该函数。实例如下:

    //可以正常调用,因为引擎会提前扫描代码,将该函数存储到函数定义区
    f();
    function f(){}
    
    //报错,因为虽然g也进行了变量提升,但此时g的值是undefined,不能调用
    g();
    var g = function(){}
    
    

    另外,如果函数名发生了重名,浏览器会以通过栈区变量引用的函数优先。如:

    var f = function(){}
    
    function f(){}
    //会调用第一个函数
    f();
    

    之所以出现这种情况,是因为JavaScript引擎总是优先搜索栈区,所以上面的函数会优先被调用。但是如果调用发生在函数定义之前,那么就会调用通过函数声明定义的函数,代码如下:

    //会调用下面的以函数声明定义的函数
    f();
    var f = function(){}
    //这个函数被调用
    function f(){}
    

    究其原因,还是在调用函数时变量f的值为undefined,因此引擎才会去函数定义区搜索函数f。总的来说,引擎在调用函数时会以栈区的变量优先,如果搜索不到或为undefined,则会去函数定义区搜索。

    但是两者实际上并不冲突,我们同样可以用一个变量来指向一个声明式函数,如:

    function f(){}
    var g = f;
    

    现在变量g也拿到了函数f的内存地址,使用g同样可以访问该函数。

    5. 函数缓存区

    所谓函数缓存区,就是函数运行所用的内存区。当V8引擎需要执行一个函数时,它就会在函数缓存区开辟一块内存,保存该函数运行所需要存储的状态和变量。通常情况下,函数一旦运行完毕,浏览器就会回收这块内存,但闭包是个例外。比如下面的代码:

    function f(){
      var a = 123;
      //我们返回了一个函数,里面需要引用位于该函数外的一个变量
      return function(){
    	console.log(a);
      }
    }
    
    var g = f();
    

    现在当函数f执行完后,我们通过变量g拿到了调用f函数返回的匿名函数。按照正常的逻辑来说,执行函数f时所分配的缓存区已经可以释放了。但是等一下,我们的变量g所持有的那个匿名函数还可以访问该缓存区中定义的变量a(JavaScript的作用域链允许我们访问在函数外部定义的变量和函数),也就是说如果我们像下面这样调用g:

    //尽管f函数运行完毕了,但我们仍希望控制台输出a的值123,
    //因此为f开辟的缓存区不能释放(否则将找不到a)
    g();  
    

    如果引擎释放了运行f时开辟的内存,那么a将随之释放,上面的语句就会报错。为了让我们能正常访问变量a,引擎不会释放这块缓存区!这样除非我们将g重新赋值,释放了对之前的匿名函数的引用,否则这块内存将永远不会释放。

    现在在函数的缓存区就形成了一个闭包。为运行f函数分配的内存中的变量在外部是无法直接访问的,唯一的访问方式就是通过f返回的那个匿名函数,这样我们就很好地保护了函数f内部定义的变量,并向外部暴露出了指定的接口。除了上面的形式,我们可能还会见到如下的代码:

    (function(global, factory){
      factory(global);
    })(window, function(window){
      var jQuery = function(selector, context){
        //这使你可以不通过new关键字来构造jQuery对象
    	return new jQuery.fn.init( selector, context );
      }
      jQuery.prototype.init = function(){ ... }
      ...
      $ = jQuery = jQuery;
    })
    

    上面的代码是jQuery的一个“精简”版本(简化掉一万多行~)。将上面的代码再次简化,就变成了下面的样子:

    (function(){
    
    })()
    

    这就是一个闭包!我们在这里定义了一个匿名函数,然后通过在它后面加一对小括号的方式立即调用它。在函数内部我们定义了一个名为jQuery的对象,给它扩展了很多属性方法,最后将这个变量添加到全局对象window上。于是当匿名函数执行完毕,我们的全局对象window上就扩展了一个属性jQuery,我们可以通过jQuery对象来访问这个匿名函数内的变量和函数,但无法通过其他任何方式来访问该区域。这样我们就把这个匿名函数的内部保护了起来,只通过一个全局对象向外提供接口,这样就很好地避免了命名冲突。这也是ES5中模拟块级作用域的通用方法。

    理解内存模型可以帮助我们解决哪些疑惑?

    1. 关于==和===

    在JavaScript中,无论是==还是===,实际上都是在比较内存地址。我们来看几个例子(以===为例):

    var a = 1;
    var b = 1;
    a === b;   //值为true,同一个常量在常量区只会生成一个,因此两者得到地址是一样的
    
    var a = {};
    var b = {};
    a === b;    //值为false,引擎会分别为a和b开辟内存,因此两者的地址并不相同
    
    null === null;  //值为true,因为堆区只有一个null
    [] === [];  //值为false,原理与{}相同
    

    对于==,实际上也是地址比较,但是与===不同的是,==会先尝试对不同类型的数据进行类型转换。比如在判断123与“123”的值时,JavaScript引擎会将数值类型的123转换为字符串“123”,然后再与“123”比较,发现两者相等。因此:

    123 == "123";   //值为true,因为类型转换后两者相等
    123 === "123";  //值为false,===不会进行类型转换
    

    两者的差别不是本文的重点,因此这里不再进行深入探讨。

    2. 变量和函数提升

    这个问题在函数缓存区的讲解中已经提及,这里再进行一些补充说明。

    JavaScript引擎在执行代码之前,会首先扫描一次代码,将通过函数声明(即function f(){})定义的函数预先保存在函数定义区,并将所有通过var声明的变量先添加到栈中。注意,经过提升的函数此时已经可以直接调用了,但是变量的值仍然是undefined(变量提升并不会为其赋值,因为还没有执行代码)。这个过程就是变量和函数提升。

    在ES6中新增了let关键字用于定义变量,而使用let定义的变量不会发生变量提升(ECMAScript标准规定的,浏览器也是按照标准来实现的),所以在使用let定义变量之前,如果调用这个变量,就会报错。如下:

    a = 123;  //报错,变量a未定义
    let a;
    

    此外,let关键字定义的变量只在所在的块级作用域内有效,因此我们不需要通过闭包来模拟块级作用域了。

    3. 函数也是对象

    这一点对于没有学过JavaScript的开发者来说,理解起来可能会比较吃力。我们上面说到,一个函数所对应的就是函数定义区的一块内存,那么JavaScript引擎是如何构造出一个函数的呢?就是使用Function构造函数了(没错,JavaScript引擎使用构造函数来构造一个函数)。比如下面的例子:

    var msg = "你好";
    var f = new Function("console.log(msg)", msg);
    

    现在f就是一个函数了。构造出来的f是长这样的:

    var f = function(msg){
      console.log(msg);
    }
    

    上面两种定义函数的方式在JavaScript中是等价的。既然f是通过一个构造函数产生的,那么它理所当然就是一个对象了,因此它可以直接调用为对象定义的一些公共方法,如f.toString()。也正是因为这样,函数本身可以直接扩展属性,甚至是方法,比如:

    function f(){
      
    }
    f.a = 1;
    f.getName = function(){}
    

    上面的赋值都是合法的,因为f本身就是一个对象!

    利用这个特性,我们可以对函数的运算值进行缓存,比如下面的例子:

    function f(name){
      //假设这里我们进行了相当复杂的计算,并使用result保存了运算结果
      var result;
      ...
      //现在我们将这个值保存在函数中(注意,这里的f是一个函数,同时也是个对象)!
      f[name] = result;
    }
    

    那么下次我们需要调用这个函数时就可以先检查函数的缓存中是否已经保存了计算结果,如果已经保存了,我们直接从缓存中取用即可。比如:

    var name = "123",result;
    if(f[name]){
      //如果缓存中存储了该变量对应的计算结果,直接返回
      result = f[name];
    } else {
      //否则我们将调用f计算该值
      result = f(name);
    }
    

    这对于那些运算量特别大的函数来说是非常方便的。

    此外,因为JavaScript引擎会把基本数据类型的数据包装为对象,因此也有人说,在JavaScript中,一切皆对象。

    4. 普通函数与构造函数(this的指向问题)

    对于普通函数和构造函数,可能很多人都知道的是,普通函数的名字首字母要小写,并且通常是动词,而构造函数需要大写,通常是名词;另外普通函数是直接调用,而构造函数则需要通过new关键词来调用。那么两者从内存管理的角度究竟有什么区别呢?

    实际上首字母大小写以及是动词还是名词只是一种通用约定,它并不会对函数的执行造成任何影响。而是否通过new关键字来调用才是两者的根本区别。没有使用new关键字调用的函数,我们就认为是普通函数,而通过new关键字调用的,就是一个构造函数。如下:

    function getName(){
    
    }
    function Student(){
    
    }
    var name = getName();
    var student = new Student();
    

    这里第一个函数就是作为普通函数使用,而第二个就是构造函数。那么在JavaScript引擎中,两者的执行过程有什么区别呢?这就要从this关键字说起了。

    this是一个指向调用当前函数的对象的指针。简单来说,如果你写:

    Student.prototype.getName = function(){
      return this.name;
    }
    student.name = "夕山雨";
    student.getName();
    

    这里我们向Student的原型上添加了一个getName方法,它可以被所有Student构造出的对象使用。当使用student调用getName时,它内部的this就是student对象,因此就会返回“夕山雨”。如果我们没有通过任何对象调用函数,如直接在全局环境下写:

    getName();
    

    这时函数的调用者默认就是浏览器的全局对象window,因此这种写法在浏览器环境下等价于getName(),返回的就是window对象的name(在全局环境定义的所有函数和变量默认都属于全局对象)。

    而普通函数和构造函数在执行时的最大差别就是,前者的this就指向调用它的对象,而后者会在堆区创建一块内存,并将this指向这块内存。也就是说,如果你写了下面的代码:

    var student = new Student();
    

    JavaScript引擎会为你在堆区开辟一块内存,然后将this指向这块内存,之后你所有通过this进行的操作都是对这块内存而言的。引用网易杨钰老师的一句话:调用普通函数是为了获得一个计算结果,而调用构造函数则是为了获得一块可操作的内存。

    通过上面的解析,我们可以很容易理解apply和call的机制,无非就是将函数内的this指向传入的第一个参数(即this = arguments[0])。这样函数就相当于是被传入的第一个参数调用的了。

    5. ES6的const

    ES6中新增了一个关键字const,用来定义一个常量。注意,这里指的常量不是完全不可变,而是变量指向的内存地址是不可变的。如:

    const a = 1;
    a = 2;    //报错,a不可变
    
    const b = {};
    b = {};  //报错,这样赋值会修改b指向的内存地址
    b.name = "夕山雨";  //不报错,因为b指向的内存地址没有变
    

    从上面的例子很容易可以看出,用const定义变量,就是指定了它指向的内存地址不可变,但该内存区存储的内容仍然是可变的。实际上,一旦const定义的变量被赋值为一个基本类型的数据(Number、String、Boolean、null、undefined,虽然赋值为null和undefined没有什么意义,但测试证明不会报错),该变量就不可以再修改(即使修改为相同的值也不可以):

    const a = 1;
    a = 2;   //报错,1和2有不同的内存地址
    a = 1;  //同样报错,因为引擎一旦发现可能要操作变量地址
            //就会立即抛出错误
    

    对于Object及继承自Object的类型(如Array)来说,只要不修改变量指向的内存地址,就不受const的限制。需要注意的是,使用const定义变量必须初始化,否则会立即报错。

    const arr = [];
    arr.push(1);  //正常执行
    
    const a;  //报错,const定义的变量必须初始化
    

    补充知识点 - V8的垃圾回收机制

    V8的垃圾回收机制采用的是标记清除法,这也是现在JavaScript引擎通用的一种回收机制。

    首先我们说一下为什么要进行垃圾回收。当我们申请了堆区的一块内存后,引擎就会将这块内存标记为已分配,如果我们的系统十分庞大,就可能需要申请很多的内存,如果不进行回收,很快就会出现内存溢出,导致浏览器崩溃。因此引擎必须回收那些分配出去,但已不可访问的内存(当没有一个指针指向该内存区,它就变成不可访问的内存)。如:

    //引擎在内存开辟了一块内存,用a指向这块内存
    var a = new Student();
    //现在a不再指向那块内存,那块内存也就无法再访问了
    a = 1;
    

    对于上面的情况,JavaScript引擎就必须自动回收最初分配给a的那块内存,这就是垃圾回收机制要做的事。

    标记清除法主要分三步:

    1. 将所有的指针(变量名)和分配出去的内存打上标记。
    2. 从栈区开始查找所有可用的变量名,清除这些变量名的标记,并且清除它们指向的内存的标记。
    3. 标记清除结束后,回收所有仍然带有标记的内存(说明没有一个有效的变量名指向这块内存)。

    上述过程的第二步是一个递归的过程,它基于一个原则:既然栈区的变量名指向的内存是可访问的,那么该内存区中定义的变量名所指向的内存也是可访问的,以此类推。

    标记清除法很好地避免了旧版IE使用的引用计数法可能带来的循环引用导致内存无法释放的问题,这里就不再讨论了。

    总结

    以上就是V8内存模型的大致结构。通过学习该内存模型,我们可以理解很多JavaScript的底层原理,对我们学习JavaScript有很大的帮助。希望大家在日常工作中善于利用该模型分析问题、解决问题。