目录
前言
从锁的角度出发看CAS(CompareAndSet)机制,如果有多线程对共享变量进行写操作,一般想到都会加锁,加锁固然可以达到目的,但是如果在读多写少的高并发场景下,对于性能来说是一个很大的消耗,少量的写操作可能会使读线程也阻塞,导致大量线程的上下文切换,对内存-缓存数据和cpu都是一种消耗。而基于CAS机制的乐观锁,实现很简单,在读多写少的高并发中,对于程序性能的提升有很大的帮助,思想很重要。
1、CAS机制
在jdk提供的JUC源码包中,到处都可以看到CAS思想的应用,例如AQS中state状态的变更,Atomic包里的变量的加减等操作,ConcurrentHashMap中的节点数的统计,Synchronized中的优化升级等等吧,都用到了重要的CAS思想,可以充分的利用CPU,提升效率。
这里以锁Synchronized的优化为例分析:
用Synchronized 锁肯定是可以实现同步线程安全,这没有问题。但是在高并发的情况下,可能存在以下性能问题:
-
阻塞的线程优先级比较高怎么办?
-
拿到锁的线程一直不释放怎么办-死锁?
-
大量竞争阻塞导致线程上下文的切换严重影响程序并发性能;
有没有一种方案来解决这几个问题进一步提升程序的运行效率呢?
为了解决上面的问题,所以利用cas思想出现了一种乐观锁,在写少读多的场景大大提高了程序运行的效率。
Synchronized和CAS的区别就是:是否有阻塞。
2、悲观锁与乐观锁
锁分类
|
概述
|
使用场景
|
应用实例
|
悲观锁
|
悲观锁:对数据被外界修改持保守悲观态度,认为每一次总是有线程与自己争抢发生冲突,因此在整个操作过程中将数据处于锁定状态,而别的任务处于阻塞状态;
|
(1) 读少写多;
(2) 锁竞争冲突比较多;
|
(1) 虚拟机中的锁;synchronized/lock;
(2) 分布式环境基于数据库行锁/页锁/表锁/共享锁(读锁)/排他锁(写锁)select for update;
(3) 基于zk,redis的分布式锁-阻塞版;
|
乐观锁
|
乐观锁:对数据被外界修改持乐观态度,认为每一次提交数据不会造成冲突,总是先去尝试修改更新的时候才会检测数据冲突与否,如果发现冲突了,则返回让用户去决定重试还是放弃。
|
(1) 读多写少,重试比较少;
(2) 锁竞争冲突少;
缺点:
(1) 大量写操作无限尝试,性能反而更低;
(2) cas还会让缓存行填充的数据失效,性能更低;
(3) 不保证公平性;
(4) 需要辅助空间,且需要"n+1"次数据库查询;
|
(1)
数据库乐观锁;基于version ;
(2)
基于zk,redis实现的非阻塞分布式锁;
(3)JDK中的并发包原子类: java中的cas思想;
|
3、CAS的实现原理
CAS的三元素:
-
一个内存的地址值:stateoffset
-
期望的值:expect
-
一个新值:update
stateOffset
一个java对象可以看成是一段内存,每个字段都得按照一定的顺序放在这段内存里,通过这个方法可以准确的告诉我们某个字段相对于对象起始内存地址的字节偏移。用于后面的compareAndSwapInt中,去根据偏移量找到对象在内存汇总的具体位置,所以stateOffset表示state这个字段在AQS类的内存中相对于该类首地址的便宜量。
protected final boolean compareAndSetState(int expect, int update) {
//stateoffset和expect相等,则更新为update,成功返回true;失败返回false;
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
public final int incrementAndGet() { //返回的是新值;
for (; ; ) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return next;
}
}
实现步骤:
-
获取地址stateOffset上的值value;
-
判断value和期望的值expect是否相等,若相等就给地址stateOffset赋新值update;
-
否则:循环重复步骤1-2;
4、CPU指令对CAS的支持
思考疑问: 或许我们可能会有这样的疑问,在执行步骤2的时候,有没有可能在判断Value和Expect相同后,正要赋值时,切换了线程,更改了值。造成了数据不一致呢?
答案是否定的,因为CAS是一种
系统原语,
原语属于操作系统用语范畴,是由若干条指令组成的,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说CAS是一条CPU的原子指令,不会造成所谓的数据不一致问题。
5、CAS缺点
1、CAS的ABA问题:
-
解决方案:
-
AutomicStampsReference(动过几次) 加上时间戳;
-
AutomicMarkableReference(有没有动过)引用类型来解决)版本控制;
-
2、自旋时间过长,消耗CPU;如果资源竞争激烈,多线程长时间自旋消耗资源;
-
解决方案:
-
控制自旋次数;
-
如果是大量的写操作少量的读场景就需要考虑使用乐观锁的性价比了;
-
3、一个内存地址只能对应一个值,只能改变一个变量值;
-
解决方案:可以通过原子更新引用,(AtomicReference 通过compAndSet(旧值,新值); AtomicInteger/AtomicArray—getAndSet() )
6、CAS优点
-
1、竞争便宜,相比synchronized重量锁,synchronized会进行比较复杂的加锁、解锁和唤醒操作。
-
2、协调的粒度细,非阻塞的轻量级的乐观锁,通过CPU指令实现,在资源竞争不激烈的情况下,一定程度上允许高并发;
-
3、避免死锁;
-
4、不会阻塞,引起上下文的切换;
7、乐观锁的实现
例如正常情况,对于并发量不高的情况我们都会基于加锁的方式来解决扣减库存来防止并发安全问题,以下基于悲观锁Synchronized的代码实现。
//基于悲观锁Synchronized修改数据库
public synchronized boolean updateGoodsAmount(String code, int buys) {
//第一步:获取商品库存对象信息;
GoodsInfo info = mapper.selectByPrimaryKey(code);
//第二步:获取商品库存信息;
Integer amout = info.getAmout();
//第三步:如果库存不够直接返回;
if (amout < bugs) {
return false;
}
//第四步:直接访问数据库
return mapper.updateAmount(code, buys) > 0 ? true : false;
}
//具体执行sql:
<update id = "updateAmount">
update goods_info
set amount = amount -#{buys}
where code = #{code}
</update>
7.1、基于版本号的乐观锁
基于版本号的cas思想实现,主要是在更新的行记录增加一个version版本的字段,给行记录添加一个标签,是一种空间换时间的方案。跟新时我们检查该字段的版本是否有更新,是不是我们期望值的版本号,如果是则更新,更新的过程利用数据库操作的原子性和行锁保证数据安全;不是则放弃重试。实现如下:
//基于版本号的乐观锁修改库存
public boolean updateGoodsAmount(String code, int buys) {
while (true) {
//第一步:获取商品存库对象;
TGoodsInfo info = mapper.selectByPrimaryKey(code);
//第二步:获取商品库存;
Integer amount = info.getAmount();
if (amount < buys) {
return false;
}
//第三步:获取版本号;
Integer version = info.getVersion();
//第四步;带上版本号更新库存;
if (mapper.updateAmountByVersion(code, buys.version) > 0) {
return true;
}
//第五步:如果更新失败,当前线程随机休眠,然后继续重试;
waitForLock();
}
}
private void waitForLock() {
try {//避免过度自旋;
Thread.sleep(new Random().nextInt(10) + 1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//基于版本号的乐观锁执行的sql:
<update id = "updateAmountByVersion">
update goods_info
set about = about - #{buys},
version = version +1
where code = #{code} and version = #{version}
</update>
//推荐:基于库存的乐观锁sql方案:
<update id ="updateAmountByStat">
update goods_info set
about = about - #{buys}
where code = #{code } and amount -#{buys} >= 0
</update>
7.2、基于memberCache的实现
由于数据库的访问,有磁盘的io,性能存在瓶颈,为了进一步提升性能,我们可以通过缓存来实现,直接访问内存条,避免磁盘io,但是要做好缓存和db数据的一致性保证。主要使用MemberCache的gets()方法获取数据自带的版本号,之后通过cas()方式实现无阻塞原子更新,增加了数据访问速度和使用了无阻塞的原子更新,提高写效率;
代码实现如下:
//membercache实现cas
public boolean updateGoodsAmount(String code, int buys){
//1、获取商品的数据和版本号对象
MemcachedItem items = client.gets(code);
//2、获取商品库存版本号
long version = items.getCasUnique();
//3、获取商品库存数量
Integer amount = Integer.valueOf(items.getValue().toString().trim());
if(amount < buys){
return false;
}
//4、使用cas更新数据时带上版本号
if(client.cas(code, amount-buys,version)){
return true;
}
//5、更新失败,当前线程休眠随机时间,让请求削峰填谷,避免请求同一时间并发;
waitForLock();
//6、递归调用自身
return updateGoodsAmount(code,buys);
7.3、基于redis的实现
对于高并发的场景,数据库的磁盘io存在一定的性能问题,所以可以使用基于redis的事务机制来实现乐观锁,提高资源竞争性能;
-
multi(开启事务); 开启之后,所有的命令都会被放入队列中,不会立即执行;
-
exec(执行事务); 开始执行事务;
-
discard (取消事务); 如果中途不想执行,就使用该命令清空队列任务,放弃执行
-
watch(监视) 为redis事务提供乐观锁CAS(compare and swap)行为,多个变量更新时,只有它没有被修改的情况下,才会执行更新;否则放回null,放弃更新;
代码实现如下:
//redis的乐观锁
public boolean updateGoodsAmount(String code, int buys) {
boolean flag = true;
while (flag) {
redisCli.watch(code); //todo 监听事务
String countNum = redisCli.get("code");
int num = Integer.parseInt(countNum);
if (num < buys) { //库存不足;
return false;
}
int expectNum = num - buys;
Transaction tx = redisCli.multi(); //todo 开启事务
tx.set(code, String.valueOf(expectNum));
List<Object> list = tx.exec(); //todo 执行事务
//如果事务失败了exec会返回null
if (list == null) {
System.out.println("exec null");
continue; //todo 如果中间code的值被修改则放弃本次更新循环下一次继续;
} else {
flag = false; //修改成功结束while循环;
}
System.out.println("setting success " + expectNum);
}
return true;
}
8、小结
一般情况下,线程对临界资源的使用的时间都十分短暂,所以这就可以让CPU通过自适应自旋的方式多等一会,避免了线程直接阻塞挂起的性能消耗,并发中对程序的性能有很大的帮助,例如【
volatile + cas】 的组合通过保证变量的有序性,可见性和原子性,在源码中随处可见。
OK---山河破碎风飘絮,身世浮沉雨打萍。
水滴石穿,积少成多。学习笔记,内容简单,用于复习,梳理巩固。