V8 引擎源码解读(1) : 初始化和基本概念

506 阅读8分钟

本系列内容基于我对 v8 源码的 debug 和个人理解, 如果有错误请在评论区指正, 感谢

前言

v8 的源码比较大, 涉及到的概念也很多, 万事开头难, 所以阅读源码需要有耐心

本文重点在介绍 v8 内存初始化的过程和 v8 的一些基本概念

环境搭建

v8 官网有详细的文档介绍如何下载编译,还有IDE 环境搭建

  1. 下载和编译, 本系列的所有代码都是基于 0ceba14f822(commit hash)
  2. IDE 配置

V8 的初始化

通过 hello-worldv8 的初始化

代码在 samples/hello-world.cc 里面, 我把比较重要的内容提取出来

  // 初始化一个平台
  std::unique_ptr<v8::Platform> platform = v8::platform::NewDefaultPlatform();
  v8::V8::InitializePlatform(platform.get());
  // v8 初始化
  v8::V8::Initialize();  
  // isolate 初始化
  v8::Isolate* isolate = v8::Isolate::New(create_params);
  // 创建一个 HandleScope
  v8::HandleScope handle_scope(isolate);
  
  // 要执行的 JS 代码
  v8::Local<v8::String> source = v8::String::NewFromUtf8Literal(isolate, "'Hello' + ', World!'");
  // 编译代码
  v8::Local<v8::Script> script = v8::Script::Compile(context, source).ToLocalChecked();
  // 执行代码
  v8::Local<v8::Value> result = script->Run(context).ToLocalChecked();

总结一下

如果要初始化并运行 js 代码, v8 需要做下面的事情

  1. platform 初始化
  2. V8 初始化
  3. Isolate 初始化
  4. 创建一个 HandleScope
  5. 创建 JS 代码
  6. 编译 JS 代码
  7. 执行 JS 代码

下面一个个介绍

Platform 初始化

Platform初始化的时候会启动线程池, 并绑定out of memory的钩子

因为没做什么特殊的事情, 我就一笔带过了

截屏2024-12-25 15.48.12.png
  cppgc::internal::GetGlobalOOMHandler().SetCustomHandler(
      &GlobalFatalOutOfMemoryHandlerImpl);

V8 初始化

v8 初始化做了很多事情

比较重要的是 Sandbox 的初始化

Sandbox

套用v8 blog 里面的一句话来解释 Sandbox的作用

memory corruption cannot "spread" to other parts of the process' memory.

大致含义是使用 Sandbox 可以避免内存越界访问造成的崩溃, 下面会详细解释

先看下 Sanbox 的初始化代码

分配内存

首先判断Sandbox最大能分配的内存空间

截屏2024-12-25 16.24.40.png

这里的注释写的很清楚

如果是 40位系统, 用户空间能分配的最大内存空间是 512G, Sanbox 最多分配用户空间的 1/4, 也就是 128G ,这个对应 Sanbox 最多能分配的虚拟内存空间

Sanbox 需要的虚拟内存空间1TB, 最终分配的虚拟内存是这两者的最小值

因为现在大部分都是64位系统,对应用户空间 128 TB, 1/432TB, 远远大于 1TB, 所以 Sanbox 会分配 1TB 的虚拟内存空间

// kSandboxSize = 1TB
size_t reservation_size = std::min(kSandboxSize, max_reservation_size)

虚拟内存在实际访问的时候才分配, 即便你物理内存没有 1TB 也是可以分配的哈

在实际分配的时候, 会在前后各加 32G 内存作为保护, 实际分配的虚拟内存就是1TB + 64GB

内存的分布如下所示

截屏2024-12-25 17.12.19.png

左右加上 32GB 是为了避免越界访问, 这就跟上面提到的 Sandbox 的作用有关系了

因为 v8 只会使用从 baseend 内存空间, 如果越界访问到了前后的 32GB 里面, 就可以拦截下来, 而不至于导致崩溃, 起到一个保护的作用

之后所有的js对象和一些数据结构都会分配在 baseend 这一段内存空间中

IsolateGroup

上面预留了空间之后, v8 会继续初始化 IsolateGroup

顾名思义, IsolateGroup 管理所有的 Isolate, Isolate后面会提到, Group 主要做下面的事情

sandbox->address_space()->AllocatePages(
    base,  // sandbox 的 base 地址
    reservation_size,  // 4GB
    alignment, 
    PagePermissions::kNoAccess
);

IsolateGroupSandbox 的基础上划走 4GB 内存空间,用来分配 js 对象

截屏2024-12-26 09.16.23.png

因为v8使用了压缩指针的技术, js对象的指针都是 32 位, 所以只需要 4GB 就够了

至于什么是压缩指针, 以后会单独写一篇介绍

Isolate

Isolate 是 v8 执行的独立环境。每个 isolate 都会有自己的状态,互不干扰。

每次创建js对象,都是从 Isolate 开始分配的, 也就是说Isolate管理了所有的js对象分配和GC

是运行 js重要的概念之一, 关于里面Heap的部分, 以后会补充

比如创建一个 String

Local<String> str = v8::String::NewFromUtf8Literal(isolate, "string");

里面的调用是这样的

isolate->factory()->NewStringFromUtf8(string)

而更底层的调用在 src/heap 里面, 而这些内容都是 Isolate 来管理

AllocateRaw(
    size,  // 大小
    allocation,  // 分配类型, 新生代,老生代
    alignment // 怎么对齐
)

具体的代码可以查看 src/execution/isolate.cc, 很多内容我也没看到,后面有用到的地方再来补充

HandleScope 和 Handle

HandleScope, Handlev8 为了管理资源而设计的

RAII

先解释下 cppRAII 才能更好的理解 HandleScopeHandle

很熟悉 cpp 的话可以绕过

#define print(x) std::cout << x << std::endl;

class Scope {
public:
    Scope()
    {
        print("enter scope");
    }

    ~Scope()
    {
        print("leave scope");
    }
};

上面的 Scope, 初始化的时候会打印enter scope, 离开作用域的时候会被清理(析构), 析构的时候打印leave scope

int main()
{
    {
        Scope scope;
        // 在这里离开作用域
    }
    print("exit");
}

上面的代码会打印

enter scope
leave scope
exit

而下面的代码

int main()
{

    Scope scope;
    print("exit");
    // 在这里离开作用域   
}

输出

enter scope
exit
leave scope

也就是说在 { } 结束的时候自动析构, 释放资源, RAII 是一种管理资源的方式

HandleScope

看下面的代码, 在{} 结束的时候, 根据 RAII 策略会自动的释放 HandleScope, 同时释放在这个 block 内所有 v8 创建的 js 对象

然后 v8 可以通过GC清理掉对应的资源

{
    // 创建一个 HandleScope
    v8::HandleScope handle_scope(isolate);
     // 创建一个 Local<String>
     v8::Local<v8::String> source = v8::String::NewFromUtf8Literal(isolate, "'Hello' + ', World!'");
    // 在这里 HandleScope 析构
}

重点要看下 HandleScope的初始化和析构是怎么实现的, 下面就直接放代码了

截屏2024-12-05 10.34.06.png

上面说到了 Isolate 存储了执行 js 的所有环境信息, 其中就包含 HandleScopeData

HandleScope 创建的时候会更新 isolate->handle_scope_data_

源代码在 api/api.cc

截屏2024-12-05 10.52.28.png

首先获取 isolatehandle_scope_data, 对 current->next 做了一个记录(pre_next_), 后面HandleScope 析构的时候还原回来

current->next 里面是一个数组指针 Address*

v8 里面指针类型就是 Address

也就是说 handle_scope_data 里面存的主要是一个数组, 数组里面都是指针(Address)

截屏2024-12-05 10.45.24.png

当析构的时候, HandleScope 会去清理掉 HandleScope 所有的指针, 详细代码在 handles/handles-inl.h

截屏2024-12-05 10.50.59.png

清理的方式有两种

  1. 给这些指针都标记为一个数值0x1baddead0baddeaf, 这样如果发生了错误的访问, v8 能知道这个指针已经失效了
  2. 啥都不做, 只是把 handle_scope_data_->next 还原到 pre_next_ , 这样也算清理了, Address 无法被访问

下次 GC 的时候无法访问到这些 Address 指针, 就会清理掉它们

总结一下

HandleScope 创建的时候在 isolate -> handle_scope_data_ 里面记录一个Address 数组

每当有数据在 isolate 里面创建的时候, 就会更新 Address数组, 这样 GC 就不会清理这些数据

等到析构的时候, 把这些指针从数组里面推出, GC 无法访问到这些指针,就会释放它们

Handle

从一个 Local<String> 的创建来说 Handle

Local<String> script = String::NewFromUtf8Literal(isolate, "let x = 1");

String 的创建不是这里的重点,所以直接到 Handle 被初始化的地方

底层调用了 isolate->factory()->NewRawStringWithMap 来创建一个 String 类型,重点在 handle(string,isolate())

截屏2024-12-05 13.49.04.png

继续向里面跳

截屏2024-12-05 13.43.02.png

Handle 继承 HandleBase , 里面调用了 HandleScope::CreateHandle 方法

截屏2024-12-05 13.43.56.png

CreateHandleHandleScopeData 的指针数组里面新增了一个指针

总结一下

当创建 Handle 的时候, 会在 isolate->handle_scope_data_ 里面增加一个指针Address

上面说过HandleScope 销毁的时候会清理掉这些指针,对应的数据也就释放了

值得注意的是对 Handle 解引用,会得到 Tagged<T>, 这个后面讲压缩指针的时候会提到

截屏2024-12-05 14.39.17.png

Local

Localv8 对外输出的数据结构, 里面只有一个指针, Local 继承 LocalBase

内部有可能是Handle<T> 转换成 Local<T>

对外统一暴露 Local<T>

// v8 统一对外输出 Local<T>
Local<Boolean> a = Boolean::New(isolate, true);
Local<Number> b = Number::New(isolate, 10);
Local<String> c = String::NewFromUtf8Literal(isolate, "c");

总结

上面大致讲解了 v8 的初始化流程

Sandbox 开始向操作系统分配内存

IsolateGroupSandbox 的基础上分配了 4G 内存

Isolate 在这个基础上管理对象分配gc

剩下的 HandleScopeHandle 都是 v8 常用的基本概念

本人能力有限, 如果有理解错误的地方可以在评论区指出