引用计数--写时拷贝
原文章见www.louhang.xin
假设一下场景:
存在类A,其内含有成员变量为指针类型。首先创建对象a1,给a1 new了一块空间进行了初始化。之后创建了对象a2(可写对象),并且用对象a1进行拷贝构造来完成初始化,此时需要显现的写出拷贝构造函数,为a2 new一块新的空间,以防止浅拷贝,a1和a2都指向同一块空间,两者之间相互影响。但是,又定义了对象a3,且a3为只读对象,a3同样也是用a1拷贝构造来完成初始化,那么这个时候调用了拷贝构造函数,为a3也开辟空间。但是,实质上我们此时只需要完成浅拷贝即可。否则便造成了空间的浪费。
最好的效果便是,当进行只读的操作的时候,那么只进行浅拷贝,而如果要进行写操作,那么就进行深拷贝。
而通过 引用计数-写时拷贝,我们便可以达到这种目的。
写时拷贝(Copy-On-Write)技术,就是编程界“懒惰行为”——拖延战术的产物。举个例子,比如我们有个程序要写文件,不断地根据网络传来的数据写,如果每一次fwrite或是fprintf都要进行一个磁盘的I/O操 作的话,都简直就是性能上巨大的损失,因此通常的做法是,每次写文件操作都写在特定大小的一块内存中(磁盘缓存),只有当我们关闭文件时,才写到磁盘上 (这就是为什么如果文件不关闭,所写的东西会丢失的原因)。
写时拷贝与我之前解析的new[](详见博客《C++动态内存管理:new/delete》),具有相同的做法。
其会有一个变量用于保存引用的数量。当第一个类构造时,string的构造函数会根据传入的参数从堆上分配内存,当有其它类需要这块内存时,这个计数为自动累加,当有类析构时,这个计数会减一,直到最后一个类析构时,此时的引用计数为1或是0。此时,程序才会真正的Free这块从堆上分配的内存。
首先来看下代码:
#include<iostream>
#include<windows.h>
using namespace std;
class String
{
public:
String(char* str = "")
:_str(new char[strlen(str) + 1])
{}
String(const String& str)
:_str(new char[strlen(str._str) + 1])
{
strcpy(_str, str._str);
}
~String()
{
if (_str)
{
delete[] _str;
}
}
private:
char *_str;
};
void test()
{
String s1 = "hello";
double start= GetTickCount();
for (int i = 0; i < 100000; ++i)
{
String s2(s1);
}
double end = GetTickCount();
double len = end - start;
cout << "test: " << len << endl;
}
int main()
{
test();
system("pause");
return 0;
}
程序运行结果:
在上面代码当中,并未对s1的内容进行改变,但是for循环不断调用拷贝构造函数为s2开辟空间,之后又不断调用析构函数对s2进行释放,造成了空间的不停开辟和析构,就导致了程序效率的降低。
下面来看看写时拷贝应用:
#include<iostream>
#include<windows.h>
using namespace std;
class CopyOnWrite{
public:
//构造函数
CopyOnWrite(const char* str="")
:_str(new char[strlen(str)+5]) //多开辟五个字节,前四个字节用来存放计数器,最后一个用来存放结束标志‘\0’
{
_str+=4;
strcpy(_str,str);
GetReadCount(_str)=1;
}
//拷贝构造函数
CopyOnWrite(const CopyOnWrite& s)
:_str(s._str)
{
++GetReadCount(s._str);
}
//析构函数
~CopyOnWrite()
{
Release();
}
//获取前四个字节,用作引用计数
inline int& GetReadCount(char* str)
{
return *(int*)(str-4);
}
//通过源字符串内的计数器,判断其是否需要释放空间
inline void Release()
{
if (GetReadCount(_str)==0)
{
delete[] (_str-4);
_str=NULL;
}
else
{
--GetReadCount(_str);
}
}
private:
char* _str;
};
void test1()
{
CopyOnWrite s1 = "hello";
double start= GetTickCount();//记录此时毫秒数
for (int i = 0; i < 100000; ++i)
{
CopyOnWrite s2(s1);
}
double end = GetTickCount();//记录此时毫秒数
double len = end - start;
cout << "test: " << len << endl;
}
int main()
{
test1();
system("pause");
return 0;
}
程序运行结果:
上面的代码中应用写时拷贝技术,可以很明显的观察到,提高了程序的运行效率。
下面来看一下完整的代码段:
#include<iostream>
#include<windows.h>
using namespace std;
class CopyOnWrite{
public:
//构造函数
//多开辟五个字节,前四个字节用来存放计数器,最后一个用来存放结束标志‘\0’
CopyOnWrite(const char* str="")
:_str(new char[strlen(str)+5])
{
_str+=4;//指向开辟空间的的首地址指针向前挪动4个字节
strcpy(_str,str);
GetReadCount(_str)=1;//令前四个字节内存引用计数 1
}
//拷贝构造函数
CopyOnWrite(const CopyOnWrite& s)
:_str(s._str)
{
++GetReadCount(s._str);//引用计数加1
}
//赋值运算符的重载
CopyOnWrite operator=(CopyOnWrite& str)
{
if (this != &str)
{
Release();
_str=str._str;
str.GetReadCount(_str)++;
}
return *this;
}
// []重载
char& operator[](size_t pos)
{
if (GetReadCount(_str)>1)
{
--GetReadCount(_str);
//进行写操作的时候,new一块新空间
char* tmp = new char[strlen(_str)+1];
tmp+=4;
GetReadCount(tmp)=1;
strcpy(tmp,_str);
_str=tmp;
}
return _str[pos];
}
//打印函数
void prit()
{
cout<<_str<<endl;
}
//析构函数
~CopyOnWrite()
{
Release();
}
//获取前四个字节,用作引用计数
inline int& GetReadCount(char* str)
{
return *(int*)(str-4);
}
//通过源字符串内的计数器,判断其是否需要释放空间
inline void Release()
{
if (GetReadCount(_str)==0)//引用计数为0,则要进行空间的释放
{
delete[] (_str-4);
_str=NULL;
}
else//引用计数不为0,则将引用计数减1
{
--GetReadCount(_str);
}
}
private:
char* _str;
};
void test1()
{
CopyOnWrite s1 = "hello";
double start= GetTickCount();//记录此时毫秒数
for (int i = 0; i < 100000; ++i)
{
CopyOnWrite s2(s1);
}
double end = GetTickCount();//记录此时毫秒数
double len = end - start;
cout << "test: " << len << endl;
}
void test2()
{
CopyOnWrite s1("hello");
CopyOnWrite s2(s1);
CopyOnWrite s3;
s3 = s2;
s1[2] = 'x';
s1.prit();
s2.prit();
s3.prit();
}
int main()
{
test1();
test2();
system("pause");
return 0;
}
下面来对代码进行分析:
上面的图便是对对象内部的实现进行的分析,我们可以观察到在拷贝一个对象的时候,并没有开辟新的空间,然后把原先的对象复制到新内存,而是在新对象也指向源对象的位置,并把源对象内存的引用计数进行加加。
在对新的对象执行读操作的时候,内存数据不发生任何变动,直接执行读操作;而在对新的对象执行写操作时,将真正的开辟新的空间,将源对象复制到新的内存地址中,并修改新对象的内存映射表指向这个新的位置,并在新的内存位置上执行写操作。
因此在执行复制操作时因为不是真正的内存复制,而只是建立了一个指针,因而大大提高效率。
那么看看下面的三种方法能不能实现写时拷贝呢?
我们可以分析一下:
方案一:通过成员变量 _refCount来进行引用计数,但是_refCount是每个成员内部的各自的成员变量,其无法记录多个对象的统一情况,所以此方案不行。
方案二:通过静态成员变量 _refCount来进行引用计数,因为其是静态的,所以属于所有对象,这么看来其好像可以实现引用计数,但是,需要注意的是,当存在两个或两个以上的对象动态空间时,如果要对一个进行释放,那么势必要将_refCount置0,但是当_refCount为0时,那么势必会影响到另一个不想进行释放的对象,此时就出现了bug。所以,此方案不通过。
方案三:通过一个指针变量来进行引用计数,其实现方式上面的示例代码中异曲同工之妙,不过上述示例的代码时此方法的优化,此方法可行!
总结:
写时拷贝技术需要跟虚拟内存和分页同时使用,好处就是在执行复制操作时因为不是真正的内存复制,而只是建立了一个指针,因而大大提高效率。
但这不是一直成立的,如果在复制新对象之后,大部分对象都还需要继续进行写操作会产生大量的分页错误,得不偿失。
所以高效的情况只是在复制新对象之后,在一小部分的内存分页上进行写操作。