| |
|
开发:
C++知识库
Java知识库
JavaScript
Python
PHP知识库
人工智能
区块链
大数据
移动开发
嵌入式
开发工具
数据结构与算法
开发测试
游戏开发
网络协议
系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程 数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁 |
-> Java知识库 -> java并发编程(4) 有序性-详细说说volatile -> 正文阅读 |
|
[Java知识库]java并发编程(4) 有序性-详细说说volatile |
文章目录前言在这篇文章中对 volatile 进行说明,这个关键字在很久之前就想写一篇文章了,在《Java并发编程的艺术》这本书里面其实有很详细的介绍,这篇文章结合这本书以及自己的一些理解来写的。如果哪里有错欢迎指出!!! 一. 引入在并发编程中 synchronized 和 volatile 都有着重要的作用,volatile 是轻量级的 synchronized,在多线程器开发的过程中保证了线程的可见性。所谓的的可见性,其实就是在并发编程的时候,当共享变量发生变化的时候,其他的线程能够同步感受到这种变化。如果 volatile 使用合使,不但可以保证线程安全,还可以提高效率。 举个例子: 下面代码中主线程就算修改了 run 为false,也不会停下来,因为由于要大量访问 run 这个变量,编译器为了优化会在缓存区划出一块来,把 run 放在缓存中,此时主线程修改的变量是缓存中的变量,对于主存中的变量其实没有影响。解决方法就是加上一个 volatile,这个关键字保证了可见性,在主线程修改了缓存中的变量的时候,会马上同步修改主存中的 run 变量,那么线程再读取 run 这个变量的时候读到的就是 false 了,而不是 true。
二. volatile的应用1. volatile的定义和实现原理Java 语言规范第三版对 volatile 的定义如下:Java 程序语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁单独获取这个变量。 Java 提供了 volatile 关键字用于修饰共享变量,对于修饰的共享变量,Java 线程模型 JMM 确保所有线程获取到的共享变量的值都是同一个。 1 CPU 术语
内存屏障:是一组处理器指令,用于实现对内存操作的顺序限制,在指令重排序中会说到 缓冲行:CPU 高速缓存中可以分配的最小存储单位。处理器填写缓存行的时候会加载整个缓存行,现代 CPU 需要执行几百次 CPU 指令。作用就是读取或者写出数据的时候做一个临时存储的作用,以及对于频繁操作的变量可以存到这里面,加快读取效率。 原子操作:一个或者一系列操作不可被中断,要么全部执行,要不都不执行。就是说线程在执行这类的操作时是不可以被打断的。如果可以保证对共享变量的操作是原子操作,其实就能解决变量写回的问题。 缓存行填充:当处理器识别从内存中读取操作数是可以缓存的时候,处理器读取整个高速缓存行到适当的缓存(L1,L2,L3的或者所有),下次再读取数据的时候就可以从缓存行中直接读取 缓存命中:如果高速缓存行填充操作的内存位置仍是下次处理器访问的位置,那么处理器将从缓存行中读取数据,而不是从内存中。这部分学过计算机组成原理的朋友应该会了解。里面缓存映射这些以及LRU、FIFO等替换策略这些就不细说了 写命中:当处理器将操作数写换一个内存缓存的区域的时候,它会先检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写回缓存,而不是写回内存,这个操作被叫做写命中 写缺失:一个有效的缓存行被写到不存在的内存区域 2 volatile实现原理
然后使用汇编代码输出,我们截取其中的一段来看,其中最重要的就是这个
为了提高处理速度,处理器不直接和内存进行通信,在这里说一下,处理器访问内存的效率顺序: 那么这时候我们加上了 volatile 这条指令,在对该变量进行了写操作的时候,JVM 就会向处理器发送一条 Lock 前缀的指令,就是上面截图那种,将这个变量所在缓存行的数据刷新回到系统内存。但是这时候又会有一个问题,就是如果这时候写回了内存,但是其他线程中的数据还是旧的,那还是会按照旧的数据来执行, 下面引申出缓存一致性协议,处理器基于这个协议来保证数据的最新。 缓存一致性协议: 每个处理器通过嗅探在总线上传播的数据来检测自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从内存里读取数据到处理器的缓存中。所以从这里我们就可以发现了,数据的一致性是由 volatile 和 缓存一致性协议共同决定的。 下面再来说说 volatile 的两条实现原则(这部分不太懂,就直接按书上的来写了) (1)Lock前缀指令会引起处理器缓存写回内存 Lock前缀指令导致在执行指令期间会声言处理器的 LOCK# 信号。在多处理器环境中,这个信号会保证在声言该信号期间,处理器可以独占任何共享内存。你可以理解为有 lock 前缀的指令,会导致共享内存被一个处理器独占。但是在最近的处理器里,LOCK# 信号一般不锁总线,而是锁缓存,这一步你可以理解为总线是数据指令等都要经过的。如果把总线锁了,那么其他的指令就在这个期间发送不出去,导致其他操作无法完成,而且其他 CPU 访问不了总线,那么也访问不了内存。
(2)一个处理器的缓存回写到内存会导致其他处理器的缓存行无效
2. volatile的使用优化JDK7 有一个集合类 LinkedTransferQueue,由并发编程大神Doug lea提出,这个类在使用 volatile 变量的时候,用了一种追加字节的方式来优化入队和出队的性能。下面这段代码使静态内部类 PaddedAtomicReference 中的代码
上面的代码做了一件事,在 1 为什么追加到4字节可以提高并发效率对于英特尔酷睿i7、酷睿、Atom 和 NetBurst,以及 Core Solo 和 Pentium M 处理器的 L1、L2 或者 L3 缓存的高速缓存行是64个字节宽度,不支持部分填充行。这意味着:
2 什么情况不能追加到64字节
三. volatile 的内存语义上面讲解了 volatile 这个变量对可见性的实现原理,当一个变量声明为 volatile 之后,对这个变量的读/写会很特别。下面我们就讨论讨论读写这方面的内容 1. volatile 读写的特性我们来看下面三个方法:
然后分别在多线程下测试:
对比上面三个方法,我们发现对于 volatile 的单个变量读和单个变量的写是没有线程安全问题的,而对于多个 volatile 变量的写是有问题的,我们可以把这三个方法理解为下面的三个方法:
我们把多个 volatile 变量的写转化成这个操作之后就容易理解为什么不能保证线程安全了,因为不是原子性的,也会有线程上下文切换的影响。 总结一下:
2. volatile写-读建立的happens-before关系上面是 volatile 变量自身的特性,下面我们再来看看 volatile 变量的 happens-before关系是如何保证可见性的。
上面线程 t1 首先执行了 writer() 方法,之后线程 t2 接着执行了 reader() 方法。我们使用 happens-before 来说明上面的 1,2,3,4的执行顺序。当然,这里我们不考虑指令重排序,在后面会介绍 volatile 对指令重排序的影响,也就是内存屏障,这里单独说明由happens-before决定的执行顺序的规则
根据上面的图可以看出:
3. volatile 写-读的内存语义这里我们来讨论为什么有结论线程 t1 修改的共享变量在线程 t2 读取了 volatile 变量之后就对 t2 可见了
我们以上面的代码为例,线程 t1 首先执行 writer() 方法,随后线程 t2 执行 reader() 方法,初始的时候两个线程的本地内存中 flag=false,a=0,下面就来看看当线程 t1 进行了 volatile 变量的写之后,共享变量的状态是什么 可以看到,当线程 A 在写入了 flag 变量之后,由于使用了 volatile 修饰符,前面说过,会马上把本地内存 t1 中被修改的两个值刷新回主存,这时候本地内存 t1 和主内存中的变量的值是一致的。根据缓存一致性协议,此时我们知道,这时候 t2 会嗅探到变量已经被改变,然后把当前缓存行中的数据设置为无效状态,下次进行操作的时候再从主存中获取新的数据。 所以,volatile 读内存语义就是:
最终的效果:当线程 t2 要读取 flag 的时候,由于本地内存已经失效了,所以必须从主存中再读取一次,结果就是线程 t2 内存中变量值也和主存中的进行了同步,如果我们把读写步骤连起来看,就像是线程 t1 和 线程 t2 通信,线程 t2 在读取了线程 t1 修改的 volatile 变量之后其他修改的共享变量也对 t2 可见,看起来就像是线程 t1 和 线程 t2 通过volatile变量进行了通信,但是实际上还是 JMM 内存模型的效果,图解如下:
下面看这一段代码:我们首先定义变量 a 和 变量 flag,然后线程 t1 执行写方法,线程 t2 执行读方法,注意我们首先让线程 t2 进行读方法一秒,这是为了让 CPU 感知到我们需要频繁操作 a 和 flag 这两个变量,就加载进 t2 的高速缓存中,这时候再让 t1 去修改 a 的值和 flag 的值,这时候我们发现结果是 t1 修改的值对 t2 不可见:
总结一下 volatile 的读和写的内存语义:
4. volatile 内存语义的实现
1. 对重排序的限制
在上面这篇文章中,没有说 volatile 对重排序的影响,就是为了在这里说明,重排序分为编译器重排序和处理器重排序,为了实现 volatile 语义,JMM 会分别对这两种重排序进行限制,下面介绍对编译器制定的重排序规则:
第一个操作的意思就是第一步,第二个操作的意思就是第二步,比如最后一个表格,第一步执行了 volatile写,如果第二步又执行了 volatile写,则编译器不可以对这两步进行重排序。基于以上几点,下面是几点总结:
处理器限制涉及到了汇编代码,汇编代码我也不太懂,但是核心思想都是指定某个操作是不可被重排序的。下面是在百度上找的一张图片,有兴趣可以自己去了解了解。 2.内存屏障为了实现这种对重排序的限制规则,编译器在生成字节码的时候,会在指令序列中插入内存屏障来禁止特定类型的 处理器重排序。对于编译器来说,发现一个最优步置来最小化插入屏障的总数几乎不可能,意思就是说没办法提前预判出哪个操作到哪个操作之间需要用什么屏障,以此来实现精确插入。为此 JMM 采取了保守策略,在理解策略之前首先得知道两个指令 Store-> 保存指令,Load -> 装载指令。
3.内存屏障理解图示其实上面的策略非常保守,相当于当作下面一定会有这种情况发生来处理,而不是去预判,这样的一个好处就是能够正确得到 volatile 的内存语义,在哪个处理器平台都可以。
我们首先来看看 volatile 写插入内存屏障之后生成的指令序列示意图: 而后面的 StoreLoad 屏障作用就是避免了 volatile 写和后面可能有的 volatile 读/写操作进行重排序。至于为什么一定要插入一个这么的指令,是因为编译器常常无法确认在 volatile 写操作之后是不是需要插入这么一个 StoreLoad 屏障来防止指令重排序,有可能直接就 return 了,所以为了保证能正确实现 volatile 的内存语义,JMM 采取了保守策略:在每个 volatile 写操作后面或者 在每个 volatile 读操作前面加入一个 StoreLoad 屏障。 从整体执行的角度考虑,JMM 最终选择在每个 volatile 写操作后面加了一个 StoreLoad 的屏障。因为 volatile 写 - 读 内存语义的常见模式是:一个线程写 volatile 变量,然后多个线程读取同一个 voaltile 变量。当读线程的数量远远大于写线程时,选择在 volatile 写操作后面插入 StoreLoad 屏障带来的收益比读操作之前加要高,执行效率提升也明显。因为只需要加入少数的 StoreLoad 屏障就得到正确的结果。 从这里也可以看到,JMM 在实现上遵循的一个特点:先追求准确再追求效率
下面再来看看在保守策略下的读屏障: 图中 LoadLoad 指令可以用来禁止处理器把上面的 volatile 读和下面的普通读重排序。 LoadStore 屏障用来禁止处理器把上面的 volatile 读和下面的普通写重排序。理解一下:
4.编译器的优化其实到这也能看出来了,上述的 volatile 读和 volatile 写的内存屏障插入比较保守。实际情况下,编译器在处理这些代码的时候,只要不改变 volatile 写 - 读的内存语义,是会根据具体情况省去不必要的屏障的
在这个方法中,对于
5. JSR-133 为什么要增强 volatile 的内存语义其实这个问题在重排序中已经讨论过了。在 JSR-133 之前的旧 Java 内存模型中,虽然不允许 volatile变量之间进行指令重排序,但是是允许 voaltile 变量和普通变量进行重排序的。比如一段这样的代码:
这段代码中如果没有限制 volatile 变量普通变量的重排序,那么就会出现结果不正确的情况,给出三种情况,假设线程 A 执行 writer(),然后线程B执行 reader() 方法: 可以看到三种不同的情况,其中受指令重排序影响的是第二种,因为指令重排序带来了第二种的效果,当然了初次之外这里设以来控制依赖,这也是一种重排序,就不多说了。我们再看回第一、二种情况,这里想说明的一点就是:线程 B 执行 4 的时候是不一定能看到线程 A 在执行时候对共享变量的修改的。当然,上面这三种情况包括了线程上下文切换的效果,但用正这里也是为了说明了重排序的一个影响。 所以,在旧的缓存模型中,vlatile 的写 - 读 没有锁的释放 - 获取 所具有的内存语义。为了提供一种比锁更轻便的线程之间通信的机制,JSR-133 专家组决定增强 volatile 的内存语义:严格限制编译器和处理器对于 volatile 变量和普通变量的重排序,确保 volatile 的写 - 读和锁的 释放 - 获取具有相同的内存语义。 从编译器的重排序规则和处理器的内存屏障插入策略来看,这两种处理方法都是针对 volatile 和普通变量之间的重排序进行的限制,也确保了内存语义的正确。 而对比 volatile 和锁,其实区别就在单个和整体上。volatile 只是对于当个变量的读/写具有原子性,而锁是对于整个临界区的代码都具有原子性。试想一下,一块临界区的代码使用了 synchronized 修饰之后,除非线程释放了锁,否则就算发生了上下文切换,锁还是在这个线程手中,所以其他线程是不会执行到临界区的代码的,这就是为什么使用 synchronized 可以保证临界区的线程安全了。此外,synchronized 也使得变量具有可见性,使用 synchronized 后,会立刻从主存中读取最新的数据。但是在性能和可伸缩性上,volatile 更加有优势,具体场景具体分析。 四. 总结对于 volatile 的介绍就到这了,《Java并发编程》这本书也提到了,过多使用 volatile 也会降低程序执行的效率。其实在一些设计模式中也会见到 volatile 这个关键字,比如单例模式就是一个很典型的例子,关于为什么单例模式中会用到,其实还是防止指令重排序造成返回的单例实例是空的影响,后面还会继续写文章来说明的。
|
|
|
上一篇文章 下一篇文章 查看所有文章 |
|
开发:
C++知识库
Java知识库
JavaScript
Python
PHP知识库
人工智能
区块链
大数据
移动开发
嵌入式
开发工具
数据结构与算法
开发测试
游戏开发
网络协议
系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程 数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁 |
360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 | -2024/11/24 11:12:41- |
|
网站联系: qq:121756557 email:121756557@qq.com IT数码 |