《WebKit 技术内幕》之九(2): JavaScript引擎

发布时间:2024年01月22日

2 V8引擎

2.1 基础

????????V8是一个开源项目,也是一个JavaScript引擎的实现。它最开始是由一些语言方面的专家设计出来的,后被Google收购,成为了JavaScript引擎和众多相关技术的引领者。其目的很简单,就是为了提高性能。因为在当时之前的JavaScriptCore引擎和其他的JavaScript引擎的性能都不能令人非常满意。为了提高JavaScript代码的执行效率从而获得更好的网页浏览效果,它甚至采用直接将JavaScript编译成本地代码的方式。

????????V8支持众多的操作系统,包括但是不限于Windows、Linux、Android、Mac OS X等。同时它也能够支持众多的硬件架构,例如IA32、X64、ARM、MIPS等。这么看来,它将主流软硬件平台一网打尽,由于它是一个开源项目,开发者可以自由使用它的强大能力,一个例子就是目前炙手可热的NodeJs项目,它就是基于V8项目研发的。开源的好处就是大家可以很方便地学习、贡献和使用,就让我们首先从它的代码结构开始。

2.1.1 代码结构

????????V8的代码量超过50万行,应该也算一个中型的项目,但是它的代码结构其实非常的简单,如图9-9所示。对于想了解V8的读者来说,建议将目光主要放在两个主要目录“include”和“src”中,它们一个是包含了V8对外的接口,一个是包含了V8内部的实现,其他都是一些辅助的工具和与测试相关的设施。图9-9中只列出了一些主要目录和文件,以及它们的介绍,对于其他内容,读者可以自行参阅源代码加以理解。

????????????????????????????????????????图9-9 V8项目的代码结构

2.1.2 应用程序编程接口(API)

????????要了解V8的内部工作机制或者说原理,有必要先了解V8所提供的应用编程接口,它们在V8代码目录的include/V8.h中,通过接口中的某些类可以一窥V8的工作方式,其中一些主要的类如下。

  • 各种各样的基础类?:这里面包含对象引用类(如WeakReferenceCallbacks)、基本数据类型类(如Int32、Integer、Number、String、StringObject)和JavaScript对象(Object)。这些都是基础抽象类,没有包含实际的实现,真正的实现在“src”目录中的“objects.h/cc”中。
  • VaIue?:所有JavaScript数据和对象的基类,如上面的Integer、Number、String等。
  • V8数据的句柄类?:以上数据类型的对象在V8中有不同的生命周期,需要使用句柄来描述它们的生命周期,以及垃圾回收器如何使用句柄来管理这些数据,句柄类包括Local、Persistent和Handle。
  • IsoIate?:这个类表示的是一个V8引擎实例,包括相关状态信息、堆等,总之这是一个能够执行JavaScript代码的类,它不能被多个线程同时访问,所以,如果非要这么做的话,需要使用锁。V8使用者可以使用创建多个该类的实例,但是每个实例之间就像这个类的名字一样,都是孤立的。
  • Context?:执行上下文,包含内置的对象和方法,如print方法等,还包括JavaScript内置的库,如math等。
  • Extension?:V8的扩展类。用于扩展JavaScript接口,V8使用者基于该类来实现相应接口,被V8引擎调用。
  • Handle?:句柄类,主要用来管理基础数据和对象,以便被垃圾回收器操作。主要有两个类型,一个是Local(就是Local类,继承自Handle类),另一个是Persistent(Persistent类,继承自Handle类)。前者表示本地栈上的数据,所以量级比较轻,后者表示函数间的数据和对象访问。
  • Script?:用于表示被编译过的JavaScript源代码,V8的内部表示。
  • HandleScope?:包含一组Handle的容器类,帮助一次性删除这些Handle,避免重复调用。
  • FunctionTemplate?:绑定C++函数到JavaScript,函数模板的一个例子就是将JavaScript接口的C++实现绑定到JavaScript引擎。
  • ObjectTemplate?:绑定C++对象到JavaScript,对象模板的典型应用是Chromium中将DOM节点通过该模板包装成JavaScript对象。

????????读者看到这里,可能对一些类的描述不是很理解,这是因为缺少对V8中一些基本概念的认识,希望后面的解释能够帮助到你。下面通过一个例子来了解V8使用者是如何调用这些接口以使用V8引擎来执行JavaScript代码的。

2.1.3 接口使用示例

????????通过调用这些编程接口和对应的内存管理方式,希望读者能够初步理解V8的工作方式。图9-10是来自V8项目官方网站上的图片,主要描述使用V8的基本流程和这些接口对应的内存管理方式。

????????????????图9-10 调用V8编程接口的例子和对应的内存管理方式

????????图中没有描述如何创建一个Isolate对象,此对象可以通过Isolate::GetCurrent()来获取,它会创建一个V8引擎示例,后面的操作都是在它提供的环境中来进行的。

????????图中第一条语句表示建立一个域,前面也介绍了,用于包含一组Handle对象,便于释放它们。

????????图中第二条语句根据Isolate对象来获取一个Context对象,使用Handle来管理。Handle对象本身存放在栈上,而实际的Context对象保存在堆中。

????????图中第三条语句根据两个对象Isolate和Context来创建一个函数间使用的对象,所以使用Persistent类来管理,这里展示的是它的用处和含义,在本例中不是必需的。读者可以看到它的句柄和数据都单独存储在另外的地方。

????????图中第四条表示为Context对象创建一个基于栈的域,下面的执行步骤都是在该域中对应的上下文中来进行的。

????????图中第五条是从命令行参数读入JavaScript代码,也就是一段字符串。

????????图中第六条将代码字符串编译成V8的内部表示,并保存成一个Script对象。

????????图中第七条是执行编译后的内部表示,然后获得生成的结果。

????????一个典型的使用V8编程接口的例子就是V8项目提供的D8工具。它通过V8的接口来实现一个可执行程序,因为V8本身只是一个C++库而已。该可执行程序能够帮助V8的使用者做各种基础测试和分析,能够读入JavaScript文件并输出结果,以及提供调试JavaScript的基础能力。

2.2 工作原理

2.2.1 数据表示

????????大家知道在JavaScript语言中,只有基本数据类型Boolean、Number、String、Null和Undefined,其他数据都是对象,V8使用一种特殊的方式来表示它们。

????????在V8中,数据的表示分成两个部分,第一部分是数据的实际内容,它们是变长的,而且内容的类型也不一样,如String、对象等。第二个部分是数据的句柄,句柄的大小是固定的,句柄中包含指向数据的指针。为什么会是这种设计呢?主要是因为V8需要进行垃圾回收,并需要移动这些数据内容,如果直接使用指针的话就会出问题或者需要比较大的开销,使用句柄的话就不存在这些问题,只需要将句柄中的指针修改即可,使用者使用的还是句柄,它本身没有发生变化。

????????除了极少数的数据例如整形数据,其他的内容都是从堆中申请内存来存储它们,这是因为Handle本身能够存储整形,同时也为了快速访问。而对于其他类型,受限于Handle的大小和变长等原因,都存储在堆中。

????????下面我们来看一看句柄是如何区分这些类型的。图9-11描述了句柄在32位和64位机器上的表示方式。

????????????????????????图9-11 Handle类的定义,小整数和其他类型表示

????????由上面Handle的定义可以看出,一个Handle对象的大小是4字节(32位机器)或者8字节(64位机器),这一点不同于JavaScriptCore引擎,后者是使用8个字节来表示数据的句柄。整数(小整数,因为只有31位能表示)直接从value_中获取值,而无须从堆中分配,然后使用一个指针指向它,这可以减少内存的使用并增加数据的访问速度。而对于其他类型,则使用一个指针来指向它在堆中的数据。

????????因为堆中存放的对象都是4字节对齐的,所以指向它们的指针的最后两位都是00,所以这两位其实是不需要的。在V8中,它们被用来表示句柄中包含数据的类型。

????????JavaScript对象的实现在V8中包含3个成员,正如图9-12中所描述的那样,第一个是隐藏类的指针,这是V8为JavaScript对象创建的隐藏类。第二个指向这个对象包含的属性值。第三个指向这个对象包含的元素。

????????????????????????????????图9-12 JavaScript对象内部表示

2.2.2 V8工作过程

?????????根据前面的介绍,我们对于V8工作的整个过程应该有了一个大概的理解,该过程包括两个阶段,第一是编译,第二是运行。只不过鉴于JavaScript语言的工作方式,它们都是在用户使用它们的时候发生。同时,V8中还有一个非常重要的特点就是延迟(deferred)思想,这使得很多JavaScript代码的编译直到运行的时候被调用到才会发生,这样可以减少时间开销。

????????首先来看编译阶段。读者应该了解JavaScript引擎是如何将源代码解释执行或者转化为本地代码的。同JavaScriptCore引擎比较,V8引擎有自己特殊的地方,如图9-13所示为从源代码到最后本地代码的过程。

????????????????????????????????图9-13 V8引擎处理源代码到本地代码的过程

????????从图中可以看出,首先它也是将源代码转变成抽象语法树的,这一点同JavaScriptCore引擎一样,之后两个引擎开始分道扬镳。不同于JavaScriptCore引擎,V8引擎并不将抽象语法树转变成字节码或者其他中间表示,而是通过JIT编译器的全代码生成器(full code generator)从抽象语法树直接生成本地代码,所以没有像Java一样的虚拟机或者字节码解释器。这样做的原因,主要是因为减少抽象语法树到字节码的转换时间,这一切都在网页加载时完成,虽然可以提高优化的可能,但是这些分析可能带来巨大的时间浪费。当然,缺点也很明显,至少包括两点:第一是在某些JavaScript使用场景其实使用解释器更为合适,因为没有必要生成本地代码;第二是没有中间表示会减少优化的机会,因为缺少一个中间表示层。至于有些文章说的丧失了移植性,个人觉得对于JavaScript这种语言来说不是问题,因为并没有将JavaScript代码先编译然后再运行的明显两个阶段分开的用法,例如像Java语言那样。但是,针对V8设计思想来说,笔者认为它的理念比较先进,做法虽然比较激进,但是确实给JavaScript引擎设计者们带来很多新思路。

????????下面来看一看V8引擎编译JavaScript生成本地代码(也称为JIT编译)使用了哪些主要的类和过程。图9-14给出了主要的类,下面逐一来分析它们。

  • Script?:表示是JavaScript代码,既包含源代码,又包含编译之后生成的本地代码,所以它既是编译入口,又是运行入口。
  • Compiler?:编译器类,辅助Script类来编译生成代码,它主要起一个协调者的作用,会调用解释器(Parser)来生成抽象语法树和全代码生成器,来为抽象语法树生成本地代码。
  • Parser?:将源代码解释并构建成抽象语法树,使用AstNodeFactory类来创建它们,并使用Zone类来分配内存,这个在后面内存管理中介绍。
  • AstNode?:抽象语法树节点类,是其他所有节点的基类,它包含非常多的子类,后面会针对不同的子类生成不同的本地代码。
  • AstVisitor?:抽象语法树的访问者类,基于著名的设计模式Visitor来设计,主要用来遍历异构的抽象语法树。
  • FullCodeGenerator?:AstVisitor类的子类,通过遍历抽象语法树来为JavaScript生成本地可执行的代码。

????????????????????????????????????????图9-14 V8编译器涉及的主要类

????????根据上面类的描述,我们大致可以描绘出这样一个编译JavaScript代码的过程:Script类调用Compiler类的Compile函数为其生成本地代码。在该函数中,第一,它使用Parser类来生成抽象语法树;第二,使用FullCodeGenerator类来生成本地代码。根据前面描述的延迟编译的思想,事实上,JavaScript中的很多函数是没有被编译生成本地代码的。因为JavaScript代码编译之前需要构建一个运行环境,所以实际上在编译之前,V8引擎会构建众多全局对象并加载一些内置的库,如math库等。

????????对于编译器的全代码生成器来说,因为本地代码跟具体的硬件平台密切相关,所以它使用多个后端来生成实际的代码,如图9-15所示的过程。V8引擎至少包含四个跟平台相关的后端,用于生成不同平台上的本地汇编代码。

????????????????????????????????????????图9-15 V8代码生成器生成本地代码

????????代码生成器在不同的平台上有不同的实现。例如在IA32平台,读者会发现代码生成器中的函数是根据该平台的需求而实现的。对于ARM平台,同样有自己的实现。

????????当代码生成器遍历AST树的时候,FullCodeGenerator会为每个节点生成相应的汇编代码,不过没有了全局的视图,因此没有为节点之间考虑可能的优化。在不同的平台上,FullCodeGenerator的很多函数有不同的实现,它们在full-codegen-ia32.cc、full-codegen-x64.cc、full-codegen-arm.cc和full-codegen-mips.cc文件中分别作了不同的实现。

????????图9-13中,V8在生成本地代码之后,为了性能考虑,通过数据分析器(Profiler)来采集一些信息,以帮助决策哪些本地代码需要优化,以生成效率更高的本地代码,这是一个逐步改进的过程。同时,V8中还有一种机制,也就是当发现优化后的代码性能其实并没有提高甚至还有所降低,那么V8能够退回到原来的代码,这些都是在运行阶段涉及到的技术。

????????下面来看一下代码的运行阶段。首先依然是运行阶段的主要类,图9-16描述了V8支持JavaScript代码运行的主要类。

?? ? ? ? ? ? ? ? ? ? ? ? ? ? ?图9-16 V8引擎运行JavaScript代码的主要类

  • Script?:这个前面已经介绍过,包含编译之后生成的本地代码,运行代码的入口。
  • Execution?:运行代码的辅助类包含一些重要的函数,例如“Call”函数,它辅助进入和执行Script中的本地代码。
  • JSFunction?:需要执行的JavaScript函数表示类。
  • Runtime?:运行这些本地代码的辅助类,它的功能主要是提供运行时各种各样的辅助函数,包括但是不限于属性访问、类型转换、编译、算术、位操作、比较、正则表达式等。
  • Heap?:运行本地代码需要使用内存堆,堆的内部构成和结构相当复杂,这个在后面的内存管理中会介绍。
  • MarkCompactCollector?:垃圾回收机制的主要实现类,用来标记(Mark)、清除(Sweep)和整理(Compact)等基本的垃圾回收过程。
  • SweeperThread?:负责垃圾回收的线程。

????????结合这些类,V8引擎是按照图9-17中描述的过程来执行的。当然实际上的过程更为复杂,而且还有垃圾回收等处理,下面主要描述了几个基本的可能会被调用的函数。

????????调用发生在图中的三个子阶段。第一就是延迟编译,也就是“CompileLazy”这个函数的调用,根据需要编译和生成这些本地代码的时候,实际上也是在使用编译阶段那些类和操作。这一思想同样被广泛应用在WebKit和Chromium项目中。在V8中,函数是一个基本单位。当某个JavaScript函数被调用的时候,属于该函数的本地代码就会生成。具体工作的方式是V8查找该函数是否已经生成本地代码,如果已经生成,那么直接调用该函数。否则,V8引擎会触发生成本地代码,目的当然是节约时间,减少去处理那些使用不到的代码的时间。第二就是图9-17中的1.2.3,这时执行编译后的代码就是为JavaScript构建JS对象,这需要Runtime类来辅助创建对象,并需要从Heap类分配内存。第三就是图9-17中的1.2.4,此阶段需要借助Runtime类中的辅助函数来完成一些功能,如属性访问、类型转换等。

????????????????????????????????图9-17 V8引擎中的代码执行过程

????????因为V8是基于抽象语法树直接生成本地代码,没有中间表示层(字节码),所以很多时候代码没有经过很好的优化。关于JavaScript引擎的性能之争非常激烈,没有经过优化的代码导致该引擎在性能上没有特别大的突破,而其他引擎都在进步。有鉴于此,在2010年,V8引入了新的编译器,这就是Crankshaft编译器,它主要针对那些热点函数进行优化。该编译器基于JavaScript源代码开始分析,而不是本地代码,同时构建Hydrogen图并基于此来进行优化分析,Hydrogen图包括超过132条指令。鉴于它的复杂性,这里不再详细介绍,有兴趣的读者请自行探索。

2.2.3 优化回滚(Deoptimization)

????????前面提到V8引擎为了性能上的优化,引入了更为高效的Crankshaft编译器。但是为了性能考虑,该编译器通常会做比较乐观和大胆的预测,那就是编译器认为这些代码比较稳定,变量类型不会发生改变,所以能够生成高效的本地代码。当然这是理想情况,现实是引擎会发现一些变量的类型已经发生变化。在这种情况下,V8使用一种机制来将它做的这些错误决定回滚到之前的一般情况,这个过程称为优化回滚。

????????下面举个例子来说明为什么会出现这种情况吧。示例代码9-5介绍了其中一种情况,函数ABC被调用很多次之后,V8引擎可能会触发Crankshaft编译器来生成优化的代码,优化的代码认为示例代码的类型等信息都已经被获知了。但事实上,到目前为止,我们对于代码中的unknown变量的类型还一无所知,在这种情况下,V8只能将该段代码回滚到一个通用的状态。

示例代码9-5 会触发优化回滚的代码示例

     var counter = 0;
     function ABC(x, y) {
         counter++;
         if (counter < 10000000) {
           // do sth
           return 123;
         }
         var unknown = new Date();
         print(unknown);
     }

????????优化回滚是一个很费时的操作,所以能够不回滚,肯定不要回滚,而且回滚会将之前优化的代码恢复到一个没有经过特别优化的代码,这是一个非常不高效的过程,写代码的时候要特别注意尽量不要触发这一过程。

2.2.4 隐藏类和内嵌缓存

????????虽然JavaScript语言中没有类型的定义,那么借助于C++类的思想,是不是也能够为JavaScript的对象构建类型信息呢?当然可以,至少部分可以。V8使用类和偏移位置思想,将本来需要通过字符串匹配来查找属性值的算法改进为使用类似C++编译器的偏移位置的机制来实现,这就是隐藏类(Hidden Class)。隐藏类将对象划分成不同的组,对于相同的组,也就是该组内的对象拥有相同的属性名和属性值的情况,将这些属性名和对应的偏移位置保存在一个隐藏类中,组内的所有对象共享该信息。同时,也可以识别属性不同的对象。

????????这听起来可能比较抽象,所以使用如图9-18这样的例子来加以说明。图中这一解释来自于V8的官方文档说明,下面将逐一解释它们。

????????????????????????????????图9-18 JavaScript对象归类和隐藏类

????????因为JavaScript没有办法定义类型,所以图9-18中左半部分使用函数来定义。同时,创建了两个对象——a和b。这两个对象包含相同的属性名,在V8中,它们被归为同一个组,也就是隐藏类,这些属性在隐藏类中有相同的偏移值。这样,对象a和b可以共享这个类型信息,当访问这些对象属性的时候,根据隐藏类的偏移值就可以知道它们的位置并进行访问。因为JavaScript是动态类型语言,所以假如在上述代码之后,加入下面的代码:b.z = 2。那么,b所对应的将是一个新的隐藏类,这样a和b将属于不同的组。

????????在理解了V8的隐藏类之后,下面了解一下代码是如何使用这些隐藏类来高效访问对象的属性的。以这段简单代码为例来说明:function add(a) { return a.x; }。首先看最基本的情况,访问对象属性的过程是这样的:首先获取隐藏类的地址,然后根据属性名查找偏移值,计算该属性的地址。不过,这一过程比较费时间。实际上的情况可能要好很多,因为很多情况下该函数中的参数a可能是同一种类型,那么是否能够使用缓存机制呢?

????????是的,该缓存机制叫做内嵌缓存(Inline Cache),它可以避免方法和属性被存取的时候出现的因哈希表查找而带来的问题。该机制的基本思想是将使用之前查找的结果缓存起来,也就是说V8可以将之前查找的隐藏类和偏移值保存下来。当下次查找的时候,首先比较当前对象是否也是之前的隐藏类,如果是的话,可以直接使用之前缓存的偏移值,从而减少查找表的时间。

????????当然,如果该函数中的对象a出现多个类型,那么缓存失误的机率就会高很多。当出现缓存失误的时候,V8可以按照上面说的,退回到之前的方式来查找哈希表。但是因为效率问题,V8会在缓存失败之后,通过对象a的隐藏类来查找该类有无一段代码,这段代码可以快速查找对象,其实就如示例代码9-6所示,这段代码就是保存在a对象的隐藏类对应的表中,所以如果该段代码已经生成,就同样可以较快地实现属性值的查找。

示例代码9-6 使用内嵌缓存机制的属性值访问代码示例

    if (a->hiddenClass() == cachedClass) {
      return a->properties[cachedOffset];
    } else {
      … //退回到原来的方法
    }
2.2.5 内存管理

????????V8的内存管理部分主要讲两点,第一是V8内存的划分,第二是V8对于JavaScript代码的垃圾回收机制。

????????对于内存的划分,首先看Zone类,它的特点主要是管理一系列的小块内存。如果用户想使用一系列的小内存,并且这些小内存的生命周期类似,这时可以使用一个Zone对象,这些小内存都是从Zone对象中申请的。Zone对象首先自己申请一块内存,然后管理和分配一些小内存。当一块小内存被分配之后,不能够被Zone回收,只能一次性回收Zone分配的所有小块内存。例如抽象语法树的内存分配和使用,在构建抽象语法树之后,会生成本地代码,然后抽象语法树的内存在这之后被一次性全部收回,效率非常高。但是,该机制有一个非常严重的缺陷,那就是假如这一个过程需要很多的内存,那么Zone就需要为系统分配大量的内存,但是又不能够释放,所以这会导致系统出现需要过多的内存而导致内存不够的情况。

????????其次是堆。V8使用堆来管理JavaScript使用的数据,以及生成的代码、哈希表等,为了更方便地实现垃圾回收,同很多虚拟机一样,V8将堆分成三个部分,第一个是年轻分代,第二个是年老分代,其中还分成多个子部分,第三个是为大对象保留的空间。图9-19分别描述了这三个部分。

????????????????????????????????????????图9-19 V8中堆的划分

????????对于年轻分代,主要是为新创建的对象分配内存空间,因为年轻分代中的对象较容易被要求回收,为了方便垃圾回收,可以使用复制方式,将年轻分代分成两半,一半用来分配,另外一半在回收的时候负责将之前还需要保留的对象复制过来。对于年轻分代,经常需要进行垃圾回收。而对于年老分代,主要是根据需要将年老的对象、指针、代码等数据使用的内存较少地做垃圾回收。而对于大对象空间,主要是用来为那些需要使用较多内存的大对象分配内存,当然同样可能包含数据和代码等分配的内存,需要注意的是每个页面只分配一个对象。

????????对于垃圾回收,因为使用了分代和大数据的内存分配,V8需要使用精简整理的算法,用来标记那些还被引用的对象,然后消除那些没有被标记的对象,最后整理和压缩(Compact)那些还需要保存的对象。在目前的虚拟机中,垃圾回收机制已经发展得越来越先进,我们有理由相信,V8将引入更多的垃圾回收优化算法,如并发机制等,以后可以使用并发标记、并发内存回收等。其中一些技术已经被实现,之后还会有更多技术被引入。

2.2.6 快照(Snapshot)

????????前面介绍到,在V8引擎开始启动的时候,需要加载很多内置的全局对象,同时也要建立内置的函数,如Array、String、Math等。为了让引擎更加整洁,加载对象与建立函数等任务都是使用JS文件来实现的,V8引擎负责提供机制来支持,就是在编译和执行输入的JavaScript代码之前,先加载它们。

????????根据前面的介绍,V8引擎需要编译和执行这些内置的JS代码,同时使用堆等来保存执行过程中创建的对象、代码等,这些都需要较多的时间。为此,V8引入了快照机制。

????????快照机制就是将这些内置的对象和函数加载之后的内存保存并序列化。序列化之后的结果很容易被反序列化,经过快照机制的启动时间,可以缩减几毫秒。在编译的时候打开选项“snapshot=on”就可以让V8支持快照机制。在V8中,mksnapshot工具能够帮助生成快照。

????????快照机制同样也能够将一些开发者认为需要的JS文件序列化,以减少以后处理的时间,不过快照机制有一个非常明显的缺点,那就是这些代码没有办法被CrankShaft这样的优化编译器优化,所以存在性能上的问题,原因读者可以仔细思考一下。

2.3 绑定和扩展

????????很多时候,JavaScript引擎所提供的能力不能满足现实的需求,比如引擎本身没有HTML5的众多能力(如地理信息),这时,引擎使用者需要扩展它的能力。同很多其他的JavaScript引擎一样,V8可以提供扩展引擎的能力,如前面所述,当V8被使用在Chromium中时,它就使用V8的绑定机制来扩展DOM的实现。

????????V8提供两种机制,第一是Extension机制,就是通过V8提供的基类Extension来达到扩展JavaScript能力的目的。第二是绑定,就是使用IDL文件或者接口文件来生成绑定文件,然后将这些文件同V8引擎的代码一起编译。这两种机制在第10章中会被详细介绍。

文章来源:https://blog.csdn.net/jyl_sh/article/details/135737814
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。