当前位置:   article > 正文

Java多线程拾遗(九) Volitate关键字特性分析

volitate关键字
前言

volitate是Java虚拟机提供的轻量级同步机制关键字,但是无法保证线程安全

注意三点:保证可见性、不保证原子性、禁止进行指令重排序。

volatile关键字特性

保证可见性

线程有工作内存,在操作一个变量的时候,会先去主内存拷贝这个变量到自己的工作内存,也就是副本。因为各自保存的是主内存的一个副本,那么当多线程修改时,就会出现错误现象。如图所示
在这里插入图片描述

而使用volitate关键字可以使它修饰的变量的读写操作都必须在内存中进行,这样某个线程更改了这个变量,其他线程读时会去读主内存,前一个线程的修改对当前线程是可见的。

例如,我们使用一个静态变量来控制一个线程的中断,开一个子线程,运行1s后让其终止。

public class VolitateTest {
    public static boolean setThreadStop = false;

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (!setThreadStop)
            {

            }
            System.out.println("线程结束");
        }).start();
        Thread.sleep(1000);

        setThreadStop = true;
        System.out.println("已设置 setThreadStop = true");
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

可以发现,尽管设置了控制线程终端的boolean为true,该线程仍不会终止,这就是因为,开子线程时,拷贝到子线程工作内存的是一个副本。
在这里插入图片描述
而如果加上volitate关键字,那么对该变量的修改就是强制在主内存中进行的。
在这里插入图片描述
可以看到,线程立马结束。
在这里插入图片描述
不保证原子性
原子性跟原子操作有关,比如我们常用的i++,它就不是一个原子操作,我们可以反编译一下看看
在这里插入图片描述
虽然add方法只有一行代码,但是我们可以反编译查看字节码。
在这里插入图片描述
我们使用代码进行测试,也就是多线程进行原子性操作,修改的静态变量使用volitate关键字进行修饰。

public class VolitateTest2 {

     public  volatile static int  num = 0;
     
    public static void  add()
    {
        num++;
    }
    public static void main(String[] args)  {
        for (int i = 0; i < 20 ; i++) {
            new Thread(() -> {
                for (int j = 0; j <1000 ; j++) {
                   add();
                }

            }).start();
        }
        while (Thread.activeCount()>2)
        {
            Thread.yield();
        }
        System.out.println(num);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

在这里插入图片描述
出现了错误,由此可见volitate不可保证原子性。

为什么不可保证原子性呢?

当线程执行这个语句时,会先从内存当中读取 i 的值,并复制一份到高速缓存当中,然后CPU执行指令对i进行加1操作,然后将数据写入高速缓存,最后将高速缓存中i最新的值刷新到主存当中。

问题出在这里:如果线程A和线程B同时操作 i,A读完、改完还没来得及改到内存中的时候,B读了一下i,这时候A改完了刷到内存了,B读到的就不是真实的值了。

于是有了缓存一致性协议,最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。

它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

禁止进行指令重排序

volitate关键字修饰的变量,在编译成汇编代码后,与该变量相关的一些指令,都会加上lock前缀

什么叫指令重排序呢

指令重排是指在程序执行过程中, 为了性能考虑, 编译器和CPU可能会对指令重新排序.

那么理论上会存在重新排序导致的错误的,而volitate则禁止指令重排序,通过内存屏障实现。

内存屏障是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。

内存屏障阻碍了CPU采用优化技术来降低内存操作延迟,必须考虑因此带来的性能损失。

Store1;
Store2;
Load1;
StoreLoad;  //内存屏障
Store3;
Load2;
Load3;
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

对于上面的一组 CPU 指令(Store 表示写入指令,Load 表示读取指令),StoreLoad 屏障之前的 Store 指令无法与 StoreLoad 屏障之后的 Load 指令进行交换位置,即重排序。

但是 StoreLoad 屏障之前和之后的相同指令是可以互换位置的,即 Store1 可以和 Store2 互换,Load2 可以和 Load3 互换。
常见有 4 种屏障

  • LoadLoad 屏障 - 对于这样的语句 Load1; LoadLoad; Load2,在 Load2 及后续读取操作要读取的数据被访问前,保证 Load1 要读取的数据被读取完毕。
  • StoreStore 屏障 - 对于这样的语句 Store1; StoreStore; Store2,在 Store2 及后续写入操作执行前,保证 Store1 的写入操作对其它处理器可见。
  • LoadStore 屏障 - 对于这样的语句 Load1; LoadStore; Store2,在 Store2 及后续写入操作被执行前,保证 Load1 要读取的数据被读取完毕。
  • StoreLoad 屏障 - 对于这样的语句 Store1; StoreLoad; Load2,在 Load2 及后续所有读取操作执行前,保证 Store1 的写入对所有处理器可见。它的开销是四种屏障中最大的(冲刷写缓冲器,清空无效化队列)。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。

Java 中对内存屏障的使用在一般的代码中不太容易见到,常见的有 volatilesynchronized 关键字修饰的代码块(后面再展开介绍),还可以通过 Unsafe 这个类来使用内存屏障。

Happens-Before

JMM 为程序中所有的操作定义了一个偏序关系,称之为 先行发生原则(Happens-Before),简单来说,就是若满足先行发生原则,则代码执行是满足有序性的。

Happens-Before 是指 前面一个操作的结果对后续操作是可见的。

i = 1;       //线程A执行
j = i ;      //线程B执行
  • 1
  • 2

j 是否等于1呢?假定线程A的操作(i = 1)是happens-before 线程B的操作(j = i),那么可以确定线程B执行后j = 1 一定成立,如果他们不存在happens-before原则,那么j = 1 不一定成立。

这就是happens-before原则的威力。Happens-Before 非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们可以通过几条规则一揽子地解决并发环境下两个操作间是否可能存在冲突的所有问题。

  • 程序次序规则 - 一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。
    锁定规则 - 一个 unLock 操作先行发生于后面对同一个锁的 lock 操作。
  • volatile 变量规则 - 对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。
  • 线程启动规则 - Thread 对象的 start() 方法先行发生于此线程的每个一个动作。
  • 线程终止规则 - 线程中所有的操作都先行发生于线程的终止检测,我们可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值手段检测到线程已经终止执行。
  • 线程中断规则 - 对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted() 方法检测到是否有中断发生。
  • 对象终结规则 - 一个对象的初始化完成先行发生于它的 finalize() 方法的开始。
  • 传递性 - 如果操作 A 先行发生于 操作 B,而操作 B 又 先行发生于 操作 C,则可以得出操作 A 先行发生于 操作 C。
参考

B站狂神说
https://space.bilibili.com/95256449
https://dunwu.github.io/javacore/concurrent/Java%E5%86%85%E5%AD%98%E6%A8%A1%E5%9E%8B.html#_4-%E5%86%85%E5%AD%98%E5%B1%8F%E9%9A%9C

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/article/detail/44982
推荐阅读
相关标签
  

闽ICP备14008679号