(Java并发编程——JUC)从JMM内存模型的角度来分析CAS并发性问题

1. 共享模型-内存

在内存篇章中,将深入学习共享变量在多线程间的【可见性】问题与多条指令执行的【有序性】问题

2. JMM - Java Memory Model

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

JMM体现在以下几个方面:

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

3. CAS

3.1 原子性

原子性(Atomic)就是不可分割的意思,是指在进行一系列操作的时候这些操作要么全部执行要么全部不执行,不存在只执行一部分的情况。

原子操作的不可分割有两层含义:

  • 访问(读、写)某个共享变量的操作从其他线程来看,该操作要么已经执行要么尚未发生,即其他线程看不到当前操作中的中间结果
  • 访问同一组共享变量的原子操作时不能相交错的。如现实生活中从ATM机取款。

3.1.1 怎么处理原子性问题?

java 给出的方案就是 synchronized(同步关键字)

synchronized(对象){
    
}

不过在使用synchronized时,会大大降低程序的并发性,谨慎使用

例:

package JMM;

public class demo1 {
    static int c = 0;
    static Object obj = new Object();
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                synchronized (obj) {
                    c++;
                }
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                synchronized (obj) {
                    c--;
                }
            }
        });
        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(c);
    }
}

当代码中存在了synchronized同步关键字,monitor它会新建出三个线程Owner、Entrylist和waitSet

  1. 当t1还未被monitor指令锁定,那么Owner会将他锁定
  2. 当t2线程起来后,发现t1被锁定了,这时候就会进行EntryLsit中进行等待。

在这里插入图片描述

T1被Owner锁定,T2线程开始在EntryList区域等待

在这里插入图片描述

T1执行完毕,在EntryList中的T2线程开始被Owner锁定执行

在这里插入图片描述

3.1.2 通俗讲解:

可以将Obj想象成一个房间,线程t1、t2是两个人

当t1执行synchronized(obj)时就可以看成,t1进入了obj房间,然后锁住了这个门,在房间内执行代码

而在t1执行代码的期间,t2也执行synchronized(obj)了,它想进入obj房间,但是发现房间被锁住了,于是只能在门外找个凳子(EntryList)等待。

当t1执行完synchronized{}块内的代码后,它才会解开obj房间里门的锁,从obj房间出来。这时候t2线程才可以进入obj房间,反锁住门,执行它的代码

注意,不同线程进行synchronized同步时必须锁的是同一个对象,如果锁的不是同一个对象,就好比两个人分别进了不同的房间,达不到同步的效果

3.2 可见性

一个线程对主内存的修改可以及时的被其他线程观察到

3.2.1 循环不退出

看一个例子:

main线程对run变量的修改对于t线程不可见,导致了t线程无法停止:

/**
 * 可见性
 */
public class demo {
    static boolean flag = true;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            while (flag) {
                System.out.println("测试");
            }
        });

        t1.start();

        Thread.sleep(1000);
        flag = false;

    }
}

使用的是JDK8环境,若是JDK11或以上则不会发生这种问题了

分析:

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

    在这里插入图片描述

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

    在这里插入图片描述

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

    在这里插入图片描述

3.2.2 解决方法

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

3.2.3 可见性vs原子性

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

它只能保证看到的是最新值,不能解决指令的交错问题

这样就更加推荐使用synchronized关键字

  • synchronized语句块既可以保证代码的原子性,也同时保证块内变量的可见性
  • 但缺点是synchronizad是属于重量级操作(降低并发能力),性能相对更低

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

  • 来看一看源码就知道了

    public void println() {
        newLine();
    }
    private void newLine() {
        try {
            synchronized (this) {
                ensureOpen();
                textOut.newLine();
                textOut.flushBuffer();
                charOut.flushBuffer();
                if (autoFlush)
                    out.flush();
            }
        }
        catch (InterruptedIOException x) {
            Thread.currentThread().interrupt();
        }
        catch (IOException x) {
            trouble = true;
        }
    }
    

    可以发现使用了synchronized关键字

3.2.3 同步模式-Balking

Balking(犹豫)模式适用场景:

  • 一个线程发现另一个线程或本线程一间做了某一件相同的事,那么本线程就无需再做了,直接结束返回

说白了,就是在同步运行前检测一遍是否已经被运行过了

private static volatile boolean starting = false;

public synchronized void start(){
    if (!starting){
        return;
    }
    starting = true;
}

还会用来解决一些线程安全的单例(Spring中很常见):

@Slf4j(topic = "test5")
public class test5 {

    public test5() {
    }

    private static test5 instance = null;

    public static synchronized test5 getInstance() {
        if (instance != null) {
            return instance;
        }
        instance = new test5();
        return instance;
    }
}

对比一下保护暂停模式:保护性暂停模式用在一个线程等待另一个线程的执行结果,当条件不满足时线程等待。

  • 必中效果
public static synchronized test5 getInstance1() throws InterruptedException {
    while (instance == null) {
        Thread.sleep(2000);
    }
    instance = new test5();
    return instance;
}

public static test5 getInstance1() throws InterruptedException {
    synchronized (instance){
    	// 只要instance为空,那么就会等待
        while (instance == null) {
            instance.wait();
        }
    }
    instance.notify();
    instance = new test5();
    return instance;
}

3.3 有序性

package JMM;

/**
 * 有序性
 */
public class demo2 {
    int num = 0;
    boolean ready = false;

    static class Result{
        int num = 0;
    }

    public void actor1(Result r){
        if (ready){
            r.num = num +num;
        }else {
            r.num = 1;
        }
    }

    public void actor2(Result r){
        num = 2;
        ready = true;
    }
}

Result是一个对象,有一个属性num用来保存结果

两种运行情况:

  1. 情况一:线程1 先执行,执行完 ready = false,所以 else 分支结果为 1
  2. 情况二:线程2先执行num=2,但没执行ready=true,线程1执行,还是进入else分支,结果为1
  3. 情况三:线程2执行到ready=true,线程1执行,这回进入if分支,结果4(因为num已经执行过了)
  4. 情况二
    1. 线程2执行ready=true后
    2. 线程1进入if分支,相加为0
    3. 线程2执行num=2

现象:指令重排

  • 是JIT编译器在运行时的一些优化,这个现象需要通过大量测试才能复现

3.3.1 如何解决?

在做出关键性判断的一步上添加 volatile 关键字,保证线程之间对数值更改的可见性

volatile boolean ready = false;

为什么使用volatile?因为volatile本身就可以做到获取最新的变量值

3.3.2 有序性

JVM会在不影响正确性的前提下,可以调整语句的执行顺序,思考下面一段代码

static int i;
static int j;

// 在某个线程内执行如下赋值操作:
j= ...;
i= ...;

其实,先执行i还是先执行j,对最终的效果不会产生影响

所以上面代码真正执行时,顺序可以是随机的

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

  • 为什么要有重排指令这项优化呢?

3.3.3 指令重排序优化

现代处理器会涉及为一个时间周期完成一条执行时间最长的CPU指令。好处是还可以再划分为一个个更小的阶段,例如:每条指令都可以分为以下五个阶段:

  1. 取指令
  2. 指令译码
  3. 执行指令
  4. 内存访问
  5. 数据写回

在这里插入图片描述

在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序组合来实现指令级并行,这一技术80’s中叶到90’s中叶占据了计算架构的重要地位

分阶段、分工是提升效率的关键!

  • 指令重排的前提是:重排指令不能影响结果

例如:

// 可以重排的例子
int a = 10;
int b = 20;
System.out.println(a+b);

// 不可以重排的例子
int a = 10;
int b = a-5; // 影响了b结果

3.3.4 支持流水线的处理器

现代CPU支持多级指令流水线,例如支持同时执行

  1. 取指令
  2. 指令译码
  3. 执行指令
  4. 内存访问
  5. 数据写回

五个阶段的处理器,就可以称之为五级指令流水线

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

在这里插入图片描述

3.3.5 诡异结果

@Slf4j(topic = "test6")
public class test6 {
    int num = 0;
    boolean ready = false;

    public void actor(Result o){
        if (ready){
            o.r1 = num+num;
        }else {
            o.r1 = 1;
        }
    }

    public void actor2(Result o){
        num = 2;
        ready = true;
    }

    public static void main(String[] args) {

    }
}

@Data
class Result{
    public Integer r1 = null;
}

3.3.6 压测并发 - jcstress

通常在并发编程中,往往需要大量的测试,才能知道是否存在并发问题
这里使用 jcstress 来做压测

  • 首先添加jar包
<!-- 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>

添加相应注解:

import lombok.Data;
import org.openjdk.jcstress.annotations.*;


@JCStressTest
@State
@Outcome(id = {"1","4"},expect = Expect.ACCEPTABLE,desc = "没毛病")
@Outcome(id = {"0"},expect = Expect.ACCEPTABLE,desc = "===!!==出现异常情况")
public class ConcurrencyTest {
    int num = 0;
    boolean ready = false;

    @Actor
    public void actor(Result o){
        if (ready){
            o.r1 = num+num;
        }else {
            o.r1 = 1;
        }
    }

    @Actor
    public void actor2(Result o){
        num = 2;
        ready = true;
    }
}
//
import lombok.Data;
import org.openjdk.jcstress.annotations.State;

@org.openjdk.jcstress.annotations.Result
@Data
public class Result{
    public Integer r1 = null;
}
  • 如何解决?

为限定条件 ready 变量使用volatile关键字装饰即可

volatile boolean ready = false;

3.4 volatile 原理

volatile 的底层实现原理是内存屏障,即:Memory Barrier

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

3.4.1 如何保证可见性

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

public void actor2(Result r){
    num = 2;
    ready = true;// ready 是 volatile 赋值;带写屏障
    // 写屏障
}

读屏障(lfence)保证在该屏障之后、对共享变量的改动,加载的是主存中的最新数据

public void actor(Result o){
    if (ready){
        o.r1 = num+num;
    }else {
        o.r1 = 1;
    }
}

3.4.2 如何保证有序性

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

public void actor2(Result r){
    num = 2;
    ready = true;// ready 是 volatile 赋值;带写屏障
    // 写屏障
}

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

public void actor(Result o){
    // 读排序
    // ready 是volatile 读取值带读屏障
    if (ready){
        o.r1 = num+num;
    }else {
        o.r1 = 1;
    }
}

volatile 话说如此,但还不是不能解决指令交错:

  • 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读操作在写操作之前执行
  • 而有序性的保障也只是保证了本线程内相关代码不被重排序

如果想要解决CAS的三种条件,可以使用synchronized;

  • 只是需要斟酌 synchronized 的性能问题

3.4.3 double-checked locking 问题(二次检查锁)

public final class Singleton {

    public Singleton() {
    }

    private static Singleton instance = null;

    public static  Singleton getInstance() {
        // 首次访问会同步,而之后的使用没有 synchronized
        if(instance == null){
            synchronized(Singleton.class){
                if (instance == null) {
                    return instance;
                }
                instance = new Singleton();
            }
        }
        return instance;
    }
}

以上的实现特点是:

  • 懒惰实例化
  • 首次使用 getinstance() 才使用synchronized加锁,后续使用时无需加锁
  • 有隐含的,但很关键的一点:第一个if使用了INSTANCE变量是在同步块之外

在多线程环境下,上面的代码是有问题的,getInstance方法对应的字节码为:

原子性会出现问题,主要是指令重排问题

Compiled from "Singleton.java"
public final class Singleton {
  public Singleton();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static Singleton getInstance();
    Code:
       0: getstatic     #2                  // Field instance:LSingleton;
       3: ifnonnull     43
       6: ldc           #3                  // class Singleton
       8: dup
       9: astore_0
      10: monitorenter
      11: getstatic     #2                  // Field instance:LSingleton;
      14: ifnonnull     23
      17: getstatic     #2                  // Field instance:LSingleton;
      20: aload_0
      21: monitorexit
      22: areturn
    
      23: new           #3                  // class Singleton
      26: dup
      27: invokespecial #4                  // Method "<init>":()V
      30: putstatic     #2                  // Field instance:LSingleton;
      
      33: aload_0
      34: monitorexit
      35: goto          43
      38: astore_1
      39: aload_0
      40: monitorexit
      41: aload_1
      42: athrow
      43: getstatic     #2                  // Field instance:LSingleton;
      46: areturn
    Exception table:
       from    to  target type
          11    22    38   any
          23    35    38   any
          38    41    38   any

  static {};
    Code:
       0: aconst_null
       1: putstatic     #2                  // Field instance:LSingleton;
       4: return
}

我们主要看:

  • 23 行:创建对象,将对象引用入栈 //new Singleton
  • 26 行:复制一份对象引用 // 地址
  • 27 行:利用一个对象引用,调用构造方法 // 根据引用地址调用
  • 30 行:利用一个对象引用,赋值给 static INSTANCE

也许JVM会优化27和30行的执行顺序。代表先赋值调用构造

在这里插入图片描述

关键在于0:getstatic 这行代码,它可以越过monitor读取INSTANCE变量的值

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

对INSTACE使用volatile修饰即可,可以禁止指令重排

使用volatile请在JDK1.5以上执行

  • 总结发生的原因:

因为synchronized没有完全包裹住临界区

  • 何处此言?我们可以看到在执行synchronized前还需要if判断INSTANCE是否为null,那这样不就超出了synchronized的保护区了吗?

而synchronized保证CAS但并不能保证指令重排问题。而例子中synchronized内部是有存在指令重排问题的。

instance = new Singleton();

该代码就是指令重排问题的原因,通常来说,先执行构造创建,再进行赋值。

而JVM可能会优化为先赋值再创建构造

当出现以下两个原因:

  1. synchronized未包裹临界区
  2. 指令重排

那么就可能会发生执行共享问题

当有多个线程来访问,t1先判断外层if条件当INSTANCE==null时进入内部,获得锁,一路执行到new Singleton行代码,如果JVM先执行了赋值操作,此时Singleton还没有进行构造

而在这个节骨眼上,t2线程过来判断外层if条件当INSTANCE==null,那么就会发现INSTANCE已经存在值了,就会执行 return INSTANCE 行代码返回;但返回出去的INSTANCE的值其实依旧是空值,因为此时t1还没有完成new Singleton构造操作

3.4.4 double-checked locking 解决

public final class Singleton {

    public Singleton() {
    }

    private static volatile Singleton instance = null;

    public static  Singleton getInstance() {
        // 首次访问会同步,而之后的使用没有 synchronized
        if(instance == null){
            synchronized(Singleton.class){
                if (instance == null) {
                    return instance;
                }
                instance = new Singleton();
            }
        }
        return instance;
    }
}

我们只需要给INSTANCE变量使用volatile修饰即可

volatile在JVM调用时会禁止指令重排现象的出现

主要功能就是在读写时加入读写屏障来获取最新数据

3.4.5 happens-before

happens-before 规定了对共享变量的写操作对其他线程的读操作可见,它是可见性与有序性的一套规则总结

抛开以下happens-before规则,JMM并不能保证一个线程对共享变量的写,对于其他线程对该共享变量的读可见

  1. 线程解锁m之前对变量的写,对于接下来的m加锁的其他线程对该变量的读可见
static int x;
static Object m = new Object();

public static void main(String[] args) {
    new Thread(()->{
        synchronized (m) {
            x=10;
        }
    },"t1").start();
    new Thread(()->{
        synchronized (m) {
            System.out.println(x);
        }
    },"t2").start();
}

使用synchronized关键字进行加锁,并且没有多余未包含的临界区,所以不会有共享问题

  1. 线程对volatile变量的写,对接下来其他线程对该变量的读可见
volatile static int a;
@Test
public void demo1() {
    new Thread(()->{
        a=10;
    },"t1").start();
    new Thread(()->{
        System.out.println(x);
    },"t2").start();
}

使用volatile进行了读写屏障,读取最新值,所以不会有共享问题

  1. 线程start前对变量的写,对该线程开始后对该变量的读可见
static int a;
@Test
public void demo2() {
    a=10;
    new Thread(()->{
        System.out.println(x);
    },"t2").start();
}

在线程开始前就已经将值赋予,所以不会有共享问题

  1. 线程结束前对变量的写,对其他线程得知它结束后的读可见(比如其他线程调用t1.isAlive()或t1.join()等待它结束)
static int a;
@Test
public void demo3() throws InterruptedException {
    Thread t1 = new Thread(() -> {
        a=10;
    }, "t2");
    t1.start();

    t1.join();
    System.out.println(a);
}
  1. 线程t2打断t1前对变量的写,对于其他线程得知t1被打断后对变量的读可见
static int b;
@Test
public void demo4() throws InterruptedException {
    Thread t1 = new Thread(() -> {
        // 无限判断是否被打断
        while (true){
            if (Thread.currentThread().isInterrupted()){
                System.out.println(b);
                break;
            }
        }
    }, "t1");
    t1.start();
/
    new Thread(()->{
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        b=10;
        t1.interrupt();
    },"t2").start();
///
    while (!t1.isInterrupted()){
        // 抢先执行
        Thread.yield();
    }
    System.out.println(b);
}
///
10
10
  1. 对变量默认值(0、false、null)的写,对其他线程对该变量的读可见
  2. 具有传递性,如果x hb -> y 并且 y hb -> z那么有 x hb -> z,配合 volatile 的防指令重排,有下面的例子
volatile static int X;
static int Y;
@Test
public void demo5() throws InterruptedException {
    new Thread(() -> {
        X=10;
        Y=20;
    }, "t1").start();

    new Thread(()->{
        // x=10 对 t2 可见,同时 y = 20 也对 t2 可见
        System.out.println(X+""+Y);
    },"t2").start();
}

4. 👍JUC 专栏 - 前篇回顾👍

5. 💕👉 其他好文推荐

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值