单例模式

本文章采用循序渐进的方式对单例模式进行演化实现。

什么是单例模式,为什么要使用单例模式?

单例模式就是只允许创建一个实例的对象,这有什么实际价值?
实际开发中有些对象我们只需要一个,比方说:线程池、缓存、对话框等等,这类对象只能有一个实例,如果执照出多个实例,就会导致很多问题的产生,比如:程序异常、系统资源使用过量或者运行不一致的结果。

实现单例有两种常见的方法,这两种方法的核心就是要保持构造器为私有的,并导出公有的静态成员。

两种经典的单例实现之一:饿汉式

/**
 * @Description: 单例模式:饿汉式
 * @Author along
 * @Date 2020/3/8 17:20
 */
public class Hungry {

    private static final Hungry INSTANCE = new Hungry();

    private Hungry(){}

    public static Hungry getInstance() {
        return INSTANCE;
    }
}

恶汉式在代码初始化时就创建一个实例,实现了每次调用getInstance()得到的对象都是已经存在的唯一的实例,但是也产生了问题,万一这个对象非常耗资源,二程序在这次的执行过程中又一直没有用到它,这样就会白白浪费了资源。出于饿汉式的这个弊端,我们又有了懒汉式的单例实现

两种经典的单例实现之二:懒汉式

/**
 * @Description: 单例模式:懒汉式
 * @Author along
 * @Date 2020/3/8 17:38
 */
public class Lazy {

    private static Lazy INSTANCE;

    private Lazy() {}

    public static Lazy getInstance() {
        if (INSTANCE == null) {
            INSTANCE = new Lazy();
        }
        return INSTANCE;
    }
}

需要的时候先判断该对象是否存在,不存在再创建,在单线程模式下可以保证拿到的对象一定是唯一的,但是在多线程环境下呢,下面代码对其进行测试

public class Lazy {

    private static Lazy INSTANCE;

    private Lazy() {}

    public static Lazy getInstance() {
        if (INSTANCE == null) {
            INSTANCE = new Lazy();
        }
        return INSTANCE;
    }

    /**
     * 多线程环境测试
     * @param args
     */
    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                Lazy instance = Lazy.getInstance();
                // 输出每个线程得到的对象的hashCode
                System.out.println(instance.hashCode());
            }).start();
        }
    }
}

运行结果如下



多线程环境下出现了不一致的hashCode,证明得到的对象并不是唯一的,单例被破坏。
如何解决多线程下单例被破坏呢,能直接想到的就是加锁,保证不会有两个线程同时进入getInstance()方法即可。如下

public class Lazy {

    private static Lazy INSTANCE;

    private Lazy() {}

    public static synchronized Lazy getInstance() {
        if (INSTANCE == null) {
            INSTANCE = new Lazy();
        }
        return INSTANCE;
    }
}

使用synchronized关键字给getInstance()方法加锁,确实解决了多线程下单例被破坏的情况,可是又产生了性能问题,现实情况是,只有在第一次执行getInstance()方法时,才真正需要同步,一旦执行过getInstance()设置好INSTANCE变量后,就不需要同步这个方法了,要不然之后每次调用这个方法,同步都是一种累赘。

所以为了减少同步带来的额外性能损失,我们需要减小synchronized的作用范围,并采用双重检测加锁(即DCL),在getInstance()中减少使用同步,代码如下

public class Lazy {

    private static Lazy INSTANCE;

    private Lazy() {}

    public static Lazy getInstance() {
        if (INSTANCE == null) { // 第一重判断
            synchronized (Lazy.class) {
                if (INSTANCE == null) { // 第二层判断
                    INSTANCE = new Lazy();
                }
            }
        }
        return INSTANCE;
    }
}

这样是不是就万事大吉了?虽然你在多线程测试中多次测试都得出了正确的结果,但是并不能表明上面的代码在多线程下就是没有问题的。
问题就出在代码INSTANCE = new Lazy();上,这个创建实例的动作并不是原子性的操作。
java虚拟机创建一个对象并不是一步完成的,而是可以分成以下三个步骤:

1:为对象分配内存空间
2:初始化对象
3:将对象指向分配的内存空间

由于CPU在执行指令的时候存在重排序,上面步骤2和步骤3执行顺序可能调换,变成1=>3=>2。
我们假设有有两个线程A和B,线程A先执行getInstance(),判断INSTANCE为null,于是得到锁,并执行INSTANCE = new Lazy(),在执行过程中原本的1=>2=>3步骤由于指令重排变成了1=>3=>2,在执行完步骤3时,INSTANCE已经不为null了,这时候线程B进入getInstance(),判断INSTANCE不为null,于是直接返回实例,但是这个实例由于还没有执行步骤2初始化,所以线程B返回的对象是不完整的,单例也就被破坏了。

基于上面的分析,我们有两种方案来解决上面的问题

  • 方案1:禁止2和3指令重排
  • 方案2:允许2和3指令重排,但是不允许其它线程看到这个重排序

方案1:使用volatile关键字
votatile 可以通过内存屏障的方式禁止指令重排,代码如下

public class Lazy {
    // 使用volatile关键字禁止指令重排
    private volatile static Lazy INSTANCE;

    private Lazy() {}

    public static Lazy getInstance() {
        if (INSTANCE == null) { // 第一重判断
            synchronized (Lazy.class) {
                if (INSTANCE == null) { // 第二层判断
                    INSTANCE = new Lazy();
                }
            }
        }
        return INSTANCE;
    }
}

上面便是DCL懒汉式单例的完整实现

方案2:内部类实现

public class InnerLazy {

    private InnerLazy() {}

    private static class SingleHolder {
        private static final InnerLazy ins = new InnerLazy();
    }

    /**
     * 内部类方式获取单例
     *
     * @return
     */
    public static InnerLazy getInstance() {
        return SingleHolder.ins;
    }
}

以上就是对单例模式的完整实现,到这里就完了吗,学无止境,下面对DCL懒汉式的安全性做进一步的探究。
构造器私有就能保证安全吗?或者说构造器私有了就能保证只能通过getInstance()来得到实例吗?这时候反射举起了小手。

反射破坏单例

用下面的代码来测试反射能否破坏单例

public class DCLLazy {

    private static DCLLazy INSTANCE;

    private DCLLazy() {}

    public static DCLLazy getInstance() {
        if (INSTANCE == null) { // 第一重判断
            synchronized (Lazy.class) {
                if (INSTANCE == null) { // 第二层判断
                    INSTANCE = new DCLLazy();
                }
            }
        }
        return INSTANCE;
    }

    /**
     * 反射破坏单例测试
     * @param args
     */
    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, 
            InvocationTargetException, InstantiationException {
        
        Constructor<DCLLazy> declaredConstructor = DCLLazy.class.getDeclaredConstructor();
        DCLLazy instance1 = declaredConstructor.newInstance();
        DCLLazy instance2 = declaredConstructor.newInstance();

        System.out.println(instance1.hashCode());
        System.out.println(instance2.hashCode());
    }
}

执行结果如下



结果不同,得到了两个对象,反射被破坏了!

可惜的是目前还没有有效的方式来阻止反射攻击,下面只提供一个解决方案。
我们可以在方法中定义一个变量,在构造方法被调用时检查这个变量,来阻止反射攻击,代码如下

public class DCLLazy {

    private static boolean BBB = false;

    private static DCLLazy INSTANCE;

    private DCLLazy() {
        if (!BBB) {
            BBB = true;
        } else {
            // 这一段里面除了加入异常警告,还可以加入病毒之类的代码
            throw new RuntimeException("非法操作");
        }

    }

    public static DCLLazy getInstance() {
        if (INSTANCE == null) { // 第一重判断
            synchronized (Lazy.class) {
                if (INSTANCE == null) { // 第二层判断
                    INSTANCE = new DCLLazy();
                }
            }
        }
        return INSTANCE;
    }

    /**
     * 反射破坏单例测试
     * @param args
     */
    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, 
            InvocationTargetException, InstantiationException {
        
        Constructor<DCLLazy> declaredConstructor = DCLLazy.class.getDeclaredConstructor();
        DCLLazy instance1 = declaredConstructor.newInstance();
        DCLLazy instance2 = declaredConstructor.newInstance();

        System.out.println(instance1.hashCode());
        System.out.println(instance2.hashCode());
    }
}

执行结果如下


这样就安全了吗?当然没那么简单,这个变量是在源代码里的,只要反编译源代码,就能知道有BBB变量,然后就能用反射拿到并操作这个变量,如下,这里只给出main方法的反射代码

/**
     * 反射破坏单例测试
     * @param args
     */
    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException,
            InvocationTargetException, InstantiationException, NoSuchFieldException {
        
        Constructor<DCLLazy> declaredConstructor = DCLLazy.class.getDeclaredConstructor();
        DCLLazy instance1 = declaredConstructor.newInstance();

        Field bbb = DCLLazy.class.getDeclaredField("BBB"); // 拿到私有的BBB字段
        bbb.setAccessible(true);
        bbb.set(instance1, false);
        DCLLazy instance2 = declaredConstructor.newInstance();

        System.out.println(instance1.hashCode());
        System.out.println(instance2.hashCode());
    }

运行结果如下



单例又被破坏了,不经得感叹,没有真正安全的代码。

既然DCL单例很难避免反射攻击,那有没有另一种实现可以避免反射攻击呢?当然是有的,枚举举起了小手。

枚举(enum)实现单例(推荐)

实现非常简单,就是声明一个包含单个元素的枚举类型,如下

/**
 * @Description: 枚举实现单例
 * @Author along
 * @Date 2020/3/8 23:50
 */
public enum SingleEnum {

    INSTANCE;

    public SingleEnum getInstance() {
        return INSTANCE;
    }
}

这里要引用Effective Java中对用枚举实现单例的一段说明:

这种方法在功能上与公有域实现方法相似,但更加简洁,无偿地提供了序列化机制,绝对防止多次实例化,及时实在面对复杂的序列化或者反射攻击的时候。虽然这种方法还没有广泛采用,但是单元素的枚举类型经常成为实现Singleton的最佳方法。注意。Singleton必须扩展一个超类,而不是扩展Enum的时候,则不宜使用这个方法(虽然可以声明枚举去实现接口)。

为什么枚举实现的单例反射破坏不了呢?
下面是反射获取SingleEnum枚举类的实例的简单代码

SingleEnum.class.getDeclaredConstructor(String.class, int.class).newInstance();

我们看下newInstance()的源码,如下



看红框部分的代码,jdk直接禁止了反射对枚举类的实例获取,感觉好贴心有木有!

到这里你肯定感觉用枚举实现单例就万事大吉了,看起来是这样的,可是世上没有真正安全的代码,JDK既然在源码级别杜绝了反射破坏枚举单例的可能,那么,我能不能反编译jdk源码并修改它,去掉这个限制呢?

序列化破坏单例

再回到DCL单例实现上,有时候我们需要这个单例实现序列化,这时候仅仅在声明中加上implements Serializable是不够的,还必须声明所有的实例域都是瞬时(transient)的,并提供一个readResolve方法。代码如下

public class DCLLazy implements Serializable {

    private static final long serialVersionUID = -2603357495283280292L;

    private transient static boolean BBB = false;

    private static DCLLazy INSTANCE;

    private DCLLazy() {
        if (!BBB) {
            BBB = true;
        } else {
            throw new RuntimeException("非法操作");
        }

    }

    public static DCLLazy getInstance() {
        if (INSTANCE == null) {
            synchronized (Lazy.class) {
                if (INSTANCE == null) {
                    INSTANCE = new DCLLazy();
                }
            }
        }
        return INSTANCE;
    }

    // 防止序列化破坏单例
    private Object readResolve() {
        return INSTANCE;
    }
}

总结

本文详细介绍了单例的各种实现,重点探讨了DCL懒汉式实现的演进过程,以及反射和反序列化对单例的破坏及预防手段。并解释了为什么枚举是单例的最佳实现手段。
能力有限,如有错漏,欢迎交流探讨。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容