文章目录
1、什么是原子操作
原子操作:一个或多个操作在CPU执行过程中不被中断的特性
当我们说原子操作时,需要分清楚针对的是CPU指令级别还是高级语言级别。
比如:经典的银行转账场景,是语言级别的原子操作;
而当我们说volatile修饰的变量的复合操作,其原子性不能被保证,指的是CPU指令级别。
二者的本质是一致的。
“原子操作”的实质其实并不是指“不可分割”,这只是外在表现,本质在于多个资源之间有一致性的要求,操作的中间态对外不可见。
比如:在32位机器上写64位的long变量有中间状态(只写了64位中的32位);银行转账操作中也有中间状态(A向B转账,A扣钱了,B还没来得及加钱)
2、Java中原子操作的实现方式
Java使用锁和自旋CAS实现原子操作
2.1 用CAS实现原子操作
2.1.2 使用CAS实现原子操作
public class Counter {
private final AtomicInteger atomicI = new AtomicInteger(0);
private int i = 0;
public static void main(String[] args) {
Counter counter = new Counter();
ArrayList<Thread> list = new ArrayList<>(1000);
long start = System.currentTimeMillis();
IntStream.range(0, 100).forEach(u -> {
list.add(new Thread(() ->
IntStream.range(0, 1000).forEach(v -> {
counter.safeCount();
counter.count();
})));
});
list.forEach(Thread::start);
/* wait for all the threads to complete*/
list.forEach(u -> {
try {
u.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
System.out.println(counter.i);
System.out.println(counter.atomicI.get());
System.out.println(System.currentTimeMillis() - start);
}
/* 使用CAS 来实现原子操作*/
public void safeCount() {
for (; ; ) {
int i = atomicI.get();
/*Atomically sets the value to the given updated value if the current value == the expected value.*/
/*Parameters:
expect - the expected value
update - the new value*/
/* 其实,假如使用 原子类来实现计数器,不需要直接用 cas 的API,原子类已经提供了现成的API了*/
boolean success = atomicI.compareAndSet(i, i + 1);
if (success) {
break;
}
}
}
/* 使用 锁 来实现原子操作*/
public synchronized void safeCount1() {
i++;
}
/* 线程不安全的累加*/
public void count() {
i++;
}
}
并发包中提供了很多原子类来支持原子操作:
- AtomicInteger
- AtomicLong
- AtomicBoolean
- AtomicReference
- LongAdder
2.1.3 CAS实现原子操作的问题
CAS是并发包的基石,但用CAS有三个问题:
-
1)ABA问题
根源:CAS的本质是对变量的current value
,期望值expected value
进行比较,二者相等时,再将 给定值given update value
设为当前值。因此会存在一种场景,变量值原来是A,变成了B,又变成了A,使用CAS检查时会发现值并未变化,实际上是变化了。
对于数值类型的变量,比如int,这种问题关系不大,但对于引用类型,则会产生很大影响。ABA问题解决思路:版本号。在变量前加版本号,每次变量更新时将版本号加1,A -> B -> A,就变成 1A -> 2B -> 3A。
JDK5之后Atomic包中提供了AtomicStampedReference#compareAndSet来解决ABA问题。public boolean compareAndSet(@Nullable V expectedReference, V newReference, int expectedStamp, int newStamp) Atomically sets the value of both the reference and stamp to the given update values if the current reference is == to the expected reference and the current stamp is equal to the expected stamp. Parameters: expectedReference - the expected value of the reference newReference - the new value for the reference expectedStamp - the expected value of the stamp newStamp - the new value for the stamp
-
2)循环时间长则开销大
自旋CAS若长时间不成功,会对CPU造成较大开销。不过有的JVM可支持CPU的pause指令的话,效率可有一定提升。pause作用:
- 延迟流水线指令(de-pipeline),使CPU不至于消耗过多执行资源。
- 可避免退出循环时因内存顺序冲突(
memorey order violation
)引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。
-
3)只能保证一个共享变量的原子操作
CAS只能对单个共享变量如是操作,对多个共享变量操作时则无法保证原子性,此时可以用锁。另外,也可“取巧”,将多个共享变量合成一个共享变量来操作。比如a=2,b=t,合并起来ab=2t,然后用CAS操作ab.
JDK5提供
AtomicReference
保证引用对象间的原子性,它可将多个变量放在一个对象中来进行CAS操作。
3、Java中使用锁实现原子操作
锁机制保证只有拿到锁的线程才能操作锁定的内存区域。
JVM内部实现了多种锁,偏向锁、轻量锁、互斥锁。不过轻量锁、互斥锁(即不包括偏向锁),实现锁时还是使用了CAS,即:一个线程进入同步代码时用自CAS拿锁,退出块的时候用CAS释放锁。
synchronized
锁定的临界区代码对共享变量的操作是原子操作。
4、CPU如何实现原子操作
首先,CPU会自动保证基本的内存操作的原子性。CPU保证从内存中读写一个字节是原子的,即:当一个CPU读一个字节时,其他处理器不能访问这个字节的内存地址。
但对于复杂的内存操作如跨总线跨度、跨多个缓存行的访问,CPU是不能自动保证的。不过,CPU提供总线锁定和缓存锁定。
- 1、使用总线锁保证原子性
假如多个处理器同时读改写共享变量,这种操作(e.g. i++)不是原子的,操作完的共享变量的值会和期望的不一致。
原因:多个处理器同时从各自缓存读i,分别 + 1,分别写入内存。要想保证读改写共享变量的原子性,必须保证CPU1读改写该变量时,CPU2不能操作缓存了该变量内存地址的缓存。
总线锁就是解决此问题的。
总线锁:利用LOCK#信号,当一个CPU在总线上输出此信号,其他CPU的请求会被阻塞,则该CPU可以独占共享内存。
- 2、使用缓存锁保证原子性
同一时刻,其实只要保证对某个内存地址的操作是原子的即可,但总线锁定把CPU和内存间的通信锁住了。锁定期间,其他CPU不能操作其他内存地址的数据,所以总线锁定的开销比较大。目前CPU会在一些场景下使用缓存锁替代总线锁来优化。
频繁使用的内存会被缓存到L1、L2、L3高速cache中,原子操作可直接在高速cache中进行,不需要声明总线锁。
缓存锁是指:缓存一致性机制阻止同时修改由两个以上CPU缓存的内存区域数据,当其他CPU回写已被锁定的缓存行数据时,会使缓存行无效。
看下图:i 是同时被CPU1和CPU2缓存的内存区域变量;CPU1 修改缓存行中 i 时使用缓存锁定,则CPU2 不能同时缓存 i 的缓存行。(i 的缓存行会失效)