我是一名很普通的双非大三学生。接下来的几个月内,我将坚持写博客,输出知识的同时巩固自己的基础,记录自己的成长和锻炼自己,备战2021暑期实习面试!奥利给!!
volatile也是多线程这块经常问到的基础问题,以volatile关键字作为一个小的切入点,往往可以一问到底,把Java内存模型(JMM),Java并发编程的一些特性都牵扯出来,我们这篇文章就来学习一下volitile关键字吧!
说起volatile,肯定少不了Java内存模型, Java内存模型(Java Memory Model,JMM)是Java虚拟机规范定义的,用来屏蔽掉java程序在各种不同的硬件和操作系统对内存的访问的差异,这样就可以实现Java程序在各种不同的平台上都能达到内存访问的一致性 。
注意:这里不要把Java内存模型和Java内存结构搞混了!
从上图可以看出,在线程执行时,首先会从主存中read变量值,再load到工作内存中的副本中,然后再传给处理器执行,执行完毕后再给工作内存中的副本赋值,随后工作内存再把值传回给主存,主存中的值才更新。使用工作内存和主存,虽然加快的速度,但是也带来了一些问题。
也即JMM中的 原子性、可见性和有序性 这3个特征,如何解决这三个问题?这里我们就要用到 volatile
了, 它只保存可见性和有序性 。
volatile volatile
原子性(Atomicity)是指一个操作不可再被分隔成多步。一个操作或者多个操作要么全部执行且执行的过程不会被任何因素打断,要么就都不执行。volatile不保证原子性!
注意:在Java中,对基本数据类型的读取和赋值操作是原子性操作
比如:
i = 1; i++; 复制代码
上面两行代码中,i = 1是读取操作,所以是原子性操作,i++ 和 i = i + 1其实是等效的,如下
// i++ 其可以被拆解为 1、线程读取i 2、temp = i + 1 3、i = temp 复制代码
读取i的值,加1,再写回主存,那就是3步操作了。所以上面的举例中,最后的值可能出现多种情况,就是因为满足不了原子性。
synchronized(object){ i++; } 复制代码
java.util.concurrent.atomic.AtomicInteger
,它使用的是CAS(compare and swap,比较并替换)算法,即对基本数据类型的 自增(加1操作),自减(减1操作)、以及加法操作(加一个数),减法操作(减一个数)进行了封装,保证这些操作是原子性操作,效率优于第 1 种。 Java就是利用volatile来提供可见性的。当一个变量被volatile修饰时,那么对它的写操作会立刻刷新到主存,强制缓存和主存同步,当其它线程需要读取该变量时,会发现缓存失效,然后去主存中读取新的值,由此保证了变量的可见性。 通过synchronized和Lock也能够保证可见性,线程在释放锁之前,会把共享变量值都刷回主存,但是synchronized和Lock的开销都更大。
JMM对synchronized做了2条规定:
JMM是允许编译器和处理器对指令重排序的,但是规定了as-if-serial语义,即 不管怎么重排序,程序的执行结果不能改变 。而volatile可以禁止指令重排序,所以说其是可以保证有序性的。什么是指令重排序(Instruction Reorder)? 在Java内存模型中,允许编译器和处理器对指令进行重排序,重排序的结果不会影响到单线程的执行,但不能保证多线程并发执行时不受影响。 例如以下代码在未发生指令重排序时,其执行顺序为1->2->3->4。但在真正执行时,将可能变为1->2->4->3或者2->1->3->4或者其他。但其会保证1处于3之前,2处于4之前。所有最终结果都是a=2; b=3。
int a = 0;//语句1 int b = 1;//语句2 a = 2; //语句3 b = 3; //语句4 复制代码
另外,JMM具备一些先天的有序性,即不需要通过任何手段就可以保证的有序性,通常称为 happens-before原则 不懂的可以看这篇文章。
我们常看到的单例模式的实现,典型的双重检查锁定(DCL)这是一种懒汉的单例模式,使用时才创建对象,而且为了避免初始化操作的指令重排序,给 instance
加上了 volatile
,就是用 volatile
保证了
public class VolatileDemo { boolean flag = true; public void test() { System.out.println("test begin------"); while (flag){ // System.out.println("------"); } System.out.println("test end------"); } public static void main(String[] args) throws InterruptedException { VolatileDemo volatileDemo = new VolatileDemo(); new Thread(volatileDemo::test,"线程1").start(); TimeUnit.SECONDS.sleep(3); volatileDemo.flag = false; System.out.println("main end----"); } } 复制代码
运行结果:
这段代码
flag = false
注意:上面注释了一行输出语句,你可以试试放开,就会发现神奇的不需要volatile就可以结束死循环,这是因为println方法中使用了synchronized关键字,前面已经提到了synchronized保证可见性、原子性、有序性。
public void println(String x) { synchronized (this) { print(x); newLine(); } } 复制代码
synchronized
会做如下工作:获得同步锁 -> 清空工作内存 -> 从主内存拷贝对象副本到工作内存 -> 执行代码(计算或者输出等) -> 刷新主内存数据 -> 释放同步锁。
public class AutoIncrement { public volatile int inc = 0; public void autoIncrement() { inc++; } public static void main(String[] args) { final AutoIncrement autoIncrement = new AutoIncrement(); for (int i = 0; i < 10; i++) { new Thread(() -> { for (int j = 0; j < 10000; j++) autoIncrement.autoIncrement(); }, "线程" + i).start(); } //保证前面的线程都执行完,之所以大于2是因为idea中还有一个Monitor Ctrl-Break 线程 while (Thread.activeCount() > 2) { Thread.yield(); } Thread.currentThread().getThreadGroup().list(); System.out.println(autoIncrement.inc); } } 复制代码
按道理来说结果是100000,但是运行下很可能是个小于100000的值。
有人可能会疑问说:“volatile不是保证了可见性嘛,那么一个线程对inc的修改,另外一个线程应该立刻就可以看到的吧”
inc++
是个复合操作啊,上面的原子性内容里面已经解释了i++的分解,它包括 读取inc的值,对其自增,然后再写回主存 。 我们可以假设线程1先执行,此时它读取的inc值为100,然后此时被阻塞了,还没来得及对变量进行修改,所以没有触发volatile规则。线程2此时也读取inc的值,因为线程1没有修改,所以主存里inc的值依旧为100,做自增,然后立刻就被写回主存了,主存此时为101。此时又轮到线程A执行,由于工作内存里保存的是100,所以继续做自增,再写回主存,101又被写了一遍。所以虽然两个线程执行了两次autoIncrement(),但是线程1覆盖了线程2,结果却只加了一次。
有人可能又会问:“volatile不是会使缓存无效的吗?”
又有人说:“线程2将101写回主存,不会把线程1的缓存设为无效吗?”
综上所述,在这种复合操作的情景下,是保证不了原子性的。但是 volatile
在那种设置flag值的例子里,由于对flag的读/写操作都是单步的,所以还是能保证原子性的。
看到这,对volatile也算是有个了解了吧~,还想学习更多的可以去看看volatile底层的实现机制 -> 内存屏障