1. 问题提出
最近在我们的项目当中,出现了两次与使用string相关的问题。
1.1. 问题1:新代码引入的Bug
前一段时间有一个老项目来一个新需求,我们新增了一些代码逻辑来处理这个新需求。测试阶段没有问题,但上线之后,偶尔会引起错误的逻辑输出甚至崩溃。这个问题困扰着我们很久。我们对新增代码做周详单元测试和集成测试都没有发现问题,最后只能逼迫我们去看那一大段未修改过原始代码逻辑。该项目中经常会碰到使用string,原始代码中有这样一段逻辑引起了我们的怀疑:
2 |
//... 对string_info的赋值操作 |
3 |
char * p = ( char *)string_info.data(); |
在严格的检查下和逻辑判断后,某些逻辑分支会对p指向的内容进行一些修改。这样虽然危险,但一直工作正常。联想到我们最近的修改:将string_info这个string对象拷贝了一份,然后进行一些处理。我们意识到string的Copy-On-Write和引用计数技术可能会导致我们拷贝的这个string并没有真正的实现数据拷贝。在做了一些测试和研究之后,我们确信了这一点。如是对上述代码进行了修正处理如下:
1 |
char * p = &(string_info[0]); |
然后对项目类似的地方都做了这样的处理之后,测试,上线,一切OK,太完美了。
1.2. 问题2:性能优化
最近做一个项目的重构,对相关代码进行性能分析profile时发现memcpy的CPU占比比较高,达到8.7%,仔细检查代码中,发现现有代码大量的map查找操作。map定义如下:
1 |
typedef std::map ssmap; |
查找的操作如下:
1 |
info_map[ "some_key" ] = some_value; |
我们不经意间就会写出上述代码,如果改为下述代码,性能会好很多:
1 |
static const std::string __s_some_key = "some_key" ; |
2 |
info_map[__s_some_key] = some_value; |
这是因为第一种代码,每次查找都构造一个临时的string对象,同时会将“some_key”这个字符串拷贝一份。修改之后的代码,只需要在第一次初始化时候构造一次,以后每次调用都不会进行拷贝,因此效率上要好很多。类似代码都经过这样优化之后,memcpy的CPU占比下来了,降到4.3%。
下面我们通过深入string的源码内部来解释上述两个问题的解决过程和思路。
2. std::string定义
STL中的字符串类string的定义如下:
1 |
template < typename _CharT, typename _Traits , typename _Alloc> class basic_string; |
2 |
typedef basic_string < char , char_traits< char >, allocator< char > > string; |
不难发现string在栈内存空间上只占用一个指针(_CharT* _M_p)的大小空间,因此sizeof(string)==8。其他信息都存储在堆内存空间上。
问题1:
我们有下面这一条C++语句:
请问,name这个变量总共带来多大的内存开销?这个问题我们稍后解答。
3. std::string内存空间布局
下面我们通过常见的用法来剖析一下string对象内部内存空间布局情况。
最常见的string用法是通过c风格字符串构造一个string对象,例如:
string name(“zieckey”);
其调用的构造函数定义如下:
1 |
basic_string( const _CharT* __s, const _Alloc& __a) |
2 |
: _M_dataplus( _S_construct(__s , __s ? __s + traits_type ::length( __s) : |
3 |
__s + npos , __a), __a) |
该构造函数直接调用 _S_construct 来构造这个对象,定义如下:
01 |
template < typename _CharT, typename _Traits , typename _Alloc> |
02 |
template < typename _InIterator> |
04 |
basic_string<_CharT , _Traits, _Alloc>:: |
05 |
_S_construct(_InIterator __beg, _InIterator __end , const _Alloc& __a , |
08 |
// Avoid reallocation for common case. |
11 |
while ( __beg != __end && __len < sizeof (__buf ) / sizeof ( _CharT)) |
13 |
__buf[__len ++] = *__beg; |
17 |
//构造一个 _Rep 结构体,同时分配足够的空间,具体见下面内存映像图示 |
18 |
_Rep* __r = _Rep ::_S_create( __len, size_type (0), __a); |
21 |
_M_copy( __r->_M_refdata (), __buf, __len); |
24 |
while (__beg != __end) |
26 |
if (__len == __r-> _M_capacity) |
28 |
// Allocate more space. |
29 |
_Rep* __another = _Rep:: _S_create(__len + 1, __len, __a); |
30 |
_M_copy(__another ->_M_refdata(), __r->_M_refdata (), __len); |
31 |
__r->_M_destroy (__a); |
34 |
__r->_M_refdata ()[__len++] = * __beg; |
40 |
__r->_M_destroy (__a); |
41 |
__throw_exception_again; |
43 |
//设置字符串长度、引用计数以及赋值最后一个字节为结尾符 char_type() |
44 |
__r-> _M_set_length_and_sharable(__len ); |
47 |
return __r->_M_refdata (); |
50 |
template < typename _CharT, typename _Traits , typename _Alloc> |
51 |
typename basic_string <_CharT, _Traits, _Alloc >::_Rep* |
52 |
basic_string<_CharT , _Traits, _Alloc>::_Rep :: |
53 |
_S_create(size_type __capacity, size_type __old_capacity , |
54 |
const _Alloc & __alloc) |
57 |
// 一个数组 char_type[__capacity] |
58 |
// 一个额外的结尾符 char_type() |
59 |
// 一个足以容纳 struct _Rep 空间 |
60 |
// Whew. Seemingly so needy, yet so elemental. |
61 |
size_type __size = (__capacity + 1) * sizeof ( _CharT) + sizeof (_Rep); |
63 |
void * __place = _Raw_bytes_alloc (__alloc). allocate(__size ); //申请空间 |
65 |
_Rep * __p = new (__place) _Rep; // 在地址__place 空间上直接 new对象( 称为placement new) |
66 |
__p-> _M_capacity = __capacity ; |
67 |
__p-> _M_set_sharable(); // 设置引用计数为0,标明该对象只为自己所有 |
_Rep定义如下:
5 |
_Atomic_word _M_refcount; |
至此,我们可以回答上面“问题1”中提出的问题:
上文中”string name;”这个name对象所占用的总空间为33个字节,具体如下:
1 |
sizeof (std::string) + 0 + sizeof ( '' ) + sizeof (std::string::_Rep) |
其中:sizeof(std::string)为栈空间
上文中的提到的另一条C++语句 string name(“zieckey”); 定义了一个string变量name,其内存空间布局如下:
4. 深入string内部源码
4.1. string copy与strncpy
长期以来,经常看到有人对std::string赋值拷贝与strncpy之间的效率进行比较和讨论。下面我们通过测试用例来进行一个基本的测试:
09 |
const int array_size = 200; |
10 |
const int loop_count = 1000000; |
15 |
char * s2= new char [ array_size]; |
16 |
memset ( s2, 'c' , array_size); |
17 |
size_t start= clock (); |
18 |
for ( int i =0;i!= loop_count;++i ) strncpy ( s1,s2 , array_size); |
19 |
cout<< __func__ << " : " << clock ()- start<<endl ; |
24 |
void test_string_copy () |
28 |
s2. append(array_size , 'c' ); |
29 |
size_t start= clock (); |
30 |
for ( int i =0;i!= loop_count;++i ) s1= s2; |
31 |
cout<< __func__ << " : " << clock ()- start<<endl ; |
使用g++ -O3编译,运行时间如下:
test_strncpy : 40000
test_string_copy : 10000
字符串strncpy的运行时间居然是string copy的4倍。究其原因就是因为,string copy是基于引用计数技术,每次copy的代价非常小。
测试中我们还发现,如果array_size在10个字节以内的话,两者相差不大,随着array_size的变大,两者的差距也越来越大。例如,在array_size=1000的时候,strncpy就要慢13倍。
4.2. 通过GDB调试查看引用计数变化
上面的测试结论非常好,打消了大家对string性能问题的担忧。下面我们通过一段程序来验证引用计数在这一过程中的变化和作用。
请先看一段测试代码:
09 |
string a = "0123456789abcdef" ; |
11 |
cout << "a.data() =" << ( void *)a. data() << endl ; |
12 |
cout << "b.data() =" << ( void *)b. data() << endl ; |
13 |
assert ( a.data () == b. data()); |
17 |
cout << "a.data() =" << ( void *)a. data() << endl ; |
18 |
cout << "b.data() =" << ( void *)b. data() << endl ; |
19 |
cout << "c.data() =" << ( void *)c. data() << endl ; |
20 |
assert ( a.data () == c. data()); |
24 |
cout << "after write:\n" ; |
25 |
cout << "a.data() =" << ( void *)a. data() << endl ; |
26 |
cout << "b.data() =" << ( void *)b. data() << endl ; |
27 |
cout << "c.data() =" << ( void *)c. data() << endl ; |
28 |
assert ( a.data () != c. data() && a .data() == b.data ()); |
运行之后,输出:
a.data() =0xc22028
b.data() =0xc22028
a.data() =0xc22028
b.data() =0xc22028
c.data() =0xc22028
after write:
a.data() =0xc22028
b.data() =0xc22028
c.data() =0xc22068
上述代码运行的结果输出反应出,在我们对b、c赋值之后,a、b、c三个string对象的内部数据的内存地址都是一样的。只有当我们对c对象进行修改之后,c对象的内部数据的内存地址才不一样,这一点是是如何做到的呢?
我们通过gdb调试来验证引用计数在上述代码执行过程中的变化:
02 |
Breakpoint 1 at 0x400c35: file string_copy1.cc, line 10. |
04 |
Breakpoint 2 at 0x400d24: file string_copy1.cc, line 16. |
06 |
Breakpoint 3 at 0x400e55: file string_copy1.cc, line 23. |
08 |
Starting program: [...] /unixstudycode/string_copy/string_copy1 |
09 |
[Thread debugging using libthread_db enabled] |
11 |
Breakpoint 1, main () at string_copy1.cc:10 |
14 |
(gdb) x /16ub a._M_dataplus._M_p-8 |
15 |
0x602020: 0 0 0 0 0 0 0 0 |
16 |
0x602028: 48 49 50 51 52 53 54 55 |
此时对象a的引用计数是0
2 |
11 cout << "a.data() =" << (void*)a.data() << endl; |
b=a 将a赋值给b,string copy
1 |
(gdb) x /16ub a._M_dataplus._M_p-8 |
2 |
0x602020: 1 0 0 0 0 0 0 0 |
3 |
0x602028: 48 49 50 51 52 53 54 55 |
此时对象a的引用计数变为1,表明有另一个对象共享该对象a
06 |
Breakpoint 2, main () at string_copy1.cc:16 |
08 |
(gdb) x /16ub a._M_dataplus._M_p-8 |
09 |
0x602020: 1 0 0 0 0 0 0 0 |
10 |
0x602028: 48 49 50 51 52 53 54 55 |
12 |
17 cout << "a.data() =" << (void*)a.data() << endl; |
c=a 将a赋值给c,string copy
1 |
(gdb) x /16ub a._M_dataplus._M_p-8 |
2 |
0x602020: 2 0 0 0 0 0 0 0 |
3 |
0x602028: 48 49 50 51 52 53 54 55 |
此时对象a的引用计数变为2,表明有另外2个对象共享该对象a
07 |
Breakpoint 3, main () at string_copy1.cc:23 |
10 |
24 cout << "after write:\n" ; |
对c的值进行修改
1 |
(gdb) x /16ub a._M_dataplus._M_p-8 |
2 |
0x602020: 1 0 0 0 0 0 0 0 |
3 |
0x602028: 48 49 50 51 52 53 54 55 |
此时对象a的引用计数变为1
1 |
(gdb) p a._M_dataplus._M_p |
2 |
$3 = 0x602028 "0123456789abcdef" |
3 |
(gdb) p b._M_dataplus._M_p |
4 |
$4 = 0x602028 "0123456789abcdef" |
5 |
(gdb) p c._M_dataplus._M_p |
6 |
$5 = 0x602068 "1123456789abcdef" |
此时对象c的内部数据内存地址已经与a、b不同了,即Copy-On-Write
上述GDB调试过程,清晰的验证了3个string对象a b c的通过引用计数技术联系在一起。
4.3. 源码分析string copy
下面我们阅读源码来分析。上述过程。
先看string copy过程的源码:
02 |
basic_string( const basic_string& __str) |
03 |
: _M_dataplus( __str._M_rep ()->_M_grab( _Alloc(__str .get_allocator()), |
04 |
__str.get_allocator ()), |
05 |
__str.get_allocator ()) |
08 |
_CharT* _M_grab( const _Alloc& __alloc1, const _Alloc& __alloc2) |
10 |
return (! _M_is_leaked() && __alloc1 == __alloc2) |
11 |
? _M_refcopy() : _M_clone (__alloc1); |
14 |
_CharT*_M_refcopy() throw () |
16 |
#ifndef _GLIBCXX_FULLY_DYNAMIC_STRING |
17 |
if ( __builtin_expect( this != &_S_empty_rep(), false )) |
19 |
__gnu_cxx::__atomic_add_dispatch (& this -> _M_refcount, 1); |
上面几段源代码比较好理解,先后调用了basic_string (const basic_string& __str )拷贝构造函数、_M_grab、_M_refcopy,
_M_refcopy实际上就是调用原子操作__atomic_add_dispatch (确保线程安全)将引用计数+1,然后返回原对象的数据地址。
由此可以看到,string对象之间的拷贝/赋值代价非常非常小。
几个赋值语句之后,a、b、c对象的内存空间布局如下图所示:
4.4. Copy-On-Write
下面再来看”c[0] = ’1′; “做了些什么:
01 |
reference operator []( size_type __pos ) |
04 |
return _M_data ()[__pos ]; |
07 |
void _M_leak () // for use in begin() & non-const op[] |
09 |
//前面看到 c 对象在此时实际上与a对象的数据实际上指向同一块内存区域 |
10 |
//因此会调用 _M_leak_hard() |
11 |
if (! _M_rep ()->_M_is_leaked ()) |
17 |
if ( _M_rep ()->_M_is_shared ()) |
19 |
_M_rep()-> _M_set_leaked (); |
22 |
void _M_mutate ( size_type __pos , size_type __len1, size_type __len2 ) |
24 |
const size_type __old_size = this -> size (); //16 |
25 |
const size_type __new_size = __old_size + __len2 - __len1 ; //16 |
26 |
const size_type __how_much = __old_size - __pos - __len1 ; //16 |
28 |
if ( __new_size > this -> capacity() || _M_rep ()->_M_is_shared ()) |
31 |
const allocator_type __a = get_allocator (); |
32 |
_Rep * __r = _Rep:: _S_create (__new_size , this -> capacity (), __a ); |
36 |
_M_copy (__r -> _M_refdata(), _M_data (), __pos ); |
38 |
_M_copy (__r -> _M_refdata() + __pos + __len2 , |
39 |
_M_data () + __pos + __len1, __how_much ); |
42 |
_M_rep ()->_M_dispose ( __a); |
45 |
_M_data (__r -> _M_refdata()); |
47 |
else if (__how_much && __len1 != __len2 ) |
50 |
_M_move (_M_data () + __pos + __len2 , |
51 |
_M_data () + __pos + __len1, __how_much ); |
55 |
_M_rep()-> _M_set_length_and_sharable (__new_size ); |
上面源码稍微复杂点,对c进行修改的过程分为以下两步:
- 第一步是判断是否为共享对象,(引用计数大于0),如果是共享对象,就拷贝一份新的数据,同时将老数据的引用计数值减1。
- 第二步:在新的地址空间上进行修改,从而避免了对其他对象的数据污染
由此可以看出,如果不是通过string提供的接口对string对象强制修改的话,会带来潜在的不安全性和破坏性。例如:
1 |
char * p = const_cast < char *>(s1.data()); |
上述代码对c修改(“c[0] = ’1′; “)之后,a b c对象的内存空间布局如下:
Copy-On-Write的好处通过上文的解析是显而易见是,但也带来一些副作用。例如上述代码片段”c[0] = ’1′; “如果是通过外部的强制操作可能会带来意想不到的结果。请看下面代码:
1 |
char * pc = const_cast (c.c_str()); |
这段代码通过强制修改c对象内部数据的值,看似效率上比operator[] 高,但同时也修改a、b对象的值,而这可能不是我们所希望看到的。这是我们需要提高警惕的地方。
5. 不宜使用string的例子
我们项目组内部有一个分布式的内存kv系统,一般是md5做key,value是任意二进制数。当初设计的时候,考虑到内存容量始终有限,没有选择使用string,而是单独开发的key结构和value结构。下面是我们设计的key结构定义:
该结构所需内存大小为16字节,保持二进制的16字节MD5。相对于string做key来说,要节省33(参考上文string内存空间布局)个字节。例如,现在我们某个项目正在使用该系统的搭建的一个分布式集群,总共有100亿条记录,每条记录都节省33字节,总共节省内存空间:33*100亿=330G。由此可见,仅仅对key的一个小小改进,就能节省如此大的内存,还是非常值得。
6. 对比微软Visual Studio提供的STL版本
vc6.0的string实现是基于引用计数的,但不是线程安全的。但在后续版本的vc中去掉了引用计数技术,string copy 都直接进行深度内存拷贝。
由于string实现上的细节不一致,导致跨平台程序的移植带来潜在的风险。这种场合下,我们需要额外注意。
7. 总结
- 即使是一个空string对象,其所占内存空间也达到33字节,因此在内存使用要求比较严格的应用场景,例如memcached等,请慎重考虑使用string。
- string由于使用引用计数和Copy-On-Write技术,相对于strcpy,string copy的性能提升非常显著。
- 使用引用计数后,多个string指向同一块内存区域,因此,如果强制修改一个string的内容,会影响其他string。
- http://blogs.360.cn/360cloud/2012/11/26/linux-gcc-stl-string-in-depth/
- (转)Java中的String为什么是不可变的? -- String源码分析
背景:被问到很基础的知识点 string 自己答的很模糊 Java中的String为什么是不可变的? -- String源码分析 ps:最好去阅读原文 Java中的String为什么是不可变的 什 ...
- EasyUI学习总结(三)——easyloader源码分析(转载)
声明:这一篇文章是转载过来的,转载地址忘记了,原作者如果看到了,希望能够告知一声,我好加上去! easyloader模块是用来加载jquery easyui的js和css文件的,而且它可以分析模块的依 ...
- quartz集群调度机制调研及源码分析---转载
quartz2.2.1集群调度机制调研及源码分析引言quartz集群架构调度器实例化调度过程触发器的获取触发trigger:Job执行过程:总结:附: 引言 quratz是目前最为成熟,使用最广泛的j ...
- (1)quartz集群调度机制调研及源码分析---转载
quartz2.2.1集群调度机制调研及源码分析 原文地址:http://demo.netfoucs.com/gklifg/article/details/27090179 引言quartz集群架构调 ...
- STL 源码分析《1》---- list 归并排序的 迭代版本, 神奇的 STL list sort
最近在看 侯捷的 STL源码分析,发现了以下的这个list 排序算法,乍眼看去,实在难以看出它是归并排序. 平常大家写归并排序,通常写的是 递归版本..为了效率的考虑,STL库 给出了如下的 归并排序 ...
- String源码分析
前言:String类在日常开发过程中使用频率非常高,平时大家可能看过String的源码,但是真的认真了解过它么,笔者在一次笔试过程中要求写出String的equals方法,瞬间有点懵逼,凭着大致的理解 ...
- Java中的String为什么是不可变的? — String源码分析
原文地址:http://www.importnew.com/16817.html 什么是不可变对象? 众所周知, 在Java中, String类是不可变的.那么到底什么是不可变的对象呢? 可以这样认为 ...
- String源码分析(1)--哈希篇
本文基于JDK1.8,首发于公众号:Plus技术栈 让我们从一段代码开始 System.out.println("a" + "b" == "ab&qu ...
- 【转】Java中的String为什么是不可变的? -- String源码分析
什么是不可变对象? 众所周知, 在Java中, String类是不可变的.那么到底什么是不可变的对象呢? 可以这样认为:如果一个对象,在它创建完成之后,不能再改变它的状态,那么这个对象就是不可变的.不 ...
随机推荐
- 虚拟机VirtualBox及轻量级的CentOS
1,先下载虚拟机VirtualBox和centos(下边有链接),将VirtualBox安装在本机 2,管理 --> 导入虚拟电脑 --> 选择本地centos文件 3,点击下一步 - ...
- 使用群晖NAS:配置Git server
1.首先在群晖的DSM的控制面板中创建一个用户例如是Git_test(我给了管理员权限) 2.在套件中心安装 Git server 3.打开Git server 勾选用户 Git_test 4.在控制 ...
- 020——VUE中变异方法push的留言版实例讲解
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title> ...
- gridview 后台增加列
BoundField field1 = null; field1 = new BoundField(); //实例化 field1.HeaderText = "序号";field ...
- Falsk项目cookie中的 csrf_token 和表单中的 csrf_token实现
Flask中请求体的请求开启CSRF保护可以按以下配置 from flask_wtf.csrf import CSRFProtect app.config.from_object(Config) CS ...
- react 问题
安装依赖报错问题 可能需要按顺序安装, 不能cnpm npm 混合安装, 参考react项目入门 react an ...
- 图像和流媒体 -- Sapera 安装遇到的问题
一.下载安装包 参看:Genie Nano M1930-NIR 点击软件及例程下载 二.安装遇到的问题 (1)Installation directory must be on a local har ...
- Gradle 1.12用户指南翻译——第六十四章. 发布到Ivy(新)
其他章节的翻译请参见:http://blog.csdn.net/column/details/gradle-translation.html翻译项目请关注Github上的地址:https://gith ...
- MPAndroidChart Wiki(译文)~Part 2
7. 填充数据 这一章节将讲解给各式各样的图表设置数据的方法. 7.1 LineChart(线形图) 想给图表添加数据,使用如下方法: public void setData(ChartData da ...
- js 以函数名作为参数动态执行 函数
function myFunc() { console.log(11111); } test("myFunc"); function test(funcName) { if(typ ...