2

1. 什么是单例

保证一个类仅有一个实例,并提供一个访问它的全局访问点。适用于:

  1. 当类只能有一个实例而且客户可以从一个众所周知的访问点访问它时。
  2. 当这个唯一实例应该是通过子类化可扩展的,并且客户应该无需更改代码就能使用一个扩展的实例时。

在单例模式中,有下列参与者:

  • Singleton:

    • 定义一个Instance操作,允许客户访问它的唯一实例。Instance是一个类操作。
    • 可能负责创建它自己的唯一实例。

2. 不考虑多线程的情况下的单例

下面就是一个单例的实现:Singleton0当然,这个示例在多线程下有问题

// 单例程序: Singleton0 

class Printer{
    private static Printer printer;
    
    private Printer(){
        
    }
    
    public static Printer getInstance(){
        if(printer == null){
            printer = new Printer();
        }
        return printer;
    }
    
}

public class Singleton {

    public static void main(String[] args) {
        Printer p1 = Printer.getInstance();
        Printer p2 = Printer.getInstance();
        System.out.println(p1);
        System.out.println(p2);
    }

}

/* 运行结果:
 * Printer@659e0bfd
 * Printer@659e0bfd
 * 完全一样,表示只创建了Printer对象的一个实例
 */

3. 多线程环境下的单例

很可惜,上面的单例程序在多线程环境下,会华丽丽的出错!

3.1. 上述单例在多线程环境下的问题

我们修改一下Singleton0,成为如下形式:Singleton1

public class Singleton1 {
    
    private static Singleton1 s1;
    
    private Singleton1(){
        System.out.println("构造函数被调用!");
    }
    
    public static Singleton1 getInstance(){
        if(s1 == null){
            s1 = new Singleton1();
        }
        return s1;
    }
}

// 测试程序,采用JUnit 4.x来测试
import org.junit.Test;

public class Singleton1Test implements Runnable{
    
    @Override
    public void run() {
        Singleton1.getInstance();
    }
    
    @Test
    public void test() {
        for (int i = 0; i < 100000; i++) {
            Thread t = new Thread(new Singleton1Test(), "AnyThreadName");
            t.start();
        }
    }
}

/* 运行结果:(可以发现,构造函数被多次调用!说明无法保证单例)
 * 构造函数被调用!
 * 构造函数被调用!
 * 构造函数被调用!
 * 构造函数被调用!
 * 构造函数被调用!
 */

原因很简单,在多线程的情况下,调用 Singleton1.getInstance() 的时候,可能会多个线程同时调用到,这个时候构造函数 Singleton1() 还没有把 s1 实例化出来。这个时候判断 s1 == null 是对的,所以多个线程都会去执行:

if(s1 == null){
    s1 = new Singleton1();
}

所以,这个构造函数就会被执行多次!

知道了这个原因,我们就可以很方便的找到解决方法,那就是:懒汉模式。

3.2. 懒汉模式

既然是问题出在 getInstance() 上,那么我们就把这个方法设置成synchronized,这样就可以保证同步了,于是我们修改成 Singleton2

/**
 *  这种写法能够在多线程中很好的工作,而且看起来它也具备很好的lazy loading。
 *  但是,遗憾的是,效率很低,99%情况下不需要同步。
 * 
 * @author martin.wang
 *
 */
public class Singleton2 {
    private static Singleton2 s2;
    
    private Singleton2(){
        System.out.println("构造函数被调用!");
    }
    
    public static synchronized Singleton2 getInstance(){
        if(s2 == null){
            s2 = new Singleton2();
        }
        return s2;
    }
}

这个方式的最大问题就是:效率太低了。我们知道 synchronized 关键字很消耗资源,而且99%以上的可能性是不用 synchronized 的。每次都要 synchronized 有必要吗?那么我们就有了2种解决方案:

  1. 饿汉模式。干脆一开始就给你初始化算了。
  2. 双重检查锁定。只在必要的时候用 synchronized

3.3. 饿汉模式

饿汉模式避免了在 getInstance 的时候的判断,所以效率高一点。不过也不是无懈可击,如果这个构造的过程很消费时间,那么每次classloader的时间会非常长,没有起到 lazyload 的效果。

public class Singleton3{
    private static Singleton3 singleton3 = new Singleton3();

    private Singleton3() {
        System.out.println("构造函数被调用!");
    }

    public static Singleton3 getInstance() {
        return singleton3;
    }
}

或者,我们利用初始化块,在初始化的时候就完成实例化

public class Singleton4 {
    private static Singleton4 s4 = null;
    
    static {
        s4 = new Singleton4();
    }
    
    private Singleton4(){
        System.out.println("构造器被调用");
    }
    
    public static Singleton4 getInstance(){
        return s4;
    }
}

3.4. 双重检查锁定

避免懒汉模式造成性能低下的另一个思路就是:双重检查锁定。原理就是:

  1. Singleton1 示例中造成问题的原因是 getInstance() 不同步
  2. Singleton2 示例中造成性能低下的原因是不管三七二十一全同步
  3. 那么,我们就只检查可能存在 同步问题 的代码,让代码只在 可能存在问题的时候 再去做同步。

3.4.1. 双重检查锁定的“实现”(有问题的!)

public class Singleton7 {
    private static Singleton7 s7;
    
    private Singleton7() {
        System.out.println("构造函数被调用");
    }
    
    public static Singleton7 getInstance() {
        if(s7 == null) {
            synchronized (Singleton7.class) {   // A
                if(s7 == null) {                // B
                    s7 = new Singleton7();      // C
                }
            }
        }
        return s7;
    }
}

思路分析:

  1. 如果 s7 == null ,那么这个时候要同步了。
  2. 在注释 A 的里面,设置一个同步锁
  3. 如果线程 T1 访问同步块 A 中的代码的时候,线程 T2A 附近等待释放锁。
  4. T1 线程完成,这个时候 T2 线程开始运行 A 中的代码。这个时候 s7 已经被 T1 线程初始化了,执行 B 的时候会返回 false,不会去执行构造函数。

不过,这个办法还是不能保证完美无缺,还存在至少是理论上的缺陷。

3.4.2. 原因分析

双重检查锁定背后的理论是完美的。不幸地是,现实完全不同。双重检查锁定的问题是:并不能保证它会在单处理器或多处理器计算机上顺利运行。

双重检查锁定失败的问题并不归咎于 JVM 中的实现 bug,而是归咎于 Java 平台内存模型。内存模型允许所谓的“无序写入”。

也就是说,不能保证 A, B, C 是按顺序运行的,这个可以Google一下 指令重排,这里不展开了。

说实话,我自己测试了100+次,并没有出现构造函数出现2次或以上的情况。出现这情况的概率很小很小。Java的内存模型很复杂,牵涉到具体的JVM实现。

3.4.3. 修改后的加强版

public class Singleton7 {
    private static volatile Singleton7 s7;
    
    private Singleton7() {
        System.out.println("构造函数被调用");
    }
    
    public static Singleton7 getInstance() {
        if(s7 == null) {
            synchronized (Singleton7.class) {   // A
                if(s7 == null) {                // B
                    s7 = new Singleton7();      // C
                }
            }
        }
        return s7;
    }
}
不过,据说这个也不是特别靠谱,我不去深究了。

看到这里,你是否有种想骂人的冲动?什么鬼,做个单例模式就这么难啊。有没有更方便的办法?有,还不止一种:

3.5. 静态内部类法

public class Singleton5 {

    private static class SingletonHolder{
        private static final Singleton5 INSTANCE = new Singleton5();
    }
    
    private Singleton5() {
        System.out.println("构造函数被调用");
    }
    
    public static final Singleton5 getInstance() {
        return SingletonHolder.INSTANCE;
    }
}

// 测试方法:
import org.junit.Test;

public class Singleton5Test implements Runnable{

    @Override
    public void run() {
        Singleton5.getInstance();
    }
    
    @Test
    public void test() {
        for (int i = 0; i < 10000; i++) {
            Thread t = new Thread(new Singleton4Test(), "T5");
            t.start();
        }
    }
    
}

3.6. 枚举类法

枚举类发是《Effective Java》的作者 Josh Bloch 推荐的一种实现方式,除了具有上述方法的优点的话,还能防止反序列化重新创建新的对象、防止被反射攻击。超级牛叉!

TODO:现在还没有去写利用枚举类法防止反序列化,反射攻击的测试用例。希望以后来填坑。
public enum Singleton6 {
    INSTANCE;
    
    private Singleton6() {
        System.out.println("构造函数被调用");
    }
    
    protected void doSomething() {
        
    }
}

// 测试:
import org.junit.Test;

public class Singleton6Test implements Runnable{
    
    
    @Override
    public void run() {
        Singleton6.INSTANCE.doSomething();
    }

    @Test
    public void test() {
        for (int i = 0; i < 1000; i++) {
            Thread t = new Thread(new Singleton6Test());
            t.start();
        }
    }
}

4. 总结

如果没有什么特别需要,我个人认为还是用饿汉方式算了,简单有效。如果有懒加载要求,就用静态内部类法,也不错。有反序列化要求的,就用枚举类法。


martinwangjun
169 声望0 粉丝

码丁码丁,学艺不精