一.乐观锁 与 CAS机制
在java的多线程并发过程中:
1.当一个对象在多个内存中都存在副本时,如果一个线程在自己的工作内存修改了共享变量,其它线程也应该能够看到被修改后的值。常常用volatile关键字来保证多线程数据的可见性。
2.保证一个线程操作的数据返回给主存后,再让第二个线程取数据、操作数据,否则会造成经典的银行存取款余额不统一问题。
在之前的“11.线程”文章中提过,我们知道,为了保证线程安全,采用增加synchronized同步锁的方式,使得同步操作具有原子性,实现了线程安全。但这种方法对于锁来说其实是一种悲观锁,具有一定的性能问题:
synchronized关键字会让没有得到锁资源的线程进入BLOCKED状态,而后在争夺到锁资源后恢复为RUNNABLE状态,这个过程中涉及到操作系统用户模式和内核模式的转换,代价比较高。
针对此情况,我们可以使用一系列以Atomic开头的包装类:如AtomicBoolean,AtomicInteger,AtomicLong。它们分别用于Boolean,Integer,Long类型的原子性操作,并且不需要加锁,其底层用到的就是CAS机制,这也被称为乐观锁(其实并不是锁),第四节会介绍这些类。
二.CAS机制的原理、实例解析
cas,即Compare and Set,顾名思义,比较、再更新
2.1 实现步骤
在cas的过程中,分为几个步骤,来实现不需要synchronized也能实现线程安全:
- 获取当前主存里的值value,到线程的工作内存中后,称为prev;
- 对prev进行操作,操作后得到应该更新回去的值,称为next;
- 在将next更新回主存的value之前,先去主存里取出当前的value值,并比较更新前的这个value与第一次拿到的prev是否相同。
- 如果相同,说明在步骤 2.的过程中,没有别的线程更改这个value,更新主存将是线程安全的;
反之不安全,线程将进入重新尝试的过程,被称为自旋。
2.2 举例说明
光说步骤比较抽象,可以从下图的例子来理解:
假设线程1、2都要来对account进行取款,在乐观锁的情况下,线程1先获取value到自己的工作内存,作为prev=100,然后操作后,next=90,此时在要更新回去之前,线程2咔的一下把account减为90了,那么线程1在此时获取的当前account就是90,发现与第一次的prev=100不同,说明此时线程1还不能更新,要再次尝试。
第二次同理,而第三次,更新之前发现account的当前值与prev相同了,则更新回主存。
2.3 注意volatile
注意上文的标红文字,在从工作内存更新回主存之前,要获取最新的当前主存的value,因此这个value必须具有可见性,因此必须用volatile关键字进行修饰。
也就是说,乐观锁必须搭配volatile来进行使用。
2.4 总结
CAS的过程要从主存获取2次value:
第一次是为了拿到数据、进行业务操作;
第二次是为判断更新之前主存中该数据有没有发生更改。
三.CAS乐观锁的缺点
3.1 cpu开销大
在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复的自旋将会给CPU带来很到的压力。(竞争压力过大,那就不用它)
3.2 不能保证代码块的原子性
CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用synchronized了。
3.3 小故事 与 经典的“ABA问题”
小故事:好比说,小王在出门前看见自己老婆在家,回家后发现老婆也在家,但小王出门的这段时间老婆可能先去了老王家又回来了,可能已经被update了!然而小王却发现不了。
于是聪明的小王给老婆装了个神奇计数器,老婆每去另外一个地方,计数器都会加1分!小王回家一看,计数器还是不是出门前的数字,就知道老婆有没有出门被别人update过几次了!哈哈哈!
当从主存中取得一个value(称为A)到线程的工作内存prev后,直到将next返回给主存的过程中间,若其他线程将主存得A变为B、再变为A,会发生什么?
显然,在next更新到主存之前,主存的这个value仍然是A,所以根据cas规则会进行更新。
也就是说,单纯的CAS机制只能判断数据是否更改,无法判断数据是否被更改过多少次!
这将会带来以下等问题:
数据不一致性:尽管看起来最终结果没有变化,但是如果其他的线程依赖于 X 的值并在此期间进行操作,可能会产生与预期不一致的结果。这意味着某些操作可能依赖于中间状态的 X 值,而非最终结果。
死循环:在实际应用中,ABA 问题可能导致死循环的发生。例如,一个线程可能会反复检查变量 X 的值,然后判断它是否发生了变化。尽管最终结果没有变化,但由于发生了 ABA 问题,线程可能会错误地认为变量 X 发生了变化,从而导致死循环。
逻辑错误:ABA 问题可能导致在进行某些操作之前做出错误的判断。如果线程基于错误的假设进行操作,可能会导致逻辑上的错误结果。
解决方法是给每一次变量编版本号,每修改一次,版本号加1。这样CAS就能根据版本号判断是否数据是否被更改过。
//不考虑版本号
static AtomicReference<String> ref = new AtomicReference<>("A");
//考虑版本号的方法
public class atomic {
//版本号初始化为0
static AtomicStampedReference<String> ref = new AtomicStampedReference<>("A", 0);
public static void main(String[] args) throws InterruptedException {
log.debug("main start...");
// 获取值 A
String prev = ref.getReference();
// 获取版本号
int stamp = ref.getStamp();
log.debug("版本 {}", stamp);
// 如果中间有其它线程干扰,发生了 ABA 现象
other();
sleep(1);
// 尝试改为 C,由于Other方法内对A操作了2次,版本号为2 !=0 因此更新失败
log.debug("change A->C {}", ref.compareAndSet(prev, "C", stamp, stamp + 1));
}
private static void other() {
new Thread(() -> {
log.debug("change A->B {}", ref.compareAndSet(ref.getReference(), "B",
ref.getStamp(), ref.getStamp() + 1));//更新后版本号+1
log.debug("更新版本为 {}", ref.getStamp());
}, "t1").start();
sleep(0.5);
new Thread(() -> {
log.debug("change B->A {}", ref.compareAndSet(ref.getReference(), "A",
ref.getStamp(), ref.getStamp() + 1));
log.debug("更新版本为 {}", ref.getStamp());
}, "t2").start();
}
}
四. AtomicBoolean,AtomicInteger,AtomicLong的使用
对于这些类的对象,有专门的api来进行数据的操作,都能够自动保证原子性与线程安全,本文限于篇幅,只着重介绍常用的AtomicInteger,其他是类似的。
例如上面第2节的account例子的代码:
private AtomicInteger balance;
......
while (true) {
int prev = balance.get();
int next = prev - amount;
if (balance.compareAndSet(prev, next)) {
//compareAndSet即cas函数,如果修改成功会返回true
break;
}
}
该段代码可以简化为这一步,就可以保证线程安全。
balance.addAndGet(-1 * amount);
以下是常用api,节选自Black-Horse的pdf内容,供参考
AtomicInteger i = new AtomicInteger(0);
// 获取并自增(i = 0, 结果 i = 1, 返回 0),类似于 i++
System.out.println(i.getAndIncrement());
// 自增并获取(i = 1, 结果 i = 2, 返回 2),类似于 ++i
System.out.println(i.incrementAndGet());
// 自减并获取(i = 2, 结果 i = 1, 返回 1),类似于 --i
System.out.println(i.decrementAndGet());
// 获取并自减(i = 1, 结果 i = 0, 返回 1),类似于 i--
System.out.println(i.getAndDecrement());
// 获取并加值(i = 0, 结果 i = 5, 返回 0)
System.out.println(i.getAndAdd(5));
// 加值并获取(i = 5, 结果 i = 0, 返回 0)
System.out.println(i.addAndGet(-5));
// 获取并更新(i = 0, p 为 i 的当前值, 结果 i = -2, 返回 0)
// 其中函数中的操作能保证原子,但函数需要无副作用
System.out.println(i.getAndUpdate(p -> p - 2));
// 更新并获取(i = -2, p 为 i 的当前值, 结果 i = 0, 返回 0)
// 其中函数中的操作能保证原子,但函数需要无副作用
System.out.println(i.updateAndGet(p -> p + 2));
// 获取并计算(i = 0, p 为 i 的当前值, x 为参数1, 结果 i = 10, 返回 0)
// 其中函数中的操作能保证原子,但函数需要无副作用
// getAndUpdate 如果在 lambda 中引用了外部的局部变量,要保证该局部变量是 final 的
// getAndAccumulate 可以通过 参数1 来引用外部的局部变量,但因为其不在 lambda 中因此不必是 final
System.out.println(i.getAndAccumulate(10, (p, x) -> p + x));
// 计算并获取(i = 10, p 为 i 的当前值, x 为参数1, 结果 i = 0, 返回 0)
// 其中函数中的操作能保证原子,但函数需要无副作用
System.out.println(i.accumulateAndGet(-10, (p, x) -> p + x));