简介
单例模式可以说在平时开发过程中经常用到。当整个应用进程只需要创建一次某对象时。那么单例模式就派上用场了。很多人觉得单例模式很简单,但是里面有些细节和不同写法的差别,以及不同写法都解决了什么问题。还是可以了解了解。这样在开发过程中可以根据实际情况来使用单例模式。
UML
难点
单例,单例——那么重点就是如何保证单例类的对象在整个应用进程有且只有一个。比如在多线程情况下,异步和原子操作带来的执行顺序的差异等等。
单例1
- 饿汉模式
public class Singleton { private static final Singleton INSTANCE = new Singleton(); private Singleton() {} public static Singleton instance() { return INSTANCE; } }
这种算是最简单的一种写法,利用虚拟机只会对类加载一次,而静态变量会在类加载时进行初始化来保证单例类对象在整个应用进程期间有且只有一个。但是缺点就是:类加载便会立即创建单例对象,不管单例对象后面是否会被使用,会导致一定的资源浪费。
- 懒汉模式
class Singleton1 { private static class LazyHolder { private static final Singleton1 INSTANCE = new Singleton1(); } private Singleton1 (){} public static final Singleton1 getInstance() { return LazyHolder.INSTANCE; } }
反序列化导致单例模式失效
让Singleton实现Serializable接口。编写测试程序。
private static void testSingleton() {
Singleton singleton = Singleton.instance();
try {
FileOutputStream fos = new FileOutputStream(new File("singletonTest.txt"));
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(singleton);
fos.close();
oos.close();
System.out.println("old:" + singleton.hashCode());
FileInputStream fis = new FileInputStream(new File("singletonTest.txt"));
ObjectInputStream ois = new ObjectInputStream(fis);
Singleton newSingleton = (Singleton) ois.readObject();
fis.close();
ois.close();
System.out.println("new:" + newSingleton.hashCode());
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
打印结果:
old:2016447921
new:960604060
可以发现,前后并不是同一个对象。那么是怎么造成的呢。看一下ObjectInputStream的readObject()方法。
public final Object readObject()
throws IOException, ClassNotFoundException
...
try {
Object obj = readObject0(false);
...
return obj;
} finally {
...
}
这里obj便是序列化出来的对象。查看readObject0方法。
private Object readObject0(boolean unshared) throws IOException {
...
try {
switch (tc) {
case TC_OBJECT:
return checkResolve(readOrdinaryObject(unshared));
...
}
...
}
序列化对象由readOrdinaryObject(unshared)获得,继续向下看。
private Object readOrdinaryObject(boolean unshared)
throws IOException
{
...
Object obj;
try {
obj = desc.isInstantiable() ? desc.newInstance() : null;
} catch (Exception ex) {
...
}
...
return obj;
}
重点就在obj = desc.isInstantiable() ? desc.newInstance() : null这行代码了。
- isInstantiable:如果一个serializable/externalizable的类可以在运行时被实例化,那么该方法就返回true。
- desc.newInstance:该方法通过反射的方式调用无参构造方法新建一个对象。
所以如果不做任何处理,序列化出来的是新创建的对象。
解决办法
增加readReslove方法,该方法会在反序列化时调用,且返回一个对象,这个对象就是readObject返回的对象。
public class Singleton implements Serializable {
private static final Singleton INSTANCE = new Singleton();
private Singleton() {}
public static Singleton instance() { return INSTANCE; }
private Object readResolve() {
System.out.println("readResolve被调用了");
return INSTANCE;
}
private Object writeReplace() {
System.out.println("writeReplace被调用了");
return INSTANCE;
}
}
打印结果
writeReplace被调用了
old:1229416514
readResolve被调用了
new:1229416514
单例2
class Singleton2{
private static Singleton2 singleton2;
private Singleton2() {}
public static Singleton2 instance(){
if (null == singleton2){
singleton2 = new Singleton2();
}
return singleton2;
}
}
这种但是模式不适合运用到多线程情况,当使用场景不是在多线程时,那么这种写法的性能比第三种单例写法高。
单例3(双重检测)
class Singleton3{
private static Singleton3 singleton3;
private Singleton3() {}
public static Singleton3 instance(){
if (null == singleton3){
synchronized (Singleton3.class){
if (null == singleton3){
singleton3 = new Singleton3();
}
}
}
return singleton3;
}
}
这种方式有叫“双重检测”,即检测两次是否为null。并用synchronized解决多线程带来的问题,那么问什么用两次判null,为什么不把synchronized写到方法体或者最外层呢?
- 为什么不把synchronized写到方法体或者最外层呢?
如果singleton3不为null,那么就不会执行为null情况下的同步代码,要知道代码同步需要获取锁,释放锁等操作,是个繁重的过程,所以这样可以提高效率。 - 为什么还要进行第二次判null?
想象这种情况,有A,B两个线程同时调用instance获取对象。线程A获取锁进入第二层判null代码,进程创建单例对象,但是这个过程并不是原子操作,什么时候创建完,并不知道。在没有创建完成的情况下,线程B到达,由于单例对象还未创建成功,所以线程B到达时,顺利通过第一层判null,然后试图获取锁进入第二层判null,这是发现锁被A持有,那么等待A释放锁,当单例对象创建成功,A也释放锁,这是B进入同步代码块,如果不进行第二次判null,那么B也会重新创建一个对象。
那这种方式能够保证单例的唯一性吗?其实并不能,由于java内存模型的缘故,"双重检测"并不能保证单例的唯一性。先补一下“java内存模型”知识。
Java内存模型
参考《深入理解Java虚拟机》一书。
- 主内存和工作内存
Java内存模型规则所有的变量都存储在“主内存”,每条线程拥有自己的“工作内存”,线程的“工作内存”保存了被线程使用到的变量的“主内存”副本拷贝,线程对变量的所有操作(读取,赋值等)都必须在“工作内存”中进行,而不能直接读写“主内存”中变量。不同线程之间也无法直接访问对方的“工作内存”中变量,线程直接传递需要通过“主内存”来完成。 - 内存间的交互操作
关于“主内存”与“工作内存”直接的交互协议,Java内存模型中定义了以下8中操作来完成,虚拟机实现时必须保证下面提及的每一项操作都是原子的,不可再分的(对于double和long类型来说,load,store,read,write在某些平台有例外)。- lock(锁定):作用于“主内存”的变量,它把一个变量标识为一条线程独占的状态。
- unlock(解锁):作用于“主内存”的变量,释放锁。
- read(读取):作用于“主内存”的变量,它把一个变量的值从“主内存”传输到线程的“工作内存”中,以便随后的load动作使用。
- load(载入):作用于“工作内存”的变量,它把read操作从“主内存”中得到的变量值放入“工作内存”的变量副本中。
- use(使用):作用于“工作内存”的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用的变量的字节吗指令时将会执行该操作。
- assign(赋值):作用于“工作内存”的变量,它把一个从执行引擎接收到的值赋给“工作内存”的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store(存储):作用于“工作内存”的变量,它把“工作内存中一个变量的值传输到“主内存”中,以便随后的write操作使用。
- write(写入):作用于“主内存”的变量,它把store操作从“工作内存”中得到的变量值放入“主内存”的变量中。
- 内存间操作规则
- 不允许read和load,store和write操作之间单独出现即不允许一个变量从主内存读取了但工作内存不接受,反之一样。
- 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
- 不允许一个线程无原因地(没有发送过任何assign操作)把数据从线程的工作内存同步回主内存中。
- 一个新变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,即,对一个变量实施use,store操作之前,必须先执行过了assign和load操作。
- 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被一条线程重复执行多次,只有执行相同次数的unlock操作,变量才会被解锁。
- 如果对一个变量执行lock操作,那么将会清空工作内存中此变量的值,在执行引擎使用这个变量的前,需要重新执行load或者assign操作初始化变量值。
- 如果一个变量实现没有lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其它线程锁定的变量。
- 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中。
不完美的双重检测
有了上面的知识后,我们来试想一种场景。线程A,B同时调用instance获取实例,这时线程A,B的工作内存保存的是主内存中的副本,这是实例还没有创建,所有都为null,当线程A获得锁,进行实例创建,而B等待A释放锁,当A创建完对象,释放锁后,B获得锁进入第二层判null逻辑。重点来了,线程A的确完成了实例的创建,但是是在自己的工作内存,还没有同步到主内存,那么这时候线程B中的实例依然是null,所有还是会重新创建对象。解决办法可以给实例对象加上volatile关键字,下面补一下volatile关键字知识。
volatile
- 保证变量对所以线程的可见性
- 禁止指令重排序优化
单例4(枚举)
enum Singleton4{
INSTANCE;
public void test(){}
}
kotlin中单例模式
kotlin创建单例就很简单了,利用object 关键字即可而且对象声明的初始化过程是线程安全的。
object Singleton5 {
fun registerDataProvider(provider: DataProvider) {}
val allDataProviders: Collection<DataProvider>
get() = // ……
}