16-单例问题与线程安全性深入解析

本讲我们来了解一下单例设计模式与线程安全性问题,当然了,我们是以线程安全性问题为主,而引出来单例设计模式,对于单例模式大家可能都不陌生,我们在项目中可以说是到处都会见到单例模式的身影,举个最简单的例子,我们的任何一个应用一般都会有配置文件,当然了,如果我们使用Spring的话,可能都交给Spirng来给我们管理了,比如说我们读取一些简单的比如说proprietary这样的配置文件,那么,我们在用的时候,我们就可能就需要实例化配置对象,那么,每次都实例化的话,是不太好的, 那怎么办呢?我们就实例化这一个对象,然后,别的地方用的时候,就拿这一个对象用就可以了。单例设计模式有两种,一种是饿汉式,一种是懒汉式,我们简单的来看一下这两种形式,首先,名字起得就是比较传神的,饿汉就是非常饥饿,其实饿汉和懒汉所说的就是单例实例化的时间,饿汉显然就是立刻就来实例化。我们为了保证实例的唯一,首先就需要私有化它的构造方法,不让别人去new,不让别人去new才能保证实例唯一,如果你让别人去new的话,那么,就没有办法保证唯一了,所以,第一步就是私有化构造方法,那么,你不让别人去new了,那么,显然要提供一个公有的获取实例的方法,那么,既然不让new,那么我们显然这个方法得是类的,而不是实例的,返回值就是当前类的实例,

当然这里现在还没有当前类的实例,那么,我们就要创建这个类的实例,那么,如何创建呢?在创建的时候,因为创建方式的不同,那么,也就分成刚才我们所提到的饿汉式和懒汉式两种方式,首先说饿汉式,饿汉式非常简单,就是,我们直接在创建的过程中就实例化了,然后,直接返回这个实例就可以了

这样就保证了在全局这个实例只有这么一个,那么,我们在用的时候,我们来获取这个类的实例,显然是new不出来的

 

这样写了编译器会报错,为什么会报错呢?

显然就是,私有化的private修饰的,只能在本类中访问,别的类是访问不了的,那么,我们想获取类的实例该怎么办呢?我们就需要这样去写,通过它的静态方法

这样就获取到了,我们多来获取几个,我们来观察获取的这多个实例是不是同一个实例,我们分别打印这多个实例,看它们的哈希值是不是相同的,

我们发现这四个对象的哈希值是相同的,这就说明,我们获取的是同一个实例,我们现在是在单线程的环境下去获取这个类的实例,那么,在多线程环境下会不会有问题呢?会不会出现线程安全性问题?出现线程安全性问题的前提是必须满足三个条件,第一个条件是多线程的环境下,第二个条件是必须有共享资源,第三个条件是对资源进行非原子性操作。

本例中多线程环境下符合第一个条件,本例中instance就是共享资源,所以符合第二个条件,发现,我们在本例中的这个方法中只干了return instance这个一件事,就是返回instance,因此,本例是不满足第三个条件的,return instance其实就是一个原子型操作,所以,单例模式的饿汉式不会出现线程安全性问题。

既然饿汉式是线程安全的,实现又简单,那么我们就用饿汉式不就好了吗,那么,懒汉式是干嘛的呢,那么我们就不用去了解懒汉式了,不是这样的啊,我们发现饿汉式有我们所不希望的,比如说,

我们这个类里面,我们现在还没做任何事呢,这个在堆内存中开辟空间,在创建这个Singleton类的实例的时候,那么,占用的空间也是比较小的,那么,如果说我们要读取配置文件,比如说,我们说配置文件并非是只能做配置,比如说我们的数据文件等等,比如说这个文件比较大,那么,当我们启动Singleton类的时候,来加载Singleton类的时候,它其实就已经被实例化过了,因此,就是说,不管我们用还是不用,Singleton类的实例都已经创建了,那么内存也就已经占用了,这样就会导致很多内存的浪费,所以,我们希望我们不要在它加载的时候就去实例化,而是当我们第一次使用的时候再去实例化也不迟,就类似于我们的Windows操作系统,我们安装了很多的软件,并不是Windows操作系统启动了之后就把所有的软件都打开了,而是当我们在使用软件的时候,双击这个软件才把这个软件打开,这就是一种懒加载,所以,我们为了满足这个需求,所以

我们就不要在这里面直接new了。当然,这只是其中的一个原因,而懒汉式所蕴含的设计模式以及它的理念远比饿汉式要多得多的,比如说缓存的思想,比如说懒加载的思想,等等,这里我们就不再去扩展的说这些内容了。关于饿汉式我们就说完了。

下面我们来说懒汉式,同样的,在这个类中,首先需要一个私有化的构造方法,第二个,还是要有一个这个类的实例,

我们说这个是懒汉式,它比较懒,创建实例的时候就不去初始化了,什么时候用才什么时候初始化,

这里显然不能直接return instance,我们要在这里实例化这个instance,

那么,这样可以吗?这样显然是不可以的,因为我们每次调用getIntance()方法的时候,都会实例化一个新的,所以这显然这不符合我们的预期,那怎么办呢?

这样就可以了。

那么我们来测试一下,

运行

发现哈希值都是一样的,即创建出来的都是一样的。

我们先来分析一下为什么懒汉式会有线程安全性问题,还是从产生线程安全性问题的三个角度来分析,第一个必须是多线程的环境下,第二个必须有共享资源。在多线程的环境下,懒汉式和饿汉式的区别就在第三条,第三条是对资源进行非原子性操作。

首先,是读,判断instance是否为空

然后接着才new。这就是一个典型的非原子性操作,比如说,第一个线程进来了,

比如说线程0走到这里了,结果呢,线程0还没有执行new Singleton2()方法,然后线程1进来了,线程1进来之后,此时instance还是null,于是线程1也执行到这里了

然后接着,这两个线程分别创建了两个实例,于是就返回了两个实例,所以,这两个实例肯定是不同的,这就是所谓的线程安全性问题。那么,我们来模拟一下,看一下

 

这也就出现了线程安全性问题,那么,我们把这个问题扩大化来看,其实我们知道

是在这个地方出的问题,于是,为了模拟的更清晰,我们让线程来到这里之后都等着,等线程都来齐了在往下执行,

稍微等一会就可以了,然后我们再来看,这次我们就会发现有几个有几个线程就会出创建几个实例

发现每一个线程创建的实例都不一样,这就出现了线程安全性问题。出现了线程安全性问题,那么,我们该如何去解决呢?解决的方案也非常的简答,我们已经学过了,直接加一个synchronized就可以了,

这样显然就不会出现线程安全性问题了,

 

我们来分析这段代码,比如说是一个获取配置的单例,那么,我们说这个访问,肯定是访问的频率会非常的高,肯定是多线程的环境下在访问,那么,你加了一个synchronized之后,我们是在多个线程环境下去执行,而且

这段代码体的执行时间也不算短。结合我们上一节学的偏向锁,虽然说synchronized已经没有那么重了,我们上一节已经讲了,引入了偏向锁,还有轻量级锁,已经能够大大的提高synchronized的性能了,但是我们发现在这个地方显然还是不适用的,比如说偏向锁,偏向锁是单个线程在进行访问的时候它可以一直拿着这个锁,它不放,如果没有别的线程来竞争,那么,它就一直拿着,那么,这里显然不是,比如在多个线程环境下去访问,所以,偏向锁并不会提升性能,再说轻量级锁,轻量级锁是当第一个线程进来之后,拿到锁开始执行,第二个锁同时也能够进来,但是,第二个线程进来的时候,它显然是不能够执行这段代码的,因为它会检查对象头里面的标记,发现已经有线程拿到锁了,所以,在这里,第二个线程会自旋,自旋这个过程还是非常消耗性能的,如果说这段代码体的内容非常少,那么,第一个线程执行完毕之后,那么,第二个线程的自旋就结束了,接着第二个线程执行也是可以的,但是我们发现,本例这里每次还sleep(100),那么,这个过程是非常长的

自旋是浪费CPU资源的,它还不如wait,wait是不消耗CPU资源的,而自旋就相当于while(true),所以,如果说来的线程非常多了,那么,轻量级锁也不能够提升性能,反而可能会导致性能下降,也就是说,我们在这里加synchronized,不管是偏向锁还是轻量级锁,最终都升级为了重量级锁,那么,显然这个地方就变成了单线程执行了,那么,性能就更没有办法保证了,所以,我们为了提高程序的性能,所以,我们不能在这里加synchronized

经过我们的一番论证,不能够在这里加synchronized,如果在这里加了synchronized,那么,性能就没有办法保证了,那,怎么办呢?synchronized除了能够加在方法上以外,还能加在代码块上,所以我们就开始考虑,我们要不要把整个方法都加到同步代码中,我们发现,其实出现线程安全性问题的代码就在这里

所以,我们就不要把同步代码块的范围放的那么大,我们缩小同步代码块的范围,相当于降低锁的力度,我们发现,其实,只有在创建的时候,也就是说,在第一次执行的时候会出现,以后直接返回instance就可以了,以后也就没有写的操作了,只有读的操作,那么,读的操作肯定是不会出现线程安全性问题的

所以,我们就在这里判断一下instance恒等于null吗?如果等于null那么就有可能会出现线程安全性问题,我们要在会出现线程安全性问题的地方加一个synchronized,

我们锁谁呢?我们锁当前的Singleton2.class,

这样就没有问题了吗?这样其实还是有问题的,那么,为了保证不再执行,我们使用双重检查加锁,在里面再来判断

这就是所谓的双重检查加锁。这样似乎是没有任何问题了把,但是,这是我们表面上看没有问题了,实际上还是有问题的,实际上有什么问题呢?有一个非常大的问题,它依然不能保证完全的线程安全,但是这个例子我们是无法验证的,只是说知道有这么一回事,叫指令重排序,

也就是说,什么意思呢?在我们的虚拟机中,为了提高指令执行的性能,它会对虚拟机进行指令的优化,可能会,就是说,把一些后面的指令放到前面去进行执行,在不影响程序最终的执行结果的情况下,也就是说,我们可能看着是这么往下执行

但是,它有可能先执行第19行,然后在执行第16行,有这种可能,那么在这里,指令重排序会对我们造成什么影响呢?其实new Singleton2()在虚拟机中并不是一行代码就能完成的,而是怎么做的呢?首先第一步肯定就是申请内存,首先要申请一块内存空间,这块内存空间其实是已经确定的,申请完空间之后,下面按照我们的理解,应该是在这块空间里实例化对象,第三步是,让instance的引用指向这块空间地址。但是,并非像我们所想的这样。

步骤是这么一个步骤,但是执行的顺序却不一定是这样,这就是所谓的指令重排序。有可能是先执行了3再执行2,那这个问题就大了,如果先执行了3,那么就把instance就已经指向了划分出来的这块内存空间,也就说,instance现在就不为null了,那么,再来进行判断instance==null的时候,就又出问题了,所以,指令重排序也会导致这个地方在获取单例的过程中会出现问题,那怎么办呢?我们要避免指令重排序,那么我们就需要使用一个关键字就可以了,这个关键字叫做volatile,那么,关于这个volatile也是我们后面所要详细讲解的一个知识点。你这里只要知道加上volatile之后,那么就可以减少一些虚拟机的优化,它就不会再出现指令重排序问题,也就不会出现我们这里所遇见的线程安全性问题了。

这样就是关于单例模式以及线程安全性问题的一个讲解。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值