SingleTon概述
SingleTon单件模式(单例模式),涉及到一个特殊的类,这个类只能有一个instance。
因此类设计者设计的SingleTon模式的类必须阻止使用者生成该类的任何一个instance,且必须向使用者提供一个公共接口访问该类的唯一instance。
保证一个类仅有一个实例,并提供一个该实例的全局访问点。 ——《设计模式》GoF
SingleTon使用场景
1、要求生产唯一序列号。
2、WEB 中的计数器,不用每次刷新都在数据库里加一次,用单例先缓存起来。
3、创建的一个对象需要消耗的资源过多,比如 I/O 与数据库的连接等。
SingleTon结构
SingleTon代码实现
SingleTon类设计:
class SingleTon { private: SingleTon(); SingleTon(const SingleTon&); public: static SingleTon* singleTon; static SingleTon* getInstance(); };
(1)为了避免用户产生类实例,将构造/拷贝构造函数都设置为private
(2)singleTon为SingleTon类产生的唯一实例,getInstance获取这个实例
//singleTon初始化 SingleTon* SingleTon::singleTon = nullptr;
getInstance()实现代码
(1)单线程版。线程非安全版本,只适用于单线程环境
//线程非安全版本 SingleTon* SingleTon::getInstance() { if(singleTon == nullptr) { singleTon = new SingleTon; } return singleTon; }
(2)线程安全版本,但锁的代价太高
//线程安全版本,但锁代价太高 SingleTon* SingleTon::getInstance() { Lock lock; // 读写都加锁 if(singleTon == nullptr) { singleTon = new SingleTon; } return singleTon; }
此实现为使用者的每次访问都提供了加锁机制,因此多线程环境下保证了唯一实例singleTon的访问是安全的。
缺点在于,线程对singleTon的读操作也会产生不必要的加锁。
比如:线程A线程B同时调用getInstance(),singleTon初值为nullptr,线程A先加锁,为singleTon生成其真正实例,线程A释放锁后,singleTon已经不为空,线程B对singleTon只会产生读操作,但是依旧需要加锁。如果在高并发场景下,100w个线程同时访问singleTon,singleTon只由某个线程实例一次,即只进行一次写操作,后续线程对singleTon的访问都是读操作,即访问100w次,就加锁100w次,99 9999次都是无须加锁可直接访问的读操作,这样一看锁的代价确实很高。
优化方法:将读写分开,读操作直接访问,写操作需要加锁
(3)双检查锁,但是由于内存的reorder导致了不安全
//双检查锁,内存reorder问题 SingleTon* SingleTon::getInstance() { if(singleTon == nullptr) { // 读无须加锁 Lock lock; // 写时加锁 if(singleTon == nullptr) { singleTon = new SingleTon; //可能产生编译器指令优化,内存reorder } } return singleTon; }
假设还是线程A和B,singleTon初值为nullptr,线程A先加锁,为singleTon生成其真正实例,线程A释放锁后,singleTon已经不为空,线程B再访问singleTon时就可直接从第一个if判断跳转到return语句访问singleTon实例,从而避免了对读操作加锁。
双检查锁的代码实际上就是在(2)线程安全版本的代码中加了一个最外层的if判断优化了读操作。
问题1:Lock lock;之后的if判断可以省略吗?
不能!首先逻辑上,Lock lock;之后的代码段是一个完整的对singleTon实例访问的线程安全代码;其次,假设没有Lock lock;之后的if语句,线程A线程B同时访问进入到第一个if语句,不论谁先加锁,最终sinleTon都会被AB两个线程各自实例一次,这对线程安全本身就是一个巨大的隐患。
问题2:内存reorder
对于singleTon = new SingleTon;这条语句,理论上是三个步骤:为singleTon分配内存-->调用SingleTon构造函数-->将分配的内存地址返回给singleTon。如果是这样的,那么无论线程AB怎么调度,二者对于singleTon的访问都是线程安全的。
但是编译器的优化机制可能产生另一个顺序:为singleTon分配内存-->将分配的内存地址返回给singleTon-->调用SingleTon构造函数。被reorder后的指令代码,最容易产生的一种不安全情况就是:线程B访问到线程A还没有构造完毕的"半成品"singleTon。
假设线程A此时成功拿到了锁,并已经执行完singleTon = new SingleTon;这条语句的前两个步骤(reorder):为singleTon分配内存-->将分配的内存地址返回给singleTon,此时singleTon就不是一个nullptr指针,而是指向一段内存空间,但是这段空间上并没有存储一个SingleTon对象!如果线程B恰好在此时调用getInstance(),就会判断第一个if语句,singleTon不为nullptr,从而return singleTon,那么线程B中调用一方获取到的就是一个不能正确访问的半成品sinlgTon!
(4)C++ 11版本之后的跨平台实现 (volatile)