一个简单的单例模式的实现
单例模式可以简单的理解为一个类只能构造一个对象。
public class Singleton1 {
private Singleton1() {}
private static Singleton1 singleCase1 = null;
public static Singleton1 getIns() {
if(singleCase1 == null) {
singleCase1 = new Singleton1(); //懒汉模式,非线程安全,可能存在两个线程同时访问的情况
}
return singleCase1;
}
}
我们来分析一下以上的代码:
- 要想要获取一个单例模式就不能随便的做new操作,因此单例模式下的构造器是私有的,private修饰。
- singleCase1是静态成员,也是我们的单例对象。
- getIns方法是唯一获取单例的途径。
懒汉模式&饿汉模式
- 如果单例的初始值是null,还未构建,则构建单例对象并返回。这种写法成为懒汉模式。
- 如果单例对象一开始就被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
从以上代码可以看出反射破坏单例主要由三步骤:
- 获取单例类的构造器
- 把构造器设置为可以访问
- 使用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方法),同时代码简洁。不过其单例对象在枚举被初始化的时候创建。