xl_echo编辑整理,欢迎转载,转载请声明文章来源。欢迎添加echo微信(微信号:t2421499075)交流学习。 百战不败,依不自称常胜,百败不颓,依能奋力前行。——这才是真正的堪称强大!!
参考书籍:《Java高并发编程详解》。尊重原创,支持知识付费,以下内容标记有摘抄的为该书内容,如需查看该书的对应知识点,请购买原版书籍。
参考文章列表:
volatile
什么是volatile
volatile和synchronized相比,volatile被称为轻量级锁,并且能实现部分synchronized的语义。它在处理多线程并发的时候主要保证了共享资源的可见性,该功能可以理解为一个线程修改某一个共享变量的时候,另外一个变量可以读到该共享变量的值。
资源无可见性在代码中产生的问题
基于了解程序原理就先上代码观察结果的思想,我们可以通过观察下面这段代码,先对volatile的基本体现有一个了解。
以下代码来自摘抄
public class VolatileFoo {
final static int MAX = 5;
static int init_value = 0;
//static volatile int init_value = 0;
public static void main(String[] args) {
new Thread(() -> {
int localValue = init_value;
while (localValue < MAX) {
if (init_value != localValue) {
System.out.printf("The init_value is update to [%d]\n", init_value);
localValue = init_value;
}
}
}, "Reader").start();
new Thread(() -> {
int localValue = init_value;
while (localValue < MAX) {
System.out.printf("The init_value will be changed to [%d]\n", ++localValue);
init_value = localValue;
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "Updater").start();
}
}
如果我们对volatile没有了解的情况下,我们看到以上代码会觉得这就是实现创建了两个线程不断的去交替输出一个变量的功能。观看代码确实就是localValue不断的变更,直到localValue等于5的时候,while循环才结束。但是我们可以看到以下结果,与这里的结论不符,证明我们的结论有误
实际的输出结果是我们的Updater线程一直在输出,当localValue=5的时候,我们的Updater线程就会结束循环。而我们的Reader线程却一直在执行,并是处于死循环的状态。这里我们可以看到Reader线程读到的localValue的值应该是一直没有改变,也能够明显的看到一个问题,那就是两个线程访问一个变量,Updater修改变量的值后Reader线程并没有获取到这个值的变化。
volatile的初体验
出现以上问题之后,我们可以看到共享变量的可见性它的重要性,解决上面程序的问题其实也比较简单,只需要在上面做一个小修改即可。将init_value使用volatile进行修饰,其他的不变,我们再一次观察输出结果。
通过输出结果我们可以看到,当Updater线程对init_value进行了改变之后,我们的Reader线程有效的观察到了这个变量的变化,并且跟着输出了Reader线程观察到init_value的结果。和上面不一样的在于,这段代码都能有效的结束程序
深入了解volatile,并有效掌握实现的方式还需要去了解->CPU硬件的运行和JMM内存模型。CPU与我们程序运行之间的关系是怎么样的,底层是怎么出现这些错误的使我们了解volatile必不可少需要掌握的要点。
我们编写的程序与CPU执行的关系
在我们的程序运行中,我们一个程序被CPU有效执行的一个过程要经过写入硬盘,内存加载,CPU访问执行。,硬盘、内存和CPU之间又有很大的区别。
- 硬盘:存储资料和软件等数据的设备,有容量大,断电数据不丢失的特点。也被人们称之为“数据仓库”。我们编写的程序就是存储在硬盘里面
- 内存:1. 负责硬盘等硬件上的数据与CPU之间数据交换处理;2. 缓存系统中的临时数据。3. 断电后数据丢失。可以称为他就是硬盘和CPU之间的桥梁,并且我们的程序编写完成存储到硬盘中之后,开始执行就会被加载进入内存,并等待CPU对内存进行寻址操作。
- CPU:中央处理单元(Cntral Pocessing Uit)的缩写,也叫处理器,是计算机的运算核心和控制核心。执行我们编写的代码CPU只是接收到执行指令,然后对内存进行寻址操作。
硬盘、内存和CPU的存取速度是递增的,内存比硬盘要快很多,但是CPU又比内存块很多倍,CPU的存取速度快到内存都跟不上,所以在CPU和内存之间出现了一个新的东西,那就是CPU Cache。cache的容量远远小于主存,因此出现cache miss在所难免,这也是我们为什么会出现数据问题的关键所在。
CPU cache结构及cache操作数据导致数据不一致的问题
CPU中cache的结构我们可以打开任务管理器,点击性能即可以看到。多核CPU的结构与单核相似,但是多了所有CPU共享的L3三级缓存。在多核CPU的结构中,L1和L2是CPU私有的,L3则是所有CPU核心共享的。
在我们的系统中,由于短板效应,导致即时我们CPU速度再快也没有办法发挥它的能力。由于内存的读取速度远低于CPU,所以导致我们的程序执行速度,被内存限制。但是当cache出现之后完全改变了这个情况,它极大的增大了CPU的吞吐量。CPU只需要到cache中进行读取和写入操作即可,cache会在之后将结果同步到内存。但是当多线程情况下就会出现问题,每个线程都有自己的工作内存,本地内存,对应CPU中Cache。当多个线程同时操作一个变量的时候,都会进行读取到CPU Cache当中,然后在同步会主内存,这样就会导致数据结果不一致。也就是我们平时看到的,多线程结果与预期不一致的问题。
Java内存模型
Java的内存模型(Java Memory Mode)制定了Java虚拟机如何与计算机的主存进行工作,理解Java内存模型对于编写并发程序非常重要。在CPU cache当中我们使用文字描述了多线程情况下出现结果不一致情况,这里我们可以通过Java内存模型的图解来更直观的看到这个情况是怎么出现的。
图中线程1的工作内存和线程2的工作内存就是我们上面描述的当有多个线程操作一个变量时,每个线程就会将变量复制一份到自己的工作内存当中。当我们的多线程执行的时候,每一个线程赋值一份变量,都对值进行修改,当共享变量不可见的时候,最终就会导致结果不一致。
并发编程的三个重要特性
- 原子性
-
- 原型性是指一个操作的完整性,要么该操作改变的值或者资源全部成功,要么全部不成功。
- 有序性
-
- 所谓有序性就是指代码在执行过程当中的先后顺序。
- 可见性
-
- 可见性在我们最上面的例子里面就展现了,就是一个线程修改共享变量的值的时候,另外一个线程能够看到这个变量的值被改变。
在我们多线程并发编程当中,它的三大特性是保证并发执行不出现错误的关键,volatile我们目前能够看到在并发编程当中能够保证可见性。除了可见性外还其实它还可以保证有序性,只是不能保证原子性而已。假若能够保证原子性,它和synchronize的作用基本那就是一样的,只是底层的实现原理不一样而已。
volatile如何保证有序性(摘抄)
volatile关键字对顺序性的保证就比较霸道,直接禁止JVM和处理器对volatile关键字修饰的指令重新排序,但是对于volatile前后无依赖关系的指令则可以随便怎么排序。
volatile可见性的底层实现原理
volatile底层的实现其实是通过lock关键字进行实现的,我们可以去获取class的汇编码,当使用volatile修饰和不使用volatile的代码分别获取到class的汇编码,然后进行对比,你会发现标有volatile的变量在进行写操作时,会在前面加上lock质量前缀。而lock指令前缀会做如下两件事
- 将当前处理器缓存行的数据写回到内存。lock指令前缀在执行指令的期间,会产生一个lock信号,lock信号会保证在该信号期间会独占任何共享内存。lock信号一般不锁总线,而是锁缓存。因为锁总线的开销会很大。
- 将缓存行的数据写回到内存的操作会使得其他CPU缓存了该地址的数据无效。
volatile和synchronize的区别
- volatile只能修饰实例变量或者类变量,synchronize只能修饰方法或者语句块
- volatile无法保证原子性,synchronize能够保证原子性
- volatile和synchronize都能保证有序性,只是实现方式不一样
- volatile不会使线程陷入阻塞,synchronize相反
总结
volatile被称为轻量级的synchronize是因为他能够有效的实现并发编程的有序性和可见性。但是同时它有自己的缺点,比如不能保证原子性的问题。部分场景能够直接volatile,比如对线程的唤起和关闭。synchronize虽然能够保证并发编程有点三要素,但是会造成线程阻塞。