Java并发编程实战3-可见性与volatile关键字
1. 缓存一致性问题
在计算机中,每条指令都是在CPU执行的,而CPU又不具备存储数据的功能,因此数据都是存储在主存(即内存)和外存(硬盘)中。但是,主存中数据的存取速度高于外存中数据的存取速度(这也就是为什么内存条的价格会高),于是计算机就将需要的数据先读取到主存中,计算机运算完成后再将数据写入到外存中。
但是,CPU的计算能力太强了,CPU从主存中读取写入数据的速度还是太慢了,严重影响了计算机的性能。因此,CPU就有了高速缓存(cache)。也就是说,当程序再运行的时候,会将运算需要的数据从主存复制一份到CPU的高速缓存中,那么当CPU进行计算时就可以直接从它的高速缓存中读取和写入数据,当运算结束后,再将高速缓存中的数据刷新到主存中。
这样的逻辑在单线程中是没有问题,但是到了多线程中就有问题了。在多核CPU中,每个线程可能有运行在不同的CPU上,因此每个线程运行时都有自己的高速缓存;在单核CPU中,多线程是以线程调度的形式分别执行的,线程间的高速缓存也不同。在开始运算时,每个CPU都将主存中的数据复制到自己的高速缓存中,运算完成之后再将数据刷新到主存中,在这个过程中,问题就出现了。如果没有意识到问题的话,请看下面的例子。假设线程A和线程B都要执行下面的代码:
i = i + 1
假定i的初始值为0,线程A从主存中读取i的值到自己的高速缓存cache A中,此时cache A中i的值为0,CPU进行计算后,cache A中i的值变为了1。此时,线程B的执行进度是不确定:(1)如果线程B已经从主存中读取了i的初始值0到到了cache B中,CPU 计算完成之后,cache B中的i的值也变为了1,如果线程A和线程B分别将自己的缓存中的i值刷新到主存中,那么主存中的i的值最终为1;(2)如果线程B还没有从主存中读取i的值,线程A将cache A中的i刷新到主存中,那么主存中i的值为1,线程B再将主存中的i值读取到cache B中并计算并将i=2刷新到主存中,那么最终主存中i的值变为2。上述情况是假定线程A先执行的,如果线程B先执行时同样存在问题。
上面的问题上就是缓存一致性问题。
于是,就出现了缓存一致性协议,最著名的就是Intel的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。由于没有对缓存一致性协议进行详细的了解,本文不再介绍,想要了解的读者请参考文章《缓存一致性(Cache Coherency)入门》。此时,多线程计算模型就如下图所示:

2. volatile关键字
对于缓存一致性问题,我们可以通过对代码块加锁的方法来解决:
synchronized(lock) {
i += 1;
}
通过synchronized来解决缓存一致性问题的原因,《并发编程实战》中是这么写的:
当线程B执行到与线程A相同的锁监视的同步块时,A在同步块之中或之前所做的每件事,对B都是可见的。没有同步,就没有这样的保证。
本文的理解是:相同的锁监视的同步块,每一时刻只能保证有一个线程获得锁并进入,其它的线程就等待或阻塞直到获得获得锁。相当于,同步块中的代码是串行执行的,当然就不会出现缓存一致性的问题了。
注意,无论是书中写的还是本文理解的,都在强调相同的锁监视器,也就是说,不同线程中的synchronized使用的锁要是同一个。
至此,正如《并发编程实战》中写的:
锁不仅仅是关于同步与互斥的,也是关于内存可见的。为了保证所有线程都能够看到共享的、可变变量的最新值,读取和写入线程必须使用公共的锁进行同步。
至此,synchronized的作用:
- 复合操作的原子化和互斥;
- 内存可见性。
除了加锁之外,Java也提供另外一种保存内存可见性的方法:volatile变量。一旦一个共享变量被volatile修饰之后,那么久具备两层语义:
- 保证了不同线程对共享变量进行操作时的可见性,即一个线程修改了某个变量的值,这个新值对于其它线程来说是立即可见的;
- 禁止进行指令重排。
下面通过一段代码来描述volatile关键字的作用:
public class NoVisibility {
private static boolean ready;
private static class ReaderThread extends Thread {
public void run() {
while(!ready){
Thread.yield();
}
}
}
public static void main(String[] args){
new ReaderThread().start();
ready = true;
//when ready=true, do something
}
}
上面的这段代码是常见的采用标记中断线程的方法,然而这段代码却不一定能正常的执行。如同上文所讲,每个线程都有自己的cache,主线程main和子线程ReaderThread都有自己的缓存,开始执行之后,子线程ReaderThread会使用自己缓存中的ready值进行判断,而在主线程main中,在设置ready=ture之后,还要做其它的事情,只是将ready=true写到了自己的cache中,并没有将ready的值刷新到主存中,子线程ReaderThread也就不会停止。
但是,当用volatile修饰ready之后就变得不同了:
- 使用volatile关键字之会强制将修改的值立即刷新到主存中;
- 使用volatile关键字之后,当main线程进行修改时,会导致子线程ReaderThread的cache中的ready的值无效;
- 由于子线程ReaderThread缓存中的ready的值无效,所以再次读取ready值时回到主存中读取。
这样,就保证了main线程及其子线程的正常执行。
3. volatile与synchronized的区别:
加锁可以保证原子性和可见性; volatile变量只能保证可见性。
也就是说,volatile变量的操作不会加锁,不会使得操作对象为volatile变量的复合操作原子化,也不会引起执行线程的阻塞,相对于synchronized而言,只是轻量级的同步机制。
4. volatile的禁止指令重排
volatile关键字禁止指令重排有两层含义:
- 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定已经全部进行,且结果对后面的操作可见;在其后面的操作肯定还没有进行;
- 在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
下面通过代码来理解:
//x、y is not volatile variable
//flag is volatile variable
x = 1; //line 1
y = 2; //line 2
flag = true; //line 3
x = 3; //line 4
y = 4; //line 5
由于flag是volatile变量,x、y不是volatile变量,在进行指令重排的时候,不会将第3行的语句放到第1行或第2行语句的前面,也不会放到第4行或第5行的后面,第1行和第2行的语句可以重排,但是一定始终在第3行的语句的前面,第4行和第5行的语句也可以重排,但是也一定在第3行语句的后面。
虽然,这个小的程序中,并没有大的作用,但是当第1、2、4、5行的语句是一些重要且复杂的语句时,效果就明显了:
//线程1:
context = loadContext(); //语句1
inited = true; //语句2
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
在这个程序中,如果inited变量不是volatile变量,那么语句1和语句2可以重排,就有可能导致语句2先执行,那么导致语句1中的真正执行初始化的操作并没有执行,线程2却已经认为已经初始化了,开始执行操作,就会导致程序错误。
5. volatile的原理和实现机制
volatile是如何保证可见性和禁止指令重排呢,《深入理解Java虚拟机》中有如下解释:
观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,家兔volatile关键字时,会多出一个lock前缀指令
lock前缀指令实际上相当于一个内存屏障(也称内存栅栏),内存屏障会提供3个功能:
- 它确保执行重排时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令重排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
- 它会强制将对缓存的修改操作立即写入主存;
- 如果是写操作,它会导致其它CPU中对应的缓存行无效。
6. volatile使用的场景
synchronized关键字可以保证原子性和可见性,但是影响执行效率;volatile可以保证变量的可见性,但是不能保证原子性,在某些情况下性能优于synchronized。因此,volatile不能替代synchronized,volatile使用时,应该满足下面2个条件:
- 写入变量时不依赖变量的当前值;
- 变量不需要与其它的状态变量共同参与不变约束。
本文的理解是,第一个条件是说,使用这个volatile变量仅仅是提供数值给其它的语句用,本身的修改依赖于自己的当前值;第二个条件是说,volatile变量不能与其它的变量一起参与运算作为某种约束,也就是说此时这个约束仍然不是原子的。
volatile适用的场景:
1. 状态标记位
volatile boolean inited = false;
//线程1:
context = loadContext();
inited = true;
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
2. double check
class Singleton {
private volatile static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if(null == instance){
synchronized (Singleton.class) {
if(null == instance){
instance = new Singleton();
}
}
}
return instance;
}
}
参考资料
致谢
本文主要是在参考文献Java并发编程:volatile关键字解析,以及《并发编程实战》的相关章节的基础上,加上作者的理解写的,非常感谢前辈们的无私奉献!
Java并发编程实战3-可见性与volatile关键字的更多相关文章
- JAVA并发编程:相关概念及VOLATILE关键字解析
一.内存模型的相关概念 由于计算机在执行程序时都是在CPU中运行,临时数据存在主存即物理内存,数据的读取和写入都要和内存交互,CPU的运行速度远远快于内存,会大大降低程序执行的速度,于是就有了高速缓存 ...
- Java并发编程学习笔记 深入理解volatile关键字的作用
引言:以前只是看过介绍volatile的文章,对其的理解也只是停留在理论的层面上,由于最近在项目当中用到了关于并发方面的技术,所以下定决心深入研究一下java并发方面的知识.网上关于volatile的 ...
- Java并发编程实战 01并发编程的Bug源头
摘要 编写正确的并发程序对我来说是一件极其困难的事情,由于知识不足,只知道synchronized这个修饰符进行同步. 本文为学习极客时间:Java并发编程实战 01的总结,文章取图也是来自于该文章 ...
- Java并发编程实战 02Java如何解决可见性和有序性问题
摘要 在上一篇文章当中,讲到了CPU缓存导致可见性.线程切换导致了原子性.编译优化导致了有序性问题.那么这篇文章就先解决其中的可见性和有序性问题,引出了今天的主角:Java内存模型(面试并发的时候会经 ...
- 【java并发编程实战】-----线程基本概念
学习Java并发已经有一个多月了,感觉有些东西学习一会儿了就会忘记,做了一些笔记但是不系统,对于Java并发这么大的"系统",需要自己好好总结.整理才能征服它.希望同仁们一起来学习 ...
- 《Java并发编程实战》/童云兰译【PDF】下载
<Java并发编程实战>/童云兰译[PDF]下载链接: https://u253469.pipipan.com/fs/253469-230062521 内容简介 本书深入浅出地介绍了Jav ...
- 《java并发编程实战》笔记
<java并发编程实战>这本书配合并发编程网中的并发系列文章一起看,效果会好很多. 并发系列的文章链接为: Java并发性和多线程介绍目录 建议: <java并发编程实战>第 ...
- 《Java并发编程实战》文摘
更新时间:2017-06-03 <Java并发编程实战>文摘,有兴趣的朋友可以买本纸质书仔细研究下. 一 线程安全性 1.1 什么是线程安全性 当多个线程访问某个类时,不管运行时环境采用何 ...
- java并发编程实战《二》java内存模型
Java解决可见性和有序性问题:Java内存模型 什么是 Java 内存模型? Java 内存模型是个很复杂的规范,可以从不同的视角来解读,站在我们这些程序员的视角,本质上可以理解为, Java 内存 ...
随机推荐
- Object C学习笔记10-静态方法和静态属性
在.NET中我们静态使用的关键字static有着举足轻重的作用,static 方法可以不用实例化类实例就可以直接调用,static 属性也是如此.在Object C中也存在static关键字,今天的学 ...
- css清除浮动clearfix:after的用法详解
如果外部有一个div容器,其内部div容器设置了float样式,则外部的容器div因为内部没有clear,导致不能撑开.解决方法: CSS代码: 复制代码 代码如下: .clearfix:after ...
- linux安装anaconda3
1,查看系统的版本 Uname –r 2,安装git 等依赖库 yum install git yum install zlib-devel bzip2-devel openssl-devel nc ...
- [leetcode]从中序与后序/前序遍历序列构造二叉树
从中序与后序遍历序列构造二叉树 根据一棵树的中序遍历与后序遍历构造二叉树. 注意: 你可以假设树中没有重复的元素. 例如,给出 中序遍历 inorder = [9,3,15,20,7] 后序遍历 po ...
- day16 类
初识面向对象 1. 面向过程: 一切以事物的流程为核心. 核心是"过程"二字, 过程是指解决问题的步骤, 即, 先干什么, 后⼲什么. 基于该思想编写程序就好比在编写一套流 ...
- Codeblocks自动代码格式化快捷键(自带)
代码区域右击 点format use AStyle 估计也就是考试竞赛逼着用这个
- printf命令详解
基础命令学习目录首页 本文是Linux Shell系列教程的第(八)篇,更多shell教程请看:Linux Shell系列教程 在上一篇:Linux Shell系列教程之(七)Shell输出这篇文章中 ...
- 第九次psp例行报告
本周psp 本周进度条 代码累积折线图 博文字数累积折线图 饼状图
- 20145214 《网络对抗技术》 Web基础
20145214 <网络对抗技术> Web基础 1.实验后回答问题 (1)什么是表单 表单在网页中主要负责数据采集,提供了填写数据.选择数据,收集数据并提交给后台的功能 一个表单有三个基本 ...
- WebGL学习笔记四点二
前几章对图形图形内部多是 以纯色填充,但是现实中已经有许多好的图片了我们没必要一点点画,这一章第五章就是将图片以纹理的形式加载到片元中,主要过程如下,首先是定义点的坐标的attribute变量用于在j ...