设计模式详解(四)

时间:2022-08-25 15:24:56

上一章我们学习了装饰者模式,这章LZ带给大家的是单例模式。

首先单例模式是用来干嘛的?它是用来实例化一个独一无二的对象!那这有什么用处?有一些对象我们只需要一个,比如缓存,线程池等。而事实上,这类对象只能有一个示例,如果制造多个示例,就会导致许多问题产生,比如程序的行为异常,资源使用过量。而单例模式可以帮助我们确保只有一个实例会被创建。首先我们来看一段代码:

public class MyClass {
private static MyClass myClass;
private MyClass(){

}
public static MyClass getInstance(){
if(myClass ==null){

myClass
= new MyClass();
}
return myClass;
}
}

 

1.首先我们创建一个静态实例,而带有static关键字的属性在每一个类中都是唯一的。

 2.接着我们将构造方法私有化,从而限制调用者随意创造实例,这也是保证单例的最重要的一步。

 3.当然,我们必须要给一个可供调用方使用的获取实例的静态方法,这里必须是静态方法,为什么呢?请注意,如果我们给的是非静态的,那么调用方必须拥有实例才能调用这个方法,但是既然没有调用这个方法,调用方又哪里来的实例呢?这不是自相矛盾吗

4.我们加一个判断,当只有持有的静态实例为null时才调用构造方法创造一个实例并把它赋予myClass静态变量中,注意,如果我们不需要这个实例,它就永远不会产生,这就是“延迟实例化”。

由此我们可以看出来,单例模式确保一个类只有一个实例,并提供一个全局访问点。

设计模式详解(四)

是不是很简单?事实上单例模式确实特别简单,不过LZ还有些内容没有说完。

如果各位去公司面试,面试官让你们写一个单例模式,你们把上面LZ给的代码写给面试官,如果你们是应届生,也许面试官会觉得不错,但如果你们已经是工作超过一年的同学,那么写出上面的代码恐怕你们就要完蛋。为什么呢?其实这是一个并发的问题,上面的代码在不考虑并发的情况下,确实没有问题,但是一旦考虑多线程并发,就会出现问题。

 下面LZ用事实说话,给大家模拟一下多线程并发的情况

public class TestMyClass { 
boolean myLock ;

public boolean isMyLock() {
return myLock;
}

public void setMyLock(boolean myLock) {
this.myLock = myLock;
}

public static void main(String[] args) throws Exception {
int num=100;
final CyclicBarrier cyclicBarrier = new CyclicBarrier(num);
final Set<String> set=Collections.synchronizedSet(new HashSet<String>());
ExecutorService executorService
= Executors.newCachedThreadPool();
for(int i=0;i<num;i++){
executorService.execute(
new Runnable() {
public void run() {
try {
cyclicBarrier.await();
MyClass myClass
= MyClass.getInstance();
set.add(myClass.toString());
}
catch (Exception e) {
e.printStackTrace();
}
}
});
}
Thread.sleep(
2000);
System.out.println(
"------并发情况下我们取到的实例------");
for (String instance : set) {
System.out.println(instance);
}
executorService.shutdown();

}

}

代码比较简单,这里LZ是用的栅栏阻塞等待所有线程创建完毕,然后同时执行获取实例的操作。

  LZ在程序中同时开启了100个线程来访问getInstance方法,然后把获得实例的实例字符串装入同步的set集合,这里为什么要放到set集合就不用LZ解释了吧=。=set集合会自动去重,所以我们看结果输出了多少实例字符串,就说明我们在并发访问的过程中产生了多少实例。

               这里我让main线程睡眠了一次,是为了给足够的时间让100个线程全部开启。下面我们看一下结果(如果你照我的代码演示结果出现了一个,不要惊讶。我试了试大概3次之内就会出现我这种情况,甚至出现4个的都有)

设计模式详解(四)

那么为什么会造成这种情况呢?

当并发访问的时候,第一个调用getInstance方法的线程A,在判断完myClass是null的时候,线程A就进入了if块准备创造实例,说时迟那时快,在这同时另外一个线程B在线程A还未创造出实例之前,就又进行了myClass是否为null的判断,这时myClass当然依然为null,所以线程B也会进入if块去创造实例,那么问题就出来了,有两个线程都进入了if块去创造实例,结果就造成产生了两个对象出来。接下来LZ做的一个类似于图的东西,各位可以看看,虽然看起来不太直观,但是配合LZ的讲解详细各位一目了然。

 1 public static MyClass getInstance(){                                            对象的状态
2 public static MyClass getInstance(){                      null
3 if(myClass ==null){                                                    null
4 if(myClass ==null){                         null
5 myClass = new MyClass();                                             object1
6 }
7 return myClass;                                                      object1
8 myClass = new MyClass();                   object2
9 }
10 return myClass;                         object2
11 }

那么,我们又应该怎么解决这个线程并发导致的问题呢?

详细各位会立刻想起synchronized关键字,我们只要把getInstance()变成同步方法,就可以以上的问题了。

public class MyClass {
private static MyClass myClass;
private MyClass(){

}
public synchronized static MyClass getInstance(){

if(myClass ==null){

myClass
= new MyClass();
}
return myClass;
}
}

设计模式详解(四)

通过加上synchronized关键字到getInstance()方法前,我们迫使每个线程在进入此方法前,必须先等待其他线程离开,就是说,不会有两个线程同时进入此方法。

但是,如果我们这样做,就会导致性能降低,因为,我们只有第一次调用getInstance()这个方法的时候需要同步,而当一旦设置好了myClass这个变量,我们就不需要再同步了,那么之后我们每次都同步,会导致性能降低。那么顺着这个角度去思考,我们可以先去判断myClass是否为null,当它为null时再同步。

public class MyClass {
private static MyClass myClass;
private MyClass(){

}
public static MyClass getInstance(){

if(myClass ==null){
synchronized(MyClass.class){
if(myClass ==null){
myClass
= new MyClass();
}
}
}
return myClass;
}
}

 

这种做法也被称为双重加锁

  经过刚才LZ的分析,这种做法应该是满足了要求,看起来是没有问题了,但如果我们再进一步深入考虑的话,其实仍然是有可能出现问题的。

   这里我们深入到JVM中去探索上面这段代码,相信各位都知道虚拟机在执行创建实例的这一步操作的时候,其实是分了好几步去进行的,专业点说,创建一个新的对象并非是原子性操作。在有些JVM中上述做法是没有问题的,但是有些情况下是会造成莫名的错误。

              我们先来搞清楚在JVM创建新的对象时,主要要经过三步。

              1.分配内存

              2.初始化构造器

              3.将对象指向分配的内存的地址

              这种顺序在上述双重加锁的方式是没有问题的,因为这种情况下JVM是完成了整个对象的构造才将内存的地址交给了对象。但是如果2和3步骤是相反的(2和3可能是相反的是因为JVM会针对字节码进行调优,而其中的一项调优便是调整指令的执行顺序),就会出现问题了。

              我们假设2与3位置相反了,针对上述的双重加锁来讲,因为这时会先将内存地址赋给对象myClass,然后再进行初始化构造器,这时候后面的线程去请求getInstance方法时,会认为myClass对象已经实例化了,直接返回一个引用。如果在初始化构造器之前,这个线程使用了myClass,就会产生莫名的错误。

那么我们要如何避免这一个问题呢?我们可以给静态的实例属性加上关键字volatile,这样就不会出现实例化发生一半的情况,因为加入了volatile关键字,就等于禁止了JVM自动的指令重排序优化,并且强行保证线程中对变量所做的任何写入操作对其他线程都是即时可见的。volatile会强行将对该变量的所有读和取操作绑定成一个不可拆分的动作。由于本节我们讲的是设计模式,所以这里LZ不会去详细介绍volatile以及JVM中变量访问时所做的具体动作(或者以后LZ会单独将),感兴趣的读者可以去翻阅相关的资料。

             另外由于volatile关键字是在JDK1.5版本出现的,所以凡是1.4及1.4之前的版本都无法使用。这里LZ把这种写法完整的列出来。

public class MyClass {
private volatile static MyClass myClass;
private MyClass(){

}
public static MyClass getInstance(){

if(myClass ==null){
synchronized(MyClass.class){
if(myClass ==null){
myClass
= new MyClass();
}
}
}
return myClass;
}
}

另外,这就是我们常说的“懒汉式”,大家可以这样记“因为懒汉太懒了,所以只有用的时候才创建对象。”

  懒汉式单例类。 只在外部对象第一次请求实例的时候才会去创建
优点:第一次调用时才会初始化,避免内存浪费。
缺点:必须加锁synchronized 才能保证单例,效率低

当然,除了这种写法,我们还有一种办法可以解决线程并发的问题,相信大家都听过“饿汉式”
 class MyClassTo {

private static MyClassTo myClassTo = new MyClassTo();

private MyClassTo(){}

public static MyClassTo getInstance(){
return myClassTo;
}

}

因为太饿了,所以上来就创建=。=

 饿汉式单例类。 它在类加载时就立即创建对象。
优点:没有加锁,执行效率高。 用户体验上来说,比懒汉式要好。
缺点:类加载时就初始化,浪费内存

那么为什么饿汉比懒汉要好,一个是空间换时间,一个是时间换空间,你们说是时间终于还是空间重要?=。=
另外,还有一种单例模式,被称为"登记式"
  class MyClassThree{
private MyClassThree(){}
public static MyClassThree getInstance(){ return SINGLETON.myClassThree;}
private static class SINGLETON{//内部类
private static final MyClassThree myClassThree= new MyClassThree();
}
}
 

内部类只有在外部类被调用才加载,产生SINGLETON实例,又不用加锁,这个模式有上述俩模式的优点,屏蔽了他们的缺点,是最好的单例模式。

首先来说一下,这种方式为何会避免了双重加锁的漏洞,主要是因为一个类的静态属性只会在第一次加载类时初始化,这是JVM帮我们保证的,所以我们根本无需担心并发访问的问题。所以在初始化进行一半的时候,别的线程是无法使用的,因为JVM会帮我们强行同步这个过程。另外由于静态变量只初始化一次,所以singleton仍然是单例的。

那么我们总结一下这种模式帮助我们做到了什么:

             1.在不考虑反射强行突破访问限制的情况下,MyClassThree最多只有一个实例 

             2.保证了并发访问的情况下,不会由于初始化动作未完全完成而造成使用了尚未正确初始化的实例。

     3.保证了并发访问的情况下,不会发生由于并发而产生多个实例。

 好了,到这里单例模式LZ就讲完了,下期预告,等下次再说=。=