【JavaEE】常见锁策略、CAS
目录
常见的锁策略
乐观锁 vs 悲观锁
重量级锁 vs 轻量级锁
自锁锁和挂起等待锁
读写锁
可重入锁 vs 不可重入锁
公平锁 vs 非公平锁
CAS
ABA问题
synchronized几个重要的机制
1、锁升级
2、锁消除
3、锁粗化
常见的锁策略
乐观锁 vs 悲观锁
乐观锁和悲观锁是锁的一种特性,不是一把具体的锁。
悲观和乐观,是对后续锁冲突是否频繁给出的预测:
- 如果预测接下来锁冲突的概率不大,就可以少做一些工作,称为乐观锁
- 如果预测接下来锁冲突的概率很大,就可以多做一些工作,称为悲观锁
重量级锁 vs 轻量级锁
轻量级锁,锁的开销比较小
重量级锁,锁的开销比较大
这两种锁和刚才的乐观悲观有关,乐观锁通常也就是轻量级锁,悲观锁通常也就是重量级锁。
自锁锁和挂起等待锁
自旋锁,属于轻量级锁的一种典型实现,往往是纯用户态实现,比如使用一个while循环,不停的检查当前锁是否被释放,如果没释放,就继续循环,释放了就获取到锁,从而结束循环(忙等,虽然消耗了cpu,但是换来了更快的响应速度)。
挂起等待锁,属于重量级锁的一种典型实现,要借助系统api来实现,一旦出现锁竞争了,就会在内核中触发一系列的动作(比如让这个线程进入阻塞状态,暂时不参与cpu调度)
读写锁
两个线程加锁过程中:
- 读和读之间,不会产生竞争
- 读和写之间,会产生竞争
- 写和写之间,会产生竞争
把加锁分成两种:
- 读加锁,读的时候,别的线程能读,但是不能写
- 写加锁,写的时候,其他线程不能读也不能写
可重入锁 vs 不可重入锁
一个线程针对同一把锁,连续加锁2次,不会死锁,就是可重入锁,会死锁就是不可重入锁。
可重入锁会记录加锁线程信息,以便线程二次加锁,同时引入计数器,每加锁一次就+1,直到计数器为0才释放锁
公平锁 vs 非公平锁
当很多线程去尝试加一把锁的时候,一个线程能够拿到锁,其他线程阻塞等待,一旦第一个线程释放锁之后,接下来是哪个线程能够拿到锁呢?
公平锁:按照“先来后到”的顺序
非公平锁:剩下的线程以“均等”的概率,来重新竞争锁
操作系统提供的加锁api,默认情况就属于“非公平锁”,如果要想实现公平锁,还需要引入额外的队列,维护这些线程的加锁顺序
上述这些锁策略都是描述了一把锁的基本特点的,synchronized属于哪种锁呢?
- 对于“悲观乐观”,自适应的
- 对于“重量轻量”,自适应的
- 对于“自旋 挂起等待”,自适应的
- 不是读写锁
- 是可重入锁
- 是非公平锁
所谓自适应就是,初始情况下,synchronized会预测当前的锁冲突的概率不大,此时以乐观锁的模式来运行(此时也就是轻量级锁,基于自旋锁的方式来实现)
在实际使用过程中,如果发现锁冲突的情况比较多,synchronized就会升级成悲观锁(也就是重量级锁,基于挂起等待的方式来实现)
CAS
什么是CAS
CAS:全称Compare and swap,字面意思“比较并交换”,一个CAS涉及到以下操作:
假设内存中存在原数据V,旧的预期值A,需要修改的新值B
- 比较A与V是否相等
- 如果比较相等,将B写入V
- 返回操作是否成功
比较交换的也就是内存和寄存器 ,如下例子:
有一个内存 M,两个寄存器 A,B
CAS(V,A,B)
- 如果M和A的值相同,把M和B的值进行交换,同时返回true
- 如果M和A的值不同,无事发生,返回false
CAS其实就是一个cpu指令,一个cpu指令就能完成上述比较交换的逻辑,单个的cpu指令,是原子的,就可以使用CAS完成一些操作,进一步的替代“加锁”。
基于CAS实现线程安全的方式,也称为“无锁编程”
java中AtomicInteger和其他原子类,就是基于CAS的方式对int进行了封装 ,进行++就是原子的了。
public class Main {
public static AtomicInteger count=new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(()->{
//count++
count.getAndIncrement();
//++count
count.incrementAndGet();
//count--
count.getAndDecrement();
//--count
count.decrementAndGet();
});
Thread t2=new Thread(()->{
//count++
count.getAndIncrement();
//++count
count.incrementAndGet();
//count--
count.getAndDecrement();
//--count
count.decrementAndGet();
});
t1.start();
t2.start();
}
}
前面的“线程不安全”本质上是进行自增的过程中,穿插执行了
CAS也是让这里的自增,不要穿插执行,核心思路和加锁是类似的
- 加锁是通过阻塞的方式,避免穿插
- CAS则是通过重试的方式,避免穿插
基于CAS实现自旋锁
public class spinLock{
private Thread owner=null;
public void lock()
{
while(!CAS(this.owner,null,Thread.currentThread())) {}
}
public void unLock(){
this.owner=null;
}
}
ABA问题
CAS进行操作的关键,是通过值“没有发生变化”来作为“没有其他线程穿插执行的判断依据”,但是这种判断方式不够严谨,更极端的情况下,可能有另一个线程穿插进来,把值从A->B->A,针对第一个线程来说,看起来好像是这个值没变,但是实际上已经被穿插执行了。
要避免ABA问题,我们可以让判定的数值,按照一个方向增长即可,有增有减就可能出现ABA,只是增加,或者只是减少就不会出现ABA,但是一些情况下,本身就应该要能增能减,我们可以引入一个额外的变量,版本号,约定每次修改余额,都会让版本号自增,此时在使用CAS判定的时候,就不是直接判定余额了,而是判定版本号,看版本号是否是变化了,如果版本号不变,注定没有线程穿插执行了。
synchronized几个重要的机制
1、锁升级
JVM将synchronized锁分为无锁,偏向锁,轻量级锁,重量级锁状态,会依据情况,进行依次升级。
无锁->偏向锁->自旋锁(轻量级锁)->重量级锁
锁升级的过程是单向的,不能再降级了。
偏向锁:不是真的加锁,只是做了一个标记,核心思想就是“懒汉模式”的另一种体现,如果有别的线程竞争锁再升级成轻量级锁。
2、锁消除
锁消除是编译器优化的手段,编译器会自动针对你当前写的加锁代码,做出判定,如果编译器觉得这个场景不需要加锁,此时就会把你写的synchronized给优化掉。
比如StringBuilder不带synchronized,StringBuffer带有synchronized,如果在单个线程中使用StringBuffer,此时编译器就会自动的把synchronized给优化掉
3、锁粗化
锁的粒度,synchronized里头,代码越多,就认为锁的粒度越粗,代码越少锁的粒度越细。
粒度细的时候能够并发执行的逻辑更多,更有利于充分利用好多核CPU资源,但是如果粒度细的锁,被反复进行加锁解锁,可能实际效果还不如粒度粗的锁。