奇技淫巧学 V8 之七,字符串的扁平化

1,160 阅读6分钟

先划重点:字符串在最终输出前调用 Number 方法来扁平化,可以提高后续操作的性能。

先从一段 benchmark 开始说起:

setup block:

function makeExpoStr() {
    var s = "a";
    for (var i = 0; i < 12; i++) {
        s += s;
    }
    return s;
}

case 1:直接读入 Buffer(无 Number 操作)。

//env: Node.js
var str = makeExpoStr();
Buffer.from(str);

case 2:Number 操作后再读入 Buffer(存在 Number 操作)。

//env: Node.js
var str = makeExpoStr();
Number(str);
Buffer.from(str);

通过测试发现存在 Number 操作(case #2)的比直接读入(case #1)的效率高 2.8x 左右。

(条状图越长,性能越高)

要解释这个现象,首先先要了解 makeExpoStr 返回的字符串组成。

通过上一章 《技淫巧学 V8 之六,字符串在 V8 内的表达》 我们了解到: V8 为了提升字符串拼接时的性能采用 ConsString 来表达拼接后的字符串。故 makeExpoStr 返回的字符串是由 ConsString 组成的多层树形结构(Rope Structure)。

采用 ConsString 虽然提升了字符串在拼接时的性能,但对其进行操作(如字符的访问等)的复杂度就会变高(特别是有存在大量拼接时)。

在实际应用过程中,字符串的生命周期可以粗略的划分为以下两个阶段:

  • 构造期,使用 ConsString 提升多次拼接时的性能。
  • 使用期(如访问读取等),使用 SeqString 降低字符串访问的复杂度。

当字符串进行特定操作(如 CharAt )时, V8 会假设后续可能在此字符串上还有更多(类似的)操作,故此字符串由构造期进入使用期,V8 将多层 ConsString 拷贝(扁平化)为 SeqString 以降低后续访问的复杂度,这种过程我们将其称为 FlattenString。

非 ConsString 表达的字符串一定是扁平(Flat)的,ConsString(SeqString, empty_string) 所表达的字符串也可认为是扁平(Flat)的。

在开发调试过程中,可以调用 V8 的 Runtime Call (%FlattenString)来强制扁平化字符串。

// flags: --allow-natives-syntax

var name = "I am " + "superzheng";
// name = ConsString("I am ", "superzheng")

%FlattenString(name);
// name = ConsString("I am superzheng", "")

而在运营环境中,通过如下的字符串操作(让 V8 认为此字符串进入使用期)来隐式扁平化:

  • 转换为数值(Number / parseInt / ~ )
  • 读取字符(s[0] / charCodeAt )
  • 正则表达式操作( test / exec )

在这些操作中,Number 的性能最高,所以在实际应用中一般采用 Number 方法的 side effects 来隐式扁平化字符串。

请注意:频繁的扁平化操作会严重影响性能,故应仅在字符串最终输出(调用外部代码,如 Buffer.from)时进行。

要不然 V8 也不用 ConsString 来表达拼接后的字符串了。

这里有一个工程化技巧,不应在代码中直接使用 Number,而应将 Number 包装成为方法(或使用现成模块如 flatstr),以防对于代码的误解(认为无用代码而造成误删除)同时也便于全局更新。

而在外部 C/C++ 代码(如 Node.js)调用 v8::String::Write 方法拷贝字符串内容时,(如外部代码认为此字符串可能被拷贝多次)可指定拷贝选项 HINT_MANY_WRITES_EXPECTED ,V8 会将传入的字符串扁平化以降低后续拷贝消耗。

V8 希望屏蔽内部实现对于开发者的影响,所以对外(不仅 JavaScript 层面,甚至提供给 C/C++ 调用的接口)并没有扁平化字符串的概念。
取而代之的是针对不同的场景有着不同意义的选项(flags)。

文章开头例子中的代码在调用 Buffer.from(也就是 Node.js C++ 调用 StringBytes::Write) 时,Node.js 指定了多次拷贝选项(HINT_MANY_WRITES_EXPECTED),此时 V8 中 Write 方法会先将字符串扁平化后再进行拷贝,如下图所示:

这里存在一个非常大的疑问:

既然(Node.js)Buffer.from 也会要求 V8 将字符串扁平化与调用 Number 隐式扁平化的作用是完全一样的,所以理论上不应有任何的性能提升。

但在实际测试中确实存在有 2.8x 倍的差距!这到底是差在哪里了?

这里先要肯定之前分析(方向)是正确的,而问题就出在 Node.js 默认字符编码 UTF-8 上。

UTF-8 是一种针对 Unicode 的可变长度字符编码。
字符基本范围是 1 ~ 3 字节,Unicode6.1 范围是 1 ~ 4 字节, UCS-4 范围是 1 ~ 6 字节(废弃)。

Node.js 默认采用 UTF-8 字符编码,从而导致当字符串以 UTF-8 表达时所占用的空间不能直接通过字符数(线性)计算得到,需要遍历每个字符(cons-part)才可得到

调用 Buffer.from (默认编码 UTF-8)时,Node.js C++ 调用 v8::String::Utf8Length 获得字符串以 UTF-8 表达时所需的空间大小,并在空间分配后传递给 v8::String::WriteUtf8 写入字符串,过程如下图所示:

当字符串以多层 ConsString 表达时,存在有两个热点函数:

  • 计算所需空间大小的遍历(v8::String::Utf8Length)
  • 读取拷贝的遍历(v8::String::WriteUtf8)

所以将字符串在传入(Buffer.from)前先行扁平化,可以减轻两个热点函数消耗以提升性能。

讲到这里我们了解到,开头例子中的性能差距主要由 UTF-8 编码特性引发。如果将其换为 ASCII 编码是否就可以规避这个问题(而不需使用奇技淫巧)?

case 3:以 ASCII 编码读入 Buffer 。

// env: Node.js
var str = makeExpoStr();
Buffer.from(str, "ascii");

通过测试我们发现 ASCII 编码(case #3)比 UTF-8 编码(case #1)的读入性能高 6x 倍左右,甚至比扁平化后以 UTF-8 编码读入(case #2)的性能还要高 2.8x 倍左右。

(条状图越长,性能越高)

总结一下:

  • 拼接后的字符串,在最终输出前调用 Number 方法进行隐式扁平化,可以提高后续操作的性能。
  • 当字符串以 UTF-8 表达时,需要遍历每个字符才可得到其所占用的空间大小。

只有了解奇技淫巧背后的原理,才能举一反三,甚至会发现更优美、更高效的解决方案。

欢迎收看下一章:

奇技淫巧学 V8 对象(JSObject)部分章节: