单例模式与多线程

单例模式是设计模式的一种,在应用中也是比较常见的。单例模式本身是比较简单的,但是如果使用在多线程环境时,就会造成一些意想不到的情况。多线程的应用现在也很普及,因此有必要了解单例模式在多线程环境下使用时会遇到的问题,以及使用多线程技术如何解决这些问题。

当单例模式应用在多线程环境中,我们要考虑的是:如何使单例模式遇到多线程时是安全的,正确的。

下面介绍单例模式结合多线程技术在使用时的相关知识。

1.单例模式

单例模式,简单来讲就是一个类只能构建一个对象的设计模式。

那么如何实现单例模式?
首先,单例模式创建的是类。类的创建是根据构造方法创建的,因此如果单例类有构造方法的话需要将构造方法私有化(禁止其他程序创建类的对象)。
其次,需要在本类中自定义一个对象(这个对象就是单例对象,禁止其他程序创建类的对象就要自己创建一个,否则就无法创建对象了)。
最后,提供一个可访问类自定义对象的类成员方法(对外提供该对象的访问方式)。

基于上面的解释,我们就可以写出单例模式的简单代码实现:

单例模式第一版:

public class Singleton{
       private Singleton(){} //私有构造函数
       private static Singleton instance =null;//单例对象
       //静态工厂方法
       public static Singleton getInstance(){
           if(instance==null){
              instance = new Singleton();
           }
           return instance;
       }
}

为什么这样写呢?我们来解释几个关键点:

<1>和上面介绍的一样,要想让一个类只构建一个对象,自然不能让它随便去做new,因此Singleton的构造方法是私有的。

<2>instance是Singleton类的静态成员,也就是我们的单例对象。它的初始值可以写成null,也可以写成new Singleton()。

<3>getInstance是获取单例对象的方法。

那么为什么单例模式中的单例对象和访问单例对象的方法要设置成静态的呢?
要想实现单例,我们不能用该类在其他地方创建对象,只能通过该类提供的方法访问类中的那个自定义对象。
那么关键来了,使用类中方法只有两种方式,①创建类的一个对象,用对象去调用方法;②使用类名直接调用类中方法。
显然第一种情况不能用,因为一个类只能创建一个对象,因此只能使用第二种方法。

而想要使用类名直接调用类中方法,类中方法必须是静态的,因此访问单例对象的方法是静态的。而静态方法不能访问非静态态成员变量,因此类自定义的实例变量也必须是静态的,所以从语法上考虑是合适的。另外,类的静态成员变量就是指的类共享的对象,而单例模式的对象设成静态就是为了让该类所有成员共享同一个对象,所以把类的成员变量设置成静态的从语义上讲也是合适的。

在第<2>点中,如果单例对象的初始值是null,还未构建,则构建单例模式并返回。这个写法属于单例模式中的懒汉模式

如果单例对象一开始就被new Singleton()主动构建,则不再需要判空操作,这种写法属于饿汉模式

//饿汉模式
public class Singleton{
       private Singleton(){} //私有构造函数
       private static Singleton instance = new Singleton();;//单例对象
       //静态工厂方法
       public static Singleton getInstance(){
           return instance;
       }
}

关于这两种模式一个形象的比喻就是:饿汉主动找食物吃,懒汉躺在地上等着人喂。

2.单例模式和线程安全

但是这段代码并非是线程安全的,为什么说刚才的代码不是线程安全的呢?

假设Singleton类刚刚被初始化,instance对象还是空,这时候两个线程同时访问getInstance( )方法:

单例模式与多线程_第1张图片

因为Instance是空,所以两个线程同时通过了条件判断,开始执行new操作:

单例模式与多线程_第2张图片

这样一来,显然instance被构建了两次。

那我们怎么修改一下代码,实现一个线程安全的单例模式呢?这就使用到了可以实现线程同步的synchronized关键字。

让我们对代码做一下修改:

 单例模式第二版:

public class Singleton{
       private Singleton(){} //私有构造函数
       private static Singleton instance =null;//单例对象
       //静态工厂方法
       public static Singleton getInstance(){
           if(instance==null){  //双重检测机制
               synchronized(Singleton.class){  //同步锁
                  if(instance==null){  //双重检测机制
                  instance = new Singleton();
                  }
               }
           }
           return instance;
       }
}

为什么这样写,进行双重检测呢?我们来解释几个关键点:

<1>为了防止new Singleton被执行多次,因此在new操作之前加上Synchronized 同步锁,锁住整个类(注意,这里不能使用对象锁)。

<2>进入Synchronized 临界区以后,还要再做一次判空。因为当两个线程同时访问的时候,线程A构建完对象,线程B也已经通过了最初的判空验证,不做第二次判空的话,线程B还是会再次构建instance对象。

单例模式与多线程_第3张图片

单例模式与多线程_第4张图片

单例模式与多线程_第5张图片

单例模式与多线程_第6张图片

 

单例模式与多线程_第7张图片

像这样两次判空的机制叫做双重检测机制

经过双重检测机制,基本上实现了线程安全,但是这段代码仍然不是绝对的线程安全。

这段代码里有一个隐藏的漏洞,让我们来分析一下。

假设这样的场景,当两个线程一先一后访问getInstance方法的时候,当A线程正在构建对象,B线程刚刚进入方法:

单例模式与多线程_第8张图片

这种情况表面看似没什么问题,要么Instance还没被线程A构建,线程B执行 if(instance == null)的时候得到true;要么Instance已经被线程A构建完成,线程B执行 if(instance == null)的时候得到false。

真的如此吗?答案是否定的。这里涉及到了JVM编译器的指令重排

指令重排是什么意思呢?比如java中简单的一句 instance = new Singleton,会被编译器编译成如下JVM指令:

memory =allocate();    //1:分配对象的内存空间 

ctorInstance(memory);  //2:初始化对象 

instance =memory;     //3:设置instance指向刚分配的内存地址 

但是这些指令顺序并非一成不变,有可能会经过JVM和CPU的优化,指令重排成下面的顺序:

memory =allocate();    //1:分配对象的内存空间 

instance =memory;     //3:设置instance指向刚分配的内存地址 

ctorInstance(memory);  //2:初始化对象 

当线程A执行完1,3,时,instance对象还未完成初始化,但已经不再指向null。此时如果线程B抢占到CPU资源,执行  if(instance == null)的结果会是false,从而返回一个没有初始化完成的instance对象。如下图所示:

单例模式与多线程_第9张图片

 

单例模式与多线程_第10张图片

如何避免这一情况呢?我们需要在instance对象前面增加一个修饰符volatile。 

单例模式第三版:

public class Singleton{
       private Singleton(){} //私有构造函数
       private volatile static Singleton instance = null;//单例对象
       //静态工厂方法
       public static Singleton getInstance(){
           if(instance==null){  //双重检测机制
               synchronized(Singleton.class){  //同步锁
                  if(instance==null){  //双重检测机制
                  instance = new Singleton();
                  }
               }
           }
           return instance;
       }
}

关于volatile关键字,维基百科上的描述:

The volatile keyword indicates that a value may change between different accesses, it prevents an optimizing compiler from optimizing away subsequent reads or writes and thus incorrectly reusing a stale value or omitting writes.

用最简单的方式理解, volatile修饰符阻止了变量前后的指令重排,保证了指令执行顺序。

经过volatile的修饰,当线程A执行instance = new Singleton的时候,JVM执行顺序是什么样?始终保证是下面的顺序:

memory =allocate();    //1:分配对象的内存空间 

ctorInstance(memory);  //2:初始化对象 

instance =memory;     //3:设置instance指向刚分配的内存地址 

如此在线程B看来,instance对象的引用要么指向null,要么指向一个初始化完毕的Instance,而不会出现某个中间态,保证了安全。

另外,volatile关键字除了防止指令重排序,还可以使一个线程对某个共享变量的修改对其他的线程是立即可见的,从而避免其他的线程读取到该共享变量的过期数据。

有关单例模式,还有更多的实现方式。实现单例模式的方式非常多,除了使用双重检测机制之外,还可以使用静态内部类实现单例模式。

用静态内部类实现单例模式:

public class Singleton{
       private Singleton(){} //私有构造函数  
       //单例对象    
       private static class LazyHolder{
          private static final Singleton INSTANCE = new Singleton();//单例对象
       }        
       //静态工厂方法
       public static Singleton getInstance(){
           return LazyHolder.INSTANCE;
       }
}

这里有几个需要注意的点:

1.从外部无法访问静态内部类LazyHolder,只有当调用Singleton.getInstance方法的时候,才能得到单例对象INSTANCE。

2.INSTANCE对象初始化的时机并不是在单例类Singleton被加载的时候,而是在调用getInstance方法,使得静态内部类LazyHolder被加载的时候。因此这种实现方式是利用classloader的加载机制来实现懒加载,并保证构建单例的线程安全。

静态内部类的实现方式虽好,但是也存在着单例模式共同的问题:无法防止利用反射来重复构建对象。

那利用反射是如何打破单例模式的只能构建一个对象的约束呢?其实很简单,我们来看下代码。

利用反射打破单例:

//获得构造器
Constructor con = Singleton.class.getDeclaredConstructor();
//设置为可访问
con.setAccessible(true);
//构造两个不同的对象
Singleton singleton1 = (Singleton)con.newInstance();
Singleton singleton2 = (Singleton)con.newInstance();
//验证是否是不同对象
System.out.println(singleton1.equals(singleton2));

代码可以简单归纳为三个步骤:

第一步,获得单例类的构造器。

第二步,把构造器设置为可访问。

第三步,使用newInstance方法构造对象。

最后为了确认这两个对象是否真的是不同的对象,我们使用equals方法进行比较。毫无疑问,比较结果是false。 

既然单例模式可以用反射来强行重复构建,那怎样才能阻止反射的构建方式,写出无懈可击的单例实现呢?

我们可以使用枚举来实现单例,这是一种优雅而简洁的方式。

用枚举实现单例模式:

public enum SingletonEnum {
    INSTANCE;
}

虽然用枚举构建单例模式的代码只有一行,但它确实可以防止使用反射的方式构建对象。

有了enum语法糖,JVM会阻止反射获取枚举类的私有构造方法。

让我们来做一个实验,仍然执行刚才的反射代码:

//获得构造器
Constructor con = SingletonEnum.class.getDeclaredConstructor();
//设置为可访问
con.setAccessible(true);
//构造两个不同的对象
SingletonEnum singleton1 = (SingletonEnum)con.newInstance();
SingletonEnum singleton2 = (SingletonEnum)con.newInstance();
//验证是否是不同对象
System.out.println(singleton1.equals(singleton2));

执行获得构造器这一步的时候,就会在main( )方法中抛出java.lang.NoSuchMethodException异常。

使用枚举实现的单例模式不仅能够防止反射构造对象,而且可以保证线程安全。

不过这种方式也有唯一的缺点,就是它并非使用懒加载,其单例对象是在枚举类被加载的时候进行初始化的。

上面就是单例模式的几种常见的实现方式。下面对这几种单例模式的实现做一个总结:

单例模式实现 是否线程安全 是否懒加载 是否防止反射构建
双重锁检测
静态内部类
枚举

几点补充:

1. volatile关键字不但可以防止指令重排,也可以保证线程访问的变量值是主内存中的最新值。有关volatile的详细原理,可以参考《深入理解Java虚拟机》后面几章的内容。关键字volatile的使用参考:关键字volatile的使用。

2.使用枚举实现的单例模式,不但可以防止利用反射强行构建单例对象,而且可以在枚举类对象被反序列化的时候,保证反序列的返回结果是同一对象。

对于其他方式实现的单例模式,如果既想要做到可序列化,又想要反序列化为同一对象,则必须实现readResolve方法。

参考:单例模式的唯一实例为什么设置为静态的?

         《程序员小灰》—什么是单例模式?

你可能感兴趣的:(多线程,Java线程)