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. Windows7共享设置

    问题描述:Win7共享文件夹时提示“您没有权限访问xxxx.请与网络管理员联系请求访问权限”     解决方案: 控制面板→网络和共享中心→更改高级共享设置→选中“启用共享以便可以访问网络的用户可以读 ...

  2. python的字符串格式化

    1.python到底有那几种字符串格式化模块? python有3种格式化字符串的方法: 传统的%字符串格式符 str.format函数 字符串模版template 新的python 3.6+还提供了新 ...

  3. mtr的用法场景

    ---引用自阿里云 mtr (My traceroute)也是几乎所有 Linux 发行版本预装的网络测试工具.他把 ping和 traceroute 的功能并入了同一个工具中,所以功能更强大. mt ...

  4. java按照字节切割字符串,解决汉字的问题

    编写一个截取字符串的函数,输入为一个字符串,截取开始地址,截取字节数,输出为按字节截取的字符串. 但是要保证汉字不被截半个, 如“我ABC”,0,4,应该截为“我AB”,输入“我ABC汉DEF”,1, ...

  5. Annotation 使用备忘

    title: Annotation 使用备忘 date: 2016-11-16 23:16:43 tags: [Annotation] categories: [Programming,Java] - ...

  6. Spring Data REST PATCH请求远程代码执行漏洞(CVE-2017-8046) 本地复现方法

      #1背景 Spring Data REST是Spring Data项目的一部分,可以轻松地在Spring Data存储库之上构建超媒体驱动的REST Web服务. 恶意的PATCH请求使用精心构造 ...

  7. bc命令详解

    基础命令学习目录首页 原文链接:https://www.cnblogs.com/lovevivi/p/4359296.html 最近经常要在linux下做一些进制转换,看到了可以使用bc命令,如下: ...

  8. fs - 文件系统

    fs 模块提供了一些 API,用于以一种类似标准 POSIX 函数的方式与文件系统进行交互. 用法如下: const fs = require('fs'); 所有的文件系统操作都有异步和同步两种形式. ...

  9. [linux] ssh远程执行本地脚本

    1.ssh密钥登录 略 2.免确认机器指纹,ssh -o StrictHostKeyChecking=no [root@XM-v125 ~]# ssh wykai@192.168.0.110 The ...

  10. 在虚拟机下安装Ubuntu

    目录: 1.安装虚拟机 2.在虚拟下安装Ubuntu 本文将按照目录分两步来讲一下在虚拟机下安装Ubuntu.第一步是安装虚拟机,第二步是在虚拟机下安装Ubuntu. 安装虚拟机 下载虚拟机链接以及激 ...