聊聊线程安全下的单例模式

157 阅读5分钟
原文链接: my.oschina.net

一个简单的单例模式的实现

单例模式可以简单的理解为一个类只能构造一个对象。

public class Singleton1 {
	
	private Singleton1() {}
	
	private static Singleton1 singleCase1 = null;
	public static Singleton1 getIns() {
		if(singleCase1 == null) {
			singleCase1 = new Singleton1(); //懒汉模式,非线程安全,可能存在两个线程同时访问的情况
		}
		return singleCase1;
	}
	
}

我们来分析一下以上的代码:

  1. 要想要获取一个单例模式就不能随便的做new操作,因此单例模式下的构造器是私有的,private修饰。
  2. singleCase1是静态成员,也是我们的单例对象。
  3. getIns方法是唯一获取单例的途径。

懒汉模式&饿汉模式

  1. 如果单例的初始值是null,还未构建,则构建单例对象并返回。这种写法成为懒汉模式。
  2. 如果单例对象一开始就被new Singleton1()主动构建,不需要判断。这种写法被称为饿汉模式。
    可是,这种方式实现的单例模式是线程不安全的。为什么?
    假设Singleton1还未被初始化,这时候两个线程同时访问getIns方法,并且同时通过了singleCase1 == null的判断,这时候就会new两次。

双重检测法实现单例模式

根据第一版的缺陷进行优化

public class Singleton5 {
	
	private Singleton5() {}
	
	private static Singleton5 singleCase1 = null;
	public static Singleton5 getIns() {
		if(singleCase1 == null) {
			synchronized (Singleton5.class) { //这里不能使用对象锁
				if(singleCase1 == null)
					singleCase1 = new Singleton5(); //懒汉模式
			}
		}
		return singleCase1;
	}
	
}

以上实现方式称为双重检测机制,为了防止new操作之前对象被初始化了多次,因此在new之前使用synchronized进行加锁。在进入synchronized临界区后需要在进行一次判断是否为空,因为假设线程A在new对象之前,线程B也通过第一次判断,等线程A放弃锁后,线程B又会进入new操作,所以这时候必须再次进行非空判断。
虽然这种方法实现看起来很不错了,但是还是存在缺陷。
这是因为JVM编译器可能进行重排序。编译器编译JVM指令如下:

memory = allocate     //1、分配对象内存空间
ctorInstance(memory)  //2、初始化对象
singleCase1 = memory  //3、设置singleCase1指向刚分配的内存地址

JVM对其进行重排序后执行步骤可能变成了132。如果是这种情况的话,假设线程A已经执行了13,还未初始化对象,此时线程B抢到cpu,执行if(singleCase1 == null)为false,从而返回没有初始化的singleCase1。

优化双重检测

为了避免这种情况,我们需要使用volatile修饰singleCase1。
volatile的好处事不仅可以防止指令重排,还可以保证线程访问的变量值是主内存中的最新值。
代码如下:

public class Singleton6 {
	
	private Singleton6() {}
	
	private volatile static Singleton6 singleCase1 = null; //使用volatile修饰,
	//阻止变量访问前后的指令重排,volatile还可以保证变量值是主内存中的最新值
	public static Singleton6 getIns() {
		if(singleCase1 == null) {
			synchronized (Singleton6.class) { //这里不能使用对象锁
				if(singleCase1 == null)
					singleCase1 = new Singleton6(); //懒汉模式
			}
		}
		return singleCase1;
	}
	
}

双重检测方式虽然可以保证线程安全,但是这种写法比较丑陋,复杂。在低版本的JDK中无法保证其正确性。

使用饿汉模式实现单例

public class Singleton2 {
	
	private Singleton2() {
		System.out.println("create Singleton2...");
	}
	
	public static int TEMP = 2;
	
	private static Singleton2 singleCase1 = new Singleton2(); //饿汉模式
	public static Singleton2 getIns() {
		return singleCase1;
	}
	
}

以上实现单例的方式是饿汉模式。
这个单例中我们还定义了一个TEMP的变量,如果在getIns方法执行前先引用了TEMP,会在getIns方法执行前就初始化对象,但是只会有一个实例,因为类只被初始化一次。
例如:

System.out.println(Singleton2.TEMP);

打印结果:

create Singleton2...
2

虽然这种写法有所欠缺,但是它实现简单、代码易读且性能优越,而且这种缺陷无伤大雅。

使用synchronized修饰getIns方法

如果我们想精确的控制singleCase1的创建时间,同时不考虑双重检测法,我们可以通过如下方式实现:

public class Singleton3 {
	
	private Singleton3() {}
	
	private static Singleton3 singleCase1 = null;
	public synchronized static Singleton3 getIns() {
		if(singleCase1 == null) {
			singleCase1 = new Singleton3(); //懒汉模式
		}
		return singleCase1;
	}
	
}

我们在方法处添加synchronized修饰,可以避免双重检测方式的复杂逻辑。但是这两种方式都使用了锁,在竞争激烈的并发环境下对性能会产生一定的影响。

利用类加载机制实现单例

这种实现结合饿汉模式和在方法前加锁的优点,在高并发情况下性能优越且singleCase1创建时间可以控制在第一次调用getIns方法的时候。具体实现如下:

public class Singleton4 {
	
	private Singleton4() {}
	
	private static class SingletonHolder {
		private final static Singleton4 singleCase4 = new Singleton4();
	}
	public static Singleton4 getIns() {
		return SingletonHolder.singleCase4;
	}
	
}

这种方式巧妙地使用了内部类和类的初始化方式。将SingletonHolder声明为private,防止外部调用。SingleCase4的实例实在静态内部类被加载的时候实例化。

如何避免反射?

上述的所有单例模式都无法避免反射机制,从而打破了单例。
我们以利用类加载机制实现单例来看一下反射是如何破坏单例的。代码如下:

//获取构造器
Constructor<Singleton4> con = Singleton4.class.getDeclaredConstructor();
//设置属性可见
con.setAccessible(true);
//构造两个不同对象
Singleton4 s1 = (Singleton4) con.newInstance();
Singleton4 s2 = (Singleton4) con.newInstance();
System.out.println(s1.equals(s2));

执行结果如下:

false

从以上代码可以看出反射破坏单例主要由三步骤:

  1. 获取单例类的构造器
  2. 把构造器设置为可以访问
  3. 使用newInstance方法获取实例 那么,我们如何避免反射呢?
    我们可以采用枚举的方式实现。代码如下:
public enum SingletonEnum {
	INSTANCE;
}

将之前进行反射实验的代码中的Singleton4替换成SingletonEnum进行实验,得到结果如下:

java.lang.NoSuchMethodException: com.alien.singleCase.SingletonEnum.<init>()
	at java.lang.Class.getConstructor0(Unknown Source)
	at java.lang.Class.getDeclaredConstructor(Unknown Source)
	at com.alien.singleCase.Demo3.main(Demo3.java:10)

所以使用枚举的方式不仅能够阻止反射,而且可以保证线程安全,反序列化的时候保证反序列的返回结果是同一对象(其它方式实现单例既要可序列化又要反序列化为同一对象,则必须使用readResolve方法),同时代码简洁。不过其单例对象在枚举被初始化的时候创建。