Volatile的引出:
理解volatile关键字需要对java内存模型,cpu缓存模型有一定了解,接下来呢我们深入浅出的看下。先看下下面demo,讲的是一个类里面有两个变量,一个静态常量,一个静态变量,然后其中起两个线程,一个对其修改,一个对其侦查,如果发现修改则输出打印信息。
package VolatilePkg;
import java.util.concurrent.TimeUnit;
/**
* @author Heian
* @time 19/01/18 15:43
* @copyright(C) 2019 深圳市北辰德科技股份有限公司
* 用途:初步认识volatile
*/
public class Demo1 {
/**
* 定义两个线程,一个做数字自增操作,一个做检查数字自增时输出信息
*/
static int init_num = 0;
static final int max_num = 10;
//修改方法,让其不断自增
public void update(){
synchronized (this){
int num = this.init_num;
while (num < max_num){
System.out.println (Thread.currentThread ().getName () + "准备修改" + this.init_num++);
num = this.init_num;
try {
TimeUnit.SECONDS.sleep (2);
} catch (InterruptedException e) {
e.printStackTrace ();
}
}
System.out.println (Thread.currentThread ().getName () + "结束了");
}
}
//侦测线程,当发现数字被修改,则打印信息
public void syso(){
//synchronized (this){这里不能做同步,因为要让此线程检测数据改动
int nu = this.init_num;
while(nu < max_num){
if(nu != this.init_num){
System.out.println (Thread.currentThread ().getName () + "侦查到数字被修改");
nu = this.init_num;
}
}
// }
}
public static void main(String[] args) {
Demo1 demo1 = new Demo1 ();
new Thread (() -> {
demo1.update ();
},"修改线程").start ();
new Thread (() -> {
demo1.syso ();
},"侦查线程").start ();
}
}
运行上面程序,输出结果何如??理想是每修改一条就显示侦查修改线程的打印信息一条,但结果大跌眼镜。
修改线程准备修改0
修改线程准备修改1
修改线程准备修改2
修改线程准备修改3.......
但是将初始值设为用volatile修饰,你会发现侦查线程能感受到数值的变化。原因是:首先侦查线程会先从主内存中所需要的数据缓存到cpu cache中,也就是从主内存缓存到本地内存,修改线程则如此,直到运行结束将从cpu cache中最新的数据刷新到主内存中,但由于修改线程对这个数据是不可见的,所以你修改的东西别人压根拿不到,造成上述结果。
并发编程的三个重要特征:
- 原子性:所谓原子性就不多哔哔了,要么全部成功,要么全部失败。
- 可见性:当一个线程对一个共享变量进行了修改,则另外的线程是可以看到修改后的最新值
- 有序性:程序代码在执行期间,由于Java在编译器以及运行期间的优化,导致了代码的执行顺序未必就是开发者编写代码的顺序,在单线程情况下,无论怎样的重排序都会保证执行结果和代码顺序执行的效果完全一致,但在多线程的情况下,如果有序性得不到保证可能会出现非常大的问题。
private boolean flag = false; private 类 类名 public 类 load(){ if(!flag){ 类=方法(); flag = true; } return 类; }
单线程的情况下,无论怎样重排序最终返回给使用者的类都是可用的,但是多线程的情况下发生了重排序,,比如赋值的操作先执行赋值为true的操作,其它线程也在执行此方法,发现if(!true),直接执行返回类了,但是这个类还是未被加载的类,那么在程序运行的过程中必然出错。
JMM(Java Memory Model)如何保证三大特性:
高并发编程需要三个主要特征,这对正确的运行起着至关重要的作用,JVM采用内存模型的机制来屏蔽各个平台和操作系统之间的内存访问差异,以实现让java程序在各种平台下达到一致的访问内存的效果,比如C语言中整形变量在某些平台占用了两个字节的内存,但在某些平台占用了四个字节的内存,java在任何平台,int类型就是四个字节,这就是所谓的访问一致的效果。
Java的内存模型规定了所有的变量都是存在于主内存中的(RAM),而每个线程都有自己的工作内存或者本地内存(这一点很像CPU的Cache),线程对变量的所有操作都必须在自己的工作内存中进行,而不能直接对内存进行操作,并且每一个线程都不能访问其他线程的工作内存或者本地内存。比如某个线程对变量i进行赋值操作,该线程必须是在本地内存中对i进行修改之后才能将其写入到主内存中。
原子性:
在Java中对基本的数据类型的变量读取赋值操作都是原子性的,对引用类型的变量读取和赋值操作也是原子性的,因此诸如此类的操作都是不可被中断的,要么执行,要么不执行,一荣俱荣一损俱损。不过话虽然简单,但是理解起来未必不会出错。
(1)x=10;赋值操作
x=10的操作是原子的,执行线程首先会将x=10写入到工作内存中,然后将其写入到主内存中(有可能在往主内存刷新数据的时候其它线程也在进行刷新操作,比如另外一个线程将其写为11,但最终结果肯定要么是10,要么数11,单就赋值语句这一点是原子性的)
(2)y=x;赋值操作
1)执行线程从主内存中读取x的值(如果x已经存在于执行线程的工作内存中,则直接获取)r然后将其存入当前的线程的工作内存中
2)在将当前线程的工作内存中的y的值修改为x,然后将y的值写入主内存中
虽然第一步和第二步都是原子操作但合在一起就不是原子操作了
(3)y++;自增操作 (y=y+1也一样)
这条操作语句是非原子性的,因为它包含是三个重要的步骤:
1)执行线程从主内存中读取y的值(如果y已经存在于工作内存中,则直接获取)然后将其存入当前线程的工作内存中
2)在执行线程工作内存中将y的值执行加一的操作
3)将y的值写入主内存中
综合上上面例子,我们可以发现只有一条操作具备原子性,其余都是非原子操作,由此我们可以得出以下结论:
- 多个原子性的操作在一起就不再是原子性了
- 简单的读取和赋值操作是原子性的,将一个变量赋值给另一个变量操作不是原子性的
- java内存模型只保证了基本的读取和赋值操作是原子性的,其他的均不保证,如果想要是的某些代码具备原子性,需要使用关键字syncronized或者JUC中的lock,如果想要使得int等类型具备原子性,可以使用JUC包下的原子封装类型:java.util.concurrent.atomaic.*
总结:volatile关键字不具备保证原子性的语义
可见性:
在多线程的环境下,如果某个线程首次读取共享变量,则首次到主内存中获取该变量,然后存入工作内存中,以后只需要在工作内存读取该变量即可。同样个人对该变量的执行了修改操作,则先将新值写入到工作内存中,然后再刷新到主内存中,但是什么时候最新的值会被刷新到主内存中是不太确定的,这也就解释了为什么上面案例中侦查线程无法获取到最新值得变化
java提供了一下三种方式来保证可见性:
- 使用关键字volatile,当着一个变量被volatile关键字修饰的时候,对于共享资源变量的读操作会直接在主内存中进行(当然也会缓存到工作内存中,当其他线程对共享资源进行修改,则会导致当前线程在工作内存中的共享资源失效,所以必须从主内存中再次获取),对于共享资源的写操作当然是先要修改工作内存,但是修改结束后立即会将其刷新到新的主内存中。
- 通过syncronzied关键字能够保证可见性,能够保证同一时刻只有一个线程获得锁,然后执行同步方法,并且还会确保在释放之前会将对变量的修改刷新到主内存中。
- 通过JUC提供的显示锁Lock也能够保证可见性,Lock的lock方法能够保证同一时刻只有一个线程获得锁然后执行同步方法,并且会确保在锁释放前之前会将对变量的修改刷新到主内存中。
总结:volatile关键字具有保证可见性的语义。
有序性:
在java内存模型中,允许编译器和处理器对指定进行重排序,在单线程的情况下,重排序并不会引起什么问题,但是在多线程的情况下会影响到程序的正常运行,java提供有序性的方式。
- 使用volatile关键字保证有序性
- 使用syncronized关键字保证有序性
- 使用显示锁保证有序性
后两者采用同步的机制,同步代码在执行的时候与单线程情况下自然能够保证顺序性(最终结果的顺序性),此外java内存模型具备一些天生的有序性规则,不需要任何同步手段就能保证有序性,这个规则被称为Happens-before原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就无法保证有序性,也就是说虚拟机或者处理器可以随意对它们重排序处理。下面我们来看看具体有哪些happens-before原则:
- 程序次序规则:在一个线程内,代码按照编写时的次序执行,编写在后面的操作发生于编写在前面的操作之后。这句话的意思看起来是按照编写的顺序执行,但是虚拟机还是可能会对程序代码的指令进行重排序,只要确保在一个线程内最终的结果和代码顺序执行的结果一致即可。
- 锁定规则:有个unlock操作要先发生于同一个锁的lock操作。这句话的意思是无论是单线程还是多线程的环境下,如果同一个锁是锁定状态,那么必须先对其进行释放操作之后才能进行lock操作。
- vloatile变量规则:对于一个变量的写操作要早于这个变量的读操作。根据字面的意思理解是,如果一个变量使用volatile关键字修饰,一个线程对它进行读操作,一个线程对它进行写操作,那么些操作肯定要发生在读操作之前。
- 传递规则:如果操作A要先于操作B,而操作B又先于操作C,则可以的出操作A肯定先于操作C,这一点说明了happnes-before原则具备传递性
- 线程启动规则:Thread对象的start方法先于发生于对该线程的任何操作,只有start之后线程才能真正运行起来,否则Thread也只是一个对象而已
- 线程中断规则:对线程执行interrupt()方法肯定要优先于捕获中断信号,这句话的意思是如果线程收到了中断信号,那么在此之前势必要interrupt()
- 线程的终结规则:线程中所有的操作都要先于发生线程终止检测,通俗来讲,线程的任务执行、逻辑单元执行肯定要发生于线程死亡前
- 对象的终结规则:一个对象初始化的完成先行发生于finalize()方法之前,这个更没什么好说的,先有生后有死
总结:volatile关键字具备保证顺序性的语义
Volatile关键字的语义:
被其修饰的实例变量或者类变量具备两层语义:
- 保证了不同线程之间对共享变量操作是的可见性,也就是说当一个线程修改了vloatile修饰的变量,另外一个线程会立即看到最新的值
- 禁止对指令进行重排序
1)理解volatile保证可见性
上面已经有案例讲的很透彻了,就是每一次修改线程对类变量的修改都会是的侦查线程看到(在happens-before规则中讲过,第三条:对于一个变量的写操作要早于对这个变量的读操作),具体的步骤如下:
- 读线程从主内存获取该类变量的值,将其缓存到本地工作内存
- 写线程在本地工作内存中将类变量的值修改为1,然后立即刷新到主内存中
- 读线程在本地工作内存中的该类变量失效(对应到硬件上就是cup的L1或L2的Chche Line失效)
- 由于读线程工作内存中的类变量失效,需要重新从主内存中获取该类变量的值
2)理解volatile关键字的保证顺序性
volatile对顺序性的保证比较霸道一点,直接禁止JVM和处理器关键字修饰的指令重排序,但是对于volatile前后无依赖关系的指令可以随便怎么排序,比如 int x = 0;int y = 1;volatile int z = 20;x++;y--;在语句vloatile int z = 20之前先执行x的定义还是y的定义我们并不关心,只要能够百分百保证执行到x=20的时候x=0;y=1即可,同理关于x的自增和y的自减都必须在x=20之后发生,在回到上面有讲过的案例,如果对flag用volatile关键字修饰,那就意味着flag = true的时候一定是执行了且完成了对方法()的调用。
private volatile boolean flag = false;
private 类 类名
public 类 load(){
if(!flag){
类=方法();
flag = true;//阻止重排序
}
return 类;
}
3)理解volatile不保证原子性,我们已经说过volatile关键字不保证操作的原子性,这里我们结合JVM的知识分析一下其中的原因:
public class demo1 {
private static vloatile int count_int = 0;
private static AtomicInteger count_atomic = new AtomicInteger(0);
public void add() {
for (int i = 0;i<10000;i++) {
count_int++;
}
}
public static void main(String[] args) {
demo1 demo = new demo1();
IntStream.range(0,100).forEach(value -> new Thread(demo::add).start());
//如果当前线程组的活动线程大于1,先暂停main线程
while (Thread.activeCount() > 1){
Thread.yield();//暂停当前正在执行的线程对象,并执行其他线程。
}
System.out.println(count_int);//998219
}
}
在我博客中有讲过这个案例,创建了100个线程对共享变量进行现在在做分析一下其中原因:
i++的操作是由三步组成:①从主内存中获取count的值,然后刷新到线程的工作内存
②在线程的工作内存中将i的值进行+1操作
③将新的count值写入到主内存中
上面三个步骤都是原子操作,但合起来就不会是了,因为在执行途中很有可能会被其它线程打断例如下面操作情况:
1)假设此时count的值为100,线程A要对变量i执行+1操作,首先需要到主内存中获取i的值,缓存到A的工作内存中,可能此时由于cpu时间调度的关系,执行权切换到了线程B,A线程进入了RUNANABLE状态而不是RUNNING状态
2)线程B同样需要从主内存中读取count的值,由于线程A没有对i做个任何修改操作,因此此时B获取到的最新的值仍然是100
3)于是线程B在工作内存执行+1操作,但是未刷新到主内存中
4)cpu时间片的调度又将执行权限给了线程A,A线程直接对工作内存中的count值+1操作,(有B线程未写入i最新的值,因此A线程工作空间的100不会被失效),变为101,然后将其刷新到主内存中
5)而后B线程也将其在工作内存中的101值刷新到主内存中;这样两次运算实际只对count进行了一次数值的修改变化。
volatile关键字原理:(被其修饰的变量存在于一个locak前缀,相当于一个内存屏障,该屏障为指令提供如下几个保障)
- 确保指令重排序时不会将其后面的代码排到内存屏障之前
- 确保指令重排序时不会将其前面的代码排到内存屏障之后
- 确保在执行到内存屏障修饰的指令时前面的代码全部执行完成
- 强制将线程工作内存中值的修改 刷新到主内存中(修改了立即刷新到主内存)
- 如果是写操作,则会导致其他工作线程中的工作内存(CPU Cache)缓存数据失效
这也解释了为什么不用volatile修改类变量,为什么侦查线程侦查不到值得改动,修改线程没有及时刷新到主内存中,及时刷新了,侦查线程也可能不到主内中获取新值缓存到工作空间,而是一直到自己的工作内存中读取该值,而vloatile会导致数据失效,则必须要获取新值
volatile的使用场景:
虽然volatile关键字有部分syncronized关键字的语义,但是不可能能完全代替syncronized,因为其不具备原子性操作语义,我们使用vloatile关键字也是充分利用了它的可见性和有序性(防止重排序)
(1)开关控制利用可见性特点
package VolatilePkg;
/**
* @author Heian
* @time 19/01/18 18:04
* @copyright(C) 2019 深圳市北辰德科技股份有限公司
* 用途:
*/
public class Demo2 extends Thread{
private static volatile boolean flag = true;
@Override
public void run() {
while (flag){
//do work
}
}
public void shutdowm(){
this.flag = false;
}
}
当外部线程执行shutdown方法时,Demo2会立即看到flag发生了变化(Demo2的工作内存flag失效了,不得不到主内存中重新获取),如果不用volatile修饰,很有可能外部线程在其工作内存中修改了start之后不能及时刷新到主内存中或者Demo2一直到自己的工作内存中读取satrt变量,都有可能导致flag=false不生效,线程无法关闭
(2)状态标记利用顺序性特点(上面有讲过)
private volatile boolean flag = false;
private 类 类名
public 类 load(){
if(!flag){
类=方法();
flag = true;//阻止重排序
}
return 类;
}
(3)Singleton设计模式的double-check也是利用了顺序性的特点
单例模式与类的主动加载以及高并发情况下如何获取唯一实例且保持高效等信息息息相关,尤其是使用volatile关键字对double-check的改进。待续
volatile和syncronized区别:
(1)使用上的区别:
- volatile关键字只能用于修饰实例变量或者类变量,不能修饰方法以及方法参数和局部变量、常量以及方法参数和局部变量、常量等
- syncronized关键字不能用于对变量的修饰只能修饰方法或者代码块
- volatile修饰的变量可以使null,syncronized同步代码块的monitor对象不能为null
(2)原子性的保证
- volatile关键字无法保证原子性
- 由于syncronized关键字是一种拍他的机制,因此被syncronized修饰的代码块是无法被中途打断的,因此能够保证代码的原子性
(3)对可见性的保证
- 两者均可以保证共享资源对多线程间的可见性,但是实现机制完全不同
- syncronized借助于JVM指令monitor enter和monitor exit对通过排他的方式使得同步代码块串行化,在monitor exit时所有的共享资源都会刷新到主内存中
- 相比于syncronized关键字,volatile使用机器指令“lock”的方式迫使得其它线程工作内存中的数据失效,不得不到主内存中再次加载
(4)对有序性的保证
- volatile关键字禁止JVM编译器以及处理器对其进行重排序,所以能够保证有序性
- 虽然syncronized关键字所修饰的同步方法也可以保证顺序性,但是这种顺序性是以程序的串行化执行换来的,在syncronized关键字所修饰的代码块中代码指令也会发生重排序情况比如:
syncronized(this){ int x = 10; int y = 20; x++; y=y+1; }
x和y谁最先被定义和谁最进行运算,对程序来说没有任何影响,另外x和y之间也没有依赖关系,但是由于参与syncronized关键字同步的作用,在syncronized的作用域结束x必定是11,y必定是21,也就是说达到了最终的输出结果和代码编写顺序的一致性
(5)其它
- volatile不会使得线程陷入阻塞
- syncronized会使线程进入阻塞