前面几章介绍的安全发布同步策略的规范还有一致性, 这些安全性都来自于 JMM
16.1 什么是内存模型, 为什么需要它?
假设
a=3
内存模型要解决的问题是: 在什么条件下, 读取 a 的线程可以看到这个值为 3?
如果缺少同步会有很多因素导致无法立即甚至永远看不到一个线程的操作结果, 包括
编译器中指令顺序
变量保存在寄存器而不是内存中
处理器可以乱序或者并行执行指令
缓存可能会改变将写入变量提交到主内存的次序
处理器中也有本地缓存, 对其他处理器不可见
单线程中, 会为了提高速度使用这些技术, 但是 Java 语言规范要求 JVM 在线程中维护一种类似串行的语义: 只要程序的最终结果与在严格环境中的执行结果相同, 那么上述操作都是允许的
随着处理器越来越强大, 编译器也在不断的改进, 通过指令重排序实现优化执行, 使用成熟的全局寄存器分配算法, 但是单处理器存在瓶颈, 转而变为多核, 提高并行性
在多线程环境中, 维护程序的串行性将导致很大的性能开销, 并发程序中的线程, 大多数时间各自为政, 线程之间协调操作只会降低应用程序的运行速度, 不会带来任何好处, 只有当多个线程要共享数据时, 才必须协调他们之间的操作, 并且 JVM 依赖程序通过同步操作找出这些协调操作将何时发生
JMM 规定了 JVM 必须遵循一组最小的保证, 保证规定了对变量的写入操作在何时将对其他线程可见 JMM 需要在各个处理器体系架构中实现一份
16.1.1 平台的内存模型
在共享内存的多处理器体系架构中, 每个处理器拥有自己的缓存, 并且定期的与主内存进行协调在不同的处理器架构中提供了不同级别的缓存一致性 (cache coherence) 其中一部分只提供最小的保证, 即允许不同的处理器在任意时刻从同一个存储位置上看到不同的值操作系统编译器以及 runtime 需要弥补这种硬件能力与线程安全需求之间的差异
要确保每个处理器在任意时刻都知道其他处理器在进行的工作, 这将开销巨大多数情况下, 这完全没必要, 可随意放宽存储一致性, 换取性能的提升存在一些特殊的指令(成为内存栅栏), 当需要共享数据时, 这些指令就能实现额外的存储协调保证为了使 Java 开发人员无须关心不同架构上内存模型之间的差异, 产生了 JMM,JVM 通过在适当的位置上插入内存栅栏来屏蔽 JMM 与底层平台内存模型之间的差异
按照程序的顺序执行, 这种乐观的串行一致性在任何一款现代多处理器架构中都不会提供这种串行一致性当跨线程共享数据时, 会出现一些奇怪的情况, 除非通过使用内存栅栏来防止这种情况的发生
16.1.2 重排序
下面的代码, 4 中输出都是有可能的
- public class ReorderingDemo {
- static int x = 0,
- y = 0,
- a = 0,
- b = 0;
- public static void main(String[] args) throws Exception {
- Bag bag = new HashBag();
- for (int i = 0; i < 10000; i++) {
- x = y = a = b = 0;
- Thread one = new Thread() {
- public void run() {
- a = 1;
- x = b;
- }
- };
- Thread two = new Thread() {
- public void run() {
- b = 1;
- y = a;
- }
- };
- one.start();
- two.start();
- one.join();
- two.join();
- bag.add(x + "_" + y);
- }
- System.out.println(bag.getCount("0_1"));
- System.out.println(bag.getCount("1_0"));
- System.out.println(bag.getCount("1_1"));
- System.out.println(bag.getCount("0_0"));
- // 结果是如下的或者其他情况, 证明可能发生指令重排序
- // 9999
- // 1
- // 0
- // 0
- // 9998
- // 2
- // 0
- // 0
- }
16.1.3 Java 内存模型简介
JMM 通过各种操作来定义, 包括对变量的读写操作, 监视器 monitor 的加锁和释放操作, 以及线程的启动和合并操作, JMM 为程序中所有的操作定义了一个偏序关系, 成为 Happens-before, 要想保证执行操作 B 的线程看到 A 的结果, 那么 A 和 B 之间必须满足 Happens-before 关系如果没有这个关系, JVM 可以任意的重排序
JVM 来定义了 JMM(Java 内存模型)来屏蔽底层平台不同带来的各种同步问题, 使得程序员面向 JAVA 平台预期的结果都是一致的, 对于共享的内存对象的访问保证因果性正是 JMM 存在的理由(这句话说的太好了!!!)
因为没法枚举各种情况, 所以提供工具辅助程序员自定义, 另外一些就是 JMM 提供的通用原则, 叫做 happens-before 原则, 就是如果动作 B 要看到动作 A 的执行结果(无论 A/B 是否在同一个线程里面执行), 那么 A/B 就需要满足 happens-before 关系下面是所有的规则, 满足这些规则是一种特殊的处理措施, 否则就按照上面背景提到的对于可见性顺序性是没有保障的, 会出现意外的情况
如果多线程写入遍历, 没有 happens-before 来排序, 那么会产生 race condition 在正确使用同步的的程序中, 不存在数据竞争, 会表现出串行一致性
(1)同一个线程中的每个 Action 都 happens-before 于出现在其后的任何一个 Action// 控制流, 而非语句
(2)对一个监视器的解锁 happens-before 于每一个后续对同一个监视器的加锁 //lockunlock
(3)对 volatile 字段的写入操作 happens-before 于每一个后续的同一个字段的读操作
(4)Thread.start()的调用会 happens-before 于启动线程里面的动作
(5)Thread 中的所有动作都 happens-before 于其他线程检查到此线程结束或者 Thread.join()中返回或者 Thread.isAlive()==false
(6)一个线程 A 调用另一个另一个线程 B 的 interrupt()都 happens-before 于线程 A 发现 B 被 A 中断 (B 抛出异常或者 A 检测到 B 的 isInterrupted() 或者 interrupted())
(7)一个对象构造函数的结束 happens-before 与该对象的 finalizer 的开始
(8)如果 A 动作 happens-before 于 B 动作, 而 B 动作 happens-before 与 C 动作, 那么 A 动作 happens-before 于 C 动作
16.1.4 借助同步
piggyback(借助)现有的同步机制可见性例如在 AQS 中借助一个 volatile 的 state 变量保证 happens-before 进行排序
举例: Inner class of FutureTask illustrating synchronization piggybacking. (See JDK source)
还可以记住 CountDownLatch,Semaphore,Future,CyclicBarrier 等完成自己的希望
16.2 发布
第三章介绍了如何安全的或者不正确的发布一个对象, 其中介绍的各种技术都依赖 JMM 的保证, 而造成发布不正确的原因就是
发布一个共享对象
另外一个线程访问该对象
之间缺少一种 happens-before 关系
16.2.1 不安全的发布
缺少 happens-before 就会发生重排序, 会造成发布一个引用的时候, 和内部各个 field 初始化重排序, 比如
- init field a
- init field b
发布 ref
init field c
这时候从使用这角度就会看到一个被部分构造的对象
错误的延迟初始化将导致不正确的发布, 如下代码这段代码不光有 race condition 创建低效等问题还存储在另外一个线程会看到部分构造的 Resource 实例引用
- @NotThreadSafe
- public class UnsafeLazyInitialization {
- private static Resource resource;
- public static Resource getInstance() {
- if (resource == null)
- resource = new Resource(); // unsafe publication
- return resource;
- }
- static class Resource {
- }
- }
那么, 除非使用 final, 或者发布操作线程在使用线程开始之前执行, 这些都满足了 happens-before 原则
16.2.2 安全的发布
使用第三章的各种技术可以安全发布对象, 去报发布对象的操作在使用对象的线程开始使用对象的引用之前执行如果 A 将 X 放入 BlockingQueue,B 从队列中获取 X, 那么 B 看到的 X 与 A 放入的 X 相同, 实际上由于使用了锁保护, 实际 B 能看到 A 移交 X 之前所有的操作
16.2.3 安全的初始化模式
有时候需要延迟初始化, 最简单的方法:
- @ThreadSafe
- public class SafeLazyInitialization {
- private static Resource resource;
- public synchronized static Resource getInstance() {
- if (resource == null)
- resource = new Resource();
- return resource;
- }
- static class Resource {
- }
- }
如果 getInstance 调用不频繁, 这绝对是最佳的
在初始化中使用 static 会提供额外的线程安全保证静态初始化是由 JVM 在类的初始化阶段执行, 并且在类被加载后, 在线程使用前的静态初始化期间, 内存写入操作将自动对所有线程可见因此静态初始化对象不需要显示的同步下面的代码叫做 eager initialization
- @ThreadSafe
- public class EagerInitialization {
- private static Resource resource = new Resource();
- public static Resource getResource() {
- return resource;
- }
- static class Resource {
- }
- }
下面是 lazy initializationJVM 推迟 ResourceHolder 的初始化操作, 直到开始使用这个类时才初始化, 并且通过一个 static 来做, 不需要额外的同步
- @ThreadSafe
- public class ResourceFactory {
- private static class ResourceHolder {
- public static Resource resource = new Resource();
- }
- public static Resource getResource() {
- return ResourceFactory.ResourceHolder.resource;
- }
- static class Resource {
- }
- }
16.2.4 双重检查加锁 CDL
DCL 实际是一种糟糕的方式, 是一种 anti-pattern, 它只在 JAVA1.4 时代好用, 因为早期同步的性能开销较大, 但是现在这都不是事了, 已经不建议使用
- @NotThreadSafe
- public class DoubleCheckedLocking {
- private static Resource resource;
- public static Resource getInstance() {
- if (resource == null) {
- synchronized (DoubleCheckedLocking.class) {
- if (resource == null)
- resource = new Resource();
- }
- }
- return resource;
- }
- static class Resource {
- }
- }
初始化 instance 变量的伪代码如下所示:
- memory = allocate(); //1: 分配对象的内存空间
- ctorInstance(memory); //2: 初始化对象
- instance = memory; //3: 设置 instance 指向刚分配的内存地址
之所以会发生上面我说的这种状况, 是因为在一些编译器上存在指令排序, 初始化过程可能被重排成这样:
- memory = allocate(); //1: 分配对象的内存空间
- instance = memory; //3: 设置 instance 指向刚分配的内存地址
- // 注意, 此时对象还没有被初始化!
- ctorInstance(memory); //2: 初始化对象
而 volatile 存在的意义就在于禁止这种重排! 解决办法是声明为 volatile 类型这样就可以用 DCL 了
- @NotThreadSafe
- public class DoubleCheckedLocking {
- private static volatile Resource resource;
- public static Resource getInstance() {
- if (resource == null) {
- synchronized (DoubleCheckedLocking.class) {
- if (resource == null)
- resource = new Resource();
- }
- }
- return resource;
- }
- static class Resource {
- }
- }
16.3 初始化过程中的安全性
final 不会被重排序
下面的 states 因为是 final 的所以可以被安全的发布即使没有 volatile, 没有锁但是, 如果除了构造函数外其他方法也能修改 states 如果类中还有其他非 final 域, 那么其他线程仍然可能看到这些域上不正确的值也导致了构造过程中的 escape
写 final 的重排规则:
JMM 禁止编译器把 final 域的写重排序到构造函数之外
编译器会在 final 域的写之后, 构造函数 return 之前, 插入一个 StoreStore 屏障这个屏障禁止处理器把 final 域的写重排序到构造函数之外也就是说: 写 final 域的重排序规则可以确保: 在对象引用为任意线程可见之前, 对象的 final 域已经被正确初始化过了
读 final 的重排规则:
在一个线程中, 初次读对象引用与初次读该对象包含的 final 域, JMM 禁止处理器重排序这两个操作 (注意, 这个规则仅仅针对处理器) 编译器会在读 final 域操作的前面插入一个 LoadLoad 屏障也就是说: 读 final 域的重排序规则可以确保: 在读一个对象的 final 域之前, 一定会先读包含这个 final 域的对象的引用
如果 final 域是引用类型, 那么增加如下约束:
在构造函数内对一个 final 引用的对象的成员域的写入, 与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量, 这两个操作之间不能重排序(个人觉得基本意思也就是确保在构造函数外把这个被构造对象的引用赋值给一个引用变量之前, final 域已经完全初始化并且赋值给了当前构造对象的成员域, 至于初始化和赋值这两个操作则不确保先后顺序)
- @ThreadSafe public class SafeStates {
- private final Map < String,
- String > states;
- public SafeStates() {
- states = new HashMap < String,
- String > ();
- states.put("alaska", "AK");
- states.put("alabama", "AL");
- /*...*/
- states.put("wyoming", "WY");
- }
- public String getAbbreviation(String s) {
- return states.get(s);
- }
- }
来源: http://www.jianshu.com/p/20c3564a8c57