并发编程(学习笔记-共享模型之内存)-part4

Posted LL.LEBRON

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了并发编程(学习笔记-共享模型之内存)-part4相关的知识,希望对你有一定的参考价值。

并发编程-4-共享模型之内存

本文章视频指路👉黑马程序员-并发编程

1.Java内存模型

JMM 即 Java Memory Model,它定义了主存(共享内存)、工作内存(线程私有) 抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、 CPU 指令优化等。

JMM体现在以下几个方面:

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

2.可见性

2-1 退不出的循环

先来看一个现象,main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止:

static boolean run = true;
public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(()->{
        while(run){
            // ....
        }
    });
    t.start();
    Thread.sleep(1000);
    run = false; // 线程t不会如预想的停下来
}

为什么无法退出该循环

  1. 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存

  2. 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率

  3. 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值

解决办法

  • 使用 volatile(易变关键字)
  • 它可以用来修饰成员变量静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存

2-2 可见性vs原子性

前面例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对volatile变量的修改对另一个线程可见, 不能保证原子性,仅用在一个写线程,多个读线程的情况

注意:

  • synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性

  • 但缺点是 synchronized 是属于重量级操作,性能相对更低。

  • 如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也能正确看到对 run 变量的修改了,想一想为什么?

    进入println源码,可以看出加了synchronized,保证了每次run变量都会从主存中获取

    public void println(int x) {
        synchronized (this) {
            print(x);
            newLine();
        }
    }
    

3.有序性

3-1 诡异的结果

看下面一个栗子:

int num = 0;
boolean ready = false;
// 线程1 执行此方法
public void actor1(I_Result r) {
    if(ready) {
        r.r1 = num + num;
    } else {
        r.r1 = 1;
    }
}
// 线程2 执行此方法
public void actor2(I_Result r) {
    num = 2;
    ready = true;
}

看到这里可能聪明的小伙伴会想到有下面三种情况:

情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1

情况2:线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结果为1

情况3:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)

但其实还有可能为0哦! 😲

有可能还是:线程 2 执行 ready=true ,切换到线程1 ,进入if分支,相加为0,在切回线程 2 执行 num=2

这种现象就是指令重排

3-2 解决方法

volatile 修饰的变量,可以禁用指令重排

@JCStressTest
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State
public class ConcurrencyTest {
    int num = 0;
    volatile boolean ready = false;//可以禁用指令重排
    @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;
    }
}

3-3 有序性理解

同一线程内,JVM会在不影响正确性的前提下,可以调整语句的执行顺序,看看下面的代码:

static int i;
static int j;
// 在某个线程内执行如下赋值操作
i = ...; // 较为耗时的操作
j = ...;

可以看到,至于是先执行 i 还是 先执行 j ,对最终的结果不会产生影响。所以,上面代码真正执行时, 既可以是

i = ...; // 较为耗时的操作
j = ...;

也可以是

j = ...;
i = ...; // 较为耗时的操作

这种特性称之为指令重排多线程下指令重排会影响正确性

3-4 happens-before

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

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

    static int x;
    static Object m = new Object();
    new Thread(()->{
        synchronized(m) {
            x = 10;
        }
    },"t1").start();
    new Thread(()->{
        synchronized(m) {
            System.out.println(x);
        }
    },"t2").start()
    
  • 线程对 volatile 变量的写,对接下来其它线程对该变量的读可见

    volatile static int x;
    new Thread(()->{
        x = 10;
    },"t1").start();
    new Thread(()->{
        System.out.println(x);
    },"t2").start();
    
  • 线程 start 前对变量的写,对该线程开始后对该变量的读可见

    static int x;
    x = 10;
    new Thread(()->{
        System.out.println(x);
    },"t2").start();
    
  • 线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive()t1.join()等待它结束)

    static int x;
    Thread t1 = new Thread(()->{
        x = 10;
    },"t1");
    t1.start();
    t1.join();
    System.out.println(x);
    
  • 线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通 过t2.interruptedt2.isInterrupted

    static int x;
    public static void main(String[] args) {
        Thread t2 = new Thread(()->{
            while(true) {
                if(Thread.currentThread().isInterrupted()) {
                    System.out.println(x);//10
                    break;
                }
            }
        },"t2");
        t2.start();
        new Thread(()->{
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            x = 10;
            t2.interrupt();
        },"t1").start();
        while(!t2.isInterrupted()) {
            Thread.yield();
        }
        System.out.println(x);//10
    }
    
  • 对变量默认值(0,false,null)的写,对其它线程对该变量的读可见

  • 具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z

    volatile static int x;
    static int y;
    new Thread(()->{ 
        y = 10;
        x = 20;//写屏障,y也会同步到主存
    },"t1").start();
    new Thread(()->{
        // x=20 对 t2 可见, 同时 y=10 也对 t2 可见
        System.out.println(x); 
    },"t2").start();
    

以上变量都是指共享变量即成员变量或静态资源变量

4.volatile 原理

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

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

4-1 如何保证可见性

  • 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中

    public void actor2(I_Result r) {
        num = 2;
        ready = true; // ready 是 volatile 赋值带写屏障
        // 写屏障
    }
    
  • 读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中新数据

    public void actor1(I_Result r) {
        // 读屏障
        // ready 是 volatile 读取值带读屏障
        if(ready) {
            r.r1 = num + num;
        } else {
            r.r1 = 1;
        }
    }
    

4-2 如何保证有序性

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

    public void actor2(I_Result r) {
        num = 2;
        ready = true; // ready 是 volatile 赋值带写屏障
        // 写屏障
    }
    
  • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前

    public void actor1(I_Result r) {
        // 读屏障
        // ready 是 volatile 读取值带读屏障
        if(ready) {
            r.r1 = num + num;
        } else {
            r.r1 = 1;
        }
    }
    
    

但是,不能解决指令交错

  • 写屏障仅仅是保证本线程之后的读能够读到最新的结果,但不能保证其他线程读跑到它前面去
  • 有序性的保证也只是保证了本线程内相关代码不被重排序

4-3 double-checked locking 问题

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

public class Singleton {
    private Singleton() {
    }
    private static Singleton INSTANCE = null;
    public static Singleton getInstance() {
        //实例没创建,才会进入内部的 synchronized 代码块,提高性能,防止每次都加锁
        if (INSTANCE == null) {
            //可能第一个线程在synchronized 代码块还没创建完对象时,第二个线程已经到了这一步,所以里面还需要加上判断
            synchronized (Singleton.class) {
                //也许有其他线程已经创建实例,所以再判断一次
                if (INSTANCE == null) {
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}

以上的实现特点是:

  • 懒惰实例化
  • 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁
  • 有隐含的,但很关键的一点:第一个 if 使用了 INSTANCE 变量,是在同步块之外但在多线程环境下,上面的代码是有问题的

INSTANCE = new Singleton() 对应的字节码为:

0: new #2 // class cn/itcast/jvm/t4/Singleton
3: dup
4: invokespecial #3 // Method "<init>":()V
7: putstatic #4 // Field INSTANCE:Lcn/itcast/jvm/t4/Singleton;

其中4 7 两步顺序不是固定的,也许 jvm 会优化为:先将引用地址赋值给 INSTANCE 变量后,再执行构造方法,如果两个线程 t1,t2 按如下时间顺序执行:

时间1 t1 线程执行到 INSTANCE = new Singleton();
时间2 t1 线程分配空间,为Singleton对象生成了引用地址(0 处)
时间3 t1 线程将引用地址赋值给 INSTANCE,这时 INSTANCE != null7 处)
时间4 t2 线程进入getInstance() 方法,发现 INSTANCE != nullsynchronized块外),直接
返回 INSTANCE
时间5 t1 线程执行Singleton的构造方法(4 处)

这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将 是一个未初始化完毕的单例

INSTANCE 使用 volatile 修饰即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才 会真正有效

最后喜欢的小伙伴别忘了一键三连哦🍭🍭🍭

以上是关于并发编程(学习笔记-共享模型之内存)-part4的主要内容,如果未能解决你的问题,请参考以下文章

并发编程(学习笔记-共享模型之管程)-part3

并发编程(学习笔记-共享模型之JUC-ReentrantLock原理)-part6

并发编程(学习笔记-共享模型之JUC-读写锁原理)-part6

Java高级工程师进阶学习:kafka应用场景

volatile 学习笔记

啃碎并发:内存模型之基础概述