CAS
什么是CAS
CAS: 全称Compare and swap,字面意思:“比较并交换”.
先来看它的伪代码 :
boolean CAS(M, A, B) { if (&M == A) { &M = B; return true; } return false; }
寄存器A的值 与 内存M存放的值 进行比较, 如果值相同, 就把寄存器B的值给到内存M.
这段代码是非原子的, 运行过程中随着线程调度可能会产生问题.
注意: CAS操作是一条CPU指令, 并非上述代码, 这一条指令就能完成上述代码功能.(CAS操作是原子的)
CAS 的应用
1. 实现原子类
标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的.
典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.(这里就是通过CAS实现线程安全, 而没有用到锁)
import java.util.concurrent.atomic.AtomicInteger; public class Test { public static void main(String[] args) { AtomicInteger atomicInteger = new AtomicInteger(1); atomicInteger.getAndIncrement(); System.out.println(atomicInteger); //输出:2 } }
来看下它的伪代码 :
class AtomicInteger { private int value; public int getAndIncrement() { int oldValue = value; while ( CAS(value, oldValue, oldValue+1) != true) { oldValue = value; } return oldValue; } }
如果发现 value 的值与 oldValue 的值相同, 则将 oldValue+1 放到 value 中, 相当于++了, 然后返回 true ,循环结束. 反之如果 value 的值与 oldValue 的值不相同, 则放回 false, 进入循环, 将 value 的值重新赋给 oldValue, 再来比较.
这个操作不涉及阻塞等待, 比加锁方案快得多.
注意 : 如果是第二次进入循环判定, 也就是将 value 赋值给 oldValue 后, 再进入CAS比较是否相等时, 这时 value 和 oldValue 的值一定相等.(赋值操作后面就是CAS操作, 都是一条指令 执行非常快)
CAS在这里的作用是什么呢?
其实就是在确定, 看当前的 value 是否变过, 如果没变过 则自增, 否则先更新 再自增.
2. 实现自旋锁
反复检测当前锁状态, 看是否解开了.
自旋锁伪代码:
public class SpinLock { private Thread owner = null; //记录当前锁被那个对象持有, 现在为null 表示没有人持有 public void lock(){ // 加锁操作 // 通过 CAS 看当前锁是否被某个线程持有. // 如果这个锁已经被别的线程持有, 那么就继续循环. // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程. while(!CAS(this.owner, null, Thread.currentThread())){ } } public void unlock (){ // 解锁操作 this.owner = null; } }
优点 : 当这个锁被别的线程持有时, 循环就会一直执行, 一直获取锁状态, 一旦锁释放了 就能立刻获取到锁.
同时这也是它的缺点, 它如果没获取到锁就会一直占着CPU忙等, 消耗资源.
一般来说, 乐观锁发生锁冲突的概率很低, 比较适合实现自旋锁.
CAS 的 ABA 问题 (面试经典问题)
什么是 ABA 问题
CAS 操作关键是比较 内存 和 寄存器 的值是否相同, 如果相同, 则进行赋值操作.
假设内存的值改变过, 只是最后又变回来了, 这时候值确实是相同的, 但可能会出问题.
(比如内存的值由 A 变为 B, 然后又变回 A, 这时候进入CAS)
两个值都一样了, 为什么还会出现问题呢?
大部分的情况下, 是没有影响的. 但是不排除一些特殊情况, 比如 : 我去某鱼上买电脑, 本以为是个新电脑, 结果是别人翻新了的, 外表和新电脑没差别, 但用起来就出问题了.
同样的, 两个值虽然相同, 但它里面的东西可能就不一样了.
解决方法
给要修改的数据引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期.
如果发现当前版本号和之前读到的版本号一致, 就真正执行修改操作, 并让版本号自增; 如果发现当
前版本号比之前读到的版本号大, 就认为操作失败.
synchronized 原理
基本特点
- 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
- 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
- 实现轻量级锁的时候大概率用到自旋锁策略.
- 是一种不公平锁 (产生阻塞等待时, 不是按顺序来得到锁)
- 是一种可重入锁.
- 不是读写锁.
关键锁策略 : 锁升级
偏向锁不是真的 “加锁”, 只是打上一个 “标记”, 记录这个锁属于哪个线程.
如果后续没有其他线程来竞争该锁, 那么就不用进行其他操作了(避免了加锁解锁的开销)
如果后续其他线程来竞争这把锁了, 偏向锁就升级为自旋锁(轻量级锁), 如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会变为重量级锁.
其他锁优化
锁消除
编译器+JVM 判断锁是否可消除. 如果可以, 就直接消除.
在有些场景中, 比如单线程代码加锁, 这显然是没必要的, 这个时候编译器就会判断出当前状态不会引发线程安全问题, 就不会加锁了.
比如 : StringBuilder 与 StringBuffer.
二者相比, StringBuffer 是线程安全的, 它的关键方法都加上了 synchronized 关键字, 如果我们在单线程情况下使用 StringBuffer, 编译器就会自动把锁去掉, 提高代码效率.
锁粗化
一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化.
锁的粒度有粗有细, 粗就代表加锁的代码块代码多, 细就恰好相反.
当我们在一个线程里频繁加锁解锁时, 编译器就可能会将几个加锁操作和为一个, 就是将锁粗化, 毕竟频繁的加锁解锁很耗费资源.