个人主页:兜里有颗棉花糖
欢迎 点赞👍 收藏? 留言? 加关注💓本文由 兜里有颗棉花糖 原创
收录于专栏【Java系列专栏】【JaveEE学习专栏】
本专栏旨在分享学习JavaEE的一点学习心得,欢迎大家在评论区交流讨论💌
在讲解单例模式之前,我们先来看一下什么是设计模式。
在实际的软件开发中,我们肯定会碰到很多典型的实际问题来进行解决,而针对这些实际的问题有的人就总结出了特定的一套解决方案来进行问题的解决。
设计模式中就提供给了我们很多典型场景的解决问题的处理方式。
什么是单例模式:
单例模式能保证某个类在程序中只存在唯一一份实例, 而不会创建出多个实例(比如JDBC中的DataSource实例就只需要一个,即使我们实例出了多个DateSource对象的话我们此时描述的依然是同一个服务器,这是完全没有必要的)。
在单例模式中又分为两种模式,一种是饿汉模式,一种是懒汉模式。
我们来写一段代码进行单例模式(饿汉模式)的举例,请看:
class Singleton {
private static Singleton instance = new Singleton();
public static Singleton getInstance() {
return instance;
}
}
public class Demo17 {
public static void main(String[] args) {
Singleton s1 = Singleton.getInstance();
Singleton s2 = Singleton.getInstance();
System.out.println(s1 == s2);
}
}
代码运行结果如下:
解释:上述代码使用饿汉式单例模式来实现单例的创建,即在类加载的时候就已经创建好了实例,并且在整个应用程序的生命周期中都只保留了一个实例。
所以最后打印出来的结果是true,因为引用s1和引用s2指向的对象是同一个对象。
再来看下图代码的运行结果:
引用s1和引用s3指向的对象并不是同一个对象。
现在我们需要对上述代码进行限制,来禁止创建Singleton类的实例(
只需要把类Singleton的构造方法的权限设置为private
),更改之后的代码如下:
此时如果我们再向创建Singleton的实例的话就会报错,报错信息如下:
此时我们能够使用的Singleton实例就有且只有一个了。如果想要获得Singleton的实例的话只能通过get方法来进行获取,并且获取到的对象一定是一个唯一的对象。
我们依然是写一段代码来进行举例,请看:
class Singletonlazy {
private static Singletonlazy instance = null;
public static Singletonlazy getInstance() {
if(instance == null) {
instance = new Singletonlazy();
}
return instance;
}
private Singletonlazy() {}
}
public class Demo18 {
public static void main(String[] args) {
Singletonlazy s1 = Singletonlazy.getInstance();
Singletonlazy s2 = Singletonlazy.getInstance();
System.out.println(s1 == s2);
}
}
运行结果如下:
上述代码依旧是无法创建多个实例的,报错信息如下:
上述写法就是单例模式中的懒汉模式的。
我们现在来看看饿汉模式和懒汉模式中的线程安全问题。
饿汉模式中的线程安全问题解释:当我们多次调用getInstance方法的时候,并不会修改实例instance
的内容,同时我们直到多线程读取同一个变量的时候,此时是不会出现线程安全的问题的,因为多线程读取同一个变量的时候是不会对变量进行修改的;因为在这里并不会修改instance实例中的内容。综上,饿汉模式并不会引起线程安全的问题。
线程A获取到锁,并创建了一个新的Singletonlazy实例并将其赋值给instance。
然后,线程B获取到锁,由于此时instance不为null,线程B也会创建一个新的Singletonlazy实例并将其赋值给instance。这样,就导致了多个实例的创建,违反了单例模式的定义。
上图就很好的演示了为什么懒汉模式在多线程下会创建出多个实例,即违背了单例模式的初衷。
所以懒汉模式中这里的线程是不安全的。我们可以通过
加锁操作
来解决上述问题:
请看下图代码,我们能不能通过这种方式来进行加锁呢,请看:
上述的加锁操作的写法是错误的,并没有解决线程安全问题。
正确的写法是这样的,请看下图:
同时我们要知道加锁的基本原则应该是非必要不加锁
(加锁本身是一个成本比较高的操作,加锁之后就有可能引起其它线程的等待阻塞)。
在上述正确的加锁之后的代码中每次调用getInstance方法
都要进行加锁操作,但是这样的加锁其实是没有必要的(懒汉模式的线程不安全的问题最主要的是出现在实例刚刚创建的时候,一旦实例创建好了之后我们其实就没必要进行加锁操作了,后续再调用getInstance
方法的时候也就不存在线程不安全的问题了)。
请看上图,当实例创建好了之后,getInstance方法
中的if判断就永远不会成立,所以上图代码的加锁方式其实是不合适的(因为我们每次调用getInstance方法
都需要进行加锁操作),所以我们需要再次对代码进行修改(需要再添加一个if判断)。
修改之后的代码如下图:
如上图,相同的if判断我们写了两遍,但是这两个if判断之间的执行间隔时间可能是非常长的(加锁操作所引起的阻塞等待时间是不确定的,也有可能时间是非常长的),在这段间隔时间内,其它线程很有可能对instance实例
进行修改,所以我们再添加一个if判断是非常有必要的(即一共有两个相同的if判断)。
其实,这两个if判断条件之所以一样也算是一个巧合,第一个if判断是为了判断是否要进行加锁操作,第二个if判断是为了判断是否要创建实例
。
然而修改后的上述代码依然存在一些其它的问题,比如是否能够保证内存可见性,比如:在一个多线程环境下,线程A和线程B同时调用了
getInstance方法
,线程A获得了锁,并在检查instance变量为null后,实例化出来一个instance对象,然后将其赋值给instance变量。然而,在这个赋值操作完成之后,由于内存可见性可能无法保证(因为我们不知道编译器是否会对我们的代码进行优化),此时线程B中第二个if判断读取到的instance的值依然是null值,所以第二个线程,即线程B可能也会创建出来一个instance对象。所以此时我们为例能够保证内存可见性,我们此时需要使用volatile对instance变量进行修饰,以保证内存可见性。
另外,上述解释中我们使用了volatile对instance变量进行修饰其实还有另外一个用途,就是防止指令重排序(指令重排序也是线程不安全问题的一个重要原因)。
指令重排序也是编译器对我们代码进行优化的一种手段,即保证代码在原有逻辑不变的情况下,对代码的执行顺序进行一些调整,从而是调整之后的代码的执行效率有所提高。
如上图,创建出实例对象这个操作站在指令的角度就可以分为三个步骤进行执行:
上述指令的三个操作由于编译器优化,三个指令的执行顺序就有可能发生改变。
执行完第一步之后对于当单线程而言,先执行第二步还是先执行第三步其实都是可以的。
但是如果是多线程环境下,二、三、步骤的执行顺序进行颠倒的话就有可能出现问题。
举个栗子,假设上述指令是按照132的顺序进行执行的,即还没来得及对对象进行初始化就调度给其它线程了,当第二个线程执行先判定instance不为空然后返回instance的时候,并且可能会使用instance实例对象中的一些属性和方法,但是我们得到的instance对象是一个不完整的对象(即没有进行初始化的对象)。
由于这里我们得到的是一个不完整的对象(即该对象并没有被完全的初始化)
,所以后续我们使用这个对象的时候很大可能就会出现一些问题(因为我们使用的这个对象是一个不完整的对象啊)。
当然,上述出现的问题比较极端,为什么极端我们再来分析一下加深印象:第一点:new对象的指令是按照132的顺序执行的;第二点:在执行3指令之后2指令之前恰好出现了线程调度;第三点:恰好线程调度切换的时候,切换到的那个线程返回了一个未被初始化的一个instance对象。
所以,我们使用volatile对instance变量进行修饰之后就不会出现上述的指令重排序问题,即new对象指令一定会按照123的顺序进行执行。
最终代码版本如下图,请看:
好了,以上就是关于上述单例模式代码的书写,一共有三个点需要我们注意:加锁操作、双重if的判断、volatile关键字。
本文到这里就结束了,希望友友们可以支持一下一键三连哈。嗯,就到这里吧,再见啦!!!