在常用的23种设计模式中,单例模式是很常用的一种。但是当单例模式与上多线程的时候,如果没有注意特殊情况,可能会产生一些意想不到的异常,有可能会导致灾难性的后果。下面我们就来研究一下单例模式再多线程环境下的应用。首先先简单的介绍一下单例模式。
一、“饿汉模式”和“懒汉模式”
1、饿汉模式
首先,单例模式简单的说就是一个类在整个程序的运行过程中只创建唯一的一个实例化对象。所有有关这个类的操作都是针对同一个对象,并且不允许出现另一个此类的实例化对象。而立即加载从字面上来看,就是在使用类的时候已经将对象创建完毕,常见的办法就是直接使用new来实例化。这种方法也称作“饿汉模式”。
最简单的饿汉模式实现如下:
public class MyObject { private static MyObject myObject = new MyObject(); private MyObject() { } public static MyObject getInstance() { return myObject; } } public class MyThread1 extends Thread{ @Override public void run() { System.out.println("对象的hashcode为:"+MyObject.getInstance().hashCode()); } } public class Run { public static void main(String args[]) { MyThread1 mt1 = new MyThread1(); MyThread1 mt2 = new MyThread1(); MyThread1 mt3 = new MyThread1(); mt1.start(); mt2.start(); mt3.start(); } }
运行结果如下:
/*
对象的hashcode为:1356914048
对象的hashcode为:1356914048
对象的hashcode为:1356914048
*/
可以看到,三个线程调用getInstance返回的都是同一个实例对象,他们的hashcode都相同。饿汉模式不存在线程不安全的问题,因为单例类的实例早在类加载的时候已经创建了,在多线程环境下每个线程使用的都是同一个对象。
2、懒汉模式
懒汉模式顾名思义就是不着急创建类的实例,先歇着,直到需要使用的时候再创建这个实例。从这个描述中可以看出,懒汉模式可能会出现线程安全问题,当多个线程同时使用到这个类的时候,就有可能会产生多个单例类的实例,从而出现错误。
那么该如何解决线程安全的问题呢?有以下几个方法:
1)使用synchronized关键字声明方法:
既然多个线程可以同时进入getInstance()方法,那么我们就对getInstance()方法进行synchronized修饰。
public class LazySingleton { private static LazySingleton lazySingleton; private LazySingleton(){ } //getInstance()方法设为同步方法,保证线程安全 public synchronized static LazySingleton getInstance() { //若object已存在则直接返回,否则创建对象 if(lazySingleton!=null){ }else{ lazySingleton = new LazySingleton(); } return lazySingleton; } } public class MyThread2 extends Thread{ @Override public void run() { System.out.println("调用对象的hashcode为:"+LazySingleton.getInstance().hashCode()); } } public class Run1 { public static void main(String args[]) { MyThread2 mt = new MyThread2(); MyThread2 mt1 = new MyThread2(); MyThread2 mt2 = new MyThread2(); mt.start(); mt1.start(); mt2.start(); } }
运行结果如下:
/*
调用对象的hashcode为:1723650563
调用对象的hashcode为:1723650563
调用对象的hashcode为:1723650563
*/
2)使用同步代码块
使用synchronized修饰getInstance()方法虽然能保证懒汉模式下单例类的线程安全,但是同步方法是对方法的整体加锁,在效率上并不是最优办法,我们可以尝试将同步代码的范围缩小,在getInstance()方法内部设置同步代码块。
下面示例使用了同步代码块,其中线程类省略:
public class LazySingleton { private static LazySingleton lazySingleton; private LazySingleton(){ } //getInstance()方法设为同步方法,保证线程安全 public static LazySingleton getInstance() { try { synchronized(LazySingleton.class) { if(lazySingleton!=null){ }else{ //模拟在创建对象前的一些准备性工作 Thread.sleep(2000); lazySingleton = new LazySingleton(); } } }catch(InterruptedException e) { e.printStackTrace(); } return lazySingleton; } } public class Run1 { public static void main(String args[]) { MyThread2 mt = new MyThread2(); MyThread2 mt1 = new MyThread2(); MyThread2 mt2 = new MyThread2(); System.out.println("开始时间:"+System.currentTimeMillis()); mt.start(); mt1.start(); mt2.start(); while(mt.isAlive()||mt1.isAlive()||mt2.isAlive()) { } System.out.println("结束时间:"+System.currentTimeMillis()); } }
运行结果:
/*
开始时间:1527240729469
调用对象的hashcode为:278778395
调用对象的hashcode为:278778395
调用对象的hashcode为:278778395
结束时间:1527240735478
*/
可以看到虽然我们实现了单例模式,但是由于在创建单例前有一些额外操作,而三个线程在执行这些操作的时候采用了同步方式,所以程序总共用时6s,效率较低。
这时我们立即想到了办法,继续缩小同步代码块不就好了吗?把准备工作从同步代码块中脱离出来,下下面这样:
public class LazySingleton { private static LazySingleton lazySingleton; private LazySingleton(){ } //getInstance()方法设为同步方法,保证线程安全 public static LazySingleton getInstance() { try { if(lazySingleton!=null) { }else{ //模拟在创建对象前的一些准备性工作 Thread.sleep(2000); synchronized(LazySingleton.class) { lazySingleton = new LazySingleton(); } } }catch(InterruptedException e) { e.printStackTrace(); } return lazySingleton; } }
运行结果:
/*
开始时间:1527241572027
调用对象的hashcode为:278778395
调用对象的hashcode为:389001266
调用对象的hashcode为:1356914048
结束时间:1527241574030
*/
虽然程序的执行效率提升,总共只运行了2s,并且我们对关键的创建对象代码进行了加锁,可是结果却是创建了三个不同的实例,单例创建失败。
那么在“懒汉模式”下怎样在保证效率的情况下确保线程安全呢?这里是用到了DCL双检查锁机制,即在进入方法时先检查一次object是否为null,若为null则开始创建单例,在执行new语句之前,再次检查object是否为null,若为null则获取锁开始执行new语句。这种方法不仅将同步范围缩小到只同步关键语句,提高效率,同时两次检查对象是否为空也保证了单例的实现。下面为更改后使用DCL双检查锁机制后的代码:
public class LazySingleton { private static LazySingleton lazySingleton; private LazySingleton(){ } //getInstance()方法设为同步方法,保证线程安全 public static LazySingleton getInstance() { try { if(lazySingleton!=null) { }else{ //模拟在创建对象前的一些准备性工作 Thread.sleep(2000); synchronized(LazySingleton.class) { if(lazySingleton==null) lazySingleton = new LazySingleton(); } } }catch(InterruptedException e) { e.printStackTrace(); } return lazySingleton; } }
运行结果:
/*
开始时间:1527400292101
调用对象的hashcode为:389001266
调用对象的hashcode为:389001266
调用对象的hashcode为:389001266
结束时间:1527400294103
*/
可以看到在解决效率问题的同时成功的创建了单例。
二、其他方法保证线程安全
1)使用静态内置类
此方法类似于“饿汉模式”,在调用类之前已经将对象创建完毕。
public class MyObject{
private static class MyObjectHandler{
private static MyObject myObject = new MyObject();
}
private MyObject(){}
public static MyObject getInstance(){
return MyObjectHandler.myObject();
}
}
2)序列化与反序列化的单例模式
有关序列化的概念参考:
当我们要把一个单例类的对象序列化存进硬盘,在从硬盘取出反序列化生成对象时,就会生成一个和原来的单例类对象一样的新的对象。这样就违背了单例模式的原则,那么在遇到实现了Serializable接口的类时,应该怎样保证单例模式的实现呢?
这是应该用到readResolve()方法,也就是在这个单例类内部声明以下方法:
protected Object readResolve() throws ObjectStreamException{
return MyObjectHandler.myObject;
}
这样当JVM从内存中反序列化地"组装"一个新对象时,就会自动调用这个 readResolve方法来返回我们原本已经指定好的对象了, 单例规则也就得到了保证。
3)使用static代码块实现单例
由于类中的static静态代码块仅当类在JVM中初始化的时候执行一次,所以完全可以将创建单例类对象的代码写在类的静态代码块中,这样只有在类初始化的时候会创建实例化对象,之后每次调用都会是同一个对象,也就实现了单例模式。
public class MyObject{
private static MyObject myObject = null;
private MyObject(){}
static {
myObject = new MyObject();
}
public static MyObject getInstance(){
return myObject;
}
}
4)使用枚举类enum实现单例模式
枚举类enum和static静态代码块类似,在首次使用枚举类的时候会自动调用相应枚举的构造函数,并且只在第一次使用此枚举类时调用,我们也可以用枚举类的这个特性来实现单例模式。
用Dota装备黑皇杖为创建枚举类,合成一件黑皇杖需要三件配件食人魔之斧(OgreAxe)、秘银锤(MithrilHammer)和卷轴(Reel)。创建枚举类MyEnumSingleton其中定义了一个枚举bkbFactory,还定义了相应的私有变量和构造方法。当我们第一次使用枚举类时构造方法会被调用创建实例对象,之后再次调用的都是同一个实例。
public class EnumSingleton { public enum MyEnumSingleton{ bkbFactory; private BKB bkb; private MyEnumSingleton() { System.out.println("合成黑皇杖!"); String p1 = "ogreAxe"; String p2 = "mithrilHammer"; String p3 = "reel"; bkb = new BKB(p1, p2, p3); } public BKB getBKB() { return bkb; } } public static BKB getBKB() { return MyEnumSingleton.bkbFactory.getBKB(); } } public class BKB { private String p1; private String p2; private String p3; public BKB(String p1, String p2, String p3){ this.p1 = p1; this.p2 = p2; this.p3 = p3; } } public class MyThread3 extends Thread{ @Override public void run() { for(int i=0;i<2;i++) { System.out.println(MyEnumSingleton.bkbFactory.getBKB().hashConde()); } } } public class Run3 { public static void main(String args[]) { MyThread3 mt1 = new MyThread3(); MyThread3 mt2 = new MyThread3(); MyThread3 mt3 = new MyThread3(); mt1.start(); mt2.start(); mt3.start(); } }
运行结果如下:
/*
合成黑皇杖!
1356914048
1356914048
1356914048
1356914048
1356914048
1356914048
*/
可以看到只有在第一个线程第一次使用枚举类时调用了构造方法,其余线程使用枚举类时其实使用的都是第一次创建出来的实例。