【Java并发编程】—–深入分析CAS原子操作
本文主要从源码的角度分析JDK中的原子操作的实现原理,并且结合一些简单的例子来说明其使用的场景。主要内容包括一下方面:
- CAS原理
- 使用原子操作的好处
- java.util.atomic包中几个重要类的源码分析
1.CAS原理
CAS的全称为Compare And Set,其作用是对某一个变量进行原子化的更新操作。该算法的思想是:cas(v,e,u);v表示要更新的变量,e表示变量的预期值,u表示变量的新值。当且仅当v的实际值等于e值时,才会将v的值设为u,如果v值和e值不同,则说明已经有其他线程做了更新,则当前线程什么都不做,即更新失败。
CAS操作是抱着乐观的态度进行的,它总是认为自己可以成功完成操作。当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败。失败的线程不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。基于这样的原理,CAS操作即时没有锁,也可以发现其他线程对当前线程的干扰,并进行恰当的处理。
注:CAS其底层是通过CPU的1条指令来完成3个步骤,因此其本身是一个原子性操作,不存在其执行某一个步骤的时候而被中断的可能。
2.使用原子操作的好处
在了解了CAS的原理操作之后,我们下面分析一下使用原子操作的好处,我主要从两个方面出发来考虑这个问题。
2.1 性能角度
当多个线程访问临界区(数据共享的区域)的数据时,如果使用锁来进行并发控制,当某一个线程(T1)抢占到锁之后,那么其他线程再尝试去抢占锁时就会被挂起,当T1释放锁之后,下一个线程(T2)再抢占到锁后并且重新恢复到原来的状态大约需要经过8W个时钟周期。而假设我们业务代码本身并不具备很复杂的操作,执行整个操作可能就花费3-10个时钟周期左右,那么当我们使用无锁操作时,线程T1和线程T2对共享变量进行并发的CAS操作,假设T1成功了,T2最多再执行一次,它执行多次的所消耗的时间远远小于由于线程所挂起到恢复所消耗的时间,它基本不可能运气差到要执行几千次才能完成操作,因此无锁的CAS操作在性能上要比同步锁高很多。
2.2 业务本身的需求
使用同步锁机制锁保证的"先行发生原则(happen before)"过于的粗力度,它虽然可以保证线程T1的操作如果早于线程T2获取锁,那么T1一定在T2之前完成操作;而CAS操作却不能保证这样的顺序的一致性,但是CAS操作保证了关键的修改一步具有先行发生原则。在我们实际的业务场景下,由锁机制保证的这种看似所谓的有序性其实没有太大的意义,因为我们只需保证最终结果的一致性就能满足业务的需要。我们以商品秒杀为例,当多个用户并发访问时,我们其实只需确保的就是其在抢占的那一刻是一个原子操作即可,当商品数目为0时提示操作失败,而无需保证先来的用户一定能够抢到商品。因此,在业务本身的需求上,无锁机制本身就可以满足我们绝不多数的需求,并且在性能上也可以大大的进行提升。
我们可以再举一个生活化的例子来理解无锁的原子化操作与锁的不同,我们使用的版本控制工具与之其实非常的相似,如果使用锁来同步,其实就意味着只能同时一个人对该文件进行修改,此时其他人就无法操作文件,如果生活中真正遇到这样的情况我们一定会觉得非常不方便,而现实中我们其实并不是这样,我们大家都可以修改这个文件,只是谁提交的早,那么他就把他的代码成功提交的版本控制服务器上,其实这一步就对应着一个原子操作,而后操作的人往往却因为冲突而导致提交失败,此时他必须重新更新代码进行再次修改,重新提交。
3.原子类使用及源码分析
接下来就让我们分析一下JDK中的原子类,这些原子操作类都位于java.util.concurrent.atomic包中。首先我们以AtomicInteger为来进行分析。
3.1 AtomicInteger分析
在分析该类之前,首先来看看为什么Java中的++运算符是非原子化操作,可以通过2个角度来证明该结论。
3.1.1 为什么Java中的自增运算符是非原子操作?
package concurrency.unlock;
/**
* 非线程安全的++操作
*/
public class UnsafeIncr {
static int count = 0;
public static void main(String[] args) throws Exception{
Thread[] threads = new Thread[100];
for(int i = 0 ;i < threads.length;i++){
threads[i] = new Thread(){
public void run() {
for(int i = 0;i < 10000;i++){
count++;
}
};
};
threads[i].start();
}
for (Thread thread : threads) {
thread.join();
}
/*
* 输出结果理论上是1000000,而实际结果总是小于该值
* 造成的原因是:执行顺序不确定性以及中断的不可预知性产生的数据不一致
*/
System.out.println(count);
}
}
执行上面的代码,其结果可能总是小于实际值。这正是由于一个非原子操作在并发执行的情况下所导致。
上面我们通过代码证明了自增操作是非原子化的,我们接下来从字节码指令的角度来证明一下。
public class UnsafeThreadIncr{
private int count;
public void incr(){
count++;
}
}
将上面的代码进行编译,通过javap -verbose UnsafeThreadIncr.class
会得到如下结果:
我们可以看到,该操作对应着4条字节码指令,而每条字节码执行>=1机器指令,因此从这里我们也可以很清楚地看到++操作的非原子性。
3.1.2 AtomicIntger源码分析
1-1.png 1-2.png通过上面的截图,我们可以看到,AtomicInteger底层维护了一个value属性,并且该属性是使用volatile关键字进行修饰,其目的是为了确保内存的可见性,同时防止指令的重排序。通过Unsafe去获取到一 个属性在对象的基地址中的偏移量,之后通过对象的地址+偏移量,从而确定value在内存中的位置,然后完成更新操作。(注:Java之所以比起C而言要安全,其原因就在于其屏蔽了指针的操作,但是通过Unsafe类我们再次看到了指针的影子,因此该操作通过Unsafe这个名字就很好的表达了该操作是一个不安全的操作)
下面我们来看看AtomicInteger中的compareAndSet方法。 1-3.png
通过图1-3我们可以清楚看到,AtomicInteger底层的CAS操作其实是通过Unsafe类来完成的,Unsafe的compareAndSwapInt接受4个参数,第一个参数为要更新的对象,第二个参数为对象中的属性(实际更新的值),第三个参数为期望值,第四个参数为更新值。当且仅当期望值与实际值相等时才更新成功,返回true,否则即更新失败,返回false。
unsafe中的compareAndSwapInt方法是一个native方法,可以通过OpenJDK进行查看。
下面我们再来看看AtomicInteger中是如何如何保证线程安全的加法操作,我们以incrmentAndGet方法为例来说明。
incrementAndGet.png在AtomicInteger还提供了如下的方法:
方法 | 说明 |
---|---|
public final int get() | 取得当前值 |
public final void set(int newValue) | 设置当前值 |
public final int getAndSet(int newValue) | 设置新值,并返回旧值 |
public final boolean compareAndSet(int expect, int u) | 如果当前值为expect,则设置为u,否则则不进行修改 |
public final int getAndIncrement() | 当前值加1,返回旧值 |
public final int getAndDecrement() | 当前值减1,返回旧值 |
public final int getAndAdd(int delta) | 当前值增加delta,返回旧值 |
public final int incrementAndGet() | 当前值加1,返回新值 |
public final int decrementAndGet() | 当前值减1,返回新值 |
public final int addAndGet(int delta) | 当前值增加delta,返回新值 |
这些方法的具体实现与前面分析的incrementAndGet的实现基本一致,都是通过一个死循环进行反复的进行CAS操作,直到更新成功才返回。