V8 引擎源码解读(2): String

427 阅读6分钟

前言

本文重点介绍 字符串V8 内的内存布局和使用

字符串的编码

v8 使用两种方式编码 String

  1. oneByteString
  2. twoByteString

当字符串内的所有字符都可以用 Latin1 编码表示时, 使用 oneByteString 的形式编码, 每个字符对应一个字节, 底层是 uint8_t[], 为了节约空间.

Latin1 编码的表达范围在 0-255, 可以用一个字节表示

如果字符串中存在非 Latin1 编码能表示的字符, 那么就使用 UTF-16 编码, 固定每个字符2 个字节, 也就是 twoByte 的含义

举个例子

let str = 'abcd'

str 被存储在单位为 byte 的数组, 4 个字符大小为 4Byte

如果字符串存在非 Latin1 编码的字符

let str = 'abcd字'

str 底层为uint16_t[], 每个字符 2 字节, 对应大小是 5*2Byte = 10Byte

对应的源代码

v8parse js 代码 时确定某个字符串是 oneByte 还是 twoByte

截屏2025-01-02 10.51.31.png

parse 阶段, 会依次处理 a b c d 每个字符

abcd都可以用一个字节表示, 所以一开始用 uint8_t[] 存储, 调用 AddOneByteChar 添加下一个字符

当发现了字符 , 就会将前面的所有字符重新拷贝一份, 重新存储为 uint16_t[] 的结构, 每个字符用 2 个字节存储

V8 String 的生命周期

以下面这段代码为例

let str = 'abcdef'

String 的创建

v8parse 阶段对 abcdef 进行 hash 计算, 放入 string_table_ 里面, 用于去重, 避免重复分配 string 内存, 返回AstRawString

AstRawString 表示 parse ast 中产生的所有 string片段, 里面包含了 strabcdef

截屏2025-01-03 14.00.46.png

然后将所有 parse 阶段发现的 AstRawString 拷贝到 isolate 里面

上一篇文章说过了, 所有 js 对象都存储在 isolate

入口在 PostProcessParseResult

截屏2025-01-03 18.30.42.png

这个过程一定会发生拷贝, isolate 会重新拷贝一遍 string 内存, 实际调用 isolate->factory()->InternalizeStringWithKey(&key), 这个过程也会根据 hash 去重, 重复的字符串在内存里不会存两次

之后 AstRawString 被替换成 InternalizeString

截屏2025-01-03 18.40.56.png

创建 String 对象的过程

截屏2025-01-07 10.30.40.png

创建 string 对象有两个比较重要的概念,一个是内存分配 allocate,一个是 Map

内存分配 allocate

string分配的内存大小要求 4 字节对齐, 所有 js 对象在 v8 中分配都是需要对齐的

对齐后实际分配的内存如下

// oneByteString
allocate_size = (14 + str_length) + 3 & ~3

14 字节是 SeqOneByteString 对象本身的大小, v8 用该对象表示连续的 oneByteString

例如 abcdef 长度是 6, 实际分配的内存大小等于 20Byte

Map

Map 里面描述了当前的 js 对象的 shape(形状)

所有 js 对象在 V8 内都存有一个 Map 用来描述对象的结构

比如访问一个对象的属性,首先要通过 Map 找到对应属性字段的位置,然后再访问

比如判断 Handle<String> 的类型是 SeqOneByteString 还是 SeqTwoByteString, 需要去 Map 里面去查找

ConsString(拼接 String)

当两个字符串拼接的时候

let a = "abc";
let b = "def";

let c = a + b;

拼接操作的判断还是挺多的,因为要考虑左右两边的类型情况,抛开类型判断分支的逻辑不说

如果a.length + b.length 小于 kMinLength, 那么就将两个字符串都拷贝一遍,生成一个新的字符串

  // Minimum length for a cons string.
  static const uint32_t kMinLength = 13;

如果大于kMinLength, 那么就使用 ConsStringConsString 将两个字符串的指针分别存放在 firstsecond 字段,这样就不会发生拷贝 下面是示意图

截屏2025-01-21 18.21.10.png

截屏2025-01-13 18.16.50.png

可以用 d8 验证一下

d8 是 V8 引擎的命令行调试工具,可以查看 js 代码对应的字节码

let a = '123456';
let b = '123456';
let c = a + b; // c.length 小于 13
%DebugPrint(c);
v8-debug --allow-natives-syntax ./example.js

输出

DebugPrint: 0x5cd0028852d: [String]: "123456123456"
0x5cd000000b5: [Map] in ReadOnlySpace
 - map: 0x05cd00000475 <MetaMap (0x05cd0000002d <null>)>
 - type: SEQ_ONE_BYTE_STRING_TYPE

重点是SEQ_ONE_BYTE_STRING, 说明它是一个整体连续的字符串

把代码稍微改一下

let a = '123456';
let b = '1234567';
let c = a + b; // c.length 超过了 13
%DebugPrint(c);

输出结果

DebugPrint: 0x399700288531: [String]: c"1234561234567"
0x399700000385: [Map] in ReadOnlySpace
 - map: 0x399700000475 <MetaMap (0x39970000002d <null>)>
 - type: CONS_ONE_BYTE_STRING_TYPE

结果是 CONS_ONE_BYTE_STRING, 这个过程没有发生拷贝

常量折叠

如果是两个字符串字面量拼接

let str = "abc" + "def";

上面的代码在 parse ast 阶段会发生常量折叠

截屏2025-01-10 17.48.03.png

如果出现1 + 2 这样的表达式,v8parse 阶段就会直接计算出结果

如果出现两个字符串相加,v8 会生成AstConsString, 内部用两个指针将两个 String 拼接起来,这个过程并不会发生内存拷贝

截屏2025-01-10 17.53.53.png

但是常量池会被整理,最后所有 ConsString都会被 flat, 所以 str 还是连续的,而不是 ConsString

SlicedString 字符串的切片

SlicedString 也是一种为了避免内存拷贝的数据结构

let a = "fdafdafds";
let sliced_str = a.slice(1);

String.prototype.sliceString.prototype.trim 等函数都有可能返回 SlicedString

源代码在 NewSubString

截屏2025-01-15 17.48.15.png

这里会有一个逻辑判断,如果结果的长度小于SlicedString::kMinLength, 那么就完整 copy 一份出来

SlicedString::kMinLength 的长度也是 13

如果大于等于 13,就会使用偏移量和 length 的形式表示

let str = "a".repeat(14);
let sliced_str = str.slice(2) // length: 12; 这里会发生完成的拷贝

let sliced_str = str.slice(1) // length: 13; 这里不会发生拷贝,内部使用偏移量 + length 表示

大致结构如下,使用 parent 指向 str, 内部存一个 offsetlength,

截屏2025-01-21 18.42.53.png

ThinString

let str1 = "abcdefg";
let str2 = "abcdefg"
let str = str1 + str2; // ConsString

运行上面的代码时 StringTable 会存储所有的 变量名abcdefg 作为内部字符串

str 作为 Objectkey

const object = {}
object[str] = 0 

str 会被放入 StringTable,便于后面快速查找,在这个过程中,str 实际内容转换成了 内部字符串

str 会原地留下一个指针, 也就是 ThinString , v8 给的解释是,因为无法原地内部化,所以才会搞出这个设计

如何查找 V8 String 相关函数的定义

比如你想查看 String.prototype.at

v8 把内部方法调用放在 src/buildtins 下面

需要去 src/builtins 目录下面查找,你会发现如下的代码,这个是用 Torque 实现的

截屏2025-01-10 10.23.54.png

内部会调用 args.receiver() 得到 Handle<Object>

然后将 Handle<Object> 重新解释成为 Handle<String>

最后一行,用了两个函数StringCharCodeAtStringFromSingleCharCode

具体代码实现在CodeStubAssembler::StringCharCodeAtCodeStubAssembler::StringCharCodeAt, 具体代码我就不贴了

可以自行搜索

结尾

本文主要介绍了 V8 String 的创建和一些特殊的 String 结构, 算是对V8 String 的入门介绍

因为个人能力有限,如果有错误请在评论区指出