关于volatile与指令重排序的探讨

简介: 关于volatile与指令重排序的探讨

写在开头

在之前的学习我们了解到,为了充分利用缓存,提高程序的执行速度,编译器在底层执行的时候,会进行指令重排序的优化操作,但这种优化,在有些时候会带来 有序性 的问题。

那何为有序性呢?我们可以通俗理解为:程序执行的顺序要按照代码的先后顺序。 当然,之前我们还说过发生有序性问题时,我们可以通过给变量添加volatile修饰符进行解决。那么今天,我们继续学习,一起探讨一下volatile与指令重排之间的冤家路窄!

有序性问题

首先,我们来回顾一下之前写的一个关于有序性问题的测试类。

【代码示例1】

int a = 1;(1)
int b = 2;(2)
int c = a + b;(3)

上面的这段代码中,c变量依赖a,b的值,因此,在编译器优化重排时,c肯定会在a,b赋值以后执行,但a,b之间没有依赖关系,可能会发生重排序,但这种重排序即便到了多线程中依旧不会存在问题,因为即便重排对执行结果也无影响。

但有些时候,指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致,我们继续看下面这段代码:

【代码示例2】

public class Test {
   

    private static int num = 0;
    private static boolean ready = false;
    //禁止指令重排,解决顺序性问题
    //private static volatile boolean ready = false;

    public static class ReadThread extends Thread {
   

        @Override
        public void run() {
   

            while (!Thread.currentThread().isInterrupted()) {
   
                if (ready) {
   //(1)
                    System.out.println(num + num);//(2)
                }
                System.out.println("读取线程...");
            }
        }
    }

    public static class WriteRead extends Thread {
   

        @Override
        public void run() {
   
            num = 2;//(3)
            ready = true;//(4)
            System.out.println("赋值线程...");
        }
    }

    public static void main(String[] args) throws InterruptedException {
   
        ReadThread rt = new ReadThread();
        rt.start();

        WriteRead wr = new WriteRead();
        wr.start();

        Thread.sleep(10);
        rt.interrupt();
        System.out.println("rt stop...");
    }
}

我们定义了2个线程,一个用来求和操作,一个用来赋值操作,因为定义的是成员变量,所以代码(1)(2)(3)(4)之间不存在依赖关系,在运行时极可能发生指令重排序,如将(4)在(3)前执行,顺序为(4)(1)(3)(2),这时输出的就是0而不是4,但在很多性能比较好的电脑上,这种重排序情况不易复现。
这时,我们给ready 变量添加一个volatile关键字,就成功的解决问题了。

原因解析

volatile关键字可以禁止指令重排的原因主要有两个!

一、3 个 happens-before 规则的实现
  1. 对一个 volatile 变量的写 happens-before 任意后续对这个 volatile 变量的读;
  2. 一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作;
  3. happens-before 传递性,A happens-before B,B happens-before C,则 A happens-before C。

二、内存屏障
变量声明为 volatile 后,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。

内存屏障(Memory Barrier 又称内存栅栏,是一个 CPU 指令),为了实现volatile 内存语义,volatile 变量的写操作,在变量的前面和后面分别插入内存屏障;volatile 变量的读操作是在后面插入两个内存屏障。

具体屏障规则:
1) 在每个 volatile 写操作的前面插入一个 StoreStore 屏障;
2) 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障;
3) 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障;
4) 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。

屏障说明:
1) StoreStore:禁止之前的普通写和之后的 volatile 写重排序;
2) StoreLoad:禁止之前的 volatile 写与之后的 volatile 读/写重排序;
3) LoadLoad:禁止之后所有的普通读操作和之前的 volatile 读重排序;
4) LoadStore:禁止之后所有的普通写操作和之前的 volatile 读重排序。

OK,知道了这些内容之后,我们再回头看代码示例2中,增加了volatile关键字后的执行顺序,在赋值线程启动后,执行顺序会变成(3)(4)(1)(2),这时打印的结果就为4啦!

volatile为什么不能保证原子性?

我们讲完了volatile修饰符保证可见性与有序性的内容,接下来我们思考另外一个问题,它能够保证原子性吗?为什么?我们依旧通过一段代码去证明一下!

【代码示例3】

public class Test {
   
    //计数变量
    static volatile int count = 0;
    public static void main(String[] args) throws InterruptedException {
   
        //线程 1 给 count 加 10000
        Thread t1 = new Thread(() -> {
   
            for (int j = 0; j <10000; j++) {
   
                count++;
            }
            System.out.println("thread t1 count 加 10000 结束");
        });
        //线程 2 给 count 加 10000
        Thread t2 = new Thread(() -> {
   
            for (int j = 0; j <10000; j++) {
   
                count++;
            }
            System.out.println("thread t2 count 加 10000 结束");
        });
        //启动线程 1
        t1.start();
        //启动线程 2
        t2.start();
        //等待线程 1 执行完成
        t1.join();
        //等待线程 2 执行完成
        t2.join();
        //打印 count 变量
        System.out.println(count);
    }
}

我们创建了2个线程,分别对count进行加10000操作,理论上最终输出的结果应该是20000万对吧,但实际并不是,我们看一下真实输出。

输出:

thread t1 count 加 10000 结束
thread t2 count 加 10000 结束
14281

原因:
Java 代码中 的 count++并非原子的,而是一个复合性操作,至少需要三条CPU指令:

  • 指令 1:把变量 count 从内存加载到CPU的寄存器
  • 指令 2:在寄存器中执行 count + 1 操作
  • 指令 3:+1 后的结果写入CPU缓存或内存

即使是单核的 CPU,当线程 1 执行到指令 1 时发生线程切换,线程 2 从内存中读取 count 变量,此时线程 1 和线程 2 中的 count 变量值是相等,都执行完指令 2 和指令 3,写入的 count 的值是相同的。从结果上看,两个线程都进行了 count++,但是 count 的值只增加了 1。这种情况多发生在cpu占用时间较长的线程中,若单线程对count仅增加100,那我们就很难遇到线程的切换,得出的结果也就是200啦。

要想解决也很简单,利用 synchronized、Lock或者AtomicInteger都可以,我们在后面的文章中会聊到的,请继续保持关注哦!

结尾彩蛋

如果本篇博客对您有一定的帮助,大家记得留言+点赞+收藏呀。原创不易,转载请联系Build哥!

目录
相关文章
|
缓存
指令重排序的探讨
指令重排序是现代处理器为了提高指令级并行性和性能而进行的一种优化技术。在高并发场景下,指令重排序可能会引发一些问题,本文将详细介绍指令重排序的概念、原因、影响以及如何解决这些问题。
180 0
|
6月前
|
编译器
volatile是如何禁止指令进行重排序的
volatile是如何禁止指令进行重排序的
|
6月前
|
缓存 Java 编译器
JMM内存模型 volatile关键字解析
JMM内存模型 volatile关键字解析
42 0
|
6月前
|
Java 编译器 开发者
为什么代码会重排序
在并发编程中,重排序是一项为了提高性能而进行的优化策略。理解重排序的原理和可能引发的问题对于编写高效且正确的多线程代码至关重要。Java提供了一些机制,如内存屏障,来帮助开发者在多线程环境下保持程序的正确性和可靠性。
57 0
|
6月前
|
Java
6.什么是内存屏障?具有什么作用?
6.什么是内存屏障?具有什么作用?
86 0
6.什么是内存屏障?具有什么作用?
|
缓存 Java 编译器
避免重排序之使用 Volatile 关键字
volatile 关键字本身就包含了禁止指令重排序的语义,仅保证赋值过程的原子性,保障变量的可见性,但不具备排它性
250 0
|
编译器
什么是指令重排序?
什么是指令重排序?
什么是指令重排序?
|
编译器
【内存屏障】
【内存屏障】
111 0
volatile的指令禁重排(六)
volatile的指令禁重排
133 0
volatile的指令禁重排(六)
|
存储 缓存 编译器