0%

设计模式学习之-05单例模式

1. 为什么需要单例模式

有很多对象我们只需要拥有一个实例:线程池、缓存、日志对象等。对于这些对象,如果我们有多于一个的实例,可能会造成不正确的程序行为,过多使用系统资源等问题。

单例模式是确保某一个类有且仅有一个实例的惯用方法。单例模式是经过时间检验的设计模式,它也提供了一种全局访问的方法,就像一个全局变量,但是没有全局变量的缺点:如果一个全局变量所关联的对象占用大量的系统资源,那么从程序启动,无论是否使用这个变量都会占用大量的系统资源,单例模式可以保证我们仅在需要它时才创建对象。

2. 经典的单例模式实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Singleton {
private static Singleton uniqueInstance;

// other useful instance variables here

private Singleton() {}

public static Singleton getInstance() {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
return uniqueInstance;
}

//other useful methods here
}

解释:

  1. 使用私有的静态变量uniqueInstance来保存Singleton类的一个实例。
  2. 构造函数Singleton()声明为私有,这样只有它本身可以调用构造函数来创建对象。
  3. getInstance()方法提供了实例化Singleton类的方法,并且返回Singleton的实例。
    1. 只有当变量uniqueInstance为null时才进行对象的创建,否则返回uniqueInstance

3. 单例模式的线程安全

3.1. 非线程安全

我们要保证单例模式在多线程的情况下也只产生一个实例,这里我们以秦始皇为例。

下面的程序不是线程安全的,也就是说在多线程的情况下可能会产生多个实例。当两个线程同时执行到qinShiHuang==null时,两个线程均判断为空,就会在两个线程中都进行实例化。

代码路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class QinShiHuang {
private static QinShiHuang qinShiHuang;

private QinShiHuang() {
System.out.println("秦始皇驾到");
};

public static QinShiHuang getQinShiHuang() {
if (qinShiHuang==null) {
qinShiHuang = new QinShiHuang();
}

return qinShiHuang;
};
}

测试程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class TestSingleton {
public static void main(String[] args) {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
QinShiHuang.getQinShiHuang();
}
});

Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
QinShiHuang.getQinShiHuang();
}
});
t1.start();
t2.start();
}
}

执行结果

1
2
3
4
秦始皇驾到
秦始皇驾到

Process finished with exit code 0

从上面的结果可以看出,秦始皇的实例被创建了两次。

下面我们对其进行线程安全的改进

3.2. 线程安全的第一次尝试

代码路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class QinShiHuangT1 {
private static QinShiHuangT1 qinShiHuang;

private QinShiHuangT1() {
System.out.println("秦始皇驾到");
};

public static QinShiHuangT1 getQinShiHuang() {
if (qinShiHuang==null) {
synchronized (QinShiHuangT1.class) {
qinShiHuang = new QinShiHuangT1();
}
}

return qinShiHuang;
};
}

我们在判断qinShiHuang==null之后为变量的赋值添加一个锁。

这样写有问题吗?是的,当两个线程同时判断qinShiHuang==null后,某一个线程进入临界区,创建了一个实例,之后释放锁,随后另一个线程进入临界区,再次创建一个实例。

测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class TestSingleton {
public static void main(String[] args) {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
QinShiHuangT1.getQinShiHuang();
}
});

Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
QinShiHuangT1.getQinShiHuang();
}
});
t1.start();
t2.start();
}
}

执行结果

1
2
3
4
秦始皇驾到
秦始皇驾到

Process finished with exit code 0

从上面的结果我们可以看到,这种写法仍然不是线程安全的。

3.3. 线程安全的第二次尝试

代码路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class QinShiHuangT2 {
private static QinShiHuangT2 qinShiHuang;

private QinShiHuangT2() {
System.out.println("秦始皇驾到");
};

public static QinShiHuangT2 getQinShiHuang() {
if (qinShiHuang==null) {
synchronized (QinShiHuangT2.class) {
if (qinShiHuang==null) {
qinShiHuang = new QinShiHuangT2();
}
}
}

return qinShiHuang;
};
}

与第一次尝试代码的不同在于,在线程进入临界区后我们又进行了一次判断if (qinShiHuang==null),这样的双重检测机制保证了线程在实例化之前判断有没有其他线程进行了实例化。

但是这样仍然会有线程安全的问题。问题在于指令重排序。关于指令重排序的视频讲解

上面的函数对应的字节码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
 0 getstatic #6 <QinShiHuangT2.qinShiHuang>
3 ifnonnull 37 (+34)
6 ldc #7 <QinShiHuangT2>
8 dup
9 astore_0
10 monitorenter
11 getstatic #6 <QinShiHuangT2.qinShiHuang>
14 ifnonnull 27 (+13)
17 new #7 <QinShiHuangT2>
20 dup
21 invokespecial #8 <QinShiHuangT2.<init>>
24 putstatic #6 <QinShiHuangT2.qinShiHuang>
27 aload_0
28 monitorexit
29 goto 37 (+8)
32 astore_1
33 aload_0
34 monitorexit
35 aload_1
36 athrow
37 getstatic #6 <QinShiHuangT2.qinShiHuang>
40 areturn

synchronized代码块对应的字节码为10-28 行的部分

1
2
3
4
5
6
7
8
9
10 monitorenter
11 getstatic #6 <QinShiHuangT2.qinShiHuang>
14 ifnonnull 27 (+13)
17 new #7 <QinShiHuangT2>
20 dup
21 invokespecial #8 <QinShiHuangT2.<init>>
24 putstatic #6 <QinShiHuangT2.qinShiHuang>
27 aload_0
28 monitorexit

从上面的字节码我们来看这段程序做了什么,首先获取了静态变量[11],然后判断非空[14],然后申请内存[17],然后复制栈顶元素20,然后调用构造函数[21],然后赋值给静态变量[24]。

指令重排序是指:指令重排序

Java语言规范JVM线程内部维持顺序花语义,即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码逻辑顺序不一致,这个过程就叫做指令的重排序。

指令重排序的意义:使指令更加符合CPU的执行特性,最大限度的发挥机器的性能,提高程序的执行效率。

假设21 和 24 被交换了执行顺序,那么静态变量先获得了赋值,此时静态变量不为空,但是却没有完成构造,此时如果有一个线程进入getQinShiHuang()函数,它的判断结果就是qinShiHuang不为空,并且返回这个变量,但是由于没有完成实例化,因此qinShiHuang是个没有数据的变量,另一个线程使用时就会出现问题。因此应该禁止指令重排序,来保证线程安全。

3.4. 线程安全

使用volatile关键字

volatile关键字的作用:

  1. 保证变量对所有线程的可见性
  2. 禁止指令重排序

代码路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class QinShiHuangT2 {
private volatile static QinShiHuangT2 qinShiHuang;

private QinShiHuangT2() {
System.out.println("秦始皇驾到");
};

public static QinShiHuangT2 getQinShiHuang() {
if (qinShiHuang==null) {
synchronized (QinShiHuangT2.class) {
if (qinShiHuang==null) {
qinShiHuang = new QinShiHuangT2();
}
}
}

return qinShiHuang;
};
}

参考文献