单例模式
- 案例1-经典单例
/**
* @author Sunhui
*/
public class Singleton {
private static Singleton instance;
private Singleton (){}
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
案例1 是经典的懒汉式单例实现,但在多线程的情况下,多个线程有可能会同时进入if (singleton == null) ,从而执行了多次singleton = new Singleton(),从而破坏单例。
- 案例2-双重校验锁
public class Singleton {
private static Singleton singleton;
private Singleton() {
}
public static Singleton getInstance() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
案例2 在检测到singleton为null后,会在同步块中再次判断,可以保证同一时间只有一个线程可以初始化单例。但仍然存在问题,原因就是Java中singleton = new Singleton()语句并不是一个原子指令,而是由三步组成,因此高并发下,这里会出现问题,下面会讲。
- 案例3-volatile关键字
public class Singleton {
private static volatile Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
if (singleton == null) {
synchronized (Singleton.class) { //1
if (singleton == null) { //2
singleton = new Singleton(); //3
}
}
}
return singleton;
}
}
案例3 对singleton变量添加了volatile修饰,可以阻止局部指令重排序,并且保证内存一致性。
疑问:为什么 getInstance() 方法内需要使用两个 if (singleton == null) 进行判断呢?
假设高并发下,线程A、B 都通过了第一个 if 条件。若A先抢到锁,new 了一个对象,释放锁,然后线程B再抢到锁,此时如果不做第二个 if判断,B线程将会再 new 一个对象。使用两个 if判断,确保了只有第一次调用单例的时候才会做同步,这样也是线程安全的,同时避免了每次都同步的性能损耗。
执行过程
案例2的执行过程如下:
-
线程A进入 getInstance() 方法。
-
由于 singleton为 null,线程A在 //1 处进入 synchronized 块。
-
线程A被线程B预占。
-
线程B进入 getInstance() 方法。
-
由于 singleton仍旧为 null,线程B试图获取 //1 处的锁。然而,由于线程A已经持有该锁,线程B在 //1 处阻塞。
-
线程B被线程A预占。
-
线程A执行,由于在 //2 处实例仍旧为 null,线程A还创建一个 Singleton 对象并将其引用赋值给 instance。
-
线程A退出 synchronized 块并从 getInstance() 方法返回实例。
-
线程A被线程B预占。
-
线程B获取 //1 处的锁并检查 instance 是否为 null。
-
由于 singleton是非 null 的,并没有创建第二个 Singleton 对象,由线程A所创建的对象被返回。
产生的问题
-
双重检查锁定背后的理论是完美的。不幸地是,现实完全不同。双重检查锁定的问题是:并不能保证它会在单处理器或多处理器计算机上顺利运行。
-
双重检查锁定失败的问题并不归咎于 JVM 中的实现 bug,而是归咎于 Java 平台内存模型。内存模型允许所谓的“无序写入”,这就是导致失败的一个主要原因。
singleton = new Singleton();
该语句非原子操作,实际是三个步骤:
- 给singleton分配内存-堆栈
- 调用 Singleton 的构造函数来初始化成员变量-赋予对象属性以及行为
- 将singleton对象指向分配的内存空间(此时singleton才不为null)
虚拟机的指令重排序:
- 执行命令时虚拟机可能会对以上3个步骤交换位置,顺序可能是1 >> 3 >> 2,分配内存并修改指针后未初始化,多线程获取时可能会出现问题。
重排序后:
- 给singleton分配内存-堆栈
- 将singleton对象指向分配的内存空间(此时singleton才不为null)
- 调用 Singleton 的构造函数来初始化成员变量-赋予对象属性以及行为
当线程A进入同步方法执行singleton = new Singleton();代码时,恰好以上面这个顺序执行,也就是先指向内存空间,再进行初始化,程序就可能出现问题。
分析:线程A初始化对象到一半,也就是执行到第2步时,对象已经创建,不为null,但是还未完全初始化,此时,线程B进来,线程B执行getInstance()方法,执行到第一个if (singleton == null) 时发现已经不是null,就直接将线程A创建的singleton对象返回了,但实际上该对象此时还没有完全初始化,程序会产生问题。
解决
使用volatile关键字,禁止指令重排序,使得 singleton = new Singleton();语句一定会按照上面拆分的步骤1 >> 2 >> 3顺序来执行。
volatile
原理
- 规定线程每次修改变量副本后立刻同步到主内存中,用于保证其它线程可以看到自己对变量的修改
- 规定线程每次使用变量前,先从主内存中刷新最新的值到工作内存,用于保证能看见其它线程对变量修改的最新值
- 为了实现可见性内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来防止指令重排序。
优缺点
优点:
- volitile修饰的成员变量在每次被线程访问时,都强迫从共享内存中重读成员变量的值;当成员变量发生变化时,强迫线程将变化值回写到共享内存;这样可以让每个线程看到某个成员变量的最新值。Volite关键字就是提示VM:对于这个成员变量不能保存它的私有拷贝,而应该直接与共享成员变量交互。
缺点:
- volatile屏蔽掉了JVM必要的代码优化,所以在效率上比较低,所以在必要时再使用此关键字。
注意
-
volatile关键字可以保证直接从主存中读取一个变量,如果这个变量被修改后,总是会被写回到主存中去。JMM 是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此,普通变量与volatile变量的区别是:volatile的特殊规则保证了新值能立即同步到主内存,以及每个线程在每次使用volatile变量前都立即从主内存刷新。因此我们可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。
-
volatile只能保证基本类型变量的内存可见性,对于引用类型,无法保证引用所指向的实际对象内部数据的内存可见性。关于引用变量类型详见:Java的数据类型。
-
volilate只能保证共享对象的可见性,不能保证原子性:假设两个线程同时在做x++,在线程A修改共享变量从0到1的同时,线程B已经正在使用值为0的变量,所以这时候可见性已经无法发挥作用,线程B将其修改为1,所以最后结果是1而不是2。