字符串的一般封装方式的内存布局 (0): 拿在手上的是什么_Ruby_编程开发_程序员俱乐部

中国优秀的程序员网站程序员频道CXYCLUB技术地图
热搜:
更多>>
 
您所在的位置: 程序员俱乐部 > 编程开发 > Ruby > 字符串的一般封装方式的内存布局 (0): 拿在手上的是什么

字符串的一般封装方式的内存布局 (0): 拿在手上的是什么

 2013/11/19 16:23:16  RednaxelaFX  程序员俱乐部  我要评论(0)
  • 摘要:(Disclaimer:未经许可请勿转载。如需转载请先与我联系。作者:RednaxelaFX->rednaxelafx.iteye.com)字符串的一般封装方式的内存布局系列:(0):拿在手上的是什么(1):元数据与字符串内容,整体还是分离?原本我写这个是作为一个讨论JavaScriptString的内存布局的回帖的一部分,不过越写越长觉得跑题有点多所以干脆抽出来单独写一系列笔记好了。下面的讨论有原帖背景影响:*JavaScriptString分配在栈上还是在堆上
  • 标签:什么 方式 字符串 内存
(Disclaimer:未经许可请勿转载。如需转载请先与我联系。
作者:RednaxelaFX -> rednaxelafx.iteye.com)

字符串的一般封装方式的内存布局系列:
(0): 拿在手上的是什么
(1): 元数据与字符串内容,整体还是分离?

原本我写这个是作为一个讨论JavaScript String的内存布局的回帖的一部分,不过越写越长觉得跑题有点多所以干脆抽出来单独写一系列笔记好了。下面的讨论有原帖背景影响:
* JavaScript String分配在栈上还是在堆上?
* Lua的string是copy-on-write的吗?
请留意讨论的背景。

引用字符串不就是一坨内存么?这坨内存不是就得在栈上或者堆上么?
嗯…不是。且不说还有全局数据区而字符串也可以存在那里,字符串它不一定只是“一坨内存”。下面展开来看看。

关于字符串的一般封装方式的内存布局

就不说char*这种裸字符串,只说面向对象的字符串封装。
进一步限制范围,这里只讨论“扁平”(flat)的字符串,也就是字符串内容按顺序紧密排布在内存中的数据结构,而不讨论像rope那样用链式结构来拼接字符串的数据结构。

回顾上面提到需要完全动态分配内存的条件,其中一个是无法事先判断数据大小。通用的string类型字符串内容的长度不固定,因而整个string的大小无法事先确定,无论是无论可变还是不可变字符串。这就意味着string整体看至少在某些情况下得有一部分(变长的部分)要使用动态内存分配,也就是“分配在堆上”。

string类型的封装可以在几个维度上表现出差异:
0、“拿在手上”的是什么?
1、字符串元数据与字符串内容打包为整体存放,还是分离存放;
2、不同字符串实例是否共享字符串内容;
3、字符串是否显式记录长度;
4、字符串是否有'\0'结尾(null-terminated),字符串内容是否允许存'\0'(embedded null);
5、外部指针或引用指向字符串的什么位置;
6、字符串的存储容量(capacity)是否可以大于字符串内容的长度(length);
7、是否有对齐要求,结尾是否有padding。

0、拿在手上的是什么?

假设有
class="x" name="code">mystringtype s = "foobar";
mystringtype s1 = s;

那拿在手上的“s”与“s1”也要占存储空间,它里面到底装着什么?
按照离“真实数据”的距离从近到远,可以有下面几种情况:
a) 直接是字符串内容?
b) 是指向字符串实体的指针?
c) 是指向字符串实体的“指针的指针”?
d) 是一个代表某个字符串的token?


a) 直接是字符串内容

比较少见,但并不是不存在。有些C++标准库实现的std::basic_string采用了SSO(short string optimization),可以把短字符串(7个wchar_t或者15个char之类的)直接塞在std::string结构体里;长度大于阈值的字符串就还是把字符串内容分配在堆上。此类实现中,
std::string s("foobar");
std::string s1 = s;

里面的s就会直接持有"foobar"内容,而不是“指向字符串实体的指针”。

例如VS2012/VC11的实现就是此类。把VC11的std::string极度简化,它的数据部分如下:
class string {
  enum { _BUF_SIZE = 16 };
  union _Bxty {
    // storage for small buffer or pointer to larger one
    char  _Buf[_BUF_SIZE];
    char* _Ptr;
  } _Bx;
  size_t _Mysize; // current length of string
  size_t _Myres;  // current storage reserved for string
};

可以看到它的第一个实例成员_Bx是个大小为16字节的union,里面既可以装下长度小于_BUF_SIZE的字符串内容,也可以装下一个指针(当字符串长度不小于_BUF_SIZE时)。这种称为SSO的技巧可以让小字符串直接内嵌在std::string实例结构内,此时不需要额外在堆上分配buffer所以减少了堆空间开销,也提高了数据的局部性。当然也有代价,也就是每次要访问字符串内容都得先根据_Myres与_BUF_SIZE的比较来判断当前处于"short string"还是"long string"模式,增加了一点代码的复杂度,不过总体看由于提高了数据局部性,倒未必增加了时间开销。

对"foobar"的例子,在32位x86上VC11的std::string在内存里可以是这样:
0x0042FE54  66 6f 6f 62 61 72 00 00 b9 21 a2 00 68 f7 0c 95
0x0042FE64  06 00 00 00 0f 00 00 00 

s: 0x0042FE54 (24字节)
 (+0) [ _Bx._Buf = 0x66 ('f') 0x6F ('o') 0x6F ('o') 0x62 ('b') 0x61 ('a') 0x72 ('r') 0x00 ('\0') ... ]
(+16) [ _Mysize  = 0x00000006 ]
(+20) [ _Myres   = 0x0000000F ]

64位x86上则可以是这样:
0x000000000024F8E8  66 6f 6f 62 61 72 00 00 69 2f d5 a1 1d d9 ce 01
0x000000000024F8F8  06 00 00 00 00 00 00 00 0f 00 00 00 00 00 00 00

s: 0x000000000024F8E8 (32字节)
 (+0) [ _Bx._Buf = 0x66 ('f') 0x6F ('o') 0x6F ('o') 0x62 ('b') 0x61 ('a') 0x72 ('r') 0x00 ('\0') ... ]
(+16) [ _Mysize  = 0x0000000000000006 ]
(+24) [ _Myres   = 0x000000000000000F ]

头16字节就是_Bx成员的范围,该例中头6字节是"foobar"的内容,接着是'\0'(null-terminate),剩余部分是未使用数据(并不保证清零);然后是_Mysize = 6与_Myres = 15。

到s1 = s的时候,s1就完整拷贝了s的内容,然后s1里就也内嵌着一份"foobar"了,两者没有共享数据。

b) 是指向字符串实体的指针

许多高级语言虚拟机的实现都会选用这种方案。它们会限制对对象的访问,不允许直接访问对象的内容,而是必须通过引用来间接访问。这样就至少有一层间接。当这个间接层通过“直接指针”来实现时,这种管理内存的方式叫做pointer-based memory management。

例子中的“s”“s1”是引用,引用自身的值是个指针;“s”“s1”两个引用指向同一个String实例。例如说由CLR实现的.NET和由HotSpot VM实现的Java都是这样。后面还会有相关例子所以现在先不展开写。
s:              string object:
[ pointer ] --> [ "foobar" ]
             /
s1:         /
[ pointer ]


c) 是指向字符串实体的“指针的指针”

比上一种情况更多一层或多层间接。多出来的间接层通常叫做handle(句柄),相应的内存管理方式叫做handle-based。

句柄的常见实现方式是“指针的指针”(pointer-to-pointer),也就是比直接指针多一层间接:
s:             handle table:   string object:
[ handle ] --> [ pointer ] --> [ "foobar" ]
            /
s1:        /
[ handle ]

像Sun JDK 1.0.2里的JVM就是这样的。

使用句柄的好处是实现起来可以偷懒。假如有内存管理器需要移动对象(例如说mark-compact或者copying GC),那就得修正所有相关指针。但遍历所有相关指针需要费点功夫,想偷懒的话就可以像这样增加一个间接层,不允许外界直接拥有指向对象的指针,而是让外界持有句柄,句柄可以是指向“句柄表”(handle table)的指针,而句柄表里的元素才真的持有指向对象的指针。要修正指针的时候只要遍历句柄表来修正即可。

用句柄的坏处就是时间和空间开销都较大。合适的使用场景有两种:1、想偷懒;2、想隐藏信息。

d) 是一个代表某个字符串的token

这算是上一种情况的进一步特例。所谓“句柄”不一定要是“指针的指针”,也可以是更加间接的东西,例如说如果“句柄表”是一个数组,那“句柄”可以只是下标而不是指针;如果“句柄表”是一个稀疏数组(可能用哈希表来实现),那“句柄”可能也只是个稀疏数组的下标(可能用哈希表的键来实现)。这样的句柄有时候也叫做token、ID之类的。

Ruby 1.8.7的Symbol就是这种特殊句柄的实际应用
Ruby的Symbol跟String都可用来表示字符串信息,区别在于:
* Symbol是驻留(interned)的,String不是。驻留的意味着相同内容的“Symbol对象实例”只会有一份;
* Symbol不可变,String可以可变(也可以是frozen string,那就不可变)。

Symbol在Ruby里是如此特别,在表示Ruby的值的VALUE类型里都有针对Symbol的特化。
下面的例子连续使用了3个Symbol,赋值给局部变量s:
s = :rednaxelafx
s = :rednaxelapx
s = :rednaxelagx

假定这3个Symbol都是之前没出现过的,那么它们3个就会按顺序被接连intern起来。

局部变量s的类型从C的角度看是VALUE。三次赋值后s的内容(VALUE的值)可能分别是:
(例子在Mac OS X 10.7.5/x86-64/Ruby 1.8.7上运行)
0x00000000005F390E
0x00000000005F410E
0x00000000005F490E

看不出来有什么联系?换成二进制来看:
ID                                                    | ID_LOCAL | SYMBOL_FLAG
00000000000000000000000000000000000000000101111100111 | 001      | 00001110
00000000000000000000000000000000000000000101111101000 | 001      | 00001110
00000000000000000000000000000000000000000101111101001 | 001      | 00001110

Ruby 1.8.7的VALUE是一种tagged pointer类型:最低8位是用来标识值的特殊类型的标记(tag),其中用来标记Symbol的SYMBOL_FLAG值为0x0e;
当VALUE的标记是SYMBOL_FLAG时,紧挨着标记的3位用来表示Symbol的作用域(scope),其中用来标记局部标识符的ID_LOCAL的值为0x01;
再上面的高位是与Symbol一一对应的唯一值,是个整数ID。

把ID的部分单独抽出来看,可以看到例子里s的ID分别是
3047
3048
3049

是逐个递增上去的整数序列。这个ID与作用域标记一同构成了Ruby里用于表示Symbol的token,可以看作特殊形式的句柄。

这样,Symbol其实没有真正的“对象实例”,至少没有整体存在于堆上的对象实例。整个Symbol系统由3部分组成:
* 与Symbol一一对应的ID值,通常嵌在标记为SYMBOL_FLAG的VALUE里。这个ID除去作用域标记外的部分由一个全局计数器生成而来。而Symbol#object_id其实返回的也是由这个ID算出来的值。参考rb_intern()的实现;
* 一个全局的symbol table,是个哈希表,记录着ID到实际字符串内容的映射关系;
* 存有实际字符串信息的char数组。

知道Symbol#object_id与底层ID之间的映射关系后可以写出这样的小程序:
def id_with_scope(object_id)
  # sizeof(RVALUE) = 40 on 64-bit platform for Ruby 1.8.7
  ((object_id << 1) - (4 << 2)) / 40
end
ID_LOCAL = 1
ID_SHIFT = 3
def to_id(sym)
  return nil unless sym.is_a? Symbol
  id_with_scope(sym.object_id) >> ID_SHIFT
end

(只对Ruby 1.8系列在64位上正确。其它版本/平台的细节稍有不同,但原理一样。)
然后算出某个Symbol对应的ID值:
>> to_id :rednaxelafx
=> 3047
>> to_id :rednaxelapx
=> 3048
>> to_id :rednaxelagx
=> 3049


Rubinius的Symbol也是用相似方式实现的。

从驻留的角度看,Ruby的Symbol跟Lua的string相似:两者的所有实例都是驻留的。但前者的引用的值(VALUE)有特殊表现形式,是一个整数ID;而后者还是用普通指针来实现引用的值(Value)。驻留、实例的特殊性,与是否使用指针来表现引用,是两个正交的问题。
发表评论
用户名: 匿名