并发编程 | JMM、volitle、CAS

本文是juc专题视频的笔记
哔哩哔哩尚硅谷juc专题
p56-p79
中间跳过了一些native方法的讲解,
比如UnSafe类,还有读写屏障(就是内存屏障)
没听清的点是:为啥volitle不是原子性的 + 8个happens-before原则…
必看:一篇质量高、排版棒、且有demo的文章:JMM 三大特性,有demo举例,总结得比较全面


1. JMM(java memory model)

是一种抽象、规范
在这里插入图片描述

1.1 JMM三大特性(原子,可见,有序性)

JMM 三大特性,有demo举例,总结得比较全面(很推荐)
JVM 内存模型(JMM) 三大特性(可以不看)
并发编程的三大特性(可以不看)

(以下是我自己的理解,靠谱点的解释看上面两个链接)

  • 原子性:多线程情况下,单个线程的任务执行不会被其他线程打断
  • 可见性:多线程场景下,共享变量的更新对于所有线程都可感知
  • 有序性:代码中前面的操作结果是可以被后续的的内容感知的。。(!!咋描述)

1.1.1 概念复制粘贴

1.1.1.1 Java内存模型(JMM)

在讲三大特性之前先简单介绍一下Java内存模型(Java Memory Model,简称JMM),了解了Java内存模型以后,可以更好地理解三大特性。

Java内存模型是一种抽象的概念,并不是真实存在的,它描述的是一组规范或者规定。

JVM运行程序的实体是线程,每一个线程都有自己私有的工作内存。Java内存模型中规定了所有变量都存储在主内存中,主内存是一块共享内存区域,所有线程都可以访问。但是线程对变量的读取赋值等操作必须在自己的工作内存中进行,在操作之前先把变量从主内存中复制到自己的工作内存中,然后对变量进行操作,操作完成后再把变量写回主内存。线程不能直接操作主内存中的变量,线程的工作内存中存放的是主内存中变量的副本。

1.1.1.2 原子性(Atomicity)

原子性是指:在一次或者多次操作时,要么所有操作都被执行,要么所有操作都不执行。

原子性示例
示例一

i = 1;

根据上面介绍的Java内存模型,线程先把i=1写入工作内存中,然后再把它写入主内存,就此赋值语句可以说是具有原子性。

示例二

i = j;

这个赋值操作实际上包含两个步骤:线程从主内存中读取j的值,然后把它存入当前线程的工作内存中;线程把工作内存中的i改为j的值,然后把i的值写入主内存中。虽然这两个步骤都是原子性的操作,但是合在一起就不是原子性的操作。

示例三

i++;

这个自增操作实际上包含三个步骤:线程从主内存中读取i的值,然后把它存入当前线程的工作内存中;线程把工作内存中的i执行加1操作;线程再把i的值写入主内存中。和上一个示例一样,虽然这三个步骤都是原子性的操作,但是合在一起就不是原子性的操作。

从上面三个示例中,我们可以发现:简单的读取和赋值操作是原子性的,但把一个变量赋值给另一个变量就不是原子性的了;多个原子性的操作放在一起也不是原子性的。

如何保证原子性
在Java内存模型中,只保证了基本读取和赋值的原子性操作。如果想保证多个操作的原子性,需要使用synchronized关键字或者Lock相关的工具类。如果想要使int、long等类型的自增操作具有原子性,可以用java.util.concurrent.atomic包下的工具类,如:AtomicIntegerAtomicLong等。另外需要注意的是,volatile关键字不具有保证原子性的语义。

1.1.1.3 可见性(Visibility)

什么是可见性
可见性是指:当一个线程对共享变量进行修改后,另外一个线程可以立即看到该变量修改后的最新值。

可见性示例:

package onemore.study;

import java.text.SimpleDateFormat;
import java.util.Date;

public class VisibilityTest {
    public static int count = 0;

    public static void main(String[] args) {
        final SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss.SSS");

        //读取count值的线程
        new Thread(() -> {
            System.out.println("开始读取count...");
            int i = count;//存放count的更新前的值
            while (count < 3) {
                if (count != i) {//当count的值发生改变时,打印count被更新
                    System.out.println(sdf.format(new Date()) + " count被更新为" + count);
                    i = count;//存放count的更新前的值
                }
            }
        }).start();

        //更新count值的线程
        new Thread(() -> {
            for (int i = 1; i <= 3; i++) {
                //每隔1秒为count赋值一次新的值
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(sdf.format(new Date()) + " 赋值count为" + i);
                count = i;

            }
        }).start();
    }
}

在运行代码之前,先想一下运行的输出是什么样子的?在更新count值的线程中,每一次更新count以后,在读取count值的线程中都会有一次输出嘛?让我们来看一下运行输出是什么:

开始读取count...
17:21:54.796 赋值count为1
17:21:55.798 赋值count为2
17:21:56.799 赋值count为3

从运行的输出看出,读取count值的线程一直没有读取到count的最新值,这是为什么呢?因为在读取count值的线程中,第一次读取count值时,从主内存中读取count的值后写入到自己的工作内存中,再从工作内存中读取,之后的读取的count值都是从自己的工作内存中读取,并没有发现更新count值的线程对count值的修改。

如何保证可见性
在Java中可以用以下3种方式保证可见性。

使用volatile关键字
当一个变量被volatile关键字修饰时,其他线程对该变量进行了修改后,会导致当前线程在工作内存中的变量副本失效,必须从主内存中再次获取,当前线程修改工作内存中的变量后,同时也会立刻将其修改刷新到主内存中。

使用synchronized关键字
synchronized关键字能够保证同一时刻只有一个线程获得锁,然后执行同步方法或者代码块,并且确保在锁释放之前,会把变量的修改刷新到主内存中。

使用Lock相关的工具类
Lock相关的工具类的lock方法能够保证同一时刻只有一个线程获得锁,然后执行同步代码块,并且确保执行Lock相关的工具类的unlock方法在之前,会把变量的修改刷新到主内存中。

1.1.1.4 有序性(Ordering)

什么是有序性
有序性指的是:程序执行的顺序按照代码的先后顺序执行。

在Java中,为了提高程序的运行效率,可能在编译期和运行期会对代码指令进行一定的优化,不会百分之百的保证代码的执行顺序严格按照编写代码中的顺序执行,但也不是随意进行重排序,它会保证程序的最终运算结果是编码时所期望的。这种情况被称之为指令重排(Instruction Reordering)。

有序性示例

package onemore.study;

public class Singleton {
    private Singleton (){}

    private static boolean isInit = false;
    private static Singleton instance;

    public static Singleton getInstance() {
        if (!isInit) {//判断是否初始化过
            instance = new Singleton();//初始化
            isInit = true;//初始化标识赋值为true
        }
        return instance;
    }
}

这是一个有问题的单例模式示例,假如在编译期或运行期时指令重排,把isInit = true;重新排序到instance = new Singleton();的前面。在单线程运行时,程序重排后的执行结果和代码顺序执行的结果是完全一样的,但是多个线程一起执行时就极有可能出现问题。比如,一个线程先判断isInit为false进行初始化,本应在初始化后再把isInit赋值为true,但是因为指令重排没后初始化就把isInit赋值为true,恰好此时另外一个线程在判断是否初始化过,isInit为true就执行返回了instance,这是一个没有初始化的instance,肯定造成不可预知的错误。

在这里插入图片描述
在这里插入图片描述

2. volitle

2.1 volitle的特点

  • 不是原子性的(如何用demo演示?)
  • 可以禁止指令重排
  • 可见性

2.2 volitle适用场景

  • 适合修饰表示状态的变量(比如 Boolean类型的),或者是不依赖值去变化的变量(如int类型的)
  • 不适合修饰依赖值去变化的变量(比如i++

2.2.1 volitle为啥不适合修饰 i++?

举个例子,假设变量i用volitle修饰,现在起100个线程,每个线程里做的任务都是:for循环100次做i++操作。最后打印i的结果会发现,原本预期值为10000的,结果值往往会少于10000

这是因为i++是个组合操作而不是原子操作:取值->计算->重新赋值
多线程情况下,多个线程可能会同时拿到i的值。但会出现线程A先更新了主存的i值的情况。这样,原本也拿到同一个i值的线程B就会放弃它做的加一的计算。如此,最后累积下来就少加了很多次。于是结果值就比没有线程安全问题情况下的预测值少了。

改进方法是:对i++做加锁操作,用sync

2.2.2 volitle在单例模式的双检锁中的应用

双检锁 DCL double checked Locking
可以避免指令重排引发的空指针问题?

比如 User user = new User();
可拆成三条指令:

  1. 申请内存空间,得到内存地址(此时这个内存地址上啥也没有,是个null)
  2. 创建实例,就是new User(),此时拿到实例了
  3. 将实例放到那个内存地址上,也就是引用user指向对象地址,即user=new User()这句

以上过程指令重排之后会变成:1 3 2
那么user就等于null了,
谁要是在此刻访问user的值后续就有可能造成空指针

而对user对象使用volitle修饰的话,就能禁止上述的指令重排

3.1 volitle底层原理

内存屏障,分为读屏障和写屏障。
读屏障:收到更新通知会立刻放弃读缓存,转从主存获取数据
写屏障:线程在本地内存完成变量更新后,会立即写回主存

4. CAS(无锁,乐观锁)

4.1 CAS的操作过程

compare and swap 比较并交换

4.2 CAS的优势

不阻塞、轻量级

4.3 CAS的缺点

  • 长时间自旋会加剧CPU消耗,相当于死循环
  • 存在ABA问题

4.3.1 什么是ABA问题?

由于CAS是通过比较并交换来实现线程安全的、对共享变量值变更的操作。
那么在特殊场景下,将会出现线程t2无法感知线程t1所有对共享变量变更的操作(我的理解是可见性遭到破坏)

举个例子:
线程 t1 t2 都在某个时刻拿到了共享变量val=A
t1 先去两次变更val:将A变为B,再将B又变回A
t2 后尝试变更val:将A变为C

以上过程,t2是可以成功变更的。但是t2无法发现t1已经将val变更了两次、它仍认为“A”就是旧值,即t1对共享变量的变更,对于t2来说不是完全可见的

ps:此处有通过普通原子类演示ABA问题的demo,可以下滑到第5part去看

4.3.2 ABA问题如何解决?

引入版本号,标记共享变量的每一次变更。修改时预设本次修改的版本号,如果改完值之后发现自身的版本号已经在出现过了:则认为在这期间数据已经被别的线程修改过了,于是修改失败

Atomic原子类里有带版本号的类(命名里带Stamp的),比如AtomicStampReference

ps:此处有通过AtomicStampedReference类解决ABA问题的demo,可以下滑到第5part去看

4.4 CAS的底层是通过UNSafe类

5. demo(todo)

5.1 写出单例模式的双检锁形式

5.2 验证volitle不可修饰 i++,并使用sync使之线程安全

5.3 用volitle实现中断机制

5.4 通过原子类AtomicReference重现CAS的ABA问题

package com.example.juc.atomic;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;

/**
 * 用普通的原子类(不带邮戳的版本)演示CAS的ABA问题
 */
public class ShowABA {

    /**
     * 模拟过程:
     * t1 t2 获取到同个旧value;
     * t1 重现ABA
     * t2 带着旧的期望数据A去尝试更新
     *
     * 预测结果:
     * t2可以更新成功
     *
     * 下文有注释预测的执行顺序
     */

    public static void main(String[] args) {

        AtomicReference<String> reference = new AtomicReference<>("A");

        Thread t1 = new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + " 初始化val:" + reference.get());  // 1.

            try {
                TimeUnit.MILLISECONDS.sleep(200); // 暂时让出CPU执行权,让t2拿到同个val值
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            boolean flag1 = reference.compareAndSet("A", "B"); // 3.
            System.out.println(Thread.currentThread().getName() + " 更新结果:" + flag1 + " 更新后的val:" + reference.get());
            boolean flag2 = reference.compareAndSet("B", "A");
            System.out.println(Thread.currentThread().getName() + " 更新结果:" + flag1 + " 更新后的val:" + reference.get());

        }, "t1");
        t1.setPriority(9);
        t1.start();

        new Thread(() -> {
            String val = reference.get();
            System.out.println(Thread.currentThread().getName() + " 初始化val:" + reference.get()); // 2.

            try {
                TimeUnit.MILLISECONDS.sleep(1000); // 暂时让出CPU执行权,让t1能模拟完ABA
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            boolean flag1 = reference.compareAndSet(val, "CCC");  // 4.
            System.out.println(Thread.currentThread().getName() + " 更新结果:" + flag1 + " 更新后的val:" + reference.get());
        }, "t2").start();
    }
}

5.5 通过AtomicStampReference邮戳原子引用类解决CAS的ABA问题

package com.example.juc.atomic;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicStampedReference;

/**
 * 使用邮戳原子引用类来解决CAS的ABA问题
 */
public class ABAResolver {

    /**
     * 测试过程:
     * 创建两个线程t1 t2
     * 先令t1 t2 拿到同一个版本号;
     * 然后让t1重现ABA;
     * 最后让t2拿着老版本号和老数据去尝试更新,并记录更新结果和更新后拿到的最新版本号
     *
     * 结果预测:t2会更新失败,因为t1的两次更新已经被版本号记录下来了,t2持有旧版本号去更新是不会成功的
     *
     * 下文会标注执行顺序
     */


    public static void main(String[] args) {

        AtomicStampedReference<String> reference = new AtomicStampedReference<>("A", 1);

        Thread t1 = new Thread(() -> {

            System.out.println(Thread.currentThread().getName() + " 初始版本号:" + reference.getStamp());  // 1.

            try {
                TimeUnit.MILLISECONDS.sleep(1000); // 让出一段时间的CPU执行权,让t2线程能拿到同样的版本号
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            reference.compareAndSet("A", "B", reference.getStamp(), reference.getStamp() + 1);  // 3.
            System.out.println(Thread.currentThread().getName() + " 最新版本号:" + reference.getStamp());
            reference.compareAndSet("B", "A", reference.getStamp(), reference.getStamp() + 1);  // 4.
            System.out.println(Thread.currentThread().getName() + " 最新版本号:" + reference.getStamp());
        }, "t1");
        t1.setPriority(9); // 设置t1更高优先级的本意是想让t1先开始执行,但当前场景下测试发现貌似不设置优先级t1也能总是先执行。或许是当前参与竞争的线程太少了,看不出设置优先级的必要性吧
        t1.start();


        new Thread(() -> {

            int stamp = reference.getStamp();  // 模拟两个线程同时去拿到了同个版本号
            System.out.println(Thread.currentThread().getName() + " 初始版本号:" + stamp);  // 2.

            try {
                TimeUnit.MILLISECONDS.sleep(3000); // 让出CPU执行权,让t1继续执行
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            boolean flag = reference.compareAndSet("A", "CCC", stamp, stamp + 1); // 5.
            System.out.println(Thread.currentThread().getName() + " 变更结果:" + flag + " " + "最新版本号:" + reference.getStamp());
        }, "t2").start();

    }
}

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值