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的作用:

  1. 复合操作的原子化和互斥;
  2. 内存可见性。

除了加锁之外,Java也提供另外一种保存内存可见性的方法:volatile变量。一旦一个共享变量被volatile修饰之后,那么久具备两层语义:

  1. 保证了不同线程对共享变量进行操作时的可见性,即一个线程修改了某个变量的值,这个新值对于其它线程来说是立即可见的;
  2. 禁止进行指令重排。

下面通过一段代码来描述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之后就变得不同了:

  1. 使用volatile关键字之会强制将修改的值立即刷新到主存中;
  2. 使用volatile关键字之后,当main线程进行修改时,会导致子线程ReaderThread的cache中的ready的值无效;
  3. 由于子线程ReaderThread缓存中的ready的值无效,所以再次读取ready值时回到主存中读取。

这样,就保证了main线程及其子线程的正常执行。

3. volatile与synchronized的区别:

加锁可以保证原子性和可见性; volatile变量只能保证可见性。

也就是说,volatile变量的操作不会加锁,不会使得操作对象为volatile变量的复合操作原子化,也不会引起执行线程的阻塞,相对于synchronized而言,只是轻量级的同步机制。

4. volatile的禁止指令重排

volatile关键字禁止指令重排有两层含义:

  1. 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定已经全部进行,且结果对后面的操作可见;在其后面的操作肯定还没有进行;
  2. 在进行指令优化时,不能将在对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个功能:

  1. 它确保执行重排时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令重排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
  2. 它会强制将对缓存的修改操作立即写入主存;
  3. 如果是写操作,它会导致其它CPU中对应的缓存行无效。

6. volatile使用的场景

synchronized关键字可以保证原子性和可见性,但是影响执行效率;volatile可以保证变量的可见性,但是不能保证原子性,在某些情况下性能优于synchronized。因此,volatile不能替代synchronized,volatile使用时,应该满足下面2个条件:

  1. 写入变量时不依赖变量的当前值;
  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关键字解析

缓存一致性(Cache Coherency)入门

致谢

本文主要是在参考文献Java并发编程:volatile关键字解析,以及《并发编程实战》的相关章节的基础上,加上作者的理解写的,非常感谢前辈们的无私奉献!

Java并发编程实战3-可见性与volatile关键字的更多相关文章

  1. JAVA并发编程:相关概念及VOLATILE关键字解析

    一.内存模型的相关概念 由于计算机在执行程序时都是在CPU中运行,临时数据存在主存即物理内存,数据的读取和写入都要和内存交互,CPU的运行速度远远快于内存,会大大降低程序执行的速度,于是就有了高速缓存 ...

  2. Java并发编程学习笔记 深入理解volatile关键字的作用

    引言:以前只是看过介绍volatile的文章,对其的理解也只是停留在理论的层面上,由于最近在项目当中用到了关于并发方面的技术,所以下定决心深入研究一下java并发方面的知识.网上关于volatile的 ...

  3. Java并发编程实战 01并发编程的Bug源头

    摘要 编写正确的并发程序对我来说是一件极其困难的事情,由于知识不足,只知道synchronized这个修饰符进行同步. 本文为学习极客时间:Java并发编程实战 01的总结,文章取图也是来自于该文章 ...

  4. Java并发编程实战 02Java如何解决可见性和有序性问题

    摘要 在上一篇文章当中,讲到了CPU缓存导致可见性.线程切换导致了原子性.编译优化导致了有序性问题.那么这篇文章就先解决其中的可见性和有序性问题,引出了今天的主角:Java内存模型(面试并发的时候会经 ...

  5. 【java并发编程实战】-----线程基本概念

    学习Java并发已经有一个多月了,感觉有些东西学习一会儿了就会忘记,做了一些笔记但是不系统,对于Java并发这么大的"系统",需要自己好好总结.整理才能征服它.希望同仁们一起来学习 ...

  6. 《Java并发编程实战》/童云兰译【PDF】下载

    <Java并发编程实战>/童云兰译[PDF]下载链接: https://u253469.pipipan.com/fs/253469-230062521 内容简介 本书深入浅出地介绍了Jav ...

  7. 《java并发编程实战》笔记

    <java并发编程实战>这本书配合并发编程网中的并发系列文章一起看,效果会好很多. 并发系列的文章链接为:  Java并发性和多线程介绍目录 建议: <java并发编程实战>第 ...

  8. 《Java并发编程实战》文摘

    更新时间:2017-06-03 <Java并发编程实战>文摘,有兴趣的朋友可以买本纸质书仔细研究下. 一 线程安全性 1.1 什么是线程安全性 当多个线程访问某个类时,不管运行时环境采用何 ...

  9. java并发编程实战《二》java内存模型

    Java解决可见性和有序性问题:Java内存模型 什么是 Java 内存模型? Java 内存模型是个很复杂的规范,可以从不同的视角来解读,站在我们这些程序员的视角,本质上可以理解为, Java 内存 ...

随机推荐

  1. IDEA 出现 updating indices 卡进度条问题的解决方案并加快索引速度

    缺点: 这样的话,前端的接口(也就是字符串)就搜索不到了. C:\Users\Administrator\.IntelliJIdea2017.3\system  删除里面的caches文件夹(这里的 ...

  2. 菜鸟vimer成长记——第4.1章、通用插件

    简介 关于插件的分类,此系列只会简单的区分为通用插件和编程插件.通用插件的意思是,不基于任何编程语言都可以使用的插件.编程插件,是针对于编程习惯,或者说是针对于某种语言的插件. 计划中,是先把基本常用 ...

  3. JavaScript——引用类型之数组

    前言 之前本菜打算在写完基本类型后写引用类型Object的,因为Object是引用类型的基础,其他的引用类型也是以Object为根本.只是关于对象的基本认识与简单操作确实可写的不多,打算之后与原型.原 ...

  4. MySQL数据库引擎、事务隔离级别、锁

    MySQL数据库引擎.事务隔离级别.锁 数据库引擎InnoDB和MyISAM有什么区别 大体区别为: MyISAM类型不支持事务处理等高级处理,而InnoDB类型支持.MyISAM类型的表强调的是性能 ...

  5. Tetris(俄罗斯方块)

    一天有个小朋友问我OpenGL俄罗斯方块怎么写. 俄罗斯方块分成两部分游戏逻辑和画面渲染. 1. 游戏逻辑 一个简单的俄罗斯方块的逻辑部分需要考虑的情况如下: 1. 方块的表示(坐标, 旋转, 上下左 ...

  6. linux 下awk后统计某一列数据之和简单的命令

    例如有文件test.txt格式如下: aaa:1 bbb:2 cat a|awk -F\: '{print"+"$2}'|xargs echo 0|bc -l

  7. 2019第十届蓝桥杯C++B组题解(赛后重写的,不确保答案正确性,仅供参考)

    先说一下这次的感受吧,我们考场比较乱,开始比赛了,还有的电脑有故障,(向这些人发出同情),第一次认真参加比赛,真正比赛的时候感觉没有那么正式,很乱,各种小问题,(例如博主就没找到题目在哪里,找到后又不 ...

  8. Paxos共识算法

    Paxos共识算法 paxos是一族用来解决分布式系统共识的基础算法,共识过程就是在一组节点上达成一个一致的结果.由于节点可能会错误,通讯消息也可能会丢失,所以建立共识是一个比较复杂的过程. paxo ...

  9. Notes of Daily Scrum Meeting(11.4)

    Notes of Daily Scrum Meeting 2014年11月4日  星期二  20:30—21:00 团队成员 今日团队任务 当日工作分配额 完成情况 陈少杰 阅读理解代码中底层与数据库 ...

  10. BNUOJ 52317 As Easy As Possible 树上倍增/主席树

    题目链接: https://acm.bnu.edu.cn/v3/problem_show.php?pid=52317 As Easy As Possible Case Time Limit: 1000 ...