常见锁策略_CAS(Compare And Swap)_synchronized优化
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了常见锁策略_CAS(Compare And Swap)_synchronized优化相关的知识,希望对你有一定的参考价值。
目录
1.常见锁策略
1.1乐观锁vs悲观锁
1.2轻量级锁vs重量级锁
1.3自旋锁vs挂起等待锁
自旋锁
挂起等待锁
1.4互斥锁vs读写锁
1.5公平锁vs非公平锁
公平锁
非公平锁
1.6可重入锁vs不可重入锁
1.7使用锁策略描述synchronized
2.CAS(Compare And Swap)
2.1CAS应用场景
实现原子类
实现自旋锁
2.2CAS的ABA问题
3.synchronized原理
3.1锁升级/锁膨胀
无锁
偏向锁
轻量级锁
重量级锁
3.2锁消除
3.3锁粗化
1.常见锁策略
锁策略不仅仅局限于java,任何与"锁"相关的话题(操作系统,数据库...),都会涉及到锁策略,这些策略是给锁的实现者用来参考的
1.1乐观锁vs悲观锁
这个不是两把具体的锁.而是两类锁,是在锁冲突的概率上进行区分的
乐观锁指的是预测锁竞争不是很激烈(做的工作相对少一些),悲观锁预测锁竞争会很激烈(这里做的工作会多一些).
1.2轻量级锁vs重量级锁
是从锁开销的角度区分的
轻量级锁加锁解锁开销比较小,效率更高.重量级锁加锁解锁开销比较大,效率更低.
多数情况下,乐观锁也是一个轻量级锁,悲观锁也是一个重量级锁
1.3自旋锁vs挂起等待锁
自旋锁是典型的轻量级锁
挂起等待锁是典型的重量级锁]
自旋锁
自旋锁伪代码:
while (抢锁(lock) ==失败)
自旋锁如果获取锁失败,立即再尝试获取锁,无限循环..一旦锁被其他线程释放,就能第一时间获取到锁
自旋锁的优点:
没有放弃cpu,不涉及线程阻塞和调度,一旦锁被释放,就饿能第一时间获取到锁
缺点:
如果锁被其它线程持有的时间较长,那么就会持续的消耗cpu资源(挂起等待是不需要消耗资源的)
挂起等待锁
挂起等待锁:如果一个锁被另外的线程持有,挂起等待锁会一直等待,不会主动去获取锁
这种做法不会消耗大量cpu资源,就可以做别的工作了.
1.4互斥锁vs读写锁
互斥锁
提供加锁和解锁操作,就像我们使用过的synchronized这样的锁.如果一个线程加锁了,另一个线程也尝试获取锁,就会阻塞等待
读写锁
提供了三种操作
1.针对读加锁
2.针对写加锁
多线程针对同一个变量并发读是没有线程安全问题的.也不需要加锁.
读锁和读锁之间没有互斥
写锁和写锁之间是互斥的
写锁和读锁之间存在互斥
假设一组线程并发读同一个变量,这时线程之间是没有锁竞争的,也没有线程安全问题!假设一组线程有读又有写,才会产生锁竞争..实际开发中,读操作非常高频
3.解锁
1.5公平锁vs非公平锁
公平锁
把公平锁定义为"先来后到"
B比C先来获取锁然后阻塞等待的,当A释放锁之后,B就能先于C获取到锁
非公平锁
不遵守"先来后到"
不管BC谁先来的,当A释放锁之后,BC都有可能获取到锁,synchronized就是非公平锁!
操作系统内部的线程调度就是随机的,如果不做额外的限制,锁就是非公平锁,如果要实现公平锁,就需要额外的数据结构来保存先后顺序
公平锁和非公平锁没有优劣,要看适用的场景
1.6可重入锁vs不可重入锁
不可重入锁:一个线程针对同一把锁,连续加锁两次,出现死锁
可重入锁:一个线程针对同一把锁,连续加锁多次都不会出现死锁
1.7使用锁策略描述synchronized
上述种锁策略,就像是锁的形容词.任何一个锁,都能用上述锁策略来描述,形容,我们看synchronized是怎样的
1.synchronized既是一个悲观锁,又是个乐观锁
synchronized默认是乐观锁,但是如果发现锁竞争比较激烈,就会变成悲观锁!!
2.synchronized既是轻量级锁,又是一个重量级锁
synchronized默认是轻量级锁,当锁冲突剧烈后,就变成重量级锁!
3.synchronized这里的轻量级锁是基于自旋锁的方式实现的
synchronized这里的重量级锁是基于挂起等待锁的方式实现的
4.synchronized不是读写锁
5.synchronized是非公平锁
6.synchronized是可重入锁
2.CAS(Compare And Swap)
一个CAS涉及到以下操作:
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B
1.比较A与V是否相等
2.如果相等,将B写入V
3.返回操作是否成功
上述交换过程中,大多数不关心B后续的情况了,更关心的是V这个变量的情况.近似可以理解成赋值了
如果AV不同,则没有其他操作
我们看一下CAS的伪代码:
boolean CAS(V,A,B)
if(A == V)
V = B;
return true;
return false;
但是CAS的过程并非是通过代码实现的!!而是通过一条CPU指令完成的!CAS操作是原子的,因此它是线程安全的.那么解决线程安全问题除了加锁,就又有个新的思路了.
CAS是CPU提供的一个特殊指令,通过这个指令,就可以一定程度的处理线程安全问题!
2.1CAS应用场景
实现原子类
Java标准库中提供的有原子类,之前我们学习线程安全时,写过一个问题,两个线程对同一个变量进行自增操作后,这个变量没有达到预期的结果,我们是通过加锁解决线程安全问题的.这里我们直接使用原子类,就不会出现线程安全问题
AtomicInteger count = new AtomicInteger();
AtomicInteger是原子类,基于CAS实现了自增,自减等操作,此时进行自增等操作不需要加锁,也线程安全的
public class Test
public static void main(String[] args) throws InterruptedException
//使用原子类解决线程安全问题
AtomicInteger count = new AtomicInteger();
Thread t1 = new Thread(()->
for (int i = 0; i < 50000; i++)
count.getAndIncrement();
);
Thread t2 = new Thread(()->
for (int i = 0; i < 50000; i++)
count.getAndIncrement();
);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
结果:
我们看一下伪代码实现的原子类
class AtomicInteger
private int value;
public int getAndIncrement()
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true)
oldValue = value;
return oldValue;
这里的oldValue可以理解为是寄存器中的值,相当于先把内存中的值读到寄存器里
正常情况下,oldValue应该是和value的值是相同的,然后这里发生CAS,把old Value+1写到value中
但是也可能会有:执行完读取value到寄存器中后,线程切换了,另外一个线程也修改了内存中value的值,此时这个线程如果继续执行进行CAS判定,就会认为value和oldValue不相等了
value和oldValue不相等,然后重新读取oldValue
我们画图解释一下这个过程:
按照这个时间执行两个线程
t1,t2都进行加载
然后t2开始CAS
比较oldValue和value的值,发现相等,oldValue+1赋给value
t2线程执行完毕,切换回t1线程,t1线程开始CAS,发现oldValue和value的值不相等,返回false,不进行任何交换...然后进入循环,循环内部重新读取value的值到oldValue 中,此时再次比较,发现相等了,进行CAS操作,并返回true,循环结束
原子类这里的实现,每次修改之前都会再确认一下这个值是否符合要求
CAS是属于特殊方法,特定场景能使用,加锁操作是通用方式,各种场景都能使用,打击面很广!
实现自旋锁
我们看一下自旋锁的伪代码
public class SpinLock
private Thread owner = null;
public void lock()
// 通过 CAS 看当前锁是否被某个线程持有.
// 如果这个锁已经被别的线程持有, 那么就自旋等待.
// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
while(!CAS(this.owner, null, Thread.currentThread()))
public void unlock ()
this.owner = null;
Thread owner是记录当前锁是谁加的
this.owner是检测当前的owner是否是null,如果是null的,就进行交换,也就是把当前的线程的引用赋值给owner.如果赋值成功,此时循环结束,加锁完成!
如果当前锁已经被别的线程占用了,那么owner就不是null的,那么CAS就不会产生赋值,同时返回false,循环继续执行,进行下次判断,这就完成了自旋过程!!
在Java中,并不是直接提供了一个方法CAS.此处伪代码是便于理解
2.2CAS的ABA问题
CAS在运行中的核心是检查oldValue和value是否一致,如果一致,就认为value中途没有被修改过.所以进行下一步操作是没问题的
但是还有可能是中途被修改过,然后又还原回来了.把value值设为A,CAS判定value为A,此时value确实可能始终是A,也有可能本来是A,然后被修改为B,最后又还原成了A!这就是ABA问题
ABA情况大部分是不会对代码/逻辑产生太大影响的,当然也有极端情况,我们看下面这个情景:
如果ATM取钱使用的是CAS来扣款,假设A的账户余额1000,要取500.当按下取款按键时,机器卡顿了,A没忍住多按了几下,此时就会产生bug,可能出现重复扣款的现象
正常情况下,机器卡顿多按两次,t1线程的CAS发现余额是1000,然后就交换成500.扣款成功,然后t2线程加载时余额也是1000,CAS发现余额不是1000,就不扣款.正确的逻辑
下面这种情况,当t2执行CAS的时候,正好有人给A转入了500.那么余额就变成1000了, 执行CAS操作,又扣了500,出现了bug!!
当然这种情况出现的概率是很低的,但是还是可能出现,针对这种情况,采取的解决方案就是加入一个版本号,初始版本号是1,每次修改版本号都加1,然后进行CAS的时候,不是以金额多少为准了,是以版本号为准,此时如果版本号没变,就一定没有发生改变
3.synchronized原理
两个线程针对同一个变量加锁,就会阻塞等待.除了上述基本原理,synchronized还有一些内部的优化机制,存在的目的就是为了让锁更高效,好用.
3.1锁升级/锁膨胀
当执行到加锁的代码块儿时,加锁过程就可能经历下面几个升级阶段
无锁
无锁状态,还没开始加锁
偏向锁
进行加锁的时候,首先会进入偏向锁状态
偏向锁,并不是真正的加锁,而只是先占个位置,如果有需要就加锁,没需要就不加锁了
相当于"懒汉模式"提到的懒加载一样,非必要,不加锁
synchronized加锁的时候,并不是真正的加锁,而是先进入偏向锁状态,就相当于做一个标记,如果一直没有别的线程来获取这个锁,那么就不会升级,仅仅只做个标记,因为这个变量本来就只有这个线程要使用,过程也没有出现锁竞争,执行完synchronized代码块后,再取消掉标记(偏向锁)即可
但是如果出现了锁竞争,再另一个线程加锁之前,偏向锁会迅速升级为真正的加锁状态!!另一个线程阻塞等待...
轻量级锁
当synchronized发生锁竞争的时候,就会从偏向锁升级为轻量级锁(自旋锁)
此时,synchronized是通过自旋的方式来进行加锁的(就和刚刚伪代码一样的逻辑)
但是,如果很快就释放锁了,自旋是值得的,可以立即获取被释放的锁,反之,迟迟不被释放,那么久迟迟拿不到锁,自旋就不划算了..这时候就需要再次升级了!
重量级锁
一直自旋但是又拿不到锁,synchronized也不会无止境的自旋,此时升级为重量级锁(挂起等待锁)
重量级锁(挂起等待锁)则是基于操作系统原生的API来进行加锁了
linux原生提供了mutex一组API,操作系统北河提供的加锁功能,这个锁是会影响到线程的调度的
此时,如果线程进行了重量级锁的加锁,并且发生了锁竞争,此时线程就会被放入阻塞队列中,暂时不参加CPU的调度了,直到锁被释放了,这个线程才有机会被调度到并有机会获取到锁
锁升级了就不能降级了
3.2锁消除
这是编译器的智能判定,看当前代码是否真的需要加锁,如果这个场景不用加锁,就会自动把加的锁销毁
就像StringBuffer中的关键的方法都是带有synchronized修饰的,就不需要程序员再加锁,加了编译器也会自动销毁!
3.3锁粗化
锁的粒度:synchronized包含的代码越多,粒度就越粗.包含的代码越少,粒度就越细.
通常情况下,粒度细一点比较好,加锁的代码是不能并发执行的,锁的粒度越细,能并发的代码就越多,粒度越粗,能并发的越少.
有些情况,粒度粗反而更好
这种情况下,两次加锁解锁之间的间隙非常小,反反复复加锁解锁效率低开销大,可以直接加一个大锁,将间隙也包括,效率反而高些,毕竟间隙很小,这块儿代码能不能并发执行影响不大!
以上是关于常见锁策略_CAS(Compare And Swap)_synchronized优化的主要内容,如果未能解决你的问题,请参考以下文章
非阻塞同步算法与CAS(Compare and Swap)无锁算法
Java多线程和并发,CAS(Compare and Swap)