问题发现
最近在做一些前端优化的时候,意外发现内存快照中对象上相同的 string
字符串的地址竟然是相同的!!

一时间有点儿懵逼,按照之前的认知 string
作为js里面的基础数据类型,在传递时候应该是直接传递字符串的值,创建一个新的内存来存储吗?问题似乎不是想的这么简单...
分析病因
搞懂这个问题之前,我们先想想 js 字符串类型的数据应该是存储在哪儿呢? 堆内存? 还是 栈内存?
网上对于这二者的区别是这么介绍的:
栈内存
: 存放基本类型的数据,系统自动分配自动释放,占据固定大小的空间;堆内存
: 存放引用类型的数据,系统动态的分配空间,且不会自动释放,占据空间大小不固定;
按照这样的说法,string
类型作为js里面的基本类型就应该是存放在栈中的呀?🤔
结果显然不是这样的,在 v8 引擎中,字符串实际是以对象的形式存储在 堆内存 中的,包含了字符串的长度,字符编码等信息;当我们在 js 中创建一个字符串的时候,v8 引擎会在堆中为这个字符串分配内存空间,然后将堆内存空间的地址存储在栈中;所以我们的字符串变量实际是堆上字符串的地址引用
......
那么现在的问题是,两个相同的字符串在创建堆内存存储的时候为什么不是新建呢?就比如我们基于同一个类创建的两个实例对象也都是两块不同的内存呀?
通过一通资料翻找,发现在 v8 引擎中字符串实际上是存储在一个叫做 string-table 的对象中,这个对象本质上就是一个映射表,用于存储内部化
的字符串,它的目的就是为了实现字符串的内存空间共享,降低内存占用和提高性能. 在 v8 源码中我们找到对应的说明

翻译一下注释大概的意思就是 在字符串表中查找给定的字符串,如果没有找到就添加一个,否则直接返回找到的字符串
这样做也不难理解,一方面可以减少字符串内存空间的占用,相同的字符串只需要在内存中存储一次即可,同时相同的字符串只需要获取内存地址即可,比重建一个新的肯定更快;同时在比较字符串是否相同的时候,内部化的字符串可以直接比较地址是否相同即可,性能也能得到显著提升;
那么我们文章开头的问题就很好解释了,相同的字符串被内部化之后,他们使用的就应该是同一个地址空间的内容了.
......
难道问题真就这么简单吗?
异常表现
当满心欢喜的以为已经获得了功法秘诀,准备大展拳脚的去实战验证的时候,又出现了让我不淡定的发现;
在某个类的多个实例中,竟然出现了有的字符串明明是相同的,但是它的内存又不一样了,例如:

(再次懵逼,tell me why? look my eyes! 🤔)
不是说相同的字符串在多次创建的时候会用同一份堆中的对象吗?告诉我这又是为什么!!
既然上面的结论又不完全成立了,那就只能用最简单的方法了:"找不同"
经过细致的一比一对比发现,唯一可能导致差异的点是,这部分数据是通过 JSON.parse
解析出来然后赋值给对象的属性的,难道是 JSON.parse
会对字符串的内存有什么影响吗?
创建个🌰来实验一下:
ts
class User {
constructor() {
const json = JSON.parse('{"name": "张三", "company": "无业游民"}');
this.name = json.name;
this.company = json.company;
this.tag = "developer";
}
}
const a = new User();
const b = new User();

通过内存快照我们发现,对象中的name 和 company 两个由JSON解析出来的字符串地址都不相同,而直接写入的字符串 tag 则是相同的;
既然如此,那是不是可以认为通过JSON解析出来的字符串就是独立的,不会通过string-table共享内存?
但是正当我以为要结案的时候,一个简单的改动,又打破了这个判断:
ts
class User {
constructor() {
JSON.parse('{"name": "张三", "company": "无业游民", "tag": "developer"}');
this.name = json.name;
this.company = json.company;
this.tag = json.tag;
}
}
const a = new User();
const b = new User();
当我将 tag 字段从直接写入变成也从JSON中解析时,又有了惊奇的发现: 两个对象中 tag 字符串的地址又一样了!!

不是说JSON解析出来的字符串是独立的吗?那这咋又相同了呢?要不要这么折磨人😭😭😭
诶,事已至此,只能搬出终极大法了,遇事不决找 v8 !!
终极大法
经过一顿翻找,终于在v8源码中找到了这一段逻辑:
c++
template <typename Char>
Handle<String> JsonParser<Char>::MakeString(const JsonString& string,
Handle<String> hint) {
// 如果是字符串串,就直接返回工厂创建的空字符串
if (string.length() == 0) return factory()->empty_string();
// 对于长度为1的字符串,根据是否有转义字符决定如何获取字符值,然后使用工厂方法创建单字符字符串。
if (string.length() == 1) {
uint16_t first_char;
if (!string.has_escape()) {
first_char = chars_[string.start()];
} else {
DecodeString(&first_char, string.start(), 1);
}
return factory()->LookupSingleCharacterStringFromCode(first_char);
}
// 如果字符串需要内部化且没有转义字符,先检查是否匹配,然后根据字符是否可能重定位选择不同的内部化方法。
if (string.internalize() && !string.has_escape()) {
if (!hint.is_null()) {
base::Vector<const Char> data(chars_ + string.start(), string.length());
if (Matches(data, hint)) return hint;
}
if (chars_may_relocate_) {
return factory()->InternalizeSubString(Cast<SeqString>(source_),
string.start(), string.length(),
string.needs_conversion());
}
base::Vector<const Char> chars(chars_ + string.start(), string.length());
return factory()->InternalizeString(chars, string.needs_conversion());
}
// 根据字符类型和是否需要转换,创建适当类型的字符串(单字节或双字节),然后调用 `DecodeString` 进行解码。
if (sizeof(Char) == 1 ? V8_LIKELY(!string.needs_conversion())
: string.needs_conversion()) {
Handle<SeqOneByteString> intermediate =
factory()->NewRawOneByteString(string.length()).ToHandleChecked();
return DecodeString(string, intermediate, hint);
}
Handle<SeqTwoByteString> intermediate =
factory()->NewRawTwoByteString(string.length()).ToHandleChecked();
return DecodeString(string, intermediate, hint);
}
简单分析一下就是:
- 如果json-string字符串是一个空串,则直接用v8工厂返回一个空字符串即可
- 如果json-string长度为1的字符串,根据是否有转义字符决定如何获取字符值,然后使用工厂方法创建单字符字符串。
- 如果json-string字符串需要内部化,并且没有转义字符,则内部化处理这个字符串(也就是放到 string-table)
- 如果不能内部化,无论是单字节还是双字节字符的字符串都直接创建一个新的来存储
那么现在就是要找到这个内部化的判断逻辑是如何的了,从v8源码里可以看到: internalize
方法实际就是返回 JsonString 构造函数的 internalize 参数

而 internalize
参数的值要么是传入的 needs_internalization
参数 或者是 单字节字符且字符串长度小于10
;

而继续前往它的上游可以发现这里的 needs_internalization
参数实际是 false

现在我们可以给出结论了:
对于JSON.parse解析的json数据,默认是不会开启字符串共享内存的,只有在单字节字符串且字符串长度小于10的情况下会采用内部化字符串。
最后我们通过实验来再验证一下上面的几种场景:
ts
class User {
constructor() {
// 空字符串 - 地址相同
this.empty = JSON.parse('""');
// 长度为1的字符串 - 地址相同
this.one = JSON.parse('"a"');
// 单字节字符且长度小于10 - 地址相同
this.internalize = JSON.parse('"hello"');
// 双字节字符且长度小于10 - 地址不同
this.externalize = JSON.parse('"你好"');
// 长度超过10的单字节字符 - 地址不同
this.long = JSON.parse('"aaaaaaaaaaaa"');
}
}
const a = new User();
const b = new User();

终于疑问完美解决,虽然过程曲折 😂😂😂😂😂