单例模式

前言

单例模式算是设计模式中比较好理解,也比较重要的一种设计模式。
单例模式属于创造型的设计模式。主要用于类的实例化。
很多资料吧单例模式分为懒汉式,饿汉式.. 等等各种变化,这里可以已这为基础举一些简单的例子,好理解

单例模式主要我们可以分为三步

  1. 私有构造函数
  2. 初始化.可以提前初始化,可以在使用需要时初始化。
  3. 开放一个方法来获取唯一实例

优点:

  1. 一个类只有一个实例,减少了内存的开销,尤其是频繁的创建和销毁实例
  2. 避免对共享资源的多重占用

缺点:

  1. 不适用于变化的对象,如果同一类型的对象总是要在不同的用例场景发生变化,单例就会引起数据的错误,不能保存彼此的状态。
  2. 由于单利模式中没有抽象层,因此单例类的扩展有很大的困难。
  3. 单例类的职责过重,在一定程度上违背了“单一职责原则”。

饿汉式

什么叫饿汉式,其实就是提前加载。看代码

1
2
3
4
5
6
7
8
9
10
class A{
// 提前初始化
private final static A a = new A();
// 私有构造函数
private A(){}
// 开放一个方法获取唯一实例
public static A getInstance(){
return a;
}
}

饿汉式是怎么做的?很简单,就是初始化的时候是提前初始化。这里初始化我只一个简单的new了一个A对象,实际中如果A对象比较重,可以写在静态代码块中。

这种写法是天然支持并发操作的。因为在类装载时便完成了初始化操作。实际就是在调用getInstance之前完成了A的初始化操作。所以getInstance实际已经没有了并发的危险

懒汉式

什么叫懒汉式,其实就是懒加载。看代码

1
2
3
4
5
6
7
8
9
10
11
12
13
class A{
private static A a = null;
// 私有构造函数
private A(){}
// 开放一个接口获取实例
public static A getInstance(){
// 初始化
if(a == null){
a = new A();
}
return a;
}
}

上面的代码,讲初始化的操作搬到了getInstance中了。在单线程中可以使用,但是如果涉及到并发,可能就不行了。
因为很简单 a==null 这一步,在并发的时候,单线程1进入a==null,还没来得及初始化a。可能其他线程也进入a==null的判断。所以这不是线程安全的。

怎么改进?

最简单的方法加锁

1
2
3
4
5
6
7
8
9
class A{
private static A a = null;
private A(){}
// synchronized 加锁
public synchronized static A getInstance(){
if(a == null) a = new A();
return a;
}
}

这种synchronized加法比较简单,但是效率太差。比如100个线程要获取a,实际上a已经初始化好了。那么为什么还有100个线程去抢锁了?

改进一下,缩小锁的颗粒度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class A{
private static A a = null;
private A(){}

public static A getInstance(){
if(a == null){
// 标识1
synchronized(A.class){
a = new A();
}
}
return a;
}
}

这种锁的颗粒度是小了,但是问题在于标记1的位置,很可能一次性进入很多个线程,实际上导致刚开始每个线程拿到的都是不同的对象。

再改进一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class A{
private static A a = null;
private A(){}

public static A getInstance() {
if (a == null) {
synchronized (A.class) {
if(a == null){
a = new A();
}
}
}
return a;
}
}

这就是我们常见的加锁时使用的双重检测,即可以保证性能,又可以保证稳定性,这中代码习惯很多地方都会使用。

但是其实上诉代码可不是完全可靠的。主要是因为指令重排序的问题。
首先我们先了解一下a=new A()这句代码实际做了什么。

  1. 分配一块内存 M
  2. 在内存M上初始化A的数据
  3. 将M的内存地址给a

实际上a == null是针对第3步。但是由于指令重排序,在实际情况下,可以是123,可以是132 毕竟在单线程中都不影响。
来讲一下实际情况。
线程A调用getInstance方法,进入了a=new A(),然后按132的步骤初始化a,初始化到2的时候。线程B调用getInstance方法。发现a!=null(此时A虽然不等于null,但是a还没有初始化数据,直接使用可能出现异常)。直接返回了一个还没有初始化好的a对象。这样就会造成线程B拿到的a是有问题的。

怎么解决这个问题?

通过添加volatile关键字来解决,volatile只要是保证a的可见性,同时也禁止指令重排序。所有123步骤是永远按123步骤走的。
实际上volatile经常和双重检测一起使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class A{
private static volatile A a = null;
private A(){}

public static A getInstance() {
if (a == null) {
synchronized (A.class) {
if(a == null){
a = new A();
}
}
}
return a;
}
}

其他

单例模式除了以上这些写法还可以通过内部类和枚举来使用,利用java的类加载来保证其安全性,也是推荐使用的

1
2
3
4
5
6
7
8
9
10
class A {
private A() { }
// 子类B
private static class B {
static A a = new A();
}
public static A getInstance() {
return B.a;
}
}

总结

实际代码中用什么可以自己去考虑一下,比如有些类在项目中是肯定使用的,可以直接使用饿汉式,有些不确定使用性,可以考虑使用饿汉式,双重检测和内部类使用都可以。