单例模式有几种写法

48次阅读

共计 4341 个字符,预计需要花费 11 分钟才能阅读完成。

世上没有白费的努力,也没有碰巧的成功,所有的无心插柳,最后都会是水到渠成。

“你知道茴香豆的‘茴’字有几种写法吗?”
纠结单例模式有几种写法有用吗?有点用,面试中经常选择其中一种或几种写法作为话头,考查设计模式和代码风格的同时,还很容易扩展到其他问题。
这里讲解几种常用的写法,但切忌生搬硬套。大体可分为 4 类,下面分别介绍他们的基本形式、变种及特点。

饱汉模式

饱汉是变种最多的单例模式。我们从饱汉出发,通过其变种逐渐了解实现单例模式时需要关注的问题。

基础的饱汉

饱汉,即已经吃饱,不着急再吃,饿的时候再吃。所以他就先不初始化单例,等第一次使用的时候再初始化,即“懒加载”。

// 饱汉
// UnThreadSafe
public class Singleton1
{
    private static Singleton1 singleton = null;
    private Singleton1()
    {}
    public static Singleton1 getInstance()
    {if(singleton == null)
        {singleton = new Singleton1();
        }
        return singleton;
    }
}

饱汉模式的核心就是懒加载。好处是更启动速度快、节省资源,一直到实例被第一次访问,才需要初始化单例;小坏处是写起来麻烦,大坏处是线程不安全,if 语句存在竞态条件。

写起来麻烦不是大问题,可读性好啊。因此,单线程环境下,基础饱汉是笔者最喜欢的写法。但多线程环境下,基础饱汉就彻底不可用了。下面的几种变种都在试图解决基础饱汉线程不安全的问题。

饱汉 – 变种 1

最粗暴的犯法是用 synchronized 关键字修饰 getInstance() 方法,这样能达到绝对的线程安全。

// 饱汉
// ThreadSafe
public class Singleton1_1
{
    private static Singleton1_1 singleton = null;
    private Singleton1_1()
    {}
    public synchronized static Singleton1_1 getInstance()
    {if(singleton == null)
        {singleton = new Singleton1_1();
        }
        return singleton;
    }
}

变种 1 的好处是写起来简单,且绝对线程安全;坏处是并发性能极差,事实上完全退化到了串行。单例只需要初始化一次,但就算初始化以后,synchronized 的锁也无法避开,从而 getInstance() 完全变成了串行操作。性能不敏感的场景建议使用。

饱汉 – 变种 2

变种 2 是“臭名昭著”的 DCL 1.0。

针对变种 1 中单例初始化后锁仍然无法避开的问题,变种 2 在变种 1 的外层又套了一层 check,加上 synchronized 内层的 check,即所谓“双重检查锁”(Double Check Lock,简称 DCL)。

// 饱汉
// UnThreadSafe
public class Singleton1_2
{
    private static Singleton1_2 singleton = null;
    public int f1 = 1; // 触发部分初始化问题
    public int f2 = 2;
    private Singleton1_2()
    {}
    public static Singleton1_2 getInstance()
    {
        // may get half object
        if(singleton == null)
        {synchronized(Singleton1_2.class)
            {if(singleton == null)
                {singleton = new Singleton1_2();
                }
            }
        }
        return singleton;
    }
}

变种 2 的核心是 DCL,看起来变种 2 似乎已经达到了理想的效果:懒加载 + 线程安全。可惜的是,正如注释中所说,DCL 仍然是线程不安全的,由于指令重排序,你可能会得到“半个对象”,即”部分初始化“问题。

饱汉 – 变种 3

变种 3 专门针对变种 2,可谓 DCL 2.0。

针对变种 3 的“半个对象”问题,变种 3 在 instance 上增加了 volatile 关键字,原理见上述参考。

// 饱汉
// ThreadSafe
public class Singleton1_3
{
    private static volatile Singleton1_3 singleton = null;
    public int f1 = 1; // 触发部分初始化问题
    public int f2 = 2;
    private Singleton1_3()
    {}
    public static Singleton1_3 getInstance()
    {if(singleton == null)
        {synchronized(Singleton1_3.class)
            {
                // must be a complete instance
                if(singleton == null)
                {singleton = new Singleton1_3();
                }
            }
        }
        return singleton;
    }
}

多线程环境下,变种 3 更适用于性能敏感的场景。但后面我们将了解到,就算是线程安全的,还有一些办法能破坏单例。

当然,还有很多方式,能通过与 volatile 类似的方式防止部分初始化。读者可自行阅读内存屏障相关内容,但面试时不建议主动装逼。

饿汉模式

与饱汉相对,饿汉很饿,只想着尽早吃到。所以他就在最早的时机,即类加载时初始化单例,以后访问时直接返回即可。

// 饿汉
// ThreadSafe
public class Singleton2
{private static final Singleton2 singleton = new Singleton2();
    private Singleton2()
    {}
    public static Singleton2 getInstance()
    {return singleton;}
}

饿汉的好处是天生的线程安全(得益于类加载机制),写起来超级简单,使用时没有延迟;坏处是有可能造成资源浪费(如果类加载后就一直不使用单例的话)。

值得注意的时,单线程环境下,饿汉与饱汉在性能上没什么差别;但多线程环境下,由于饱汉需要加锁,饿汉的性能反而更优。

Holder 模式

我们既希望利用饿汉模式中静态变量的方便和线程安全;又希望通过懒加载规避资源浪费。Holder 模式满足了这两点要求:核心仍然是静态变量,足够方便和线程安全;通过静态的 Holder 类持有真正实例,间接实现了懒加载。

// Holder 模式
// ThreadSafe
public class Singleton3
{
    private static class SingletonHolder
    {private static final Singleton3 singleton = new Singleton3();
        private SingletonHolder()
        {}}
    private Singleton3()
        {}
        /**
        * 勘误:多写了个 synchronized。。public synchronized static Singleton3 getInstance() {return SingletonHolder.singleton;}
        */
    public static Singleton3 getInstance()
    {return SingletonHolder.singleton;}
}

相对于饿汉模式,Holder 模式仅增加了一个静态内部类的成本,与饱汉的变种 3 效果相当(略优),都是比较受欢迎的实现方式。同样建议考虑。

枚举模式

用枚举实现单例模式,相当好用,但可读性是不存在的。

基础的枚举

将枚举的静态成员变量作为单例的实例:

// 枚举
// ThreadSafe
public enum Singleton4
{SINGLETON;}

代码量比饿汉模式更少。但用户只能直接访问实例 Singleton4.SINGLETON——事实上,这样的访问方式作为单例使用也是恰当的,只是牺牲了静态工厂方法的优点,如无法实现懒加载。

丑陋但好用的语法糖

Java 的枚举是一个“丑陋但好用的语法糖”。

枚举型单例模式的本质

通过反编译打开语法糖,就看到了枚举类型的本质,简化如下:

// 枚举
// ThreadSafe
public class Singleton4 extends Enum < Singleton4 >
{...
    public static final Singleton4 SINGLETON = new Singleton4();...}

本质上和饿汉模式相同,区别仅在于公有的静态成员变量。

用枚举实现一些 trick

这一部分与单例没什么关系,可以跳过。如果选择阅读也请认清这样的事实:虽然枚举相当灵活,但如何恰当的使用枚举有一定难度。一个足够简单的典型例子是 TimeUnit 类,建议有时间耐心阅读。

上面已经看到,枚举型单例的本质仍然是一个普通的类。实际上,我们可以在枚举型型单例上增加任何普通类可以完成的功能。要点在于枚举实例的初始化,可以理解为实例化了一个匿名内部类。为了更明显,我们在 Singleton4_1 中定义一个普通的私有成员变量,一个普通的公有成员方法,和一个公有的抽象成员方法,如下:

// 枚举
// ThreadSafe
public enum Singleton4_1
{SINGLETON("enum is the easiest singleton pattern, but not the most readable")
    {public void testAbsMethod()
        {print();
            System.out.println("enum is ugly, but so flexible to make lots of trick");
        }
    };
    private String comment = null;
    Singleton4_1(String comment)
    {this.comment = comment;}
    public void print()
    {System.out.println("comment=" + comment);
    }
    abstract public void testAbsMethod();
    public static Singleton4_1 getInstance()
    {return SINGLETON;}
}

这样,枚举类 Singleton4_1 中的每一个枚举实例不仅继承了父类 Singleton4_1 的成员方法 print(),还必须实现父类 Singleton4_1 的抽象成员方法 testAbsMethod()。

总结

上面的分析都忽略了反射和序列化的问题。通过反射或序列化,我们仍然能够访问到私有构造器,创建新的实例破坏单例模式。此时,只有枚举模式能天然防范这一问题。反射和序列化笔者还不太了解,但基本原理并不难,可以在其他模式上手动实现。

下面继续忽略反射和序列化的问题,做个总结回味一下:

正文完
 0