复盘
大概半年前第一次接触到单例的概念,当时可以去了解了一下懒汉式单例,但当时的功力实在无法理解为什么要双重检查,也不明白为什么要加synchronize和volatile。最近翻阅Effactive Java时其中再次提到了懒汉式单例,回过头来理解,确实是精妙无比,暗藏玄机。
什么是单例
- 定义
单例实际上是一种23个常用设计模式中的一种,单例的作用只有一个:让一个类的实例从系统运行开始到结束,在同一时间只有一个。单例的思想广泛应用于各种语言、框架,尤其对于Java,不管学习Spring还是其他框架单例都是绕不过的话题。 - 目的
这样做的目的只有一个:将逻辑具有独立性的类做成单例,缓存起来,从而使这个类的实例能够全局复用,避免重复创建与销毁带来的资源开销,减少同一时间内的内存占用。 - 实现
单例的实现方式有很多,可以是枚举,也可以是懒汉式,或者通过一些代码逻辑进行缓存控制都能做到,并不是说一定要按照某一个代码逻辑组织起来的才是单例,只要满足单例的的定义,都是单例。
样例
枚举类型的单例是Java API所实现的,由于枚举的定义是,不同的枚举全局只存在一个,所以在构造一个枚举对象时Java API会检查,如果已经存在了就会抛出一个异常,具体源码可以自行搜索。
懒汉式单例的实现方式是极其具有学习意义的,下面是一个标准的懒汉式单例,以此为例对为什么要双重检查,为什么要synchronize,为什么要volatile进行说明。
import org.springframework.data.repository.query.ParameterOutOfBoundsException;
public class Singleton {
private static volatile Singleton singleton;
private Singleton() {
}
public static Singleton getInstance() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
public void singletonAction() {
System.out.println("这是一个单例");
}
}
为什么双重检查
- 原因
第一次检查针对的是单例实例化完成以后的操作。当调用 getInstance() 时,若已完成初始化则直接返回,如果没有第一次检查,那么每次调用 getInstance() 都会加锁(就算已经完成了初始化),引起不必要的开销。第二次检查针对的是初始化时的操作。可能有多个线程同时通过第一次判断,第一个进入同步块的线程应完成初始化操作,后面的线程应避免重复初始化。如果没有第二次检查,那么及时初始化操作是置于同步块中的,但仍然会对 singleton 进行多次初始化,引起不必要的开销。
为什么加synchronize
- 原因:
在对单例的目的描述中说了,单例在全同一时空下只有一个,那么在并发环境下,就必须保证执行 new Singleton() 的线程只有一个,而 new 关键字构造对象是通过类模板反射来构造的,所以 syschronize(Singleton.class),使同时访问类模板的线程只有一个。
为什么synchronize不放在方法上
- 原因
synchronize 是需要资源消耗的,对于 synchronize 的使用,理论上应该保证它的影响范围最小化,当单例已经构造完成之后,调用 getInstance() 的对象会在第一次检查就被拦截,不会出现线程不安全的情况。如果用 synchronize 修饰方法,那么无论单例是否构造完成,一旦被调用都会上锁,这笔开销是没必要的。
为什么加volatile
- 原因 1:多线程下的内存可见性问题
虽然通过synchronize保证了Singleton的全局唯一,但Singleton的使用仍然是并发的。问题在于,实际上同一个变量在不同线程之中并不是进程内存中的那个变量,而是将它拷贝到了线程自己的局部内存中,换句话说,不同线程对同一个全局变量的操作彼此是不知道的。样例中的Singleton是一个简单的例子,但在具体的场景中,你可能希望它具有一些其他属性,比如一个整型 i 。那么问题就来了,如果在你的使用逻辑中对 i 进行了 ++ 或者 - -,那将会导致类似“幻读”的现象,因为并发环境下,可能存在其他线程正同时对 i 进行相同的操作,这就导致出现你逻辑之外的结果。 - 解决方式
- 方式1,加synchronize:
给共享变量加锁可以解决该问题。因为在线程获得锁后会清空线程的工作内存,从主内存中获取最新的值在锁的代码块间只有单线程对变量进行操作,在锁结束后又会将工作内存中的变量值刷新到主内存,因此可以解决。该方式将保证线程操作的原子性(因为在锁代码块中,工作线程是单一的),但消耗更大。 - 方式2,加volatile:
用volatile修饰共享变量可以解决该问题。volatile通过总线嗅探机制解决。各线程要操作线程时都会从主内存中拷贝新的变量,并且当对变量副本进行操作后,写回主内存。通过CPU总线嗅探机制告知其他线程所持有的变量副本已失效,需要从主内存获取最新变量值。该方式将不会保证线程操作的原子性,也就是说,会出现“脏读”、“不可重复读”的情况,是线程不安全的,但消耗更小。总线嗅探机制是最底层的实现,我还不了解。
- 方式1,加synchronize:
- 原因 2:编译器编译时可能进行优化性指令重排序,导致单例不完整
singleton = new Singleton() 语句并不是一个原子性的语句,new 关键字中间经过了三个过程,分别是分配内存空间、初始化实例、返回this引用(如果是有参构造中间还有一步填充属性),其中任意一个过程的失败并不会导致回滚。其中,除了初始化实例和返回this引用两个步骤彼此之间是不存在数据性依赖的,在编译过程中,编译器可能会为了优化性能进行指令优化重排序,从而导致这一段空间尚未完成初始化就被返回,得到一个不完整的singleton,因此需要volatile禁止指令重排序。