Java中的volatile实现机制详细解析
作者:Smallc0de
前言
在任何编程语言中,多线程操作同一个数据都会带来数据不一致的问题,这是由于在多线程的情况下CPU分配时间片并不是按照线程创建顺序去分配的,具有一定的随机性。
一个任务被首先创建出来,并不意味着这个特定的任务一定会首先执行,为了解决并发状态下数据不一致的问题,就有了Lock、synchronized、volatile等等一系列的解决方法。
线程对资源的感知
我们现在有一个游戏的充钱系统,这个系统只有两种功能:充钱、显示余额。我们希望这个系统的功能是这样的,如果玩家一旦充钱,账户变化立刻会被感知到并输出出来。假设账户里有0元:
public class VolatileTest { final static int MAX = 500; //最多500元作为退出条件 static int deposit = 0; //初始余额 public static void main(String[] args) { //显示账户余额线程 new Thread(() -> { int calculate = deposit; while (calculate < MAX) { if(calculate!=deposit){ //当发现本地变量和全局变量不一致时输出 System.out.println("当前余额" + deposit); calculate = deposit; } } }).start(); //充钱线程,每次充钱100元 new Thread(() -> { int calculate = deposit; while (calculate < MAX) { calculate += 100; //改变金额 System.out.println("充钱100元,当前总额" + calculate); deposit = calculate; //回写给全局变量 try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } } }).start(); } }
运行程序,拿到下面的结果:
结果发现,我们的感知线程除了第一次感知到变化外,后续的充钱都没有感知到。但是线程一直再运行并没有停下。
我们接着分析代码逻辑,充钱线程之所以不再打印了是因为超出了充钱最大限制while (calculate < MAX),那么理论上来说deposit现在已经是500了。
但是在显示余额的线程上,并没有感知到deposit被修改,仍然认为while (calculate < MAX)条件依然成立。
在这个显示线程中calculate==deposit==100,所以显示线程会一直空转,直到把CPU资源消耗完毕崩溃才能停下来,这显然是一个非常坏的结果。
如何修改程序呢,其实也非常简单,只要在初始余额变量上加上volatile关键字即可。
static volatile int deposit = 0; //初始余额,加上关键字
重新运行输出结果,这次不仅程序运行符合预期,而且程序顺利执行完毕。每次充钱的线程执行完毕,查询余额的线程立刻感知到并且执行了相应的逻辑,整个程序逻辑上执行顺利,结果符合预期。
问题分析
之所以会发生这样的问题,其实和目前的Java的内存模型有关系。我们知道现在的主机一般都会在CPU和主存之间方置缓存,用来缓冲CPU过快的执行速度与主存相对较慢的IO速度。其实Java的线程也有类似的结果,只不过缓存被本地工作空间代替了,类似于下图。但是要说明一点,我们可以看作每个线程有自己的私有空间(local workspace)和共享空间(main memory) ,实际上Java并没有在物理内存上这样划出来一块,这只是Java执行中的一个概念模型。物理上仍然是CPU – Cache – Memory这样的结构。
在这种结构中,主存中的数据每个线程都可以访问,但是本地工作空间只有本地线程可以访问。
而本地工作空间中方置的东西就是本地变量和主存资源副本,因此线程并不能直接修改修改主存的数据,只能读取到工作空间中,然后由线程拿到CPU中修改。修改完成后,再从私有工作空间刷新回主存。这样所有的线程就可以拿到最新的数据到自己的工作空间里进行操作,这个逻辑在单线程下自然没有问题。
但是多线程的时候,就会出现数据不一致的问题,比如Thread 0在私有工作空间中做了deposit变量修改,但是还没有刷新到主存中的时候。
Thread 1就把主存中的deposit变量copy到了自己的工作空间进行操作,那么Thread 1用的就是旧的数据,计算的结果也就出现了偏差,后来再经过各个线程的互相刷新共享空间的数据,偏差就会越来越大。
volatile的原理
根据我们写的例子来看,volatile关键字加上以后,就可以保证当某个线程对主存中数据修改的时候,其他线程能够感知到这种修改,因此可以基于最新的版本进行后续的操作。
所以volatile关键字应该具有以下用作:
保证数据的可见性:
某个线程对共享数据的修改,其他数据能够立刻感知到,这点是如何做到的呢?首先要先引入一个知识点:缓存的一致性协议(MESI)。
这个协议会使得:
读操作,CPU读取cache中的数据时,不做任何锁操作。
写操作,当CPU将要修改某个共享变量的时候,CPU会发出信号,通知其他的CPU将该变量在其他中缓存中副本所对应的cache line置为无效,这也就导致了其他CPU的缓存中该变量失效,只能再次从主存中读取。
有了这个前提知识以后,有些读者一定想到了:一旦给某个共享变量加上volatile关键字以后,当某个线程要修改共享变量的时候,会通知其他线程,来把其他线程的私有空间中的该共享变量的副本的cache line置为无效,使得其他线程再次去主存中读取最新的值,其对应的硬件原理就是上面所说的MESI协议。通过这样的办法,保证了共享变量在各个线程中的修改可见性,使得所有的线程对共享变量的修改具有感知。但是重新读取并不能可以保证一定可以读取到最新的数据,因此volatile还必须要有更多的功能。
保证线程的有序性:
程序在编译阶段和指令优化阶段会对执行的指令进行重排序,也就是说我们写的代码顺序,并不是程序的执行顺序。这样做的目的是为了提高CPU的执行效率和吞吐量,比如赋值指令的执行效率明显会远远高于运算指令,那么在重排序阶段就会把赋值指令放在一起,运算指令放在一起,以提高总体的效率。这样做在单线程状态下没有问题,但是在多线程状态下,一个变量的先赋值后运算和先运算后赋值就可能会产生很明显的区别。但是对于volatile修饰的变量有这样一个规则来保证程序执行的顺序:
- volatile之前的代码不能调整到它的后面。
- volatile之后的代码不能调整到它的前面。
- volatile修饰的代码,不可以调整顺序。
最终是如何实现这个功能的呢?当解析到被volatile修饰的变量的时候,在汇编代码上该变量会有一个Lock标记,表示该变量被锁住。也就是说想某一个线程使用共享变量的时候,该变量就会被锁住。其他线程由于MESI导致必须去主存哪取新数据的时候,会因为这里有Lock而阻塞,直到这个线程释放该共享变量。分析到最后其实volatile依然是由锁构建的功能,但是这个锁也是一个轻量级的锁。
注意:volatile即使具有上述这些作用,但是并不具有原子性。
volatile的应用
说了半天volatile关键字的原理,这里列举几个常用的场景。
Flag标志:作为控制某个功能或者分支的flag标志使用。
public class VolatileTest implements Runnable{ private volatile boolean flag=false; @Override public void run() { if (flag){ //...code }else{ //...code } } public void close(){ flag=false; } }
双重检查锁定:Double Checked Locking(DCL),一般用在单例模式上。
public class VolatileTest2 { private volatile static VolatileTest2 vt; public static VolatileTest2 getInstance(){ if(Objects.isNull(vt)){ vt=new VolatileTest2(); } return vt; } }
程序的执行顺序必须保证:如果有场景中必须要求某些变量在程序的限定位置出现,而且不能随意变更执行顺序,那么可以对这些变量加上volatile去保证,这些代码的执行顺序是完全按照代码所写的顺序执行的。
volatile与synchronized的区别
之前的博客已经对synchronized做了讲解,我们知道synchronized是加锁用的,那么volatile和synchronized有什么区别呢?我们用一个表格做个对比。
区别 | volatile | synchronized |
语法上 | 只能修饰变量 | 只能修饰方法和语句块 |
原子性 | 不能保证原子性 | 可以保证原子性 |
可见性 | 通过对变量加lock,使用缓存的一致性协议保证可见性 | 使用对象监视器monitor保证可见性,monitorenter,monitorexit,ACC_SYNCHRONIZED |
有序性 | 可以保证有序性 | 可以保证有序性,但是加锁部分变为单线程执行 |
阻塞 | 轻量级锁不会阻塞 | 偏向锁,可能会引发阻塞; 重量级锁,会引起阻塞 |
总结
本文的主要内容就在于要理解volatile的缓存的一致性协议导致的共享变量可见性,以及volatile在解析成为汇编语言的时候对变量加锁两块理论内容。
到此volatile关键字的基本内容告一段落,谢谢大家。
到此这篇关于Java中的volatile实现机制详细解析的文章就介绍到这了,更多相关volatile实现机制内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!