为什么在Node.js中比较两个字符串时'==='比逐字符比较慢

YDS*_*DSS 13 javascript string performance

我发现在Node.js中,通过比较两个字符串的每个字符来比较两个字符串比使用语句'str1 === str2'更快。这是什么原因呢?在浏览器中,情况恰恰相反。

这是我尝试过的代码,两个长字符串相等。节点版本是v8.11.3

function createConstantStr(len) {
  let str = "";
  for (let i = 0; i < len; i++) {
    str += String.fromCharCode((i % 54) + 68);
  }

  return str;
}

let str = createConstantStr(1000000);
let str2 = createConstantStr(1000000);

console.time('equal')
console.log(str === str2);
console.timeEnd('equal')

console.time('equal by char')
let flag = true;
for (let i = 0; i < str.length; i++) {
  if (str[i] !== str2[i]) {
    flag = false;
    break;
  }
}

console.log(flag);
console.timeEnd('equal by char');
Run Code Online (Sandbox Code Playgroud)

Lou*_*uis 26

已经向您指出,如果您翻转两个测试,则与进行比较===会比逐个进行比较要快。到目前为止,您对原因的解释还没有确切地说明原因。有一些问题会影响您的结果。

第一次console.log打电话很贵

如果我尝试这样做:

console.time("a");
console.log(1 + 2);
console.timeEnd("a");

console.time("b");
console.log("foo");
console.timeEnd("b");
Run Code Online (Sandbox Code Playgroud)

我得到类似的东西:

3
a: 3.864ms
foo
b: 0.050ms
Run Code Online (Sandbox Code Playgroud)

如果我翻转代码,以便我有这个:

console.time("b");
console.log("foo");
console.timeEnd("b");

console.time("a");
console.log(1 + 2);
console.timeEnd("a");
Run Code Online (Sandbox Code Playgroud)

然后我得到这样的东西:

foo
b: 3.538ms
3
a: 0.330ms
Run Code Online (Sandbox Code Playgroud)

如果我console.log在进行任何计时之前通过添加a来修改代码,如下所示:

console.log("start");

console.time("a");
console.log(1 + 2);
console.timeEnd("a");

console.time("b");
console.log("foo");
console.timeEnd("b");
Run Code Online (Sandbox Code Playgroud)

然后我得到类似:

start
3
a: 0.422ms
foo
b: 0.027ms
Run Code Online (Sandbox Code Playgroud)

通过console.log在开始计时之前放置“ a ”,我从计时中排除了通话的初始费用console.log

设置测试的方式是,第一个console.log调用是通过第一个调用===或按字符进行测试中的第一个调用完成的,并且将第一个console.log调用的成本添加到该测试中。无论哪一个测试次之,都不承担该费用。最终,对于这样的测试,我宁愿移出console.log正在计时的区域之外。例如,第一个定时区域可以这样写:

console.time('equal');
const result1 = str === str2;
console.timeEnd('equal');
console.log(result1);

Run Code Online (Sandbox Code Playgroud)

将结果存储在定时区域之外result1,然后console.log(result1)在定时区域外使用可确保您看到结果,同时不计算产生的成本console.log

无论您使用哪种测试,首先都要承担扁平化v8内部创建的字符串树的成本。

Node使用v8 JavaScript引擎来运行JavaScript。v8以多种方式实现字符串。objects.h在注释中显示v8支持的类层次结构。这是与字符串有关部分

//       - String
//         - SeqString
//           - SeqOneByteString
//           - SeqTwoByteString
//         - SlicedString
//         - ConsString
//         - ThinString
//         - ExternalString
//           - ExternalOneByteString
//           - ExternalTwoByteString
//         - InternalizedString
//           - SeqInternalizedString
//             - SeqOneByteInternalizedString
//             - SeqTwoByteInternalizedString
//           - ConsInternalizedString
//           - ExternalInternalizedString
//             - ExternalOneByteInternalizedString
//             - ExternalTwoByteInternalizedString
Run Code Online (Sandbox Code Playgroud)

对于我们的讨论,有两个重要的类:SeqStringConsString。它们在将字符串存储在内存中的方式不同。的SeqString是一个简单的实现:该字符串是简单的字符阵列。(实际上,SeqString它本身是抽象的。实际的类是SeqOneByteStringSeqTwoByteString但在这里并不重要。)ConsString但是,将字符串存储为二进制树。A ConcString有一个first字段和一个second字段,它们是指向其他字符串的指针。

考虑以下代码:

let str = "";
for (let i = 0; i < 10; ++i) {
  str += i;
}
console.log(str);
Run Code Online (Sandbox Code Playgroud)

如果v8用于SeqString实现上面的代码,则:

  • 在第0次迭代中,它将必须分配一个大小为1的新字符串,将其旧值复制到str""),然后追加到该值,"0"然后设置str为新字符串("0")。

  • 在迭代1中,它必须分配一个大小为2的新字符串,将其旧值复制到str"0")并附加到该值上,"1"然后设置str为新字符串("01")。

  • ...

  • 在迭代9时,它将必须分配一个大小为10的新字符串,将旧值str"012345678")复制到该值,然后追加到该值并将其"9"设置str为新字符串("0123456789")。

分十步复制的字符总数为1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 = 55个字符。将55个字符移动到最后包含10个字符的字符串中。

相反,v8实际上是这样使用的ConsString

  • 在迭代0处,分配一个new ConcString,其first设置为的旧值str,并second设置为i(作为字符串)0,并设置str为该ConcString刚分配的新值。

  • 在迭代1中,分配一个新值,ConcStringfirst设置为的旧值str,并second设置为"1",并设置str为该ConcString刚分配的新值。

  • ...

  • 在迭代9,分配一个新的ConcStringfirst集到的旧值str,并second设置为"9"

如果我们将每个字段表示ConcString(<first>, <second>)<first>则表示其first字段的内容,并且<second>是该字段的内容second,那么最终结果是:

(((((((((("", "0"), "1"), "2"), "3"), "4"), "5"), "6"), "7"), "8"), "9")
Run Code Online (Sandbox Code Playgroud)

通过这种方式,v8避免了必须一遍又一遍地复制字符串。每一步只是一个分配,并调整了两个指针。虽然将字符串存储为树有助于加快连接速度,但它的缺点是其他操作会变慢。v8通过展 ConsString树木来缓解这种情况。平展上面的示例后,它变为:

("0123456789", "")
Run Code Online (Sandbox Code Playgroud)

请注意,将a展ConsString平时,ConsString对象被突变。(从JS代码的角度来看,该字符串保持不变。只是其内部v8表示形式有所更改。)比较扁平化的ConsString树更容易,实际上这正是v8所做的(ref):

bool String::Equals(Isolate* isolate, Handle<String> one, Handle<String> two) {
  if (one.is_identical_to(two)) return true;
  if (one->IsInternalizedString() && two->IsInternalizedString()) {
    return false;
  }
  return SlowEquals(isolate, one, two);
}
Run Code Online (Sandbox Code Playgroud)

我们正在讨论的字符串未内部化,因此SlowEquals称为(ref):

bool String::SlowEquals(Isolate* isolate, Handle<String> one,
                        Handle<String> two) {
[... some shortcuts are attempted ...]
  one = String::Flatten(isolate, one);
  two = String::Flatten(isolate, two);
Run Code Online (Sandbox Code Playgroud)

我在这里显示过,比较字符串是否相等以在内部进行扁平化,但是String::Flatten在许多其他地方都可以找到to 的调用。您的两个测试最终都通过不同的方法使字符串变平。

对于您的代码,结果是这样的:

  1. createConstantStr创建的字符串在内部存储为ConsString。所以,strstr2ConsString对象,至于V8而言。

  2. 您运行的第一个测试会导致str并且str2将其扁平化:a)此测试必须承担扁平化字符串的费用,b)第二个测试得益于使用ConcString已经扁平化的对象。(请记住,当某个ConcString对象被展平时,该对象会发生突变。因此,如果稍后再次访问它,则它已经被展平。)