引子
之前的文章我们简单介绍了线程安全的三个核心概念可见性和有序性和原子性, 那么这篇文章我们就来分析一下原子性操作的实现原理
原子操作
原子本意为不可分割的最小粒子,而原子操作则为不可中断的一个或者系列操作
注意的是对一部分操作保持了原子性并不意味着就不会发生线程安全问题, 而是要保证整个临界区都是原子性的。
下面我们来分析一下cpu和java中如何实现原子操作
Cpu实现
CPU使用基于缓存加锁或者总线加锁实现多个CPU的原子性操作
cpu自动保证基本内存操作的原子性 处理器保证从系统内存当中读取或者写入一个字节是原子的,意思是当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址,但是复杂的内存操作处理器不能自动保证其原子性,比如跨总线宽度,跨多个缓存行,跨页表的访问。 但是处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性
- 总线加锁使同时只有一个cpu能独占内存进行操作
原因是有可能多个处理器同时从各自的缓存中读取变量,分别进行操作,然后分别写入系统内存当中。那么想要保证读改写共享变量的操作是原子的,就必须保证CPU1读改写共享变量的时候,CPU2不能操作缓存了该共享变量内存地址的缓存。
处理器使用总线锁就是来解决这个问题的。所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占使用共享内存。
- 缓存加锁 控制指定cpu缓存达到缓存一致性来防止同时修改相同的缓存数据
总线加锁存在的问题是在同一时刻我们只需保证对某个内存地址的操作是原子性即可,但总线锁定把CPU和内存之间通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,现在比较新的处理器在大多数时候使用缓存锁定代替总线锁定来进行优化,从而降低锁的粒度 频繁使用的内存会缓存在处理器的L1,L2和L3高速缓存里,那么原子操作就可以直接在处理器内部缓存中进行,并不需要声明总线锁。 所谓缓存加锁就是如果缓存在处理器缓存行中的内容在LOCK操作期间被锁定,当它执行锁操作回写内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时会起缓存行无效。
有两种情况下处理器不会使用缓存锁定。第一种情况是:当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行,则处理器会调用总线锁定。第二种情况是:有些处理器不支持缓存锁定,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定
cpu提供了LOCK前缀的指令来实现加锁机制。比如交换指令XADD,CMPXCHG和其他一些操作数和逻辑指令,比如ADD,OR等,被这些指令操作的内存区域就会加锁,导致其他处理器不能同时访问它
Java实现
在Java中有些操作中可以定义为原子性
- 对引用变量和大部分基本类型变量(除long和double之外)的读写是原子的。
- 对所有声明为volatile的变量(包括long和double变量)的读写操作是原子的
Java可使用锁和CAS来实现原子操作,CAS既Compare and Swap比较并替换的意思。
CAS
我们先来分析一段代码
public int a = 1;
public boolean compareAndSwapInt(int b) {
if (a == 1) {
a = b;
return true;
}
return false;
}
试想这段代码在多线程并发下,会发生什么?我们不妨来分析一下:
线程A执行到 a==1,正准备执行a = b时,线程B也正在运行a = b,并在线程A之前把a修改为2;最后线程A又把a修改成了3。结果就是两个线程同时修改了变量a,显然这种结果是无法符合预期的,无法确定a的值。 解决方法也很简单,在compareAndSwapInt方法加锁同步,变成一个原子操作,同一时刻只有一个线程才能修改变量a。
CAS中的比较和替换是一组原子操作,不会被外部打断,先根据获取到内存当中当前的内存值,在将内存值和原值作比较,要是相等就修改为要修改的值,属于硬件级别的操作,效率比加锁操作高。
JDK中的atomic包的原子操作类都是基于CAS实现的,接下去我们通过AtomicInteger来看看是如何通过CAS实现原子操作的
public class AtomicInteger extends Number implements java.io.Serializable {
// setup to use Unsafe.compareAndSwapInt for updates
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
private volatile int value;
public final int get() {return value;}
}
Unsafe是CAS的核心类,JDK中有一个类Unsafe,它提供了硬件级别的原子操作。 valueOffset表示的是变量值在内存中的偏移地址,因为Unsafe就是根据内存偏移地址获取数据的原值的。 value是用volatile修饰的,保证了多线程之间看到的value值是同一份。 接下去,我们看看AtomicInteger是如何实现并发下的累加操作:
public final int getAndAdd(int delta) {
return unsafe.getAndAddInt(this, valueOffset, delta);
}
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
其中比较和替换操作放在unsafe类中实现。
如果现在线程A和线程B同时执行getAndAdd操作:
- AtomicInteger里面的value原始值为3,即主内存中AtomicInteger的value为3,线程A和线程B各自持有一份value的副本,值为3。
- 线程A通过getIntVolatile方法获取到value值3,线程切换,线程A挂起。
- 线程B通过getIntVolatile方法获取到value值3,并利用compareAndSwapInt方法比较内存值也为3,比较成功,修改内存值为2,线程切换,线程B挂起。
- 线程A恢复,利用compareAndSwapInt方法比较,发现手里的值3和内存值2不一致,此时value正在被另外一个线程修改,线程A不能修改value值。
- 线程的compareAndSwapInt实现,循环判断线程A继续利用compareAndSwapInt进行比较并替换,直到compareAndSwapInt修改成功返回true。
我们再看看Unsafe类中的compareAndSwapInt方法。
public final native boolean compareAndSwapInt(Object paramObject, long paramLong, int paramInt1, int paramInt2);
可以看到,这是一个本地方法调用,这个本地方法在调用c++代码,下面是对应于intel X86处理器的源代码片段。
inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
int mp = os::isMP(); //判断是否是多处理器
_asm {
mov edx, dest
mov ecx, exchange_value
mov eax, compare_value
LOCK_IF_MP(mp)
cmpxchg dword ptr [edx], ecx
}
}
从上面的源码中可以看出,会根据当前处理器类型来决定是否为cmpxchg指令添加lock前缀。 如果是多处理器,为cmpxchg指令添加lock前缀,反之,就省略lock前缀。 而lock前缀的作用主要有下面几点
- 使用CPU的原子操作确保对内存读改写操作的原子执行。
- 禁止该指令,与前面和后面的读写指令重排序。
- 把写缓冲区的所有数据刷新到内存中。
CAS存在的问题
CAS虽然很高效的解决原子操作,但是CAS仍然存在三大问题。
- ABA问题
因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,这样就能区分是否发送了变化
JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值
- 循环开销大,如果自旋不成功就会带来大量开销
因为同时修改相同的值的并发比较大就会导致,CAS很难成功,这样就导致会一直自旋,带来很大开销, 所以使用中要避免在并发太大的地方使用
- 只能保证一个共享变量的原子操作
当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。
锁
当然Java中也可以通过加锁来实现原子操作,但是大多数情况锁的开销更大, 接下来的文章我们会具体分析Java中锁的实现和原理
总结
本篇文章我们讨论cpu和Java实现原子操作的方式,cpu实现原子操作有总线加锁和缓存加锁2种, 而Java主要通过CAS和锁来实现原子操作,通过分析源码我们知道CAS实际上是通过lock 指令调用cpu的加锁方式, 同时我们讨论了CAS会带来的几个问题和解决方式,接下来的文章我们将继续探讨Java并发编程的相关内容。
来源:oschina
链接:https://my.oschina.net/u/1022411/blog/1561019