单例模式线程安全问题引发的思考

Posted 程序员二狗

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了单例模式线程安全问题引发的思考相关的知识,希望对你有一定的参考价值。

前言

说到面试,肯定少不了并发编程、多线程这些啦,这部分相对来说有一定的难度,而且学了就忘,让人头大,因此我打算好好捋一捋这部分,还是那句话,99%的东西是不需要智力的,惟手熟尔,多看几遍写几遍就好啦。

概述

首先需要理解多线程编程的一些由来。其实无非就是改善单线程的速度,一定能改善吗?也不尽然,在单核机器上,如果当前线程一直阻塞在IO,那么此时多线程肯定是有必要的,不然一直等着他IO也不是个事儿啊;如果面对的是一些计算任务,每个任务都需要计算资源,假如CPU1秒钟处理一个,单线程的话10秒处理10个,此时若是换成多线程,处理时间不会表还是10,可能还要在加个几秒钟的线程切换开销,此时多线程反而就慢了。

当然现代计算机基本都是多核,多线程可以更好地利用资源,同时很多业务要求异步完成,也可以使用多线程。一旦引入多线程,就会出现线程通信的安全问题,比如单例模式在多线程下的应用。

线程安全问题之单例模式

public class SingletonDemo {

private static SingletonDemo singletonDemo;

public static SingletonDemo getSingletonDemo(){
if(singletonDemo==null){
singletonDemo=new SingletonDemo();
System.out.println("创建单例");
}
return singletonDemo;
}

public static void main(String[] args) {
ExecutorService executor= Executors.newFixedThreadPool(5);
Thread task=new Thread(new Runnable() {
@Override
public void run()
{
SingletonDemo.getSingletonDemo();
}
});
for(int i=0;i<100;i++) executor.execute(task);
}
}
创建单例
创建单例

这是所谓的懒汉式单例,即需要时才加载。可以看到,多线程下这种单例模式被创建了两次,这其实就是可能同一时间两个线程进入了 if(singletonDemo==null),即两个懒汉去抢食,所以就创建了两次。在这里我们可以通过Synchronized/volatile等实现线程安全,这二者在多线程中也是相当基础且非常重要的。

Synchronized解决线程安全

public static synchronized  SingletonDemo getSingletonDemo(){
if(singletonDemo==null){
singletonDemo=new SingletonDemo();
System.out.println("创建单例");
}
return singletonDemo;
}

其实就是在方法前加上了synchronized ,这个方法是static方法,所以synchronized 在这里的语义就是锁上了这个类,每次只有一个线程能访问这个类,这也就保证了不会出现两个懒汉同时来抢食的情况,即保证了临界资源的互斥访问,进而保证了线程安全。

synchronized 的语义就是加锁,用法如下

  • 指定加锁对象,进入同步代码前要获得给定对象锁

  • 直接作用实例方法 ,相当于给当前实例加锁

  • 直接作用静态方法 给当前类加锁

这里怎么理解实例锁和类锁呢?

实例锁和类锁(static synchronized/synchronized)

实例锁

我理解的是,如果加的是实例锁,即每个实例有每个实例对应的锁,实例之间是不影响的。此时的锁只对实例自身有效。

public class SingletonDemo {

public synchronized void sys(){
System.out.println("eeeee");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

public synchronized void ycy(){
System.out.println("zzzzzz");
}

public static void main(String[] args) {
SingletonDemo singletonDemo1=new SingletonDemo();
SingletonDemo singletonDemo2=new SingletonDemo();
Thread task1=new Thread(new Runnable() {
@Override
public void run() {
singletonDemo1.sys();
}
});
Thread task2=new Thread(new Runnable() {
@Override
public void run() {
singletonDemo2.sys();
singletonDemo1.ycy();
}
});
task1.start();
task2.start();
}
}
eeeee
eeeee
// 中间 sleep 3s
zzzzzz

代码很好理解,这段代码中是synchronized,即对应实例锁。可以看到singletonDemo1先执行sys()进入它的实例锁,并睡眠把持住实例锁,此时singletonDemo2并没有被阻塞,也进入了sys()方法,验证了我们说的实例之间是不影响的。singletonDemo1的ycy()方法被自身的sys()方法阻塞,即实例锁只对实例自身有效。

类锁

如果加的是类锁,此时对应static synchronized 那么不同实例之间是共享同一把锁的,此时一个实例进入了static synchronized后,代表该实例获得了锁,其他实例不能再获得锁,即其他实例不能再访问该类中的static synchronized方法,这也是我们的单例模式可以起作用的原因。

public class SingletonDemo {

public static synchronized void sys(){
System.out.println("eeeee");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

public static synchronized void ycy(){
System.out.println("zzzzz");
}

public static void main(String[] args) {
Thread task1=new Thread(new Runnable() {
@Override
public void run() {
SingletonDemo.sys();
}
});
Thread task2=new Thread(new Runnable() {
@Override
public void run() {
SingletonDemo.ycy();
}
});
task1.start();
task2.start();
}
}
eeeee
// sleep 3s
zzzzz

这里使用的是static synchronnized,我们前边说所有实例之间共享一把锁,代码中其实可以把Singleton换成两个实例,结果是一样的(已测试),其实也很显然的。所有实例同一把锁,所以第一个实例进入sys()方法sleep后把持住锁,第二个实例就不能再获得该锁进入ycy,只能等待了。

说完了这两个锁,再回到我们的单例模式,我们可以发现,如果此时系统中有别的static synchronized,那么获取单例时也会将相对应的别的资源锁住,同时单例模式方法内部所有代码在一把锁中,锁的粒度也有点大,为了解决这些问题,我们引入了volatile。

volatile解决线程安全问题

private static volatile SingletonDemo singletonDemo;

public static SingletonDemo getSingletonDemo(){
if(singletonDemo==null){
synchronized (SingletonDemo.class) {
singletonDemo = new SingletonDemo();
System.out.println("创建单例");
}
}
return singletonDemo;
}

借助volatile,我们可以进一步细化锁的粒度,同时注意到此时一旦单例创建成功,后续获取单例操作都不需要再进入锁,这会大大减小系统开销。这一切都要归功于我们增加了volatile。

说volatile之前,我们先来了解下重排序。现代编译器为了编译效率等种种因素考虑,并不是按行编译指令的,而是对指令在不改变单线程程序语义的情况下进行了重排序,看明白了吧,单线程中没啥影响,多线程就不一定了。如果我们不使用volatile,可能会发生如下过程:

memory=allocate();    // 分配对象内存空间
ctorInstance(memory); // 初始化对象
singleton=memory; // singleton指向刚分配好的空间

这是正常的执行顺序,可能就会被重排序为如下

1 emory=allocate();    // 分配对象内存空间
2 singleton=memory; // singleton指向刚分配好的空间
3 ctorInstance(memory); // 初始化对象

如果说线程A执行到2,此时线程B执行if(singletonDemo==null),就会判定singleton不为空,接着 return singleton,return了一个空的值,Omg!这显然不可行。所以我们需要避免重排序,volatile登场了。

每个线程都有其对应的本地缓存,volatile变量修饰的变量在写的时候会被立即刷新到主内存,其他线程的该变量此时都置为无效,其他线程若是想读取该变量,就只能去主内存读取最新的。这也就解决了上述问题,所以可以保证线程安全。关于volatile,限于篇幅,留到下篇吧。

总结

通过单例模式回顾了下synchronized的相关场景用法,注意static synchronized 是全局锁,sunchronized事是实例锁,关于volatile,下篇见吧~


以上是关于单例模式线程安全问题引发的思考的主要内容,如果未能解决你的问题,请参考以下文章

设计模式之单例模式,自己引申思考

面试篇:Java的线程安全单例模式JVM内存结构等知识梳理

Java的线程安全单例模式JVM内存结构等知识梳理

关于Javakeywordsynchronized——单例模式的思考

设计模式---单例模式

蓦然回头-单例模式篇章二