js字符串最多存储多少字节?

js字符串最多存储多少字节?

V8的heap上限只有2GB不到,允许分配的单个字符串大小上限更只有大约是512MB不到。JS字符串是UTF16编码保存,所以也就是2.68亿个字符。FF大约也是这个数字。

https://www.zhihu.com/question/61105131

JavaScript字符串底层是如何实现的?

作者:RednaxelaFX

链接:https://www.zhihu.com/question/51132164/answer/124450796

来源:知乎

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

目前主流的做法是把String值的实现分为5大类使用场景:

  1. 已经要查看内容的字符串:使用flat string思路来实现,本质上说就是用数组形式来存储String的内容;
  2. 拼接字符串但尚未查看其内容:使用“rope”思路或其它延迟拼接的思路来实现。当需要查看其内容时则进行“flatten”操作将其转换为flat string表现形式。最常见rope的内部节点就像二叉树(RopeNode { Left; Right })一样,但也可以有采用更多叉树的设计的节点,或者是用更动态的多叉树实现;
  3. 子串(substring):使用“slice”思路来实现,也就是说它只是一个view,自己并不存储字符内容而只是记录个offset和length,底下的存储共享自其引用的源字符串;
  4. 值得驻留(intern)的字符串:通常也是flat string但可能会有更多的限制,存储它的空间可能也跟普通String不一样。最大的好处是在特殊场景下有些字符串会经常重复出现,或者要经常用于相等性比较,把这些字符串驻留起来可以节省内存(内容相同的字符串只驻留一份),并且后续使用可以使用指针比较来代替完全的相等性比较(因为驻留的时候已经比较过了);
  5. 外来字符串:有时候JavaScript引擎跟外界交互,外界想直接把一个char8_t或者char16_t传给JavaScript引擎当作JavaScript字符串用。JavaScript引擎可能会针对某些特殊场景提供一种包装方式来直接把这些外部传进来的字符串当作JavaScript String,而不拷贝其内容。

在上述5种场景中,涉及存储的时候都可以有

  • 使用UTF-16为单元的最常规做法以及使用Latin-1 / ASCII的压缩版本这两种变种。
  • 对于flat string,直接把字符串内容粘在对象末尾的“内嵌版”,以及把字符串内容放在独立的数组里的“独立数组版”两个变种。

如果把语言层面的一个String值类型按上述使用场景给拆分成若干种不同的底层实现类型,本质上都是在为内存而优化:要么是减少String的内存使用量(1-byte vs 2-byte、substring等),要么是减少拷贝的次数/长度(rope的按需flatten)。

底层实现类型的数量的增多,会使得相关处理的代码都变得多态,不利于编译器对其做优化,所以这里是有取舍的。如果多态换来的内存收益比不上多态的代码开销的话就得不偿失了。显然,众多JavaScript引擎都选择了在String值类型上细分出多种实现类型,反映了多态在这个地方总体来看是有利的。

把上面的场景(1)、(2)、(3)用代码来举例:

var s1 = "rednaxela"; // flat string, string literal

var s2 = "fx"; // flat string, string literal

var s3 = s1 + s2; // rope ("concat string", "cons string")

var s4 = s3.substring(0, 3); // substring / slice

// 这个操作可能会让s3所引用的String值被flatten为flat string

// 同理,如果执行 s3[0] 下标操作也可能会让原本是rope的String值被flatten

在有用rope来优化字符串拼接的JavaScript引擎上,使用二元+运算符来拼接字符串其实不会直接导致冗余的字符串内容拷贝,只有在需要使用字符串的内容时才会对它做一次批量的flatten操作,做一次拷贝。所以字符串拼接“要用Array.prototype.join()而忌讳用+运算符”的建议就不那么重要了。

=========================================

V8

于是让我们来考察一下V8的String都有上述场景的哪些。

针对5.5.339版本来看:

v8/objects.h at 5.5.339 · v8/v8 · GitHub

// - Name

// - String

// - SeqString

// - SeqOneByteString

// - SeqTwoByteString

// - SlicedString

// - ConsString

// - ExternalString

// - ExternalOneByteString

// - ExternalTwoByteString

// - InternalizedString

// - SeqInternalizedString

// - SeqOneByteInternalizedString

// - SeqTwoByteInternalizedString

// - ConsInternalizedString

// - ExternalInternalizedString

// - ExternalOneByteInternalizedString

// - ExternalTwoByteInternalizedString

// - Symbol

V8里能表示字符串的C++类型有上面这么多种。其中Name是String(ES String Value)与Symbol(ES6 Symbol)的基类。看看String类下面的子类是多么的丰富 >_<

简单说,String的子类都是用于实现ECMAScript的String值类型,从JavaScript层面看它们都是同一个类型——String,也就是说typeof()它们都会得到"string"。

其中:

  • SeqString就是上面的场景(1)(“flat string”)的实现。其中有SeqOneByteString / SeqTwoByteString分别对应使用1-byte ASCII char与2-byte UTF-16的版本。字符串内容都是直接粘在对象末尾的(“内嵌版”)。

  • ConsString就是上面的场景(2)(“rope”)的实现。本质上就是把还在拼接中的字符串用二叉树(其实是二叉DAG)的方式先存着,直到要查看其内容时再flatten成SeqString。它自身不存储字符内容所以不关心1-byte还是2-byte。

  • SlicedString就是上面场景(3)(“slice / substring”)的实现。同上它也不存储字符内容,所以1-byte还是2-byte就看引用的底层String是怎样的。

  • ExternalString就是上面场景(5)(外部传入的字符串)的实现。这个涉及存储,所以也有1-byte与2-byte两个实际实现。

  • InternalizedString系列就是上面场景(4)(“interned”)的实现。它的子类跟前面列举的几种类型一一对应。

而String的包装对象类型在V8里则是由StringWrapper来实现:

bool HeapObject::IsStringWrapper() const {

return IsJSValue() && JSValue::cast(this)->value()->IsString();

}

值得注意的是:虽然ECMAScript的String值是值类型的,这并不就是说“String值就是在栈上的”。

正好相反,V8所实现的String值全部都是在V8的GC堆上存储的,传递String值时实际上传递的是指向它的指针。但由于JavaScript的String值是不可变的,所以底层实现无论是真的把String“放在栈上”还是传递指针,对上层应用的JavaScript代码而言都没有区别。

ExternalString虽然特殊但也不例外:它实际存储字符串内容的空间虽然是从外部传进来的,不在V8的GC堆里,但是ExternalString对象自身作为一个对象头还是在GC堆里的,所以该String类型实现逻辑上说还是在GC堆里。

话说V8除了上述String类型外,还有一些跟String相关的、应用于特殊场景的类型。其中比较典型的有:

  • ReplacementStringBuilder:用于正则表达式的字符串替换等;

  • IncrementalStringBuilder:// TODO

这个版本的V8对自己字符串拼接实现已经颇有信心,所以 String.prototype.concat 也直接用JavaScript来实现了:

v8/string.js at 5.5.339 · v8/v8 · GitHub

// ECMA-262, section 15.5.4.6

function StringConcat(other /* and more */) { // length == 1

"use strict";

CHECK_OBJECT_COERCIBLE(this, "String.prototype.concat");

var s = TO_STRING(this);

var len = arguments.length;

for (var i = 0; i < len; ++i) {

s = s + TO_STRING(arguments[i]);

}

return s;

}

这就是直接把传入的参数拼接成ConsString返回出去。

V8连标准库函数都用这种代码模式来实现了,同学们也不用担心这样做会太慢啦。

而V8里的 Array.prototype.join 则针对稀疏数组的情况有些有趣的优化:

它会借助一个临时的InternalArray为“string builder”,计算出拼接结果的length之后直接分配一个合适类型和长度的SeqString作为buffer来进行拼接。而这个InternalArray里的内容可以带有编码为Smi的“下一段要拼接的字符串在什么位置(position)和长度(length)”信息,然后从当前位置到下一个要拼接的位置之间填充分隔符,这样就不会在对稀疏数组的join过程中把数组中无值的位置都填充到“string builder”的实体里去了。这是个run-length encoding的思路。

V8还有个有趣的功能:原地缩小对象而不必为了缩小而拷贝。这个有空再具体展开写。

=========================================

Nashorn

让我们看看JDK8u112-b04里的Nashorn实现

它比V8要简单一些,实现ECMAScript String值的类型都是java.lang.CharSequence接口的实现类,其中有:

  • 场景(1)(“flat string”):直接使用Java原生的 java.lang.String 类型,方便用上JVM对String的优化。在一个JDK/JVM自身就有针对1-byte / 2-byte场景做优化的实现上(例如Oracle JDK9 / OpenJDK9的Compact Strings),Nashorn就会自动获得相应的优化;
  • 场景(2)(“rope”):不免俗,有个实现了CharSequence接口的ConsString类型;
  • 场景(3)(“slice / substring”):直接用java.lang.String.substring()实现,没有额外优化。Oracle JDK / OpenJDK在JDK7后撤销了java.lang.String的子串共享实现,所以Nashorn里的slice() / substring()在这些JDK上会涉及拷贝开销…orz!
  • 场景(4)(“intern”):只有少量地方做了intern,是直接用 java.lang.String.intern() 的。
  • 场景(5)(外部传入的字符串):没有特别的对应支持。Nashorn面向的用户是其它JVM上的语言(例如Java),所以外部传入的字符串最可能的也就是 java.lang.String ,正好Nashorn自身的flat string就是直接用 java.lang.String ,所以也就不用做什么额外工作来支持这些外来字符串了。

ECMAScript的String包装对象类型则由这个NativeString类型表示:NativeString,里面就是包装着一个代表String值的CharSequence类型引用。

Nashorn在实现 String.prototype.concat() 时没有特别的实现,是直接把参数拼接成一串ConsString然后直接返回没有flatten的ConsString。

=========================================

SpiderMonkey

这里用FIREFOX_AURORA_51_BASE版代码来考察。

总体来说SpiderMonkey里的String的内部实现思路与V8的非常相似。

代码里的注释把设计思路讲解得很清楚了:

http://hg.mozilla.org/mozilla-central/file/fc69febcbf6c/js/src/vm/String.h

/*

  • JavaScript strings

  • Conceptually, a JS string is just an array of chars and a length. This array

  • of chars may or may not be null-terminated and, if it is, the null character

  • is not included in the length.

  • To improve performance of common operations, the following optimizations are

  • made which affect the engine's representation of strings:

    • The plain vanilla representation is a "flat" string which consists of a
  • string header in the GC heap and a malloc'd null terminated char array.

    • To avoid copying a substring of an existing "base" string , a "dependent"
  • string (JSDependentString) can be created which points into the base

  • string's char array.

    • To avoid O(n^2) char buffer copying, a "rope" node (JSRope) can be created
  • to represent a delayed string concatenation. Concatenation (called

  • flattening) is performed if and when a linear char array is requested. In

  • general, ropes form a binary dag whose internal nodes are JSRope string

  • headers with no associated char array and whose leaf nodes are either flat

  • or dependent strings.

    • To avoid copying the leftmost string when flattening, we may produce an
  • "extensible" string, which tracks not only its actual length but also its

  • buffer's overall size. If such an "extensible" string appears as the

  • leftmost string in a subsequent flatten, and its buffer has enough unused

  • space, we can simply flatten the rest of the ropes into its buffer,

  • leaving its text in place. We then transfer ownership of its buffer to the

  • flattened rope, and mutate the donor extensible string into a dependent

  • string referencing its original buffer.

  • (The term "extensible" does not imply that we ever 'realloc' the buffer.

  • Extensible strings may have dependent strings pointing into them, and the

  • JSAPI hands out pointers to flat strings' buffers, so resizing with

  • 'realloc' is generally not possible.)

    • To avoid allocating small char arrays, short strings can be stored inline
  • in the string header (JSInlineString). These come in two flavours:

  • JSThinInlineString, which is the same size as JSString; and

  • JSFatInlineString, which has a larger header and so can fit more chars.

    • To avoid comparing O(n) string equality comparison, strings can be
  • canonicalized to "atoms" (JSAtom) such that there is a single atom with a

  • given (length,chars).

    • To avoid copying all strings created through the JSAPI, an "external"
  • string (JSExternalString) can be created whose chars are managed by the

  • JSAPI client.

    • To avoid using two bytes per character for every string, string characters
  • are stored as Latin1 instead of TwoByte if all characters are representable

  • in Latin1.

  • Although all strings share the same basic memory layout, we can conceptually

  • arrange them into a hierarchy of operations/invariants and represent this

  • hierarchy in C++ with classes:

  • C++ type operations+fields / invariants+properties

  • ========================== =========================================

  • JSString (abstract) get(Latin1|TwoByte)CharsZ, get(Latin1|TwoByte)Chars, length / -

  • | \

  • | JSRope leftChild, rightChild / -

  • |

  • JSLinearString (abstract) latin1Chars, twoByteChars / might be null-terminated

  • | \

  • | JSDependentString base / -

  • |

  • JSFlatString - / null terminated

  • | |

  • | +-- JSExternalString - / char array memory managed by embedding

  • | |

  • | +-- JSExtensibleString tracks total buffer capacity (including current text)

  • | |

  • | +-- JSUndependedString original dependent base / -

  • | |

  • | +-- JSInlineString (abstract) - / chars stored in header

  • | |

  • | +-- JSThinInlineString - / header is normal

  • | |

  • | +-- JSFatInlineString - / header is fat

  • |

  • JSAtom - / string equality === pointer equality

  • |

  • js::PropertyName - / chars don't contain an index (uint32_t)

  • Classes marked with (abstract) above are not literally C++ Abstract Base

  • Classes (since there are no virtual functions, pure or not, in this

  • hierarchy), but have the same meaning: there are no strings with this type as

  • its most-derived type.

  • Atoms can additionally be permanent, i.e. unable to be collected, and can

  • be combined with other string types to create additional most-derived types

  • that satisfy the invariants of more than one of the abovementioned

  • most-derived types:

    • InlineAtom = JSInlineString + JSAtom (atom with inline chars, abstract)
    • ThinInlineAtom = JSThinInlineString + JSAtom (atom with inline chars)
    • FatInlineAtom = JSFatInlineString + JSAtom (atom with (more) inline chars)
  • Derived string types can be queried from ancestor types via isX() and

  • retrieved with asX() debug-only-checked casts.

  • The ensureX() operations mutate 'this' in place to effectively the type to be

  • at least X (e.g., ensureLinear will change a JSRope to be a JSFlatString).

*/

可以看到,SpiderMonkey里的 JSString 是表现ECMAScript String值的基类。它下面的子类的层次设计跟V8的颇有相似之处,完全应对了本回答开头提到的5种场景:

  • 场景(1)(“flat string”):JSFlatString 及其子类。最特别的是它的“inline string”,这是在JSString的共同header里“偷空间”来存储字符内容的设计。这种思路也叫做“small string”优化,我在以前另一个回答里提及过:在stack上做small string或small vector优化比在heap上效率高吗? - RednaxelaFX 的回答
  • 场景(2)(“rope”):JSRope 实现了典型的二叉树(二叉DAG)形式的rope。不过它具体用在字符串拼接的时候也有些有趣的优化,上面引用的代码注释以及提到了:flat string下面有一种专门为用作字符串拼接的buffer的类型JSExtensibleString,它可以在拼接过程中有一个比较长的长度,然后等拼接结束确定最终长度后再原地把自己的长度缩短到实际长度。这个功能也跟V8可以原地缩小对象大小的功能类似。
  • 场景(3)(“slice / substring”):JSDependentString
  • 场景(4)(“intern”):JSAtom 及其子类 js::PropertyName
  • 场景(5)(外部传入的字符串):JSExternalString

上述所有涉及实际字符串内容的存储的类似都有针对7-bit Latin1与2-byte UTF-16的特化支持。

=========================================

Chakra / ChakraCore

请参考

@Thomson

大大的回答。回头有空我再写点我的版本。

=========================================

其它JavaScript引擎的细节回头再更新…

编辑于 2016-10-02

310

作者:Thomson

链接:https://www.zhihu.com/question/51132164/answer/124477176

来源:知乎

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

R大已经答全了,我就填下Chakra的坑吧。

Chakra的C++实现的String的基类是JavascriptString,保存的基本上就是一个字符串指针(为了跨平台自定义了char16,在Windows上定义成WCHAR。

ChakraCore/JavascriptString.h at master · Microsoft/ChakraCore · GitHub

class JavascriptString _ABSTRACT : public RecyclableObject

{

...

private:

const char16* m_pszValue; // Flattened, '\0' terminated contents

charcount_t m_charLength; // Length in characters, not including '\0'.

为了优化常见使用场景如字符串连接,子串等操作还定义了不少子类:

JavascriptString

|- LiteralString

| |- CompundString

| |- ConcateStringBase

| |- ConcatStringN

| | |- ConcatString

| |- ConcatStringBuilder

| PropertyString

| SingleCharString

| SubString

| WritableString

比如经常使用的字符串连接操作如下:

ChakraCore/JavascriptString.cpp at master · Microsoft/ChakraCore · GitHub

inline JxavascriptString* JavascriptString::Concat(JavascriptString* pstLeft, JavascriptString* pstRight)

{

if(!pstLeft->IsFinalized())

{

if(CompoundString::Is(pstLeft))

{

return Concat_Compound(pstLeft, pstRight);

}

if(VirtualTableInfo<ConcatString>::HasVirtualTable(pstLeft))

{

return Concat_ConcatToCompound(pstLeft, pstRight);

}

}

else if(pstLeft->GetLength() == 0 || pstRight->GetLength() == 0)

{

return Concat_OneEmpty(pstLeft, pstRight);

}

if(pstLeft->GetLength() != 1 || pstRight->GetLength() != 1)

{

return ConcatString::New(pstLeft, pstRight);

}

return Concat_BothOneChar(pstLeft, pstRight);

}

对非简单的字符串连接直接构造了ConcatString对象,该对象父类(ConcatStringN)里面有一个JavascriptString指针的数组(ConcatStringN通过模板可连接的JavascriptString数量参数化,ConcatString对应最常见的N=2),在ConcatString的构造函数里面把待连接的两个JavascriptString存进数组,这样可以不用分配内存和做copy。由于左右都是JavascriptString*,同样可以使ConcatString,这样递归下去就会生成R大提到 rope 思路的DAG(我开始没注意到这里的递归,多谢R大指出)。整个字符串的 flatten 是需要的时候再做,借用了lazy computation的想法。

ChakraCore/ConcatString.h at master · Microsoft/ChakraCore · GitHub

template <int N>

class ConcatStringN : public ConcatStringBase

{

...

protected:

JavascriptString* m_slots[N]; // These contain the child nodes. 1 slot is per 1 item (JavascriptString*).

};

ChakraCore/ConcatString.cpp at master · Microsoft/ChakraCore · GitHub

ConcatString::ConcatString(JavascriptString* a, JavascriptString* b) :

ConcatStringN<2>(a->GetLibrary()->GetStringTypeStatic(), false)

{

a = CompoundString::GetImmutableOrScriptUnreferencedString(a);

b = CompoundString::GetImmutableOrScriptUnreferencedString(b);

m_slots[0] = a;

m_slots[1] = b;

this->SetLength(a->GetLength() + b->GetLength()); // does not include null character

}

另外对SubString也有类似的优化,直接构造了SubString对象作为JavascriptString的子类对象返回。

ChakraCore/SubString.h at master · Microsoft/ChakraCore · GitHub

class SubString sealed : public JavascriptString

{

void const * originalFullStringReference; // Only here to prevent recycler to free this buffer.

SubString(void const * originalFullStringReference, const char16* subString, charcount_t length, ScriptContext *scriptContext);

ChakraCore/SubString.cpp at master · Microsoft/ChakraCore · GitHub

inline SubString::SubString(void const * originalFullStringReference, const char16* subString, charcount_t length, ScriptContext *scriptContext) :

JavascriptString(scriptContext->GetLibrary()->GetStringTypeStatic())

{

this->SetBuffer(subString);

this->originalFullStringReference = originalFullStringReference;

this->SetLength(length);

...

}

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容

  • Lua 5.1 参考手册 by Roberto Ierusalimschy, Luiz Henrique de F...
    苏黎九歌阅读 13,517评论 0 38
  • package cn.itcast_01;/* 字符串:就是由多个字符组成的一串数据。也可以看成是一个字符数组。 ...
    蛋炒饭_By阅读 557评论 0 0
  • 背景 一年多以前我在知乎上答了有关LeetCode的问题, 分享了一些自己做题目的经验。 张土汪:刷leetcod...
    土汪阅读 12,660评论 0 33
  • 一、字符串在C#中,字符串是一系列不可修改的Unicode字符,创建字符串后,就不能修改它。要创建字符串,最常用的...
    CarlDonitz阅读 1,223评论 0 2
  • 发哥是我一起实习的好哥们,我两同一天上的粤电1,是同期的实习生。发哥姓陈名一发,一发不可收拾的一发。后来才知道,这...
    ab的日记阅读 1,680评论 1 8