设计模式之单例模式

什么是单例模式
    单例对象的类必须保证只有一个实例存在。对单例的实现可以分为2大类:懒汉式和饿汉式,他们区别在于:

  • 懒汉式:指全局的单例实例在第一次被使用时构建。
  • 饿汉式:指全局的单例实例在类装载时构建。
        从区别看,日常使用较多的应该是懒汉式的单例,毕竟按需加载才能做到资源的最大化利用。 懒汉式单例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // version:1.1 最简单版本
    public class Single {
    private static Single instance;
    public static Single getInstance() {
    if(instance == null) {
    instance = new Single();
    }
    return instance;
    }
    }
1
2
3
4
5
6
7
8
9
10
11
// version:1.1.1把构造器改为私有的,这样就能够防止被外部的类调用
public class Single {
private static Single instance;
private Single() {}
public static Single getInstance() {
if(instance == null) {
instance = new Single();
}
return instance;
}
}

    每次获取instance之前先进行判断,如果instance为空就new一个出来,否则就直接返回已存在的instance。这种写法在大多数的时候也是没问题的。问题在于:

1
2
3
4
5
6
7
8
9
10
11
// version:1.2 synchronized版本:可能因为多线程导致问题,那么加上一个同步锁
public class Single {
private static Single instance;
private Single() {}
public static synchronized Single getInstance() {
if(instance == null) {
instance = new Single();
}
return instance;
}
}

    加上synchronized关键字之后,getInstance方法就会锁上了。如果有两个线程(T1,T2)同时执行到这个方法,会有其中一个线程T1获得同步锁,得以继续执行,而另一个线程T2则需要等待,当T1执行完毕getInstance之后(完成了null的判断、对象创建、获得返回值之后),T2线程才会执行。所以这段代码也就避免了因多线程导致多个实例的情况了。但是,这种写法也有一个问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// version:1.3 双重检查(double-check)
public class Single {
private static Single instance;
private Single() {}
public static Single getInstance() {
if(instance == null) {
synchronized (Single.class) {
if(instance == null) {
instance = new Single();
}
}
}
return instance;
}
}

    注意其中有2次if(instance == null)判断,这个叫做双重检查(double-check)。

  • 第一个if(instance == null),其实是为了解决synochronized版本中的效率问题,只有instance为null的时候,才进入synochronized的代码,大大减少了执行的几率。
  • 第二个if(instance == null),则是跟synochronized版本一样,是为了防止可能出现多个实例的情况。
        instance = new Single(),并非是一个原子操作(),事实上在JVM中这句话大概做了下面3件事情:
            ①.给instance分配内存。
            ②.调用Single的构造函数来初始化成员变量,形成实例。
            ③.将instance对象指向分配的内存空间(执行完这步,instance才是非null)
        但是在JVM的即时编译器中存在指令重排序()的优化。也就是说上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是1->2->3,也可能是1->3->2。如果是后者,则在3执行完毕、2未执行之前,被线程T2抢占了,只是instance已经是非null了(但是没有初始化),所以线程T2会直接返回instance,然后使用,然后就报错。也就是说,由于有一个instance已经不为null了但是仍然没有完成初始化的中间状态,而这个时候,如果有其他线程正好运行到第一层if(instance == null)这里,这里读取到的instance已经不为null了,所以直接把这个中间状态的instance拿去用了,就会产生问题。
        这里的关键是:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // version:1.4 volatile版本(最终版):只需给instance的声明加上volatile关键字即可
    public class Single {
    private static volatile Single instance;
    private Single() {}
    public static Single getInstance() {
    if(instance == null) {
    synchronized (Single.class) {
    if(instance == null) {
    instance = new Single();
    }
    }
    }
    return instance;
    }
    }

    volatile关键字的一个作用就是禁止指令重排,把instance声明为volatile之后,对它的写操作就会有一个内存屏障,这样在它的赋值完成之前,就不会调用读操作。注意:
饿汉式单例
    由于类装载的过程是由类加载器(ClassLoader)来执行的,这个过程也是由JVM来保证同步的,所以这种方式有一个先天优势:能够免疫许多由多线程引起的问题。

1
2
3
4
5
6
7
8
// 饿汉式实现
public class Single {
private static final INSTANCE = new Single();
private Single() {}
public static Single getInstance() {
return INSTANCE;
}
}

    由于INSTANCE的初始化是在类加载时进行的,而类的加载是由ClassLoader来做的,所以开发者本来对它初始化的时机就很难去准确把握:

  • 可能由于初始化的太早,造成资源的浪费。
  • 如果初始化本身依赖一些其他数据,那么也就很难保证其他数据会在它初始化之前准备好。
        当然,如果所需的单例占用的资源较少,并且也不依赖于其他数据,那么这种实现方式也是很好的。

名词说明解释

什么是原子操作
    简单来说,原子操作(atomic)就是不可分割的操作,在计算机中,就是指不会因为线程调度而被打断的操作。
    简单的赋值就是一个原子操作: m = 6;
    假如m原先的值为0,那么对于这个操作,要么执行成功m变成6,要么是没执行m还是0,而不会出现诸如m=3这种中间态,即使是在并发过程中。
    而声明并赋值就不是一个原子操作: int n = 6; 这个语句至少有两个操作:
        ①.声明一个变量n。
        ②.给n赋值为6。
    这样就会有一个中间态:变量n已经被声明了,但是还没有被赋值的状态。这样,在多线程中,由于线程执行顺序的不确定性,如果两个线程都是用n,就可能导致不稳定的结果出现。
什么是指令重排
    简单来说,就是计算机为了提高执行效率,会做得一些优化,在不影响最终结果的情况下,可能会对一些语句的执行顺序进行调整。比如:

1
2
3
4
int a; // 语句1
a = 8; // 语句2
int b = 9; // 语句3
int c = a + b; // 语句4

    正常来说,对于顺序结构,执行的顺序是自上而下,也就是1234。但是由于指令重排的原因,因为不影响最终的结果,所以,实际执行的顺序可能是3124或者1324。由于语句3和语句4没有原子性的问题,语句3和语句4也可能会拆分成原子操作,再重排。
    也就是说,对于非原子性的操作,在不影响最终结果的情况下,其拆分成的原子操作可能会被重新排列执行顺序。
什么时候是类加载时
    有这么几个条件会触发一个类被加载:
        ①.new一个对象时。
        ②.使用反射创建它的实例时。
        ③.子类被加载时,如果父类还没有被加载,就先加载父类。
        ④.JVM启动时执行的主类会首先被加载。