您的位置:首页 > 编程语言 > Java开发

Java双重检查加锁单例模式的详解

2019-03-31 21:36 1246 查看

什么是DCL

DCL(Double-checked locking)被设计成支持延迟加载,当一个对象直到真正需要时才实例化:

class SomeClass {
private Resource resource = null;
public Resource getResource() {
if (resource == null)
resource = new Resource();
return resource;
}
}

为什么需要推迟初始化?可能创建对象是一个昂贵的操作,有时在已知的运行中可能根本就不会去调用它,这种情况下能避免创建一个不需要的对象。延迟初始化能让程序启动更快。但是在多线程环境下,可能会被初始化两次,所以需要把getResource()方法声明为synchronized。不幸的是,synchronized方法比非synchronized方法慢100倍左右,延迟初始化的初衷是为了提高效率,但是加上synchronized后,提高了启动速度,却大幅下降了执行时速度,这看起来并不是一桩好买卖。DCL看起来是最好的:

class SomeClass {
private Resource resource = null;
public Resource getResource() {
if (resource == null) {
synchronized(this) {
if (resource == null)
resource = new Resource();
}
}
return resource;
}
}

延迟了初始化,又避免了竞态条件。看起来是一个聪明的优化--但它却不能保证正常工作。为提高计算机系统性能,编译器、处理器、缓存会对程序指令和数据进行重排序,而对象初始化操作并不是一个原子操作(可能会被重排序);因此可能存在这种情况:一个线程正在构造对象过程中,另一个线程检查时看见了resource的引用为非null。对象被非安全发布(逸出)。

根据Java内存模型,synchronized的语义不仅仅是在同一个信号上的互斥(mutex),也包含线程和主存之间数据交互的同步,它确保在多处理器、多线程下对内存能有可预见的一致性视图。获取或释放锁会触发一次内存屏障(memory barrier)--强迫线程本地内存和主存同步。当一个线程退出一个synchronized block时,触发一次写屏障(write barrier )--在释放锁前必须把所有在这个同步块里修改过的变量值刷新到主存;同样,进入一个synchronized block时,触发一次读屏障(read barrier)--让本地内存失效,必须从主存中重新获取在这个同步块中将要引用的所有变量的值。正确使用同步能保证一个线程能以可预见的方式看到另一个线程的结果,线程对同步块的操作就像是原子的。“正确使用”的含义是:必须是在同一个锁上同步。

DCL是怎么失效的

了解了JMM后,再来看看DCL是怎么失效的。DCL依赖于一个非同步的resource字段,看起来无害,实则不然。假如线程A进入了synchronized block,正在执行resource = new Resource();此时线程B进入 getResource()。考虑到对象初始化在内存上的影响:为new对象分配内存;调用构造方法,初始化对象的成员变量;把新创建好对象的引用赋值给SomeClass的resource字段。然而线程B没有进入synchronized block,却可能以不同于线程A执行的顺序看到上述内存操作。B看到的可能是如下顺序(指令重排序):分配内存,把对象引用赋值给SomeClass的resource字段,调用构造器。当内存已经分配好,A线程把SomeClass的resource字段设值完成后,线程B进入检查发现resource不是null,跳过synchronized block返回一个未构造完成的对象!显而易见,结果不是预期的也不是想要的。

下面代码是一个试图修复DCL的加强版,遗憾的是它仍然不能保证正常工作。

// (Still) Broken multithreaded version
// "Double-Checked Locking" idiom
class Foo {
private Helper helper = null;
public Helper getHelper() {
if (helper == null) {
Helper h;
synchronized (this) {
h = helper;
if (h == null)
synchronized (this) {
h = new Helper();
} // release inner synchronization lock
helper = h;
}
}
return helper;
}
// other functions and members...
}

这段代码把Helper对象的构造放在一个内部的同步块,又用了一个局部变量h来先接收初始化完成后的引用,直觉就是当这个内部的同步块退出时,应该会触发一次内存屏障,能阻止对初始化Helper对象和给Foo的helper字段赋值的两个操作重排序。不幸的是,直觉是完全错误的,对同步规则理解得不对。对于monitorexit规则(即,释放同步),监视器被释放之前必须执行monitorexit之前的动作。然而,没有规定说monitorexit后的操作,不能在监视器释放前执行。编译器把赋值语句helper = h;移动到内部同步块之前是完全合理合法的,在这种情况下,我们又重新回到了以前。许多处理器提供执行这种单向内存屏障指令。改变语义要求释放锁是一个完整的内存屏障会有性能损失。然而即使初始化时有一个完整的内存屏障,也不能保证,在一些系统上,保证线程能看到helper的属性字段的值为非null也需要同样的内存屏障。因为处理器有自己的本地缓存拷贝,某些处理器在执行缓存一致性指令前,即使其他的处理器使用内存屏障强制把最新值写入主存,该处理器读到的还是本地缓存拷贝的旧值。

关于重排序(reorder)有3种来源:编译器、处理器、内存系统。承诺“write-once, run-anywhere concurrent applications in Java” 的Java是接受处理器和内存系统为优化而重排序的,所以DCL单例模式没有完美的解决方案,在多线程下编程要异常小心。下面讨论多线程环境下单例模式的实现。

多线程环境下单例的实现

第一种,同步方法(synchronized)

优点:所有情况下都能正常工作,延迟初始化;

缺点:同步严重损耗了性能,因为只有第一次实例化时才需要同步。

不推荐,绝大部分情况是没必要延迟初始化的,不如采用急切实例化(eager initialization)

// Correct multithreaded version
class Foo {
private Helper helper = null;
public synchronized Helper getHelper() {
if (helper == null)
helper = new Helper();
return helper;
}
// other functions and members...
}

第二种,使用IODH(Initialization On Demand Holder)

利用static块做初始化,如下定义一个私有的静态类去做初始化,或者直接在静态块代码中去做初始化,能保证对象被正确构造前对所有线程不可见。

class Foo {
private static class HelperSingleton {
public static Helper singleton = new Helper();
}
public Helper getHelper() {
return HelperSingleton.singleton;
}
// other functions and members...
}

第三种,急切实例化(eager initialization)

class Foo {
public static final Helper singleton = new Helper();
// other functions and members...
}
class Foo {
private static final Helper singleton = new Helper();
public Helper getHelper() {
return singleton;
}
// other functions and members...
}

第四种,枚举单例

public enum SingletonClass {
INSTANCE;
// other functions...
}

上面4种方式在所有情况下都能保证正常工作

第五种,只对32位基本类型的值有效

缺陷:对64位的long和double及引用对象无效,因为64位的基本类型的赋值操作不是原子的。利用场景有限。

// Lazy initialization 32-bit primitives
// Thread-safe if computeHashCode is idempotent
class Foo {
private int cachedHashCode = 0;
public int hashCode() {
int h = cachedHashCode;
if (h == 0) {
h = computeHashCode();
cachedHashCode = h;
}
return h;
}
// other functions and members...
}

第六种,DCL加上volatile语义

旧内存模型(在JDK1.5发行之前)下失效,只能在JDK1.5后使用。

另外不推荐次方法,多核处理器下线程每次写volatile字段都会把工作内存及时刷新到主存,每次读都会从主存获取数据,因为要和主存交换数据,volatile的频繁读写会占用数据总线资源。

// Works with acquire/release semantics for volatile
// Broken under current semantics for volatile
class Foo {
private volatile Helper helper = null;
public Helper getHelper() {
Helper h = helper;
if (helper == null) {// First check (no locking)
synchronized (this) {
h = helper;
if (helper == null)
helper = h = new Helper();
}
}
return helper;
}
}

第七种,不可变对象的单例

对于不可变对象(immutable object)本身是线程安全的,不需要同步,单例实现起来最简单。比如Helper是一个不可变类型,只用用final修饰singleton字段就行:

class Foo {
private final Helper singleton = new Helper();
public Helper getHelper() {
return singleton;
}
// other functions and members...
}

缺陷:旧内存模型(在JDK1.5发行之前)下失效,只能在JDK1.5后使用,因为新内存模型对final和volatile语义进行了加强。还有一个问题就是明确什么是不可变对象,如果对不可变对象含义不确定,请不要使用,另外当前是不可变对象不能保证将来此类一直是不可变对象(代码总是在不断修改),慎用!

需要使用单例时,慎用延迟初始化,优先考虑急切实例化(简单优雅,不易出错)

总结

以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,谢谢大家对脚本之家的支持。如果你想了解更多相关内容请查看下面相关链接

您可能感兴趣的文章:

内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: