概述
在Java语言规范第三版中,volatile
关键词的定义如下:
Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地使用和更新,线程应该确保通过排他锁单独获得这个变量
并发编程中有两个很常见的关键词:synchronized
和volatile
volatile
可以用来修饰一个变量,使得并发情况下所有线程得到这个对象的值都是一样的
相比与synchronized
操作会找个东西当锁,volatile
则是通过实时共享变量的值的方式来保证变量的可见性的,而并没有锁什么东西,所以说他的使用并不会引起程序的上下文切换,所以也说,volatile是轻量级的synchronized
volatile最大的两个特点就是:
- 使得内存模型中所有线程获取到的值都是统一的(可见性)
- 避免指令在执行的时候因为优化机制重排序而出错
可见性
内存可见性
:每一个工作线程看到的某个变量的值都是相同的,而且是他最新的状态
为什么有时会不可见
这首先就要从计算机的缓存说起了:
- 很久以前,计算机的CPU和内存是直接连着的,但是这样导致的是传输速度跟不上CPU的运算速度
- 后来的计算机中通过设置缓存的方式,在两者之间放了一个容量较小但是读取速度更快的容器,类似于一个中转站
- 再后来,一级的缓存也更不上了,所以又继续发展成了多级缓存:L1、L2、L3…
- 到后来,电脑变成多核的了,每个核也有自己的多级缓存缓存了…
大概类似这样一张图:
所以说,如果当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了)
参考文章:
来源:CSDN
作者:一个JavaBean
链接:https://blog.csdn.net/qq_43948583/article/details/104725108