并发之volatile底层原理
一.多线程编程底层原理剖析以及volatile原理
二.volatile底层实现原理
三.Java中Volatile底层原理与应用
四.深入分析Volatile的实现原理
-------
一.线程之volatile介绍
1. 定Java 中的1个关键字 / 修饰符。
保证被 volatile修饰的共享变量 的可见性 & 有序性,但不保证原子性
2. 总结
Java中volatile关键字,其作用为保证“共享变量的可见性 & 有序性,总结如下:
3. 具体描述
下面详细讲解 volatile是如何保证 “共享变量 的可见性 & 有序性,但不保证原子性”的具体原理
储备知识:原子性、可见性 & 有序性
3.1 保证可见性
具体描述:volatile修饰的属性保证每次读取都能读到最新的值。但不会&无法更新已经读了的值
原理:
线程A在工作内存中修改的共享属性值会立即刷新到主存,线程B/C/D每次通过读写栅栏来达到类似于直接从主存中读取属性值
只是类似,网上有些说volatile修饰的变量读写直接在主存中操作,这种说法是不对的,只是表现出类似的行为。
读写栅栏是一条CPU指令;插入一个读写栅栏 = 告诉CPU & 编译器先于这个命令的必须先执行,后于这个命令的必须后执行(有序性);
读写栅栏另一个作用是强制更新一次不同CPU的缓存。
例如,一个写栅栏会把这个栅栏前写入的数据刷新到缓存,以此保证可见性
3.2 保证有序性
具体描述:当对volatile修饰的属性进行读/写操作时,其前面的代码必须已执行完成 & 结果对后续的操作可见
原理:
重排序时,以volatile修饰属性的读/写操作代码行为分界线,读/写操作前面的代码不许排序到后面,后面同理不许排序到前面。由此保证有序性
3.3 不保证原子性
具体描述:volatile修饰的属性若在修改前已读取了值,那么修改后,无法改变已经复制到工作内存的值。即无法阻止并发的情况
原理:
// 变量a被volatile修饰
volatile static int a=0;
a++;
// 包含了2步操作:1 = 读取a、 2= 执行a+1 & 将a+1结果赋值给a
// 设:线程A、B同时执行以下语句,线程A执行完第1步后被挂起、线程B执行了a++,那么主存中a的值为1
// 但线程A的工作内存中还是0,由于线程A之前已读取了a的值 = 0,执行a++后再次将a的值刷新到主存 = 1
// 即 a++执行了2次,但2次都是从0变为1,故a的值最终为1
4. 应用场景
由于volatile
保证可见性和有序性,被volatile
修饰的共享属性一般并发读/写没有问题,可看做是一种轻量级的synchronized
实现。
关于synchronized
的讲解具体请看文章:Java:这是一份全面 & 详细的 Synchronized关键字 学习指南
一.java多线程编程底层原理剖析以及volatile原理
总结一下java多线程机制,以及volatile
首先,为什么需要多线程?
主要是因为计算机的运算能力远远大于I/O,通信传输,还有数据库访问等操作。所以缓存出现了,从而提高了访问速度。但是由于会有多个缓存,以及数据读写问题,很有可能会读到脏数据,其实这也就是缓存的一致性。
另外为了提高效率,处理器会对程序进行乱序执行优化,而对于虚拟机来说,就是指令重排序。意思说代码顺序与实际执行顺序无关,实际执行顺序是虚拟机根据前后依赖关系,结合运算器来决定的,但是结果是一样的。
走入正题,先介绍一下java内存模型,内存模型主要用来屏蔽硬件与内存访问的差异。
对于每一个线程会有工作内存,多个线程共享一个主内存,例如对象实例就在主内存会多个线程共享,而引用这个对象的变量实际在每个线程的工作内存,工作内存拥有主内存实例的副本拷贝,通过它来对实例进行,读取与赋值都在工作内存,并且线程之间无法读取对方的变量,都是通过主内存做一个过渡作用。
(这里工作内存与主内存跟堆内存与栈内存不是一个概念,这是为了好理解)
接下来工作内存与主内存怎么进行交互?
虚拟机定义了8种原子操作,包括lock(锁定主内存的变量,使其被某一线程独占),unlock(同理),read(把一个主内存的变量传递到工作内存中,以便load),load(将从主内存传递的值传递到工作内存的变量副本中),store(将工作内存中变量副本传递到主内存中去,以便write),write(将工作内存传递过来的值赋到主内存中变量),use(将工作内存的值传递给执行引擎),assign(将执行引擎的值传递到工作内存),
这8中操作可以用来确定你的访问是否安全。
下面介绍一下volatile,经常被问到一个关键字,他的作用主要有两个:
1 保证变量在各个线程的可见性,意思就是说这个变量的值一修改,其他线程可以立即得知。而一个普通变量需要先写回主内存,然后其他线程去读取这个值。
2:禁止指令重排序优化。然而它并不能保证原子性,以及运算的线程安全,下面解释一下第一个特性。
- public class VolatileTest extends Thread{
- public static volatile int a=0;
- public void run() {
- a++;
- }
- public static void main(String[] args) {
- VolatileTest array[]=new VolatileTest[10000];
- for (int i = 0; i < array.length; i++) {
- array[i]=new VolatileTest();
- array[i].start();
- }
- System.out.println(VolatileTest.a);
- }
- }
我们希望结果会是10000,然而并不是,原因就是a++这一条指令并不是原子操作,volatile的确保证从主内存获得的数据是最正确的,但是当你运算的时候,其他线程很有可能会把一个值穿进去,导致值会变小。
那么什么情况下用volatile呢?一定要明白,它的开销一定会小与同步块。下面是使用的情况,不符合这两条就要用同步块了。
1:运算结果并不依赖与当前值。
2:变量不需要与其他变量参与不变约束。
同样用代码解释一下。
- public class VolatileTest extends Thread{
- public static volatile int a=0;
- public void run() {
- a=2;
- }
- public static void main(String[] args) {
- // TODO Auto-generated method stub
- VolatileTest array[]=new VolatileTest[100];
- for (int i = 0; i < array.length; i++) {
- array[i]=new VolatileTest();
- array[i].start();
- System.out.print(array[i].a+" ");
- }
- }}
个人理解就是a的值不依赖与现在在主内存a的实际值,不管a是几,都变成1,而其他线程也会立即受到通知,因为也没有运算,也会直接变为1.
指令重排序优化?
其实指令重排序对于单线程来说有利无害,反正最后的结果是一样的,而且还提高了效率,但是对于多线程,可能会出现一些问题,而volatile修饰的变量,会在操作的时候,设置一个屏障,后面的操作,肯定不会比这个提前。否则后面的操作先执行,从而提前影响其他的线程。
下面介绍几个概念
1.原子性 :
就像前面说的那8张操作就是,粒度小到多线程也不可能拆开它,而用synchronized,内部的东西其实就是一个组装的“大原子”,但是记住volatile是不可以的
2.可见性:
是线程修改了值之后会立即同步到主内存,并且获取值会从主内存直接获取,而非缓存,volatile和synchronized都可以保证
3.有序性:
是保证线程内部执行顺序,volatile可以保证禁止指令重排序,而synchronized,直接就锁上了,所以它能解决几乎所有同步问题,造成了滥用。
线程是cpu调度的基本单位,粒度比进程小,Thread的类很多方法是native,可能会为了效率,然而同时可能会平台相关,注意线程的优先级不太靠谱,以为可能与平台线程的优先级不一样,造成冲突。
再次补充一个线程状态模型(本文章主要介绍java多线程模型,以及volatile)
阻塞状态与挂起状态的区别在于阻塞在等待一个排它锁,而挂起是等待时间到,或者是唤醒。
本博客知识来源于深入理解java虚拟机,值得一看,强力 推荐,特别底层!!!
2. volatile底层实现原理
定义:
java编程语言允许线程访问共享变量,为了确保共享变量能够被准确和一致的更新,线程应该通过排他锁获得这个变量。java提供了volatile,在某些情况下比锁更加方便。如果一个字段被声明成volatile,java线程内存模型确保所有线程看到的这个变量的值是一致的。
汇编代码:
使用命令获得汇编代码
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly
mac系统下使用此命令的前提是下载hsdis-amd64.dylib,并将其放入到jdk的jre下的lib目录下
实现原理
通过利用工具获得class文件的汇编代码,会发现,标有volatile的变量在进行写操作时,会在前面加上lock质量前缀。
而lock指令前缀会做如下两件事
-
将当前处理器缓存行的数据写回到内存。lock指令前缀在执行指令的期间,会产生一个lock信号,lock信号会保证在该信号期间会独占任何共享内存。lock信号一般不锁总线,而是锁缓存。因为锁总线的开销会很大。
-
将缓存行的数据写回到内存的操作会使得其他CPU缓存了该地址的数据无效。
二.深入分析Volatile的实现原理
引言:
在多线程并发编程中synchronized和Volatile都扮演着重要的角色,Volatile是 轻量级的synchronized ,它在多处理器开发中保证了共享变量的“可见性”。
可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。
它在某些情况下比synchronized的开销更小,本文将深入分析在硬件层面上Inter处理器是如何实现Volatile的,通过深入分析能帮助我们正确的使用Volatile变量。
- public class VolatileTest extends Thread{
- public static volatile int a=0;
- public void run() {
- a++;
- }
- public static void main(String[] args) {
- VolatileTest array[]=new VolatileTest[10000];
- for (int i = 0; i < array.length; i++) {
- array[i]=new VolatileTest();
- array[i].start();
- }
- System.out.println(VolatileTest.a);
- }
- }
Volatile的官方定义
Java语言规范第三版中对volatile的定义如下:
java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁更加方便。如果一个字段被声明成volatile,java线程内存模型确保所有线程看到这个变量的值是一致的。
为什么要使用Volatile
Volatile变量修饰符如果使用 恰当 的话,它比synchronized的 使用和执行成本会更低 ,因为它不会引起线程上下文的切换和调度。
Volatile的实现原理
那么Volatile是如何来保证可见性的呢? 在x86处理器下通过工具获取JIT编译器生成的汇编指令来看看对Volatile进行写操作CPU会做什么事情。
Java代码: |
instance = new Singleton();//instance是volatile变量 |
汇编代码: |
0x01a3de1d: movb $0x0,0x1104800(%esi); 0x01a3de24: lock addl $0x0,(%esp); |
有volatile变量修饰的共享变量进行写操作的时候会多第二行汇编代码,通过查IA-32架构软件开发者手册可知,lock前缀的指令在多核处理器下会引发了两件事情。
- 将当前处理器缓存行的数据会写回到系统内存。
- 这个写回内存的操作会引起在其他CPU里缓存了该内存地址的数据无效。
处理器为了提高处理速度,不直接和内存进行通讯,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作。
但操作完之后不知道何时会写到内存,如果对声明了Volatile变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。
这两件事情在IA-32软件开发者架构手册的第三册的多处理器管理章节(第八章)中有详细阐述。
Lock前缀指令会引起处理器缓存回写到内存 。Lock前缀指令导致在执行指令期间,声言处理器的 LOCK# 信号。
在多处理器环境中,LOCK# 信号确保在声言该信号期间,处理器可以独占使用任何共享内存。(因为它会锁住总线,导致其他CPU不能访问总线,不能访问总线就意味着不能访问系统内存),但是在最近的处理器里,LOCK#信号一般不锁总线,而是锁缓存,毕竟锁总线开销比较大。
在8.1.4章节有详细说明锁定操作对处理器缓存的影响,对于Intel486和Pentium处理器,在锁操作时,总是在总线上声言LOCK#信号。但在P6和最近的处理器中,如果访问的内存区域已经缓存在处理器内部,则不会声言LOCK#信号。相反地,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁定”,缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据 。
一个处理器的缓存回写到内存会导致其他处理器的缓存无效 。IA-32处理器和Intel 64处理器使用MESI(修改,独占,共享,无效)控制协议去维护内部缓存和其他处理器缓存的一致性。在多核处理器系统中进行操作的时候,IA-32 和Intel 64处理器能嗅探其他处理器访问系统内存和它们的内部缓存。它们使用嗅探技术保证它的内部缓存,系统内存和其他处理器的缓存的数据在总线上保持一致。例如在Pentium和P6 family处理器中,如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个地址当前处理共享状态,那么正在嗅探的处理器将无效它的缓存行,在下次访问相同内存地址时,强制执行缓存行填充。
Volatile的使用优化
著名的Java并发编程大师Doug lea在JDK7的并发包里新增一个队列集合类LinkedTransferQueue,他在使用Volatile变量时,用一种追加字节的方式来优化队列出队和入队的性能。
追加字节能优化性能? 这种方式看起来很神奇,但如果深入理解处理器架构就能理解其中的奥秘。让我们先来看看LinkedTransferQueue这个类,它使用一个内部类类型来定义队列的头队列(Head)和尾节点(tail),而这个内部类PaddedAtomicReference相对于父类AtomicReference只做了一件事情,就将共享变量追加到64字节。我们可以来计算下,一个对象的引用占4个字节,它追加了15个变量共占60个字节,再加上父类的Value变量,一共64个字节。
/** head of the queue */
private transient final PaddedAtomicReference < QNode > head;
/** tail of the queue */
private transient final PaddedAtomicReference < QNode > tail;
static final class PaddedAtomicReference < T > extends AtomicReference < T > {
// enough padding for 64bytes with 4byte refs
Object p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, pa, pb, pc, pd, pe;
PaddedAtomicReference(T r) {
super(r);
}
}
public class AtomicReference < V > implements java.io.Serializable {
private volatile V value;
//省略其他代码 }
为什么追加64字节能够提高并发编程的效率呢 ?
因为对于英特尔酷睿i7,酷睿, Atom和NetBurst, Core Solo和Pentium M处理器的L1,L2或L3缓存的高速缓存行是64个字节宽,不支持部分填充缓存行,这意味着如果队列的头节点和尾节点都不足64字节的话,处理器会将它们都读到同一个高速缓存行中,在多处理器下每个处理器都会缓存同样的头尾节点,当一个处理器试图修改头接点时会将整个缓存行锁定,那么在缓存一致性机制的作用下,会导致其他处理器不能访问自己高速缓存中的尾节点,而队列的入队和出队操作是需要不停修改头接点和尾节点,所以在多处理器的情况下将会严重影响到队列的入队和出队效率。Doug lea使用追加到64字节的方式来填满高速缓冲区的缓存行,避免头接点和尾节点加载到同一个缓存行,使得头尾节点在修改时不会互相锁定。
那么是不是在使用Volatile变量时都应该追加到64字节呢? 不是的。在两种场景下不应该使用这种方式。
第一: 缓存行非64字节宽的处理器 ,如P6系列和奔腾处理器,它们的L1和L2高速缓存行是32个字节宽。
第二: 共享变量不会被频繁的写 。因为使用追加字节的方式需要处理器读取更多的字节到高速缓冲区,这本身就会带来一定的性能消耗,共享变量如果不被频繁写的话,锁的几率也非常小,就没必要通过追加字节的方式来避免相互锁定。
三. Volatile介绍
原子操作的实现原理
1. 引言
原子(atom)本意是“不能被进一步分割的最小粒子”,而原子操作(atomic operation)意为"不可被中断的一个或一系列操作" 。在多处理器上实现原子操作就变得有点复杂。本文让我们一起来聊一聊在Intel处理器和Java里是如何实现原子操作的。
2. 术语定义
术语 | 英文 | 解释 |
---|---|---|
缓存行 | Cache line | 缓存的最小操作单位 |
比较并交换 | Compare and Swap | CAS操作需要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较下旧值有没有发生变化,如果没有发生变化,才交换成新值,发生了变化则不交换。 |
CPU流水线 | CPU pipeline | CPU流水线的工作方式就象工业生产上的装配流水线,在CPU中由5~6个不同功能的电路单元组成一条指令处理流水线,然后将一条X86指令分成5~6步后再由这些电路单元分别执行,这样就能实现在一个CPU时钟周期完成一条指令,因此提高CPU的运算速度。 |
内存顺序冲突 | Memory order violation | 内存顺序冲突一般是由假共享引起,假共享是指多个CPU同时修改同一个缓存行的不同部分而引起其中一个CPU的操作无效,当出现这个内存顺序冲突时,CPU必须清空流水线。 |
3. 处理器如何实现原子操作
32位IA-32处理器使用基于对缓存加锁或总线加锁的方式来实现多处理器之间的原子操作。
3.1 处理器自动保证基本内存操作的原子性
首先处理器会自动保证基本的内存操作的原子性。处理器保证从系统内存当中读取或者写入一个字节是原子的,意思是当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址。奔腾6和最新的处理器能自动保证单处理器对同一个缓存行里进行16/32/64位的操作是原子的,但是复杂的内存操作处理器不能自动保证其原子性,比如跨总线宽度,跨多个缓存行,跨页表的访问。但是处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。
3.2 使用总线锁保证原子性
第一个机制是通过总线锁保证原子性。如果多个处理器同时对共享变量进行读改写(i++就是经典的读改写操作)操作,那么共享变量就会被多个处理器同时进行操作,这样读改写操作就不是原子的,操作完之后共享变量的值会和期望的不一致。
举个例子:如果i=1,我们进行两次i++操作,我们期望的结果是3,但是有可能结果是2。如下图
(例1)
原因是有可能多个处理器同时从各自的缓存中读取变量i,分别进行加一操作,然后分别写入系统内存当中。那么想要保证读改写共享变量的操作是原子的,就必须保证CPU1读改写共享变量的时候,CPU2不能操作缓存了该共享变量内存地址的缓存。
处理器使用总线锁就是来解决这个问题的。所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占使用共享内存。
3.3 使用缓存锁保证原子性
第二个机制是通过缓存锁定保证原子性。在同一时刻我们只需保证对某个内存地址的操作是原子性即可,但总线锁定把CPU和内存之间通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,最近的处理器在某些场合下使用缓存锁定代替总线锁定来进行优化。
频繁使用的内存会缓存在处理器的L1,L2和L3高速缓存里,那么原子操作就可以直接在处理器内部缓存中进行,并不需要声明总线锁,在奔腾6和最近的处理器中可以使用“缓存锁定”的方式来实现复杂的原子性。所谓“缓存锁定”就是如果缓存在处理器缓存行中内存区域在LOCK操作期间被锁定,当它执行锁操作回写内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时会起缓存行无效,在例1中,当CPU1修改缓存行中的i时使用缓存锁定,那么CPU2就不能同时缓存了i的缓存行。
但是有两种情况下处理器不会使用缓存锁定。
第一种情况是:当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行(cache line),则处理器会调用总线锁定。
第二种情况是:有些处理器不支持缓存锁定。对于Inter486和奔腾处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定。
以上两个机制我们可以通过Inter处理器提供了很多LOCK前缀的指令来实现。比如位测试和修改指令BTS,BTR,BTC,交换指令XADD,CMPXCHG和其他一些操作数和逻辑指令,比如ADD(加),OR(或)等,被这些指令操作的内存区域就会加锁,导致其他处理器不能同时访问它。
4. JAVA如何实现原子操作
在java中可以通过锁和循环CAS的方式来实现原子操作。
4.1 使用循环CAS实现原子操作
JVM中的CAS操作正是利用了上一节中提到的处理器提供的CMPXCHG指令实现的。自旋CAS实现的基本思路就是循环进行CAS操作直到成功为止,以下代码实现了一个基于CAS线程安全的计数器方法safeCount和一个非线程安全的计数器count。
public class Counter {
private AtomicInteger atomicI = new AtomicInteger(0);
private int i = 0;
public static void main(String[] args) {
final Counter cas = new Counter();
List<Thread> ts = new ArrayList<Thread>(600);
long start = System.currentTimeMillis();
for (int j = 0; j < 100; j++) {
Thread t = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
cas.count();
cas.safeCount();
}
}
});
ts.add(t);
}
for (Thread t : ts) {
t.start();
}
// 等待所有线程执行完成
for (Thread t : ts) {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(cas.i);
System.out.println(cas.atomicI.get());
System.out.println(System.currentTimeMillis() - start);
}
/**
* 使用CAS实现线程安全计数器
*/
private void safeCount() {
for (;;) {
int i = atomicI.get();
boolean suc = atomicI.compareAndSet(i, ++i);
if (suc) {
break;
}
}
}
/**
* 非线程安全计数器
*/
private void count() {
i++;
}
}
在java并发包中有一些并发框架也使用了自旋CAS的方式来实现原子操作,比如LinkedTransferQueue类的Xfer方法。CAS虽然很高效的解决原子操作,但是CAS仍然存在三大问题。ABA问题,循环时间长开销大和只能保证一个共享变量的原子操作。
- ABA问题。因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。
从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
public boolean compareAndSet (V expectedReference,//预期引用 V newReference,//更新后的引用 int expectedStamp, //预期标志 int newStamp) //更新后的标志
-
循环时间长开销大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。
-
只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。
4.2 使用锁机制实现原子操作
锁机制保证了只有获得锁的线程能够操作锁定的内存区域。JVM内部实现了很多种锁机制,有偏向锁,轻量级锁和互斥锁,有意思的是除了偏向锁,JVM实现锁的方式都用到的循环CAS,当一个线程想进入同步块的时候使用循环CAS的方式来获取锁,当它退出同步块的时候使用循环CAS释放锁。详细说明可以参见文章Java SE1.6中的Synchronized。
5. 参考资料
四. Java中Volatile底层原理与应用
1. Volatile定义与原理
Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该通过排它锁单独获取这个变量
Java语言提供了Violatile来确保多处理开发中,共享变量的“可见性”,即当另外一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。它是轻量级的synchronized,不会引起线程上下文的切换和调度,执行开销更小。
使用Violatile修饰的变量在汇编阶段,会多出一条lock前缀指令,它在多核处理器下回引发两件事情:
将当前处理器缓存行的数据写回到系统内存
这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。
通常处理器和内存之间都有几级缓存来提高处理速度,处理器先将内存中的数据读取到内部缓存后再进行操作,但是对于缓存写会内存的时机则无法得知,因此在一个处理器里修改的变量值,不一定能及时写会缓存,这种变量修改对其他处理器变得“不可见”了。
但是,使用Volatile修饰的变量,在写操作的时候,会强制将这个变量所在缓存行的数据写回到内存中,但即使写回到内存,其他处理器也有可能使用内部的缓存数据,从而导致变量不一致。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期,如果过期,就会将该缓存行设置成无效状态,下次要使用就会重新从内存中读取。
追加字节优化Volatile性能
在某些情况下,通过将共享变量追加到64字节可以优化其使用性能。
在JDK 7 的并发包里,有一个队列集合类LinkedTransferQueue,它在使用volatile变量时,用一种追加字节的方式来优化队列出队和入队的性能。
队里定义了两个共享结点,头结点和尾结点,都由使用了volatile的内部类定义,通过将两个共享结点的字节数增加到64字节来优化效率,
具体分析如下:
部分CPU的L1、L2或L3缓存的高速缓存行64字节宽,不支持部分填充缓存行
这意味着,如果队列的头结点和尾结点都不足64字节,处理器会将他们读到同一个高速缓存行,在多处理器下每个处理器都会缓存同样的头尾结点,当一个处理器试图修改头结点时,会将整个缓存行锁定,那么在缓存一致性的机制下,其他处理器不能访问自己高速缓存中的尾节点,而头尾结点在队列中都是会频繁访问的,因此会影响使用性能。而通过填充字节使头尾结点加载到不同的缓存行,避免头尾结点在修改时相互锁定。
但是在以下两种场景,不应该使用这种优化方式:
- 缓存行非64字节宽的处理器(自行调整补充字节长度,原理一样)
- 共享变量不会被频繁的写。追加字节会导致CPU读取性能下降,如果共享变量写的频率很低,那么被锁的几率也很小,就没必要避免相互锁定了
Volatile无法保证原子性
volatile是一种“轻量级的锁”,它能保证锁的可见性,但不能保证锁的原子性。
public class Test {
public volatile int inc = 0;
public void increase() {
inc++;
}
public static void main(String[] args) {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println(test.inc);
}
}
上面程序输出的结果是多少?很多人可能都以为是10000,觉得对变量inc进行自增操作,由于volatile保证了可见性,那么在每个线程中对inc自增完之后,在其他线程中都能看到修改后的值啊,所以有10个线程分别进行了1000次操作,那么最终inc的值应该是1000*10=10000。这里面就有一个误区了,volatile关键字能保证可见性没有错,但是上面的程序错在没能保证原子性。可见性只能保证每次读取的是最新的值,但是volatile没办法保证对变量的操作的原子性。
由于自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。那么就是说自增操作的三个子操作可能会分割开执行,就有可能导致下面这种情况出现:
假如某个时刻变量inc的值为10,线程1对变量进行自增操作,线程1先读取了变量inc的原始值,然后线程1被阻塞了;然后线程2对变量进行自增操作,线程2也去读取变量inc的原始值,由于线程1只是对变量inc进行读取操作,而没有对变量进行修改操作,所以不会导致线程2的工作内存中缓存变量inc的缓存行无效,所以线程2会直接去主存读取inc的值,发现inc的值时10,然后进行加1操作,并把11写入工作内存,最后写入主存。
然后线程1接着进行加1操作,由于已经读取了inc的值,注意此时在线程1的工作内存中inc的值仍然为10,所以线程1对inc进行加1操作后inc的值为11,然后将11写入工作内存,最后写入主存。那么两个线程分别进行了一次自增操作后,inc只增加了1。
解释到这里,可能有朋友会有疑问,不对啊,前面不是保证一个变量在修改volatile变量时,会让缓存行无效吗?然后其他线程去读就会读到新的值,但是要注意,线程1对变量进行读取操作之后,被阻塞了的话,并没有对inc值进行修改。然后虽然volatile能保证线程2对变量inc的值读取是从内存中读取的,但是线程1没有进行修改,所以线程2根本就不会看到修改的值。
根源就在这里,自增操作不是原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。因此在使用Violatile修饰变量时,一定要保证对该变量的写操作是原子性的,例如程序中的状态变量,对该变量的修改不依赖于其当前值。
四. 术语定义
术语 |
英文单词 |
描述 |
共享变量 |
在多个线程之间能够被共享的变量被称为共享变量。共享变量包括所有的实例变量,静态变量和数组元素。他们都被存放在堆内存中,Volatile只作用于共享变量。 |
|
内存屏障 |
Memory Barriers |
是一组处理器指令,用于实现对内存操作的顺序限制。 |
缓冲行 |
Cache line |
缓存中可以分配的最小存储单位。处理器填写缓存线时会加载整个缓存线,需要使用多个主内存读周期。 |
原子操作 |
Atomic operations |
不可中断的一个或一系列操作。 |
缓存行填充 |
cache line fill |
当处理器识别到从内存中读取操作数是可缓存的,处理器读取整个缓存行到适当的缓存(L1,L2,L3的或所有) |
缓存命中 |
cache hit |
如果进行高速缓存行填充操作的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存。 |
写命中 |
write hit |
当处理器将操作数写回到一个内存缓存的区域时,它首先会检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写回到缓存,而不是写回到内存,这个操作被称为写命中。 |
写缺失 |
write misses the cache |
一个有效的缓存行被写入到不存在的内存区域。
|
个人博客: http://ifeve.com 微博: http://weibo.com/kirals http://www.infoq.com/cn/articles/atomic-operation
Linux 原子操作
所谓原子操作,就是该操作绝不会在执行完毕前被任何其他任务或事件打断,也就说,它的最小的执行单位,不可能有比它更小的执行单位,因此这里的原子实际是使用了物理学里的物质微粒的概念。
原子操作需要硬件的支持,因此是架构相关的,其API和原子类型的定义都定义在内核源码树的include/asm/atomic.h文件中,它们都使用汇编语言实现,因为C语言并不能实现这样的操作。
原子操作主要用于实现资源计数,很多引用计数(refcnt)就是通过原子操作实现的。
原子类型定义如下:
typedef struct { volatile int counter; } atomic_t;
|
volatile修饰字段告诉gcc不要对该类型的数据做优化处理,对它的访问都是对内存的访问,而不是对寄存器的访问。
原子操作API包括:
该函数对原子类型的变量v原子地减1并且返回指向v的指针。
原子操作通常用于实现资源的引用计数,在TCP/IP协议栈的IP碎片处理中,就使用了引用计数,碎片队列结构struct ipq描述了一个IP碎片,字段refcnt就是引用计数器,它的类型为atomic_t,当创建IP碎片时(在函数ip_frag_create中),使用atomic_set函数把它设置为1,当引用该IP碎片时,就使用函数atomic_inc把引用计数加1。
当不需要引用该IP碎片时,就使用函数ipq_put来释放该IP碎片,ipq_put使用函数atomic_dec_and_test把引用计数减1并判断引用计数是否为0,如果是就释放IP碎片。函数ipq_kill把IP碎片从ipq队列中删除,并把该删除的IP碎片的引用计数减1(通过使用函数atomic_dec实现)。
原子操作 函数原型
原子操作仅执行一次,在执行过程中不会中断也不会休眠;是最小的执行单元;鉴于原子操作这些特性,可以利用它来解决竞态问题。
往后其他同步机制都是在原子操作的基础上进行扩展的。
原子操作有整型原子操作、64位原子操作以及位原子操作。
1 整型原子操作
(Atomic Integer Operations)
要使用原子操作,需要定义一个原子变量,然后使用内核提供的接口对其进行原子操作。
整型原子变量结构如下
- #include <linux/type.h>
-
typedef struct
{
- int counter ;
- } atomic_t ;
可以看出整型原子变量实质上是一个32位整型变量。
整型原子变量操作接口,其实现方式与具体的架构有关。
-
#include <asm/atomic.h>
- ATOMIC_INIT ( int i ) // 定义原子变量时,将其值赋为i
- int atomic_read ( atomic_t * v ) // 读v的值
- void atomic_set ( atomic_t * v , int i ) // 设置v的值为i
- void atomic_add ( int i , atomic * v ) // v的值增加i
- void atomic_sub ( int i , atomic * v ) // v的值减少i
- void atomic_inc ( atomic * v ) // v的值加1
- void atomic_dec ( atomic * v ) // v的值减1
- int atomic_sub_and_test ( int i , atomic_t * v ) // v的值减少i , 且结果为0时返回true
- int atomic_add_negative ( int i , atomic_t * v ) // v的值增加i , 且结果为负数时返回true
- int atomic_add_return ( int i , atomic_t * v ) // v的值增加i , 且返回结果
- int atomic_sub_return ( int i , atomic_t * v ) // v的值减少i , 且返回结果
- int atomic_inc_return ( atomic_t * v ) // v的值加1,且返回结果
- int atomic_dec_return ( atomic_t * v ) // v的值减1,且返回结果
- int atomic_dec_and_test ( atomic_t * v ) // v的值减1,且结果为0时返回true
- int atomic_inc_and_test ( atomic_t * v ) // v的值加1,且结果为0时返回true
2 64位原子操作
(64-Bit Atomic Operations)
64位原子变量结构
-
typedef struct
{
- u64 __aligned ( 8 ) counter ;
- } atomic64_t ;
64位原子变量操作接口与整型变量操作接口类似,只要将整型变量接口名称的"atomic"改成"atomic64"即可。
3 位原子操作
(Atomic Bitwise Operations)
位原子操作接口
-
#include < asm / bitops . h
>
- void set_bit ( int nr , void * addr ) / / 将addr第nr位置1
- void clear_bit ( int nr , void * addr ) / / 将addr第nr位置0
- void change_bit ( int nr , void * addr ) / / 将addr第nr位值取反
- int test_and_set_bit ( int nr , void * addr ) / / 将addr第nr位置1,并将该位之前值返回
- int test_and_clear_bit ( int nr , void * addr ) / / 将addr第nr位置0,并将该位之前值返回
- int test_and_change_bit ( int nr , void * addr ) / / 将addr第nr位取反,并将该位之前值返回
- int test_bit ( int nr , void * addr ) / / 将addr第nr位的值返回