众所周知,在代码中采用合理的设计模式,不仅仅能使代码更容易被他人理解,同时也能使整体模块拥有更合理的结构,方便后期扩展维护。因此就产生了一些“套路”,而这些“套路”我们便称之为“设计模式”。
另外,如果想要弄明白一些知识,一定要分清楚顺序,即遇到了什么问题
、要怎么解决
以及有没有更好的办法
,这样带着问题去思考,可以达到事半功倍的效果。
言归正传,开始说单例模式。按照上面的思考顺序,我们一步一步来分析。
1. 有本参奏,无本退朝
开始上早朝了啊~平常我们在使用某个类的实例时,直接使用关键字new
,便可创建一个实例对象。但有时候可能会频繁使用某个实例对象,或者创建这个对象比较耗费资源,例如请了一个管家,需要管家帮你干一些事,总不能每次需要管家的时候就重新聘请一个吧?最好的方法就是长期聘请这个管家,需要的时候直接吩咐就行了。突然发现我这个例子举得是很恰当啊!
通过上面的阐述,我们遇到一个问题,那就是某个类的实例对象频繁使用,或者创建时比较费时费事时,希望只创建一次对象,并且一个就够了(你要是非得请两个管家,我只能说你有钱)。在这种情况下,我们来开始思考如果解决这个问题。
2. 建言献策,百花齐放
大家都开始献上良策啊,一个一个来,第一位,赵学士你先发言~
2.1 饿汉式
其实挺好解决的,看我下面的代码:
// 赵学士的方案
public class Singleton {
private static Singleton sInstance = new Singleton();
private Singleton() {
}
public static Singleton getInstance() {
return sInstance;
}
}
构造方法私有化这就不解释了,保证外部不能随便通过new
关键字来创建对象;静态成员变量sIntance
在Singleton
这个类加载的时候就初始化,创建了Singleton
对象,并且只存在一个;通过Singleton.getInstance()
方法可以获取该实例对象,这就是单例模式!这就解决了问题啊同志们!
不过钱大臣想了想说,不过这种方法好像有点弊端,假如我现在还不需要管家,总不能让我白花钱养着吧?能不能在我需要的时候再花钱聘请管家?
诶~~你这么一说也有道理啊,那钱大臣,说说你的办法。
2.2 懒汉式
话不多说,先看代码:
// 钱大臣的方案
public class Singleton {
private static Singleton sInstance;
private Singleton() {
}
public static Singleton getInstance() {
if (sInstance == null) {
sInstance = new Singleton();
}
return sInstance;
}
}
怎么样?这个办法不错吧!成员变量默认初始化不创建对象,当调用Singleton.getInstance()
方法时,如果sInstance
为null
再创建对象,否则就直接返回,保证了你的要求。
此时孙丞相“哼”了一下说,你这还不如赵学士呢!赵学士有可能提前白花钱聘请了一个管家,而你有可能多花钱请了好几个管家呢!你都没有考虑到多线程的情况!钱大臣一听赶紧做了修改,代码如下:
// 钱大臣的方案2
public class Singleton {
private static Singleton sInstance;
private Singleton() {
}
public synchronized static Singleton getInstance() {
if (sInstance == null) {
sInstance = new Singleton();
}
return sInstance;
}
}
给getInstance方法加了关键字synchronized,保证创建对象的时候只有一个调用者,可以了吧?孙丞相又“哼”了一声,可每次调用的时候都会因为这个锁带来的时间开销,你以为开锁不要时间啊?性能低下!钱大臣脸有点红,于是又做了修改:
// 钱大臣的方案3
public class Singleton {
private static Singleton sInstance;
private Singleton() {
}
public static Singleton getInstance() {
if (sInstance == null) {
synchronized (Singleton.class) {
sInstance = new Singleton();
}
}
return sInstance;
}
}
给sInstance = new Singleton();
语句加了锁,应该没问题了吧?孙丞相第三次“哼”了一声,我给你假设个情况啊,设现有线程A和B,在某个时刻两个线程都通过了判空语句但都没有取到锁资源,然后线程A先取得锁资源进入临界区(被锁的代码块),创建了一个对象,然后退出临界区,释放锁资源。接着线程B取得锁资源进入临界区,开始创建对象,退出临界区,释放锁资源,请问现在有几个Sinleton对象?钱大臣听后说那我直接把锁加到判空语句之前!
// 钱大臣的方案4
public class Singleton {
private static Singleton sInstance;
private Singleton() {
}
public static Singleton getInstance() {
synchronized (Singleton.class) {
if (sInstance == null) {
sInstance = new Singleton();
}
}
return sInstance;
}
}
孙丞相直接笑了,说你这样和在方法上加synchronized关键字有什么区别。连续被怼,钱大臣感觉很没面子直接反驳道,you can you up, no can no bb!
2.3 双重校验锁DCL(double checked locking)
孙丞相大手一挥说道,看好了啊,今儿让我教教你怎么做人!
// 孙丞相的方案
public class Singleton {
private static volatile Singleton sInstance;
private Singleton() {
}
public static Singleton getInstance() {
if (sInstance == null) {
synchronized (Singleton.class) {
if (sInstance == null) {
sInstance = new Singleton();
}
}
}
return sInstance;
}
}
首先,方法锁改成代码块锁,减少锁的范围;其次第一次判空,在单线程的情况下提升了效率,但此时如果同时存在两个线程并发情况,即都判空成功,接下来会由锁内的第二次判空来过滤。还是刚才的例子,假设现有线程A和B,在某个时刻两个线程都通过了第一次判空语句但都没有取到锁资源。然后线程A先取得锁资源进入临界区(被锁的代码块),执行第二次判空语句,判空成功,创建了一个对象,然后退出临界区,释放锁资源。接着线程B取得锁资源进入临界区,执行判空语句发现不通过,直接退出临界区,释放锁资源。
另外在成员变量sInstance
前面加了一个volatile
关键字,这个特别重要。容我装个逼:在Java内存模型(JMM)中,并不限制处理器的指令顺序,说白了就是在不影响结果的情况下,顺序可能会被打乱。
在执行sInstance = new Singleton();
这条命令语句时,JMM并不是一下就执行完毕的,即不是原子性
,实质上这句命令分为三大部分:
- 为对象分配内存
- 执行构造方法语句,初始化实例对象
- 把sInstance的引用指向分配的内存空间
在JMM中这三个步骤中的2和3不一定是顺序执行的,如果线程A执行的顺序为1、3、2,在第2步执行完毕的时候,恰好线程B执行第一次判空语句,则会直接返回sInstance
,那么此时获取到的sInstance
仅仅只是不为null
,实质上没有初始化,这样的对象肯定是有问题的!
而volatile
关键字的存在意义就是保证了执行命令不会被重排序,也就避免了这种异常情况的发生,所以这种获取单例的方法才是真正的安全可靠!
一直默默不做声的李将军冷不丁地开口了,孙丞相啊,你不觉得你这样写很麻烦吗?我有更简单的写法呢!
2.4 静态内部类实现的单例模式
你看你这又是判空又是加锁的,多麻烦,其实可以通过静态内部类的方式,既保证了只存在一个单例,又保证了线程安全,代码如下:
// 李将军的方案
public class Singleton {
private Singleton() {
}
public static Singleton getInstance() {
return SingletonHolder.sInstance;
}
private static class SingletonHolder {
private static Singleton sInstance = new Singleton();
}
}
当外部类Singleton被加载时,其静态内部类SingeletonHolder不会被加载,所以它的成员变量sInstance是不会被初始化的,只有当调用Singleton.getInstance()方法时,才会加载SingeletonHolder并且初始化其成员变量,而类加载时是线程安全的,这样既保证了延迟加载,也保证了线程安全,同时也简化了代码量,一举三得!
2.5 枚举单例
在说完上面4种单例模式的实现方式之后,不知道大家有没有想到过一个问题,那就是序列化。我们可以通过以下代码将实例写入磁盘,然后再从磁盘读出,即使构造方法是私有的,反序列化也是可以通过特殊的途径去重新创建一个新的实例,代码如下:
public Singleton createNewInstance() throws IOException, ClassNotFoundException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
// 此处的singleton为通过单例模式获取到的实例对象
oos.writeObject(singleton);
ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bais);
// 此时返回一个反序列化后得到的新的实例对象
return (Singleton) ois.readObject();
}
可以通过上面的代码看到,反序列化后可以得到一个新的实例对象,那么这种现象没法避免了吗?其实是可以避免的。反序列化提供了一个很特别的方法,即一个私有、被实例化的方法readResolve(),这个方法可以让开发人员控制对象的反序列化。想要杜绝上面现象的发生,那么就可以在单例模式中加入readResolve()方法,代码如下:
private Object readResolve() {
// 此处返回单例模式中的实例对象
return sInstance;
}
在《Effective Java》一书中,作者Joshua Bloch提倡可以采用枚举的方式来解决上述出现的所有问题,代码如下:
// 外国老大哥Joshua Bloch的方案
public enum SingletonEnum {
INSTANCE;
public void method(){
// do something...
}
}
可以通过SingletonEnum.INSTANCE
获取单例,然后再调用内部的各种方法。枚举实现单例有如下好处:
- 实例的创建线程安全,确保单例;
- 防止被反射创建多个实例;
- 没有序列化的问题。
虽然这种方法还没有被广泛采用,但是单元素的枚举类型已经成为实现Singleton单例模式的最佳方法。
3. 总结
通过上面的一步步分析,不知道大家有没有对单例模式有个新的认识呢?总的来说,加了volatile关键字的双重校验锁和静态内部类实现的单例模式是目前应用最为广泛的,如果你们要求更严的话,那么枚举单例也不失为一个获取单例更加的方式。欢迎各位能多多交流,指出不足,共同学习进步!