网站数据库购买,蚌埠网站建设哪家好,仿v电影wordpress,丽水房产网站建设创作内容丰富的干货文章很费心力#xff0c;感谢点过此文章的读者#xff0c;点一个关注鼓励一下作者#xff0c;激励他分享更多的精彩好文#xff0c;谢谢大家#xff01; 双重锁定检查#xff08;Double Checked Locking#xff0c;下称 DCL#xff09;是并发下实现懒…创作内容丰富的干货文章很费心力感谢点过此文章的读者点一个关注鼓励一下作者激励他分享更多的精彩好文谢谢大家 双重锁定检查Double Checked Locking下称 DCL是并发下实现懒加载的一个模式在实现单例模式时很常见但是要正确实现 DCL其中涉及到的细节和知识是非常琐碎的我们这里按照 The Double-Checked Locking is Broken Declaration 文章的脉络结合前几章学习的知识尝试理解这些知识点。
这章属于“骚操作”的内容。
初次尝试
上节中说过 Lazy Initialization我们的目标是在获取某个实例时只初始化一次在单线程语境中我们会这么实现
class Foo {private Helper helper null;public Helper getHelper() {if (helper null)helper new Helper();return helper;}// other functions and members...
}但是我们知道这个版本在多线程下是有问题的因为对 helper 和检查和赋值不是原子的有可能多个线程同时满足了 if (helper null) 的判断最终多个线程都执行了 helper new Helper 的操作。一个简单的方法是加锁
class Foo {private Helper helper null;public synchronized Helper getHelper() {if (helper null)helper new Helper();return helper;}// other functions and members...
}注意代码里的 synchronized。这个代码能正确运行但是效率低下因为 synchronized 是互斥锁后续所有 getHelper 调用都得加锁。于是我们希望在 helper 正确初始化后就不再加锁了尝试如下实现
class Foo {private Helper helper null;public synchronized Helper getHelper() {if (helper null) // ① 第一次检查synchronized(this) { // ② 对 helper 加锁if (helper null) // ③ 同上个实现helper new Helper();}return helper;}// other functions and members...
}代码的初衷是
如果正确初始化后所有的 getHelper ① 的条件失败于是不需要synchronized如果未被正确初始化则同上个实现一样加锁进行初始化。 Unfortunately, that code just does not work in the presence of either optimizing compilers or shared memory multiprocessors. 很可惜这段代码在编译器优化或多核的环境下是“错误”的。在这章中我们会尝试去理解为什么它不正确及为什么一些 bugfix 后依旧不正确。丑话说在前 There is no way to make it work without requiring each thread that accesses the helper object to perform synchronization. 用人话来说就是如果不把 helper 对象设置成 volatile 的这段代码就不可能正确。
指令重排
第一个可能的问题是重排序1。这行代码 helper new Helper(); 看上去是原子从字节码的角度可以理解成下面几个步骤 instance Helper.class.newInstance(); // 1. 分配内存
Helper::constructor(instance); // 2. 调用构造函数初始化对象
helper instance; // 3. 让 helper 指向新的对象前面章节说过JVM 可能会对指令做重排序所做的保证是不影响“单线程”的执行结果那么可能排序成这样 instance Helper.class.newInstance(); // 1. 分配内存
helper instance; // 3. 让 helper 指向新的对象
Helper::constructor(instance); // 3. 调用构造函数初始化对象那么在 #3 执行之前helper 指向的内存地址未被初始化是不安全的。在多线程下可能会变成 --------------- Thread A ---------------------------------- Thread B --------------
if (helper null) |synchronized(this) { |if (helper null) { |instance Helper.class.newInstance();|helper instance; || if (helper null) // false| return helper| // ... do something with helper.Helper::constructor(instance); |} |} |
return helper; |即由于重排helper 指针已经有值了但是还未初始化导致此时线程 B 拿着未初始化的 helper 做了其它的操作这是有风险的。
注意的是即使编译器不做重排序CPU 和缓存也可能会做重排序。
试图挽救重排序
上面的问题我们根本目标是要保证 synchronized 块结束时初始化完成后相应的值才被其它线程看到于是我们可以用下面这个 trick
class Foo {private Helper helper null;public Helper getHelper() {if (helper null) {Helper h; // ① 创建了临时变量synchronized(this) {h helper; // ② 保证读取最新的 helper 值if (h null)synchronized (this) { // ③ 尝试用内部锁解决重排序h new Helper(); // ④ 创建新的实例} // ⑤ 释放了内部的锁helper h; // ⑥ 将新的实例赋值给 helper}}return helper;}// other functions and members...
}这里的想法是想通过 ③ 处的锁来阻止重排序更准确地说是希望在 ⑤ 释放锁的地方能提供内存屏障memory barrier从而保证 h new Helper 一定在 helper h 之前执行。
很可惜这个“希望”现实中不成立。Happens Before 里规定的是 监视器上的 unlock 操作 Happens Before 同一个监视器的 lock 操作 换言之为了保证 unlock Happens Before 其它的 lock 操作JVM 需要保证在锁释放时synchronized 块之前的操作都已经完成并写回到内存里。但是这个规则并没有说 synchronized 块之后的操作不能重排序到synchronized 块之前执行。因此上面这种修改的“美好希望”实际上并不成立2。
此路不通
即使我们真的能保证 helper 在被赋值之前就已经正确初始化了3这种方式就能正确工作了吗不能。
问题不仅仅在于写的一方即使 helper 被正确初始化并赋值由于另一个线程所在的 CPU 可能会从缓存中读取 helper 的值如果 helper 的新值还没有被更新到缓存中则读取的值可能还是 null。
等等不是说 synchronized 会保证可见性吗是的但它保证的是 unlock 操作前的更新对同一个监视器的 lock 操作可见但现在另一个线程根本没有进入 synchronized 代码块此时 JVM 不保证可见。
volatile
经过前面的分析想起了前面章节提到的 volatile 关键字JDK 1.5 后支持有这么一条 Happens Before 规则 volatile 变量规则写入 volatile 变量 Happens Before 读取该变量 它可以提供额外的可见性保证。于是我们可以这么正确实现
class Foo {private volatile Helper helper null; // 注意变量声明了 volatilepublic Helper getHelper() {if (helper null) {synchronized(this) {if (helper null)helper new Helper();}}return helper;}
}这个实现里写入 helper 之前的操作如 Helper 对象的初始化在 helper 被读取如判断 helper null必须可见。换句话说前文讨论的两种情况重排序与可见性问题都由于 volatile 的语义得到保证。
那么 volatile 是不是会降低性能《Java 并发编程实战》第三章的注解里说 在当前大多数处理器架构上读取 volatile 变量的开销只比读取非 volatile 变量的开销略高一点 几个例外
例外不是说 volatile 方式的正确性有例外而是对于一些特殊情形有特殊的解法。
static 单例
对于是 static 的单例最好的初始化方式是利用 Java 类加载机制如下 public class Foo {private static class Holder {private static Helper helper new Helper();}public static Helper getInstance() {return Holder.helper;}
}32 位 primitive
这里的知识点是 32 位的 primitive 类型变量的读写是原子的。如果初始化的方法是幂等的则可以这么实现 class Foo {private int cachedHashCode 0;public int hashCode() {int h cachedHashCode;if (h 0)synchronized(this) {if (cachedHashCode ! 0) return cachedHashCode;h computeHashCode();cachedHashCode h;}return h;}// other functions and members...
}当然如果方法是幂等的甚至都不需要同步 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...
}为什么一定需要 32 位呢因为 64 位的操作不是原子的于是可能造成前后 32 位不是一起写入内存的而另一个线程只读取先写入的 32 位读到的结果不正确。
final
如果前文的 Helper 类是不可变的(immutable)具体地说Helper 的所有属性都是 final 的那么即使不加 volatileDCL 也是正确的。这是因为 JVM 对 final 关键字有一些特殊的语义有兴趣的可以参考 JSL 第 17 章
小结
本章中我们讲解了 The Double-Checked Locking is Broken Declaration 文章中关于 DCL 的各个示例并结合前面章节中学到的 Happens Before 关系的知识去理解 DCL 成立或不成立的原因。
有时候我们会认为写的时候加锁就行了读操作不需要加锁。本节的例子就说明了这种观点不成立会有可见性和顺序性的问题。最简单的解决方式是读操作也加锁如果性能达不到要求也可以像本节一样使用 volatile但我个人不建议这么用因为有太多细节需要考虑可以使用 JUC 中的 ReadWriteLock 来加读写锁。
可以看到要正确地实现并发程序难度是很大的并且要了解很多细节。当然也不必灰心已经有前人为我们辅好了路日常工作中我们只需要跟随前人的脚步就可以满足绝大多数需求。