JUC-内存

125 阅读12分钟

一、Java 内存模型

JMM 即 Java Memory Model,它定义了主存工作内存抽象概念,底层对应着 cpu 寄存器、缓存、硬件内存、cpu 指令优化等。

  • JMM 体现在以下几个方面:

    • 原子性 - 保证指令不会受到线程上下文切换的影响。
    • 可见性 - 保证指令不会受 cpu 缓存的影响。
    • 有序性 - 保证指令不会受 cpu 指令并行优化的影响。

二、可见性

2.1 示例及说明

演示:main 线程对 flag 变量的修改对于 t1 线程不可见,导致了 t1 线程无法停止。

  • 代码示例
 public class VisibilitySample {
 ​
     private static boolean flag = true;
 ​
     public static void main(String[] args) throws InterruptedException {
 ​
         new Thread(() -> {
             while (flag) {
                 // ignore...
             }
         }, "t1").start();
 ​
         TimeUnit.SECONDS.sleep(1);
         // 重新赋值成员变量。
         flag = false;
 ​
         // 一直运行...
     }
 }
  • 示意图

  • 分析说明

    1. 初始状态, t1 线程刚开始从主内存读取了 flag 的值到工作内存。
    2. 因为 t1 线程要频繁从主内存中读取 flag 的值,JIT 编译器会将 flag 的值缓存至自己工作内存中的高速缓存中,减少对主存中 flag 的访问,提高效率
    3. 等待1秒后,main 线程修改了 flag 的值,并同步至主存,而 t1 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值
  • 解决方式:使用 volatile(易变关键字),它可以用来修饰成员变量和静态成员变量,它可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存

 // 使用 volatile 修饰后,变量操作对其他线程可见。
 private static volatile boolean flag = true;

2.2 可见性与原子性

  • 前面例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一个线程可见, 不能保证原子性,仅用在一个写线程,多个读线程的情况
  • 注意synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是 synchronized 是属于重量级操作,性能相对更低

三、CPU 缓存结构原理

3.1 缓存结构

  • 示意图

  • 查看 cpu 缓存

  • cpu 取数据过程:当 cpu 需要访问内存中某个数据的时候,如果寄存器有这个数据,cpu 就直接从寄存器取数据即可,如果寄存器没有这个数据,cpu 就会查询 L1 高速缓存,如果 L1 没有,则查询 L2 高速缓存,L2 还是没有的话就查询 L3 高速缓存,L3 依然没有的话,才去内存中取数据。
  • 速度比较
从 cpu 到大约需要的时钟周期
寄存器1 cycle
L13~4 cycle
L210~20 cycle
L340~45 cycle
内存120~240 cycle

3.2 内存屏障

内存屏障(Memory Barrier / Memory Fence):也称内存栅栏,内存栅障,屏障指令等, 是一类同步屏障指令,是 cpu 或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。

  • 示意图

  • 可见性

    • 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中。
    • 而读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据。
  • 有序性

    • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后。
    • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前。

四、Balking 同步模式

Balking (犹豫)模式:用在一个线程发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做了,直接结束返回。

  • 代码示例
 @Slf4j
 public class BalkingSample {
 ​
     private volatile boolean runnning;
 ​
     public void start() {
 ​
         log.debug("try start monitor-thread");
 ​
         synchronized (this) {
             if (runnning) {
                 return;
             }
             runnning = true;
         }
 ​
         log.debug("thread is start? ({})", runnning);
         log.debug("{} doSomething...", Thread.currentThread().getName());
     }
 ​
     public static void main(String[] args) {
 ​
         BalkingSample balkingSample = new BalkingSample();
 ​
         new Thread(balkingSample::start, "t1").start();
         new Thread(balkingSample::start, "t2").start();
         // [t2] try start monitor-thread
         // [t1] try start monitor-thread
         // [t2] thread is start? (true)
         // [t2] t2 doSomething...
     }
 }
  • 它还经常用来实现线程安全的单例:
 public class Singleton {
 ​
     private Singleton() {
     }
 ​
     private static Singleton INSTANCE = null;
 ​
     public static synchronized Singleton getInstance() {
         
         if (null != INSTANCE) {
             return INSTANCE;
         }
         
         INSTANCE = new Singleton();
         return INSTANCE;
     }
 }

五、有序性

JVM 会在不影响正确性的前提下,可以调整语句的执行顺序,这种特性称之为指令重排

5.1 示例及说明

  • 代码示例
 static int i;
 static int j;
 ​
 // 在某个线程内执行如下赋值操作
 i = ...; 
 j = ...;
  • 说明:可以看到,至于是先执行 i 还是 先执行 j ,对最终的结果不会产生影响。但是,多线程下指令重排会影响正确性。

5.2 指令级并行

  • Clock Cycle Time:(时钟周期时间),等于主频的倒数,意思是 CPU 能够识别的最小时间单位,比如说 4G 主频的 CPU 的 Clock Cycle Time 就是 0.25 ns,作为对比,我们墙上挂钟的Cycle Time 是 1s。(例如,运行一条加法指令一般需要一个时钟周期时间)
  • CPI:有的指令需要更多的时钟周期时间,所以引出了 CPI (Cycles Per Instruction)指令平均时钟周期数。
  • IPC:IPC(Instruction Per Clock Cycle) 即 CPI 的倒数,表示每个时钟周期能够运行的指令数。
  • CPU 执行时间:程序的 cpu 执行时间,即我们前面提到的 user + system 时间(可以用下面的公式来表示:)。
 程序 CPU 执行时间 = 指令数 * CPI * Clock Cycle Time

5.3 指令重排序优化

事实上,现代处理器会设计为一个时钟周期完成一条执行时间最长的 cpu 指令。

为什么这么做呢?可以想到指令还可以再划分成一个个更小的阶段。

  • 例如,每条指令都可以分为五个阶段

    1. 取指令:instruction fetch ( IF );
    2. 指令译码:instruction decode ( ID );
    3. 执行指令:execute ( EX );
    4. 内存访问:memory access ( MEM );
    5. 数据写回:register write back ( WB )。
  • 示意图

  • 在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序组合来实现指令级并行。其主要方式是通过分阶段,分工来提升效率
  • 注意指令重排的前提是:重排指令不能影响结果,例如
 // 可以重排的例子
 int a = 10; // 指令1
 int b = 20; // 指令2
 System.out.println( a + b );
 ​
 // 不能重排的例子
 int a = 10; // 指令1
 int b = a - 5; // 指令2

5.4 支持流水线的处理器

现代 cpu 支持多级指令流水线,例如支持同时执行 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 的处理器,就可以称之为五级指令流水线

  • 示意图

  • 这时 cpu 可以在一个时钟周期内同时运行五条指令的不同阶段(相当于一条执行时间最长的复杂指令),IPC = 1
  • 本质上,流水线技术并不能缩短单条指令的执行时间,但它变相地提高了指令的吞吐率

5.5 SuperScalar 处理器

超标量(superscalar)cpu 架构是指在一颗处理器内核中实行了指令级并行的一类并行运算。这种技术能够在相同的 cpu 主频下实现更高的 cpu 吞吐率(throughput)。

  • 大多数处理器包含多个执行单元,并不是所有计算功能都集中在一起,可以再细分为整数运算单元、浮点数运算单元等,这样可以把多条指令也可以做到并行获取、译码等。
  • cpu 可以在一个时钟周期内,执行多于一条指令,IPC > 1
  • 示意图

5.6 验证指令重排问题

此处使用 jcstress(The Java Concurrency Stress) 工具进行验证:它是一个实验工具和一套测试工具,用于帮助研究 JVM、类库和硬件中并发支持的正确性。

  • 依赖引入
         <!--jcstress核心包-->
         <dependency>
             <groupId>org.openjdk.jcstress</groupId>
             <artifactId>jcstress-core</artifactId>
             <version>0.3</version>
         </dependency>
 ​
         <!-- jcstress测试用例包 -->
         <dependency>
             <groupId>org.openjdk.jcstress</groupId>
             <artifactId>jcstress-samples</artifactId>
             <version>0.3</version>
         </dependency>
  • 测试代码
 /* *
  * @JCStressTest 该注解标记一个类为一个并发测试的类。
  *
  * @Outcome描述测试结果,并处理这个结果:
  *  1.ACCEPTABLE 结果不一定会存在;
  *  2.ACCEPTABLE_INTERESTING 和 ACCEPTABLE 差不多,唯一不一样的是,这个结果会在生成的报告中高亮;
  *  3.FORBIDDEN 表示永远不应该出现的结果,若测试过程中有该结果,意味着测试失败;
  *  4.UNKNOWN 没有评分,不使用。
  *
  * @State标记这个类是有状态的,有状态的意思是拥有数据,而且数据是可以被修改的。
  */
  
 @JCStressTest
 @Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "expected result")
 @Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "unexpected result!")
 @State
 public class ConcurrencyTest {
 ​
     int num = 0;
 ​
     volatile boolean ready = false;
 ​
     /**
      * actor1
      *
      * @param r I_Result 是一个对象,有一个属性 r1 用来保存结果。
      */
 ​
     @Actor
     public void actor1(I_Result r) {
         if (ready) {
             r.r1 = num + num;
         } else {
             r.r1 = 1;
         }
     }
 ​
 ​
     @Actor
     public void actor2(I_Result r) {
         num = 2;
         ready = true;
     }
 ​
 }
  • 代码说明

    • 情况一:程1 先执行,这时 ready = false,所以进入 else 分支结果为 1。
    • 情况二:线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结果为1。
    • 情况三:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)。
    • 情况四:线程2 执行 ready = true,切换到线程1,进入 if 分支,相加为 0,再切回线程2 执行 num = 2。
    • 综上所述:一般情况下测试得到的 num 值会是 1 或者 4,发生指令重排后 num 的值会是 0。
  • 设置主启动类

    • jcstress 启动类路径:org.openjdk.jcstress.Main

  • 执行并获取测试结果

  • volatile 修饰的变量,可以禁用指令重排
 volatile boolean ready = false;
  • 再次测试结果

六、volatile 原理

volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)

  • volatile 变量的写指令后会加入写屏障。
  • volatile 变量的读指令前会加入读屏障。

6.1 如何保证可见性

  • 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中。
     @Actor
     public void actor2(I_Result r) {
         num = 2;
         ready = true;
         // ready 是 volatile 赋值带『写屏障』。
     }
  • 读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据
     @Actor
     public void actor1(I_Result r) {
         // ready 是 volatile 读取值带『读屏障』。
         if (ready) {
             r.r1 = num + num;
         } else {
             r.r1 = 1;
         }
     }
  • 示意图

6.2 如何保证有序性

  • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后。

  • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前。

  • 注意:保证有序性,并不能解决指令交错!

    • 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它前面去。
    • 而有序性的保证也只是保证了本线程内相关代码不被重排序
  • 更底层是读写变量时使用 lock 指令来多核 cpu之间的可见性与有序性。

6.3 单例模式 - 双重检查锁

以著名的 double-checked locking 单例模式为例。

  • 代码示例
 public class Singleton {
 ​
     /* *
      * 注意:此处位未修饰变量可见性。
      */
 ​
     private static Singleton instance;
 ​
     private Singleton() {
     }
 ​
     public static Singleton getInstance() {
         if (null == instance) {
             synchronized (Singleton.class) {
                 if (null == instance) {
                     instance = new Singleton();
                 }
             }
         }
         return instance;
     }
 }
  • 实现特点

    • 懒惰实例化。
    • 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁。
  • 隐藏问题:第一个 if 使用了 instance 变量,是在同步块之外但在多线程环境下,上面的代码是有问题的。getInstance() 方法对应的字节码如下)

  • 问题说明:也许 jvm 会优化为:先执行 26,再执行 23,此时两个线程 t1,t2 对 getInstance() 方法执行。

    • 关键在于 1: getstatic 这行代码在 monitor 控制之外,它是可以越过 monitor 读取 instance 变量的值。
    • 这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例。
    • instance 使用 volatile 修饰即可,可以禁用指令重排,但要注意JDK 5 以上的版本的 volatile 才会真正有效。
  • 问题解决

 private static volatile Singleton instance;

七、happens-before 规则

happens-before 规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见。

7.1 线程加锁

线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见。

  • 代码示例
 @Slf4j
 public class HappensBeforeSample {
 ​
     private static int x;
     private static Object m = new Object();
 ​
     public static void main(String[] args) {
 ​
         // 写线程。
         new Thread(() -> {
             synchronized (m) {
                 x = 10;
             }
         }, "t1").start();
 ​
 ​
         // 读线程。
         new Thread(() -> {
             synchronized (m) {
                 log.debug("x={}", x);
             }
         }, "t2").start();
     }
 }

7.2 volatile 修饰

线程对 volatile 变量的写,对接下来其它线程对该变量的读可见。

 private static volatile int x;

7.3 读线程等待写线程执行结束

线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive()t1.join() 等待它结束)。

  • 代码示例
 @Slf4j
 public class HappensBeforeSample {
 ​
     private static int x;
 ​
     public static void main(String[] args) throws InterruptedException {
 ​
         Thread t1 = new Thread(() -> {
             x = 10;
         }, "t1");
         
         Thread t2 = new Thread(() -> {
             log.debug("x={}", x);
         }, "t2");
 ​
         t1.start();
         // 等待写线程执行结束。
         t1.join();
 ​
         t2.start();
         // x=10
     }
 }

7.4 通过线程打断

线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过 t2.interrupted()t2.isInterrupted())。

  • 代码示例
 @Slf4j
 public class HappensBeforeSample {
 ​
     private static int x;
 ​
     public static void main(String[] args) throws InterruptedException {
 ​
         Thread t2 = new Thread(() -> {
             while (true) {
                 // 打断后读最新值。
                 if (Thread.currentThread().isInterrupted()) {
                     log.debug("x={}", x);
                     break;
                 }
             }
         }, "t2");
         t2.start();
 ​
         new Thread(() -> {
             try {
                 TimeUnit.SECONDS.sleep(1);
                 // 打断前对变量进行修改。
                 x = 10;
                 t2.interrupt();
             } catch (InterruptedException e) {
                 e.printStackTrace();
             }
         }, "t1").start();
 ​
         while (!t2.isInterrupted()) {
             // yield() 提示线程调度器让出当前线程对 cpu 的使用。
             Thread.yield();
         }
 ​
         log.debug("x={}", x);
         // [main] x=10
         // [t2] x=10
     }
 }

7.5 传递性

线程内的操作都会同步到主存中 ,具有传递性,配合 volatile 的防指令重排。

 @Slf4j
 public class HappensBeforeSample {
 ​
     private volatile static int x;
     private static int y;
 ​
     public static void main(String[] args) throws InterruptedException {
 ​
         new Thread(() -> {
             y = 10;
             x = 20;
         }, "t1").start();
 ​
         new Thread(() -> {
             // x=20 对 t2 可见, 同时 y=10 也对 t2 可见。
             log.debug("x={}", x);
         }, "t2").start();
 ​
         // [t2] x=20
     }
 }

7.6 其他规则

  • 线程 start() 前对变量的写,对该线程开始后对该变量的读可见。
  • 对变量默认值(0,falsenull)的写,对其它线程对该变量的读可见。

八、结束语

“-------怕什么真理无穷,进一寸有一寸的欢喜。”

微信公众号搜索:饺子泡牛奶