[JUC第一天]浅谈volatile关键字

概述

在Java语言规范第三版中,volatile关键词的定义如下:

Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地使用和更新,线程应该确保通过排他锁单独获得这个变量

并发编程中有两个很常见的关键词:synchronizedvolatile

volatile可以用来修饰一个变量,使得并发情况下所有线程得到这个对象的值都是一样的

相比与synchronized操作会找个东西当锁,volatile则是通过实时共享变量的值的方式来保证变量的可见性的,而并没有锁什么东西,所以说他的使用并不会引起程序的上下文切换,所以也说,volatile是轻量级的synchronized

volatile最大的两个特点就是:

  • 使得内存模型中所有线程获取到的值都是统一的(可见性)
  • 避免指令在执行的时候因为优化机制重排序而出错

可见性

内存可见性:每一个工作线程看到的某个变量的值都是相同的,而且是他最新的状态

为什么有时会不可见

这首先就要从计算机的缓存说起了:

  • 很久以前,计算机的CPU和内存是直接连着的,但是这样导致的是传输速度跟不上CPU的运算速度
  • 后来的计算机中通过设置缓存的方式,在两者之间放了一个容量较小但是读取速度更快的容器,类似于一个中转站
  • 再后来,一级的缓存也更不上了,所以又继续发展成了多级缓存:L1、L2、L3…
  • 到后来,电脑变成多核的了,每个核也有自己的多级缓存缓存了…

大概类似这样一张图:

image-20200303205119392

所以说,如果当L3中有一个变量a被Core1,2,3,4都使用的时候,会把这个变量的值复制一份到每个Core对应的自己的L1或者L2缓存中去

如果在多线程执行的情况下,那么多个核就有可能都对a变量进行修改,但是首先他们修改到的数据其实是来自自己的L1或者L2缓存的,比如下面这个例子:

Core1 修改a的值从0变成了100

Core2 从自己的L1中读取a的值,并修改a的值为50

Core2把a的值从L1回写到共享的L3中

Core1把a的值从L1回写到共享的L3中

最后得到的结果是:a是100,而不是50(而且Core2在读a的时候也认为a是0而不是100)

这就是不可见的问题

如何解决

由于OS和JVM比较菜,所以这里先不详细讲,等以后补充,具体的话:在整成汇编之前用LOCK修饰使得强制回写到主内存然后触发MESI协议???cpu总线嗅探机制???但是底层在赋值的时候还是会使用LOCK原子操作???

在对使用了volatile关键字进行了写操作后,在JIT编译的阶段会多做这样一件事:把当前对象的值写回缓存,然后发送一条信号(不确定???),使得其他CPU里的对该数据的缓存全部失效,这样,其他CPU在使用到这个值的时候,就不得不再去L3缓存里获取数据了,这样他们也就得到的是最新的数据了

所以,volatile变量就保证了在每次读取的时候,都会从主内存中获取到最新的值,每次写入后也会直接刷新主内存的值

不过这只是在操作系统层面,对于Java虚拟机来说,他也有自己的内存模型

Java内存模型中,还是差不多的,只要看到有volatile的值,那么也会这样类似地使得每个工作线程去获取最新值

防止重排序

重排序,就是你写的程序,在执行的时候,系统觉得,哪几行代码之间没有关系,可以调换顺序来执行,然后他就会按照他认为的最优的顺序来执行(所谓的乱序执行)

一个有趣的例子

public class OutofOrderExecution {
    private static int x = 0, y = 0;
    private static int a = 0, b = 0;
    
        public static void main(String[] args)
            throws InterruptedException {
        Thread t1 = new Thread(new Runnable() {
            public void run() {
                a = 1;
                x = b;
            }
        });
        Thread t2 = new Thread(new Runnable() {
            public void run() {
                b = 1;
                y = a;
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("(" + x + "," + y + ")");
    }
}

他的输出结果会是(0,0)or(1,1)or(1,0)or(0,1)

为什么呢,因为比如a=1 和x = b这两条语句就是符合乱序规则的,在执行的时候有可能被交换…

重排序会导致什么

用那个最经典的DCL单例模式的错误写法来说事:

public class Singleton {  
     private static Singleton instance = null;  
     private Singleton (){
     }
     public static Singleton getInstance() {  
     		if(instance==null)
            {
                synchronized(Singleton.class)
                {
                      if(instance==null)
                      {
                          instance= new Singleton();  
					  }
                }
            }
     }  
    return instance;
 }  

这样的话容易导致的问题是:多线程首次一起调用,有人可能会拿到“半个对象”

问题是出在这句话:

instance = new Singleton();

这句话他并不是一个原子性的操作,也就是对于JVM来说,他可以被拆分成几个小步骤然后分开执行:

//分配内存空间
memo = allocate();
//调用init方法,执行一系列比如加载元数据之类的操作
init(memo)
//传递引用    
instance = memo    

但是在实际上执行的时候,第三步的执行顺序是与前两步无关的,虚拟机有可能会把他放到第一句前面,也有可能放到第二句前面,反正对于本线程来说,都是一样的,所以他就有可能这样做

可是对于其他线程来说就不一样了

比如虚拟机现在把执行顺序变成了下面这样,刚刚开始实例化一个对象:

//分配内存空间
memo = allocate();
//传递引用    
instance = memo    
//调用init方法,执行一系列比如加载元数据之类的操作
init(memo)

然后这时候又有另外一个线程B,恰好就在调用这个方法

  public static Singleton getInstance() {  
     		if(instance==null)    <-----------另外一个线程B在这里
            {
                synchronized(Singleton.class)
                {
                      if(instance==null)
                      {
                          instance= new Singleton();  <------------A线程在这里初始化到一半,刚分配完内存地址
					  }
                }
            }
      return instance;
     }  

这时候,由于instance==memo!=null,所以if得到的结果就是false,会直接触发return instance,这就导致了线程B实际上获得到的只有没实例化完成的半个对象

内存屏障

由于OS和JVM比较菜,所以这里先不详细讲,等以后补充,比如Java happens-before

volatile关键字通过内存屏障的方式,就能够解决上面的问题

内存屏蔽,从表面上讲,就是:在一堆指令中划分一条界限,在这个界限之前的操作必须全部执行完了,后面的才能执行,比如

A

B

C

D

-----内存屏障

E

F

G

对于上面的部分ABCD,他们之间的执行顺序是可以由编译器来随便调整的,但是必须等到他们执行完了之后,程序才会接着往下走,去执行EFG

而每当使用volatile关键词的时候,在编译的时候,都会编译插入一个内存屏障

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。然而,对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,Java内存模型采取保守策略。

下面是基于保守策略的JMM内存屏障插入策略:

  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。

所以,上面说道的DCL的正确写法应该是给instance变量加上一个volatile属性,这样,在编译的时候,由于有内存屏障所在,他那个初始化的动作三条语句就不能被自动重排序了,instance指针指向的memory必定是在初始化完成了之后的空间,他就不会出现半个对象的问题了

原子性

什么是原子性

原子的本意即为:不可再被分割更小的粒子(请手动忽略夸克的存在)

不可被中断的一步或者一系列操作就是原子操作

比如:

//赋值操作
a = 1

但是,自增操作就不是原子性的!

count++;

和上面new instance的例子类似,count++也会被拆分成若干个步骤:先获取值,然后再增加,然后再赋值

所以:自增不是原子操作!!!(想自增原子请用AtomicInteger类)

volatile变量不具有原子性

CPU里有个叫时间片的概念,每个线程只有一定的时间来使用CPU,时间一到就有可能把控制权交给别人,所以说,如果不是原子性的操作(比如 上面的自增操作),很有可能执行到一半,还没来得及赋值呢,使用权就切到别人手里了

之前说过,由于volatile本身就是让变量具有共享能力,而并没有用锁,所以说,volatile变量是不具备原子性的,看这样一个例子:

某个volatile变量a的初始值是0,在A线程中执行自增操作的时候,执行到一半,自增了还没有赋值(a的值仍然是0,但是有个值为1的中间变量打算给他赋值),CPU控制权切换给了别人,首先,这时候别人读取到的值是0,这就很不原子了,然后现在控制权分到了别人手上,别人在这期间是可以修改这个变量的,假如别人对这个变量赋了另外的值:100,然后CPU又切回到了A,A接着把没有赋完的值给a,所以最后的结果又变成了1,别人的赋值就失效了


但是我后来又看到另外一个说法:比如两个volatile都增加了然后开始回写的时候,如果有一个稍微先一点点,则他会执行lock动作,然后触发MESI的CPU总线嗅探机制,让另外一个没来得及写的直接失效???

然后,我再列举一段网上都用烂了的代码来展示一下:

package com.imlehr.juc.chapter1;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class NotAtomic {
    public static volatile int num = 0;
    
    public static void main(String[] args) throws InterruptedException {
        
        Runnable r = ()->{
              for(int j=0;j<10000;j++) {
                  num++;
              }
        };

        //先不管那么多(虽然我知道这样创建线程池不好)
        ExecutorService e = Executors.newFixedThreadPool(100);

        for(int i=0;i<20;i++)
        {
            e.execute(r);
        }
        
        //等任务全部执行完
        TimeUnit.SECONDS.sleep(20);
        
        //输出结果
        System.out.println(num);

        
    }
}

这就是20个线程每个线程让num增加10000,反正结果不一定等于20*10000

所以说,综上,你想要volatile变量的这些操作也是原子性的,那你就要考虑用锁了(或者把上述的volatile int改成用AtomicInteger系列代替,就可以不同volatile了)

参考文章:

https://monkeysayhi.github.io/2016/11/29/volatile关键字的作用、原理/

https://www.hollischuang.com/archives/2648

发布了44 篇原创文章 · 获赞 105 · 访问量 7万+

猜你喜欢

转载自blog.csdn.net/qq_43948583/article/details/104725108
今日推荐