JUC从入门到成神(基础篇)- volatile

本文详细探讨了Java中的volatile关键字,解释了它如何保证可见性和禁止指令重排,以及与synchronized和内存屏障的区别,帮助理解并发编程中的关键概念。
摘要由CSDN通过智能技术生成

👏作者简介:大家好,我是笙一X,java大二练习生,喜欢算法和Java相关知识。

📕正进行的系列:算法 、JUC从入门到成神、Spring

目录

volatile

可见性

有序性

原理

保证可见性

保证有序性

总结


volatile

可见性

我们先根据一段代码分析一下:

static boolean run = true;

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

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

按步骤分析:

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

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

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

如何解决?

volatile(易变关键字)

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

volatile static boolean run = true;

public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(() -> {
        while(run){
            //....
        }
    });
    t.start();
    
    sleep(1);
    run = false;	
}

其实 synchronized 也可以保证变量的可见性(如下),但我们还是推荐使用volatile

//易变
static boolean run = true;

//锁对象
final static Object lock = new Object();

public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(() -> {
        while(true){
            //...
            synchronized(lock){
                if(!run){
                	break;
            	}
            }
        }
    });
    t.start();
    
    sleep(1);
    log.debug("停止 t");
    synchronized(lock){
        run = false;
    }
}

分析不用 synchronized的原因

前面的例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一个线程可见,不能保证原子性,仅用在一个写线程,多个读线程的情况。比较一下之前线程安全时的例子:两个线程一个 i++,一个 i--,如果是volatile,只能保证看到最新值,不能解决指令交错。

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

如果在前面示例中的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也能正确看到 run 变量的修改。这是因为这个操作可能会导致内存的刷新,从而使得其他线程能够看到之前的修改

有序性

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

static int i;
static int j;

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

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

i = ...;
j = ...;

也可以是

j = ...;
i = ...;

这种特性称之为【指令重排】,多线程下【指令重排】会影响正确性。为什么要有重排指令这项优化呢?从 CPU 执行指令的原理来理解一下:

指令重排优化

为了充分利用计算资源、减少处理器的空闲时间、提高程序的执行效率,从而使得计算机系统能够更快速地完成任务,现代处理器会设计为有个时钟周期完成一条执行时间最长的 CPU 指令。指令还可以划分成一个个更小的阶段,例如,每条指令都可以分为:取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 这5个阶段

现代CPU支持多级指令流水线,例如支持同时执行 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 的处理器,就可以称之为五级指令流水线。这时CPU可以在有个时钟周期内,同时运行五条指令的不同阶段(相当于一条执行时间最长的复杂指令),流水线技术并不能缩短单条指令的执行时间,指令重排优化的主要目的是通过重新安排计算机指令的执行顺序,以最大程度地利用计算资源、减少数据依赖、减少处理器流水线的停顿,从而提高程序的执行速度和吞吐量。

通过指令重排序和组合来实现并行操作。

指令重排的前提是,重排指令不能影响结果,例如

//可以重排的例子
int a = 10;	//指令1
int b = 20; //指令2
sout(a + b);

//不能重排的例子
int a = 10;	//指令1
int b = a - 5;	//指令2

我们可以明白,如果不保证指令的有序性,在多线程情况下,会存在问题:

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已经执行过了)

还有最离谱的情况4:线程2 执行 ready = true,切换到线程1,进入 if 分支,相加为0,再切回线程2 执行num = 2,结果为0

这种现象叫做指令重排,是JIT 编辑器在运行时的一些优化,这个现象需要大量测试才能复现

解决

给 ready 加上 volatile 修饰,防止 ready 操作之前的代码被重排序

原理

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

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

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

保证可见性

写屏障(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;
    }
}

保证有序性

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

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;
    }
}

但是不能解决指令交错

  • 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它前面去

  • 而有序性的保证也只是保证了本线程内相关代码不被重排序

总结

        本次我们主要研究了多线程操作中非常重要的 volatile 关键字,分析其作用和原理。我是笙一X,一个正在努力拼搏的人,感谢你的支持,同时期待交流和指导。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值