volatile关键字详解
- 内存模型的相关概念
- 指令在CPU中执行,数据读取在主存中,这个过程为了平衡cpu和主存的速度,cpu引入了高速缓存。也就是说运行时会把数据复制一份到高速缓存,cpu是向这个cache进行读写。运算结束后再把数据刷到主存。
- i = i + 1;这个语句的执行过程:
- 当线程执行这个语句时,会先从主存当中读取i的值,然后复制一份到高速缓存当中,然后CPU执行指令对i进行加1操作,然后将数据写入高速缓存,最后将高速缓存中i最新的值刷新到主存当中。
- 多线程环境下,每个线程运行时有自己的高速缓存,就会出问题:比如初始为0,两个线程分别读i进自己的cache,线程1把变量+1后写入内存,线程2中的值还是0,加1后又写入了内存。最终结果i的值是1,而不是2。这就是著名的缓存一致性问题。通常称这种被多个线程访问的变量为共享变量。
-
为了解决缓存不一致性问题,通常来说有以下2种解决方法:
- 1. 通过在总线加LOCK#锁的方式
- 2. 通过缓存一致性协议
- 这两种都是硬件实现
- 第一种锁住总线期间,其他CPU无法访问内存,导致效率低下。
- 所以就出现了缓存一致性协议。最出名的就是Intel 的MESI协议。MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
- 并发编程中的三个概念
- 原子性
- 即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
- i = 9; 这个在OS中不是原子操作,中间被打断就会出错。
- 可见性
- 可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
- 确实就是高速缓存内的值改了,但没更新主存的值,别的线程就看不到。所以保证可见性就是立即更新主存。
- 有序性
- 指令重排序:他会对指令执行的顺序进行重排序。但可以保证最终结果和原本的顺序执行一样,怎么保证呢?他会考虑指令之间的数据依赖性,有依赖的不会重排。所以指令重排序不会影响单线程。但多线程就有问题了
- 多线程有问题的例子:如果线程1先执行了语句2,那线程2就会跳出循环,但确实线程1的上下文还没加载的。
- 指令重排序:他会对指令执行的顺序进行重排序。但可以保证最终结果和原本的顺序执行一样,怎么保证呢?他会考虑指令之间的数据依赖性,有依赖的不会重排。所以指令重排序不会影响单线程。但多线程就有问题了
- 原子性
- Java内存模型
- Java内存模型规定所有的变量都是存在主存当中(类似于前面说的物理内存),每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。
- Java对原子性的保证:
- 在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。
- 就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。
- Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。
- Java对可见性的保证
- 用volatile关键字来保证。
- 当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
- 另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
- Java允许指令重排,这对单线程没事,但会影响多线程。可以通过volatile关键字来保证一定的“有序性”。
-
另外Java内存模型具备一些先天的“有序性”,也就是 happens-before 原则。
- 如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
- happens-before 原则主要有8个:
- 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
-
锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作
- 第二条规则也比较容易理解,也就是说无论在单线程中还是多线程中,同一个锁如果出于被锁定的状态,那么必须先对锁进行了释放操作,后面才能继续进行lock操作。
- volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
- 第三条规则是一条比较重要的规则,也是后文将要重点讲述的内容。直观地解释就是,如果一个线程先去写一个变量,然后一个线程去进行读取,那么写入操作肯定会先行发生于读操作。
- 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
-
深入剖析volatile关键字
-
1.volatile关键字的两层语义
- 一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:
- 1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
- 2)禁止进行指令重排序。
-
volatile不保证原子性
- 要保证原子性,有三种方法:1.采用synchronized;2.采用lock;4.采用AtomicInteger。
-
volatile保证有序性
- volatile关键字禁止指令重排序有两层意思:
- 1)当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
- 2)在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
- 例子:
- volatile关键字禁止指令重排序有两层意思:
-
1.volatile关键字的两层语义
- 由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。
- 并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。
- 前面:这里如果用volatile关键字对inited变量进行修饰,就不会出现这种问题了,因为当执行到语句2时,必定能保证context已经初始化完毕
-
volatile实现原理
- 观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令” ---《深入理解Java虚拟机》
- lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:
- 1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
- 2)它会强制将对缓存的修改操作立即写入主存;
- 3)如果是写操作,它会导致其他CPU中对应的缓存行无效。
参考资料:
Java并发编程:volatile关键字解析 - Matrix海子 - 博客园 (cnblogs.com)
个人总结思维导图型: volatile关键字详解 - 幕布 (mubu.com)