DomBro Studio

工厂方法引发的思考

2018/07/22

单例工厂方法引发的思考

emmm, 今天被朋友嘲讽了代码。有点小情绪,俗话说知耻而后勇,错了就要认嘛~

我的初衷——单例模式

1
2
3
4
5
6
7
8
9
10
/*** 代码清单 1 不伦不类的工厂方法 ***/
class UserService{

private UserService(){}

public static UserService getInstance(){
return new UserService();
}

}

实话实说,这代码我也没搞懂我当初要干什么。好像是为了当初让编写上层控制层的同学更好的得到 UserService,提供的一个接口。当初的想法应该是提供一个单例的 UserService (很不幸,代码清单 1 一直在创建对象),那代码就应该是代码清单 2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*** 代码清单 2 单线程的工厂方法 ***/
class UserService{

private UserService(){}

private static UserService instance;

public static UserService getInstance(){
if (instance == null){
instance = new UserService();
}
return instance;
}

}

嗯,很美丽。对象只有在需要时被创建(对象的延迟初始化),而且不需要自己手动 new ,降低初始化类的开销。实际上代码清单 2 隐藏着一个大问题——多线程访问

多线程的思考

多线程不安全的延迟初始化

代码清单 3 中,假设两个线程: 线程A 和 线程B 调用 getInstance() 方法,当线程A判断 instance 是否为空,而此时线程 B 虽然执行 instance = new UserService ,但 线程A 可能并没有看到线程B 的初始化操作(即 instance 在 线程A 看来是 null,原因见 tips 1)。 这个时候 线程A 和 线程B 会造出两个对象,线程不安全

1
2
3
4
5
6
7
/*** 代码清单 3 两个线程调用 getInstance***/
public static UserService getInstance(){
if (instance == null){ //线程 A 执行
instance = new UserService(); //线程 B 执行
}
return instance;
}
tips 1

创建一个对象,instance = new Instance() 可以分解为下面三行伪代码:

memory = allocate(); // 1.分配对象的内存空间

ctorInstance(memory); // 2.初始化对象

instance = memory; // 3.设置 instance 指向刚分配的内存地址

所以当 线程 B 创建对象时,有可能还没有到第三步,那么线程 A 读到的 instance 就是 null。

用锁的代价

既然多线程不安全,自然想到使用 sychronized 关键字,让线程同步访问 getInstance()

1
2
3
4
5
6
7
/*** 代码清单 4 加锁同步 ***/
public sychronized static UserService getInstance(){
if (instance == null){ //线程 A 执行
instance = new UserService(); //线程 B 执行
}
return instance;
}

线程当然是安全的,但是当访问线程过多时,会造成性能严重下降。因为即使 instance 已经创建了,线程还是要同步的进行获取。

看起来很美的双重检查锁定

可不可以只有当线程判断 instance == null 时,再上锁呢?

1
2
3
4
5
6
7
8
9
10
11
/*** 代码清单 5 双重检查锁定 ***/
public static UserService getInstance(){
if (instance == null){ //一重检查
sychronized(UserService.class){
if(instance == null){ //双重检查
instance = new UserService();
}
}
}
return instance;
}

看起来很美,一重检查判断 instance == null 时,会同步获取当前类对象的锁,当多个线程试图同时创建对象时,会通过加锁来保证只有一个线程能创建对象。但是,这是一个错误的优化! 代码执行到一重检查,判断 instance != null 时,instance 有可能还没有完成初始化

  • 创建对象时的重排序

tips 1 中,解释了当一个对象创建时的三个步骤,然而这三个步骤可能会被编译器重排序。所谓重排序就是为了使代码运行的更快,编译器,处理器,或者系统内存对程序进行的一种运行时优化。

1
2
3
4
/***代码清单 6 创建对象重排序后的伪代码***/
memory = allocate(); //1.分配对象的内存空间
instance = memory; //2.设置 instance 指向分配的内存地址
ctorInstance(memory); //3.初始化对象

上面的重排序在单线程中是没有任何问题的,反而效率比正常的创建对象顺序更快。 但如果在多线程环境下,问题就很大了。假设 线程A 运行到 instance == null 时,线程 B 运行到 instance = new UserService(),并在实例化过程中发生了重排序,且刚刚执行到重排序的第二步,此时,instance 并没有被初始化,仅仅是一个空的引用。线程A于 是很悲催的返回一个空的引用 。看似线程安全的双重检查,并不是很安全哦,这种实例化过程中的重排序在 JDK 7 是很常见的。

解决方式

针对双重检查锁定的解决方式,解铃换需系铃人。问题在于实例化过程中的重排序,那就针对重排序解决问题。思路有两个:

  1. 禁止实例化过程中的重排序

  2. 允许重排序,但不让其他线程看到这个重排序

禁止重排序 基于 volatile 的解决方案

说道禁止重排序,那就不得不提到并发世界中的另一个关键字 volatile了。

  • volatile 关键字禁止重排序

volatile 关键字的写 永远 happens-before volatile 的读。也就是说当一个线程在读取 volatile 变量时,读取到的永远都是 volatile 变量最新的值。所以当对象的引用为 volatile 时,再多线程环境下的重排序就被禁止了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/*** 代码清单 7 使用 volatile 关键字解决问题***/
class UserService{

private UserService(){}

private volatile static UserService instance;

public static UserService getInstance(){
if (instance == null){
sychronized(UserService.class){
if(instance == null){
instance = new UserService();
}
}
}
return instance;
}
}

代码清单 7 通过 volatile,保证线程安全的延迟初始化对象。

允许重排序,基于 类初始化 的解决方案

  • 类的初始化

JVM 在类初始化阶段(Class 被加载后,且被线程使用之前),会执行类的初始化。执行类的初始化期间,JVM 会获取一个锁,这个锁可以同步多个线程对同一个类的初始化 。———《Java并发编程艺术》

每一个类和接口C,都有一个唯一的初始化锁 LC 与之对应。JVM 在初始化期间会获取这个初始化锁,并且每个线程保证 至少获取一次 锁来确保这个类被初始化。也就是在初始化一个类时会通过这个 类的LC锁 使线程同步的初始化 这个类。初始化一个类,包括执行这个类的静态初始化和初始化在这个类中声明的静态字段

  • 初始化的条件

首次发生下面任意一种情况时,一个类或一个接口T 将立即被初始化:

  1. T 是一个类,而且一个 T 类型的实例被创建。
  2. T 是一个类,且 T 中声明的一个静态方法被调用。
  3. T 中声明的一个静态字段被赋值。
  4. T 中声明的一个静态字段被使用,而且这个字段不是一个常量字段。
  • 原理

由于涉及到线程获取类的 L锁 ,这个过程虽然好理解,但是比较复杂,简单总结一下。当线程触发了类初始化时,会获取这个类对应 Class对象的初始化锁 (LC),这个锁是同步获取的,即只有一个线程可以对类进行初始化。

  1. 若 类C 没有被初始化过,类的初始化状态为 status = noinit, 当线程A初始化该类时 status = initing,表示正在初始化。其他线程被阻塞。

  2. 线程A释放初始化锁,执行 类C 的静态初始化和初始化这个类的静态字段。此时线程B,获取到 类C 的初始化锁,查看初始化状态 status = initing, 释放初始化锁,在初始化锁的 condition 上等待(相当于wait(),进入等待队列)。

  1. 线程A 获取初始化锁(此时已经初始化完毕),更改初始化状态为 status = inited,唤醒所有在锁的 condition 等待的线程。释放锁,线程A 的初始化过程完毕。

  2. 线程B 获取初始化锁,查看初始化状态为 status = inited, 释放锁(因为已经被初始化过了),线程B 的初始化过程完毕。

  3. 其他线程获取到锁,读取到 status = inited 后,释放锁,同样不做初始化行为,完成类的初始化操作。

可以发现,从始至终只有一个线程会真正的执行类的初始化,即类只被初始化一次。回到本节的主题,当一个线程对类初始化时,其他的线程自然是允许在实例化重排序的。

  • 实现
1
2
3
4
5
6
7
8
9
10
11
/*** 代码清单 8 类初始化的解决方案 ***/
public class UserService{
//静态内部类
private static class InstanceHolder{
public static UserService instance = new UserService();
}

public static UserService getInstance(){
return InstanceHolder.instance;
}
}

代码清单 8 中,当线程调用 InstanceHolder.instance; 时,会触发类 InstanceHolder 的初始化, 属于触发类初始化的情况 4。由于 instance 是静态的,所以 instance 也是单例的。

两种方式的比较

字段的延迟初始化降低初始化类或创建实例的开销,但增加了访问被延迟初始化字段的开销。上述两种线程安全的延迟初始化方式,从胆码简介程度上来看 类初始化更加的简便。但是基于 volatile 的双重检查策略的优势是: 不仅可以延迟初始化静态字段,还可以对实例字段延迟初始化。所以得出的结论是: 如果要对静态字段延迟初始化,选用类的初始化方式;如果要对实例字段延迟初始化选用基于 volatile 的双重检查策略。

总结

有时候一个你很难去关注的点,它蕴含的知识点是难以想象的。从简单的单例模式,到对延迟初始化在并发环境下的思考。还真的是学无止境。

CATALOG
  1. 1. 单例工厂方法引发的思考
    1. 1.1. 我的初衷——单例模式
    2. 1.2. 多线程的思考
      1. 1.2.1. 多线程不安全的延迟初始化
        1. 1.2.1.0.1. tips 1
    3. 1.2.2. 用锁的代价
    4. 1.2.3. 看起来很美的双重检查锁定
    5. 1.2.4. 解决方式
    6. 1.2.5. 禁止重排序 基于 volatile 的解决方案
    7. 1.2.6. 允许重排序,基于 类初始化 的解决方案
    8. 1.2.7. 两种方式的比较
  2. 1.3. 总结