多线程之volatile特性及实现原理

多线程之volatile特性及实现原理,第1张

多线程之volatile特性及实现原理 volatile为什么只支持可见性和禁止指令重排序而不支持原子性 一、java内存模型的8种 *** 作及规则 1.8种 *** 作
虚拟机实现时必须保证这8种 *** 作都是原子的、不可分割的(long和double类型的变量来说,某些平台上可能有例外)

1.lock,*锁定*,所用于主内存变量,它把一个变量标识为一条线程独占的状态。
2.unlock,*解锁*,解锁后的变量才能被其他线程锁定。
3.read,*读取*,所用于主内存变量,它把一个主内存变量的值,读取到工作内存中。
4.load,*载入*,所用于工作内存变量,它把read读取的值,放到工作内存的变量副本中。
5.use,*使用*,作用于工作内存变量,它把工作内存变量的值传递给执行引擎,当JVM遇到一个变量读取指令就会执行这个 *** 作。
6.assign,*赋值*,作用于工作内存变量,它把一个从执行引擎接收到的值赋值给工作内存变量。
7.store,*存储*,作用域工作内存变量,它把工作内存变量值传送到主内存中。
8.write,*写入*,作用于主内存变量,它把store从工作内存中得到的变量值写入到主内存变量中

可能有的同学已经产生疑问了?为什么有read还要有load呢,有store为什么还要有write呢?简单的来说就是现代电脑都有不止一个CPU,每个CPU都有自己的1级2级甚至3级缓存,CPU之间共享主存,一个CPU对主存所做的改动并不会自动被其它CPU发现,我们的想将改变后的值写到主内存,就需要将值从JVM Stack中取出放到CPU local memory/CPU cache这一步就是store *** 作,接着就会执行write *** 作(将CPU local memory中的值同步到主内存中)read和load反之。

2、 *** 作规则

一般规则:

	1、不允许read和load、store和write *** 作之一单独出现,以上两个 *** 作必须按顺序执行,但没有保证必须连续执行,也就是说,read与load之间、store与write之间是可插入其他指令的。

    2、不允许一个线程丢弃它的最近的assign *** 作,即变量在工作内存中改变了之后必须把该变化同步回主内存。

    3、不允许一个线程无原因地(没有发生过任何assign *** 作)把数据从线程的工作内存同步回主内存中。

    4、一个新的变量只能从主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use和store *** 作之前,必须先执行过了assign和load *** 作。

    5、一个变量在同一个时刻只允许一条线程对其执行lock *** 作,但lock *** 作可以被同一个条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock *** 作,变量才会被解锁。

    6、如果对一个变量执行lock *** 作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign *** 作初始化变量的值。

    7、如果一个变量实现没有被lock *** 作锁定,则不允许对它执行unlock *** 作,也不允许去unlock一个被其他线程锁定的变量。

    8、对一个变量执行unlock *** 作之前,必须先把此变量同步回主内存(执行store和write *** 作)。

特殊规则:

- 每次使用前从主内存读取
read、load、use必须顺序整体出现。前一个 *** 作是load时才能use,后一个 *** 作时use时才能load。
- 每次修改后立即同步回主内存
assign、store、write必须顺序整体出现。前一个 *** 作是assign时才能store,后一个 *** 作时store时才能assign。
- 避免指令重排序
如果T对V的use或者assign先于T对W的use或者assign,那么T对V的load或者write必须先于T对W的load或者assign。
二、可见性

java内存模型规定:一个变量被定义成volatile之后,只要有一个线程修改了该变量,那么该值对于其他线程来说应该立刻感知的。它的 *** 作不是通过去通知其他线程,而是将强制刷出各种CPU的缓存数据使得当前的线程里面的值无效,使线程重新去读取值(它的汇编层面的实现见下文lock指令)。这就相当于你一直穿着一双臭脚的鞋,每天都放在你老婆漂亮的鞋柜里面,跟你老婆各种名牌高跟鞋放在一起。你每天都去鞋柜拿那双臭脚的鞋穿去上班,终于有一天,你老婆受不了一打开鞋柜就是你那清爽的脚气,所以,她就把你的鞋扔到了垃圾桶。你起床上班发现找不到鞋了(被其他线程刷出CPU缓存)就会去问你老婆你的鞋到哪里去了(请求主内存),你老婆说在垃圾桶。你脸色一沉,跑去垃圾桶,拿出你那双充满熟悉气息的鞋,虽然它粘上了一些奇奇怪怪的东西(被其他线程赋值),但是你还是兴冲冲地穿它去上班(加载到jvm栈中进行运算)。

老公直接就从垃圾桶里面拿出鞋穿到脚上,而不是将其放到鞋柜再穿到脚上。(如果现实中有人这样子做,那他一定是傻子)而在机器执行的时候,它会将鞋子放到鞋柜(CPU Cache)然后再将其穿到脚上(加载进JVM栈),在一般 *** 作中我们可以将这两个 *** 作看成是一个完整的 *** 作,因为内存模型中的规则已经规定了read的后一个动作是load ,load的前一个动作是read,中间可以插入其它指令,java内存模型只要求,顺序执行,没要求是连续执行。反之store和write也是。

三、禁止指令重排序

首先,我们得知道为什么会有指令重排序, 在java代码编译执行的时候,它的顺序可能跟你所编写的代码顺序不太一样。这是因为jvm为了提高效率加入了大量的优化措施,比如两个不关联的对象分配内存初始化可能跟你写的不太一样。
volatile如何实现禁止指令重排序
写一个大家都熟悉的懒汉单例程序吧

package com.hzt.test;


public class LazySingleton {

    private static volatile LazySingleton intance = null;

    
    private LazySingleton() {

    }
    public static  LazySingleton getInstance()     //获取对象公开,保证每次获取到都是统一对象
    {
        if(intance == null){               // 1.下面的检查就是为了防止这里放进去多个线程
            synchronized (LazySingleton.class){
                if(intance == null) {       //2:保证在某一时刻只有一个线程可创建
                    intance = new LazySingleton();
                }
            }

        }
        return intance;
    }
}

通过HSDIS插件输出反汇编内容可以看到

有volatile修饰的变量在movb赋值之后后面紧跟一句lock addXXXX(这句话是的意思是将ESP寄存器里面的值加0)很显然这个是一个空 *** 作,但是有了这个空 *** 作,根据java内存规则(在工作内存改变的变量必须同步到主内存中)就必须同步刷新到主内存,执行一次assign、store和write *** 作。在java内存模型中对volatile定义了特殊的规则:写入的时候有且仅有的顺序是:assign–>store–>write。所以变量在写出主内存的时候前面的 *** 作肯定是已经赋值成功了,这里就形成了一道不可逾越的屏障。
需要注意的是lock指令的作用是将本处理器的缓存写入内存。同时引起别的处理器或者别的内核无效化 ------>上文谈可见性规则的实际汇编层面的 *** 作

四、不支持原子性

错误演示

package com.hzt.test;


public class TestVolatile {

    private static volatile int count = 0;
    
    static void addCount(){
        count++;
    }
    public static void main(String[] args) {
        Thread[] threads= new Thread[20];
        for (int i = 0; i < 20; i++) {
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int j = 0; j < 1000; j++) {
                        addCount();
                    }
                }
            });
            threads[i].start();
        }
        System.out.println(count);
    }
}

上面 代码实例化20个线程,每个线程对volatile修饰的count进行加一 *** 作,为了后期查看字节码方便,我们将count++这个 *** 作进行了简单封装。

运行结果如下图所示

我们一开始的是设想是count加上volatile修饰之后,由于线程每次使用之前都会刷新自己jvm工作栈里面的最新值。所以每次加1的时候,是线程安全的,不会存在数据加多或者加少的情况。

而实际却是volatile修饰的值确实是会每次用之前先去主内存中刷新最新值,但是当volatile取值到jvm栈顶时,此时,主内存中的值已经被修改了。而jvm栈顶的值却是之前的值,当此时jvm栈中数据被刷新进主内存的时候,就发生了值覆盖

读者可自行多次允许上述代码得到的结果肯定是小于等于20000(等于有可能但是极少)

我们可以通过javap -v TestVolatile.class命令查看该类生成的字节码文件

static void addCount();
    descriptor: ()V
    flags: ACC_STATIC
    Code:
      stack=2, locals=0, args_size=0
         0: getstatic     #2    // Field count:I-->将获取指定类的静态域,并将其值压入栈顶 -->也就是将代码中的i取值到栈顶
         3: iconst_1  //将int型1推送至栈顶
         4: iadd    //将栈顶两int型数值相加并将结果压入栈顶
         5: putstatic     #2   // Field count:I-->为指定的类的静态域赋值
         8: return   //从当前方法返回void
      LineNumberTable:
        line 12: 0
        line 13: 8

可以看到,在字节码文件中,先将count取到栈顶,再进行加1 *** 作,再将其赋值返回void,该 *** 作不是原子 *** 作。读者注意,即使一个 *** 作是一行字节码,也不能简单的将其了解为是对应的一条原子 *** 作的语句。
所以,volatile修饰的值不具备原子性,想要实现线程相对安全,可采用syn或者JUC包下的原子类(这里的syn和JUC原子类也有很多原理及需要注意的地方,后期看时间不定期更新)

欢迎分享,转载请注明来源:内存溢出

原文地址: https://outofmemory.cn/zaji/5693192.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-12-17
下一篇 2022-12-17

发表评论

登录后才能评论

评论列表(0条)

保存