《深入理解 Java 内存模型》读书笔记(下)(干货,万字长文)

  • 0. 前提

  • 1. 基础

  • 2. 重排序

  • 3. 顺序一致性

  • 4. Volatile

  • 5. 锁

  • 6. final

  • 7. 总结

4. Volatile

4.1 VOLATILE 特性

举个例子:

public class VolatileTest {
    volatile long a = 1L;         // 使用 volatile 声明 64 位的 long 型

    public void set(long l) {
        a = l;                  //单个 volatile 变量的写
    }

    public long get() {
        return a;               //单个 volatile 变量的读
    }

    public void getAndIncreament() {
        a++;                    // 复合(多个) volatile 变量的读 /写
    }
}

假设有多个线程分别调用上面程序的三个方法,这个程序在语义上和下面程序等价:

public class VolatileTest {
    long a = 1L;                 // 64 位的 long 型普通变量

    public synchronized void set(long l) {    //对单个普通变量的写用同一个锁同步
        a = l;                
    }

    public synchronized long get() {        //对单个普通变量的读用同一个锁同步
        return a;           
    }

    public void getAndIncreament() {        //普通方法调用
          long temp = get();                  //调用已同步的读方法
        temp += 1L;                            //普通写操作                         
          set(temp);                          //调用已同步的写方法
    }
}

如上面示例程序所示,对一个 volatile 变量的单个读/写操作,与对一个普通变量的读/写操作使用同一个锁来同步,它们之间的执行效果相同。

锁的 happens-before 规则保证释放锁和获取锁的两个线程之间的内存可见性,这意味着对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入

锁的语义决定了临界区代码的执行具有原子性。这意味着即使是 64 位的 long 型和 double 型变量,只要它是 volatile变量,对该变量的读写就将具有原子性。如果是多个 volatile 操作或类似于 volatile++ 这种复合操作,这些操作整体上不具有原子性

简而言之,volatile 变量自身具有下列特性:

  • 可见性。对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入。

  • 原子性:对任意单个 volatile 变量的读/写具有原子性,但类似于 volatile++ 这种复合操作不具有原子性。

4.2 VOLATILE 写-读的内存定义

  • 一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。

  • 一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

假设上面的程序 flag 变量用 volatile 修饰

《《深入理解 Java 内存模型》读书笔记(下)(干货,万字长文)》

《《深入理解 Java 内存模型》读书笔记(下)(干货,万字长文)》 volatile1

4.3 VOLATILE 内存语义的实现

下面是 JMM 针对编译器制定的 volatile 重排序规则表:

《《深入理解 Java 内存模型》读书笔记(下)(干货,万字长文)》

《《深入理解 Java 内存模型》读书笔记(下)(干货,万字长文)》 重排序规则表

为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

下面是基于保守策略的 JMM 内存屏障插入策略:

  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。

  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。

  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。

  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。

下面是保守策略下,volatile 写操作 插入内存屏障后生成的指令序列示意图:

《《深入理解 Java 内存模型》读书笔记(下)(干货,万字长文)》

《《深入理解 Java 内存模型》读书笔记(下)(干货,万字长文)》 volatile3

下面是在保守策略下,volatile 读操作 插入内存屏障后生成的指令序列示意图:

《《深入理解 Java 内存模型》读书笔记(下)(干货,万字长文)》 volatile4

上述 volatile 写操作和 volatile 读操作的内存屏障插入策略非常保守。在实际执行时,只要不改变 volatile 写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。

5.1 锁

5.2 锁释放和获取的内存语义

当线程释放锁时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存中。

当线程获取锁时,JMM 会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须要从主内存中去读取共享变量。

5.3 锁内存语义的实现

借助 ReentrantLock 来讲解,PS: 后面专门讲下这块(ReentrantLock、Synchronized、公平锁、非公平锁、AQS等),可以看看大明哥的博客:http://cmsblogs.com/?p=2210

5.4 CONCURRENT 包的实现

如果我们仔细分析 concurrent 包的源代码实现,会发现一个通用化的实现模式:

  1. 首先,声明共享变量为 volatile;

  2. 然后,使用 CAS 的原子条件更新来实现线程之间的同步;

  3. 同时,配合以 volatile 的读/写和 CAS 所具有的 volatile 读和写的内存语义来实现线程之间的通信。

AQS,非阻塞数据结构和原子变量类(java.util.concurrent.atomic 包中的类),这些 concurrent 包中的基础类都是使用这种模式来实现的,而 concurrent 包中的高层类又是依赖于这些基础类来实现的。从整体来看,concurrent 包的实现示意图如下:

《《深入理解 Java 内存模型》读书笔记(下)(干货,万字长文)》 concurrent 包

6. final

对于 final 域,编译器和处理器要遵守两个重排序规则:

  1. 在构造函数内对一个 final 域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

  2. 初次读一个包含 final 域的对象的引用,与随后初次读这个 final 域,这两个操作之间不能重排序。

6.1 写 FINAL 域的重排序规则

写 final 域的重排序规则禁止把 final 域的写重排序到构造函数之外。这个规则的实现包含下面2个方面:

  • JMM 禁止编译器把 final 域的写重排序到构造函数之外。

  • 编译器会在 final 域的写之后,构造函数 return 之前,插入一个 StoreStore 屏障。这个屏障禁止处理器把 final 域的写重排序到构造函数之外。

6.2 读 FINAL 域的重排序规则

在一个线程中,初次读对象引用与初次读该对象包含的 final 域,JMM 禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读 final 域操作的前面插入一个 LoadLoad 屏障。

6.3 FINAL 域是引用类型

对于引用类型,写 final 域的重排序规则对编译器和处理器增加了如下约束:

在构造函数内对一个 final 引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

7. 总结

7.1 JMM,处理器内存模型与顺序一致性内存模型之间的关系

JMM 是一个语言级的内存模型,处理器内存模型是硬件级的内存模型,顺序一致性内存模型是一个理论参考模型。下面是语言内存模型,处理器内存模型和顺序一致性内存模型的强弱对比示意图:

《《深入理解 Java 内存模型》读书笔记(下)(干货,万字长文)》 内存模型比较

7.2 JMM 的设计示意图

《《深入理解 Java 内存模型》读书笔记(下)(干货,万字长文)》 jmm

7.3 JMM 的内存可见性保证

Java 程序的内存可见性保证按程序类型可以分为下列三类:

1.单线程程序。单线程程序不会出现内存可见性问题。编译器,runtime 和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。

2.正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。这是 JMM 关注的重点,JMM通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。

3.未同步/未正确同步的多线程程序。JMM 为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false)。

下图展示了这三类程序在 JMM 中与在顺序一致性内存模型中的执行结果的异同:

《《深入理解 Java 内存模型》读书笔记(下)(干货,万字长文)》

    原文作者:java内存模型
    原文地址: https://blog.csdn.net/zl1zl2zl3/article/details/83069358
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞