说一说 volatile

1、并行和并发

  • 并行:多个线程同时执行。线程 A 在执行某个操作的同时,线程 B 也在执行另一个操作,就像生活中,你可以一边听音乐,一边打游戏一样。
  • 并发:多个线程去访问同一个资源。典型的像电商秒杀系统,线程 A 抢订单,线程 B 支付订单,都对订单(同一个资源)进行操作。

2、JMM 的内存模型

需要强调的是,JMM 和 JVM 是两个不同的概念。

  • JVM:Java 虚拟机
  • JMM: Java 内存模型

不像 JVM 一样将内存划分为堆、栈等多个区域进行管理,JMM 是一种抽象的概念,本身并不真实存在。
这是一张来自网络的 JMM 抽象结构示意图:
在这里插入图片描述
每个线程在创建时,JVM 都会为其创建一个工作内存。这个 工作内存 属于线程,是每个线程格子的私有数据区域,其他的线程是访问不到的。例如上图中的本地内存 A 和本地内存 B。
所有的变量实际上都存储在主内存。这个 主内存 是公共区域,所有线程都可以访问这个内存区域。

2.1 关于工作内存和主内存

变量存储在主内存中。主内存其实就是电脑的物理内存。理所当然的,主内存只会有一份,它是一个公共区域,所有线程都可以去访问。

但是线程对变量的操作,例如取值、赋值,都是在线程自己的工作内存中进行的。

首先,线程会将主内存的变量拷贝回自己的工作内存,然后,在自己的这个内存区域对变量进行操作,这个操作完成后,再将自己的工作内存中的变量写回到主内存中。

2.2 JMM 特性

JMM 是大多数多线程开发需要遵守的规范。JMM 内存模型有三大特性:

  • 可见性
  • 原子性
  • 有序性

2.2.1 可见性

在这里插入图片描述
当一个线程修改了主内存的值,并写回到了主内存,其他的线程必须马上知道这种变化,以便重新读取主内存最新的值,这种机制就是 可见性。也就是说,只要主内存的值获得了修改,其他的线程必须第一时间知晓。

2.2.2 原子性

原子性,指的就是不可分割、完整性,也即某个线程正在做某个具体业务时,中间不可以被加塞、分割或者被其他线程覆盖。需要整体完整。

线程在完成一个可能含有多个步骤的操作时,要么同时成功,要么同时失败。

2.2.3 有序性

程序在执行时,为了提高性能,编译器和处理器常常会对指令做一个重排操作:
在这里插入图片描述
大概就是,假设源代码的指令执行顺序是①②③④⑤,底层为了保证执行的性能效果更好,可能会让编译器或处理器做一个优化,最后按③④⑤①②的顺序来执行。

数据的依赖性:

// 语句1
int a = 1;
// 语句2
int b = 2;
// 语句3
a = a + 3;
// 语句4
b = a + 4;

在多线程并发的场景下,由于线程间存在竞争,底层为了保证执行的性能效果更好,指令在重排后可能会按①②③④、①③②④的顺序执行,但是,像②④①③这样的顺序,就不行,因为 b 依赖于 a,这就是上图中的处理器在进行指令重排序时,必须考虑数据的依赖性

3、volatile 保证可见性

以代码说明:

/**
 * 验证 volatile 的可见性
 *  在主内存中 int num = 0;  num变量没有添加volatile关键字修饰,控制台结果
 *  添加了volatile后,控制台结果
 */
public class VolatileDemo {

    public static void main(String[] args) {
        // 访问资源类
        MyData myData = new MyData();

        // 第一个线程----“A”
        // 修改主内存中的共享变量num
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "\t come in");
            // 暂停一会儿线程
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 第一个线程对主内存中的变量值num进行修改
            myData.addTo60();
            System.out.println(Thread.currentThread().getName() + "\t update num value: " + myData.num);

        }, "A").start();

        // 3s后,第一个线程将num从0改变为60

        // 第二个线程----main
        // 第一个线程 A 修改了主内存的num值为60,此时验证第二个线程 main线程 是否知道主内存的值发生了变化
        // 如果 myData.num == 0,说明main线程感知不到主内存的值发生了变化,可见性没有被触发
        // 如果 myData.num != 0,说明main线程感知到了num已经从0变为了60,可见性被触发
        while (myData.num == 0) {
            // main线程一直在这里等待循环,直到num的值不再等于0
        }

        // 如果下面这条输出语句能打印出来,说明main线程感知到了num已经从0变为了60,可见性被触发
        // 控制台结果:main线程一直处于等待状态,说明main线程感知不到另一个线程已经修改了变量并写回了主内存,没有触发可见性
        System.out.println(Thread.currentThread().getName() + "\t mission is over, main is get num value: " + myData.num);
    }  
    
}

/**
 * 主物理内存
 */
class MyData {

    /**
     * 共享变量
     */
    int num = 0;

    public void addTo60() {
        this.num = 60;
    }
}

现在将 num 变量添加 volatile 关键字修饰:

    /**
     * 共享变量
     */
//    int num = 0;
    volatile int num = 0;
 // 在num变量前面加了关键字volatile后,控制台的结果:main线程输出了60,保证了可见性
 // 由此说明,volatile 能保证线程的可见性
 System.out.println(Thread.currentThread().getName() + "\t mission is over, main is get num value: " + myData.num);

4、volatile 不保证原子性

/**
 * 验证 volatile 不保证原子性
 */
public class VolatileDemo {

    public static void main(String[] args) {
		// 访问资源类
        MyData myData = new MyData();
		// 创建20个线程,每个线程进行num自增1000次的操作
        for (int i = 1; i <= 20; i++) {
            new Thread(() -> {
                for (int j = 1; j <= 1000; j++) {
                    // 不保证原子性
                    myData.addPlusPlus();
                }
            }, String.valueOf(i)).start();
        }

        // 等待上面20个线程都全部计算完成后,再用main线程取得最终的结果值是多少
        // Thread.activeCount() > 2 说明线程还没有算完(后台默认两个线程:main和GC)
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }

        // 如果最终结果是20000,则符合原子性,否则,不符合
        // 控制台结果:输出的计算结果每次都不同 -- 说明volatile不保证原子性
        System.out.println(Thread.currentThread().getName() + "\t finally num value: " + myData.num);
    }

}

/**
 * 主物理内存
 */
class MyData {

    /**
     * 共享变量
     */
    volatile int num = 0;

    /**
     * 注意,此时num前面是加了volatile关键字修饰的,为了验证volatile不保证原子性
     */
    public void addPlusPlus() {
        num ++;
    }

}

4.1 为什么 volatile 不保证原子性(num++)?

参考图示,一步步分析:
在这里插入图片描述

  1. 假设主内存中有个 volatile 修饰的变量 num,volatile num = 0
  2. 在并发的情况下,线程 A、B、C 都要来执行addPlusPlus()中的 num++ 操作
  3. 由于线程在自己的工作场合操作完成后,都要将自己的修改产物写回给主内存。当 A、B、C 同时读到了num = 0:
  4. 正常情况下:
    ① A、B、C 读取到了num = 0,保存在各自的工作内存;
    ② A 在执行完num ++后,写回到主内存,此时主内存中num = 1,然后,A 通知其它线程主内存的值发生了变化(num 被 volatile修饰,volatile 保证可见性);
    ③ B 及时接收到通知,再次读取到主内存的num = 1,执行完num ++后,写回主内存,此时主内存中num = 2。然后,B 通知其它线程主内存的值发生了变化;
    ④ C 及时接收到通知,再次读取到主内存num = 2,执行完num ++后,写回到主内存,此时主内存中num = 3。然后,C 通知其它线程主内存的值发生了变化。
    在这里插入图片描述
  5. 但是,由于多线程之间竞争的调度
    ① A 在准备将num = 1写回到主内存的时候,被挂起了
    ② B 顺利竞争到了执行权,执行完num ++操作后,将num = 1写回到了主内存,此时,主内存num = 1
    ③ 然后,B 要通知其它线程主内存的num值已经发生了变化
    ④ 但是,由于线程操作太快,在 B 还没有来得及通知到位的时候,A 获得了主动权,然后 A 将num = 1写回到了主内存,此时,主内存中,A 的num = 1覆盖了之前 B 的num = 1,也就是说,数据发生了丢失。 在这里插入图片描述
  6. 分析到上一步时,问题已经出现了。再进一步分析底层的汇编字节码:
  7. 执行javap -c,num ++ 在底层汇编被拆分成 3 个指令:
    getField   # 获取原始 num 值
    iadd       # 执行加1操作
    PutField   # 将累加后的值写回
    
    也就是说,num ++ 并不是一条指令,实际上是3条指令,这三条指令在执行过程中,任何一个步骤都可以被打断或加塞(如果没有加同步代码块的话),也就造成了第5步分析中出现的问题。

4.2 解决 原子性 问题

  • 使用同步代码块synchronized {}
  • 使用 j.u.c 包下的 AtomicXXX 类
/**
 * 解决原子性问题
 *   加synchronized ---- 杀鸡用牛刀
 *   直接使用j.u.c下面的AtomicInteger
 */
public class VolatileDemo {

    public static void main(String[] args) {
		// 访问资源类
        MyData myData = new MyData();
		// 创建20个线程,每个线程进行num自增1000次的操作
        for (int i = 1; i <= 20; i++) {
            new Thread(() -> {
                for (int j = 1; j <= 1000; j++) {
                    // 保证原子性
                    myData.addByAtomic();
                }
            }, String.valueOf(i)).start();
        }

        // 需要等待上面20个线程都全部计算完成后,再用main线程取得最终的结果值是多少
        // Thread.activeCount() > 2 说明线程还没有算完(后台默认两个线程:main和GC)
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }

        // 如果最终结果是20000,则符合原子性,否则,不符合
        // 控制台结果:输出的计算结果每次都不同 -- 说明volatile不保证原子性
        System.out.println(Thread.currentThread().getName() + "\t AtomicInteger type, finally num value: " + myData.ai);

    }

}

/**
 * 主物理内存
 */
class MyData {

    /**
     * 共享变量
     */
    volatile int num = 0;

    /**
     * 使用原子类
     */
    AtomicInteger ai = new AtomicInteger();
    public void addByAtomic() {
        ai.getAndIncrement();
    }

5、volatile 禁止指令重排

由于编译器或处理器存在指令重排,程序执行的顺序和数据可能会被打乱,底层认为这是一种优化,但在多线程环境下,却可能会导致数据的最终一致性无法保证。

volatile 实现禁止指令重排优化,从而避免多线程环境下出现乱序执行的现象。

5.1 内存屏障

内存屏障是一个 CPU 指令,它有两个作用:

  • 保证特定操作的执行顺序
  • 保证某些变量的内存可见性

由于编译器和处理器都能进行指令重排优化,如果在指令间插入一条内存屏障(它其实就是一条指令),就会告诉 编译器和 CPU ,不管什么指令,都不能和这条 内存屏障 指令重排序,也就是说,通过插入 内存屏障 禁止在 内存屏障 前后的指令进行重排优化。内存屏障的另外一个作用,就是强制刷出各种 CPU 的缓存数据,因此,任何 CPU 上的线程都能读取到这些数据的最新版本。
在这里插入图片描述
JMM 规定 多线程开发 必须遵守 可见性、原子性、有序性,来保证线程的安全性。而 volatile 只满足了 可见性 和 有序性,volatile不保证原子性,因此,也就是常说的,volatile 是一种轻量级的同步机制

更新记录


喜欢就点个赞呗~~(*/ω\*)

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值