此篇文章与大家分享多线程专题的第四篇(关于多线程代码案例中的单例模式)
如果有不足的或者错误的请您指出!
目录
- 九、多线程代码案例(单例模式)
- 1.单例模式
- 1.1饿汉模式
- 1.2懒汉模式
- 1.3使用场景
- 1.4上述单例模式的线程安全问题
- 1.5指令重排序问题
九、多线程代码案例(单例模式)
1.单例模式
单例模式是设计模式里面比较简单的一种,顾名思义,单例就是只有一个实例,也就是对于进程中的某个类,它只能实例化一个对象,不会new出来多的对象
那我们如何保证一个类只能有一个对象呢???单凭我们口头保证肯定是不行的,我们就需要通过特定代码的手段来实现这个功能
单例模式我们主要认识两种写法:饿汉模式、懒汉模式
1.1饿汉模式
表示这个类的唯一实例创建得比较早,类似于饿了很久的人,一看到吃的就迫不及待想去吃
用代码体现就是:
public class Singleton {
private static Singleton instance = new Singleton();
public static Singleton getInstance(){
return instance;
}
}
static修饰的其实是类属性,就是在"类对象"上的,每个类的类对象在jvm中只有一个,那么里面的静态成员就只有一个了
此处后续需要使用这个类的实例,就可以直接通过getInstance来获取已经new好的这个,而不是重新new
但是我们怎么保证,这个类就只能实例化一次呢??我们直接将构造方法私有化即可
public class Singleton {
private static Singleton instance = new Singleton();
public static Singleton getInstance(){
return instance;
}
private Singleton(){}//代码里面不需要有任何逻辑
}
将构造方法私有化是单例模式里面最核心的一步,类之外的代码尝试实例化对象的时候,就必须要通过构造方法,但是由于构造方法是私有的,无法调用,尝试实例化的时候就会编译出错
我们验证一下:
当我们尝试实例化对象:
就会编译出错
可能有人会问两种极端情况
(1)如果我在一个Singleton类里面实例化多个对象,那不就打破了单例模式了嘛??
实际上,如果你是这个类的作者,你一定不会去这么做;如果你是别人,使用这个类的时候,如果要进行修改,一般也要经过你的审核
(2)那么我们可以通过反射机制拿到私有的构造方法嘛??
原则上是可以的,但是在实际开发中,反射机制并不常见,甚至不能乱用,特殊场景下回需要用到反射,但是使用反射要付出很大的代价(会严重影响代码的可读性和封装性)
1.2懒汉模式
在计算机领域,"懒"往往是提高效率的表现
在懒汉模式中,不是在程序启动的时候就实例化好唯一对象了,而是在后续使用这个类的时候才去创建实例,此时如果不去使用这个类,那么创建实例的代价就很好地省下了
public class SingletonLazy {
private static SingletonLazy instance = null;
public static SingletonLazy getInstance(){
if(instance == null){
instance = new SingletonLazy();
}
return instance;
}
private SingletonLazy(){}
}
什么时候使用这个类就什么时候创建这个实例,本质上就是能偷懒的,能少做的就少做
1.3使用场景
代码中的有些对象,本身就不适合是有多个实例的,从业务角度就应该是单个实例
比如,你写的服务器,要从硬盘上加载100G的数据到内存里面,肯定要写一个类,封装上述加载操作,并且写一些获取/处理业务数据的业务逻辑
这样的类就应该是单例的,一个实例就管理100G的数据,搞N个实例就是N*100G的内存数据,机器肯定吃不消,也没必要,因为都是重复的数据
例如在java的JDBC中,DataSourse就应该是单例的,这种对象就类似于配置管理的对象(存储服务器的地址,端口号,用户名,密码,选项参数…)
1.4上述单例模式的线程安全问题
我们需要考虑,如果有多个线程同时调用getInstance(),线程是否安全??
如果是在饿汉模式下,实例创建的时间是程序启动的时候,比main线程调用还早,因而后续使用这个类的时候,调用getInstance()一定比创建实例要晚,此时就只是读取上述变量的值了,而在多个线程里同时读取同一个变量,是不会有线程安全问题的
我们主要是来看懒汉模式下的线程安全问题
此时一旦出现这种执行顺序,那么就会创建多一个实例
因此我们需要做的就是将if操作和new操作打包成一个原子操作
那么就要进行加锁操作
public class SingletonLazy {
private static SingletonLazy instance = null;
private static Object locker = new Object();
public static SingletonLazy getInstance(){
synchronized (locker) {
if(instance == null){
instance = new SingletonLazy();
}
}
return instance;
}
private SingletonLazy(){}
}
但是还有一个细节问题,我们知道在懒汉模式下,只有在第一次调用getInstance()才是创建实例的.而一旦创建好了,后面的就只是读操作了
但是如果我们单纯这样加,那么后面尽管是读,也是需要加锁的,而加锁的开销是很大的,也有可能导致线程堵塞
因此我们可以在外层在加上if条件判断,如果需要创建实例,才加锁
public class SingletonLazy {
private static SingletonLazy instance = null;
private static Object locker = new Object();
public static SingletonLazy getInstance(){
if (instance == null) {
synchronized (locker) {
if(instance == null){
instance = new SingletonLazy();
}
}
}
return instance;
}
private SingletonLazy(){}
}
此时两个if的作用是不一样的,外层的if是为了防止没必要的加锁操作,里层的if是判断要不要加锁(如果两个线程都进去了第一层if,那么第二层if就是防止多创建对象)
同时,为了防止编译器进行优化操作,我们还需要对instance加上volatile关键字
public class SingletonLazy {
private static volatile SingletonLazy instance = null;
private static Object locker = new Object();
public static SingletonLazy getInstance(){
if (instance == null) {
synchronized (locker) {
if(instance == null){
instance = new SingletonLazy();
}
}
}
return instance;
}
private SingletonLazy(){}
}
1.5指令重排序问题
指令重排序实际上也是编译器优化的一种策略
我们写的代码在被编译成一条一条的二进制指令后,正常来说CPU都是按照顺序一条一条执行,但是编译器比较智能,会根据实际情况,调整指令执行的顺序,调整的目的就是为了提高效率
在单线程下,编译器的优化策略是比较安全的,能够保证优化前后代码的逻辑不变,但是在多线程环境下,就可能出现问题
就拿我们上面的单例模式 - 懒汉模式来说
instance = new SingletonLazy();
这一句代码,我们简单来看就可以分成3个步骤
(1)申请内存空间
(2)调用构造方法
(3)把此时内存的地址赋值到instance
在指令重排序下,可能会出现1,2,3或者1,3,2两种执行顺序(但是1一定是再前面的),在单例模式下这两种都是OK的
但是在多线程就可能会出现下面的情况:
在t1线程进行完地址赋值操作后,意味着instance就是非null,只是此时执行的对象是一个未初始化的对象
此时t2线程恰好进来,由于instance已经非空,第一个if的条件无法满足,就直接return了,如果在这个时候,进行Singleton s = …,s.func(),这里的后续操作都是针对未初始化的对象进行操作的,会出现严重的问题
解决上述问题的方法还是使用volatile
即volatile不仅能够解决内存可见性的线程安全问题,还能解决指令重排序的问题