3.3.9 多线程章 锁的策略

本文详细介绍了Java中的各种锁策略,包括悲观锁与乐观锁、重量级锁与轻量级锁、自旋锁与挂起等待锁、读写锁与互斥锁、公平锁与非公平锁以及可重入锁与不可重入锁。解释了这些锁的特点、应用场景及优缺点,并特别讨论了`synchronized`关键字的锁策略及其原理,包括其从轻量级锁到重量级锁的升级机制和可重入特性。
摘要由CSDN通过智能技术生成

常见锁策略

1.0 什么是锁

这里的锁不是某个具体的锁,是个抽象的概念,描述的是锁的特性,描述的是"一类锁"

2.0 悲观锁 VS 乐观锁

概念

悲观锁:

  • 总是假设最坏的情况,每次去拿数据的时候都认为别人会修改/发生锁冲突,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁 (悲观锁后续做的工作往往更多)

乐观锁

  • 假设数据一般情况下不会产生并发冲突/锁冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并 发冲突进行检测,如果发现并发冲突了,则返回错误信息给用户,让用户决定如何去做。(乐观锁后续做的工作往往更少)

3.0 重量级锁 VS 轻量级锁

概念

重量级锁:

加锁的开销是比较大的(花的时间多,占用系统资源多)

悲观锁往往开销大而导致重量级锁,但不绝对

轻量级锁

加锁的开销是比较小的(花的时间少,占用系统资源少)

乐观锁往往开销少而导致轻量级锁,但不绝对

4.0 自旋锁 VS 挂起等待锁

概念

自旋锁:

  • 自旋锁是轻量级锁的一种典型实现
  • 在用户态下通过自旋的方式(比如while循环),实现类似于加锁的效果的
  • 消耗cpu资源大,但是能在第一时间抓到拿锁的机会

挂起等待锁:

  • 挂起等待所,是重量级锁的一种典型实现
  • 通过内核态,借助系统提供的锁机制,当出现锁冲突的时候,会牵扯到内核对线程的调度.使冲突的线程出现挂起(阻塞等待)
  • 消耗的cpu资源少,时不时查看当前锁的状态,不能第一时间知晓锁的状况,更依赖操作系统的调度

5.0 读写锁 VS 互斥锁

概念

读写锁:

  • 把读操作加锁和写操作加锁分开

  • 设计这个锁的原因:多线程同时去读一个变量,不涉及到线程安全问题,提高并发能力

  • 读写锁就是把读操作和写操作区分对待. Java 标准库提供了 ReentrantReadWriteLock, 实现了读写
    锁. 
        
    ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行
    加锁解锁. 
        
    ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进
    行加锁解锁. 
    

互斥锁:

  • 保证线程安全,读写锁一起的操作

6.0 公平锁 vs 非公平锁

概念

公平锁:

  • 遵守 “先来后到”. B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.

非公平锁

  • 不遵守 “先来后到”.B 比 C 先来的. 当 A 释放锁的之后, B 和 C 都有可能获取到锁.
  • 系统原生锁(pthread_mutex) 属于非公平的锁

7.0可重入锁 vs 不可重入锁

概念

概念:

  • 如果一个线程,争对一把锁,连续加锁两次会出现死锁,就是不可重入锁,不会出现死锁就是可重入锁
  • 可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。
  • 可重入锁的字面意思是 “可以重新进入的锁”,即允许同一个线程多次获取同一把锁。
  • Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括 synchronized关键字锁都是可重入的。
  • 而 Linux 系统提供的 mutex 是不可重入锁.

不可重入锁

如果是个不可重入锁,这把锁不会保存是哪个线程对它家的锁,只要他当前处于加锁状态后,收到"加锁"这样的请求,就会拒绝但当前的加锁,而不管当下线程是哪个,就会产生死锁

可重入锁

会让这个锁保存是哪个线程加的锁,后续收到加锁请求之后,就会先对比一下,看看加锁的线程是不是当前自己持有这把锁的线程,这个时候就能灵活判断了

死锁

理解 “把自己锁死”

一个线程没有释放锁, 然后又尝试再次加锁.

// 第一次加锁, 加锁成功
lock();
// 第二次加锁, 锁已经被占用, 阻塞等待. 
lock();

死锁的三种典型情况

  1. 一个线程,一把锁,但是是不可重入锁,该线程针对这个锁连续加锁两次,就会出现死锁
  2. 两个线程,两把锁,这两个线程分别获取到一把锁,然后再同时获取对方的锁(互不相让)
  3. N个线程M把锁,某个程序需要这5个线程分别跑起来,5个线程要5把锁,但是只有四把锁,且由于程序不结束,每个线程锁不释放,即每个线程一直拥有锁但不释放,而最后一个线程无锁,进程跑不了,而造成死循环

死锁产生的原因:

  1. 互斥使用,一个线程获取到一把锁之后,别的线程不能获取到这把锁(但这个是锁的基本特性)
  2. **不可抢占,**锁只能是被持有者主动释放,而不能是被其他线程直接抢走 (但这个是锁的基本特性)
  3. 请求和保持,这个一个线程去尝试获取多把锁,在获取第二把锁的过程中,会保持对第一把锁的获取状态(取决于代码结构,这里的调整会影响需求,不推荐)
  4. 循环等待, t1 尝试获取locker2, 需要t2执行完, 释放locker2, t2 尝试获取locker1,需要t1执行完,释放locker1,你等我我也等你,(取决于代码结构,可以打破循环等待而做到解除死锁)

解决死锁的方法:

  • 银行家算法(少用,但是学校学,容易写bug)
  • 好的方法:争对锁进行编号,并且规定锁的顺序, 比如,约定每个线程如果要获取多把锁,必须先获需要锁中编号最小的锁,后获取编号大的锁,只要所有线程加锁的顺序都严格遵循上述的顺序,就一定不会出现循环等待

synchronized 是可重入锁

比如在对象方法前加synchronized,又在方法内部加synchronized(this), 再在某个线程中调用这个方法,如果synchronized不是可重入锁就会导致死锁

class A{

    public synchronized  void func(){
        synchronized(this){
            System.out.println("hehe");
        }
    }
}
public class test1 {

    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            A a = new A();
            a.func();
        });
    }
}
//synchronized是可重入锁,因此他知道你两次加锁都是同一把锁,如果加了就会死锁,因此当多次锁的时候,这个锁的内部属性有个记录器,锁一次,自增一次,释放时候同理,释放一次,记录器自减,只有当减到0,这个锁才会真正释放锁,这也对应了可重入锁的特性

8.0 不同锁概念的对比

  • 乐观悲观是在加锁之前,对锁冲突的预测,决定工作的多少,而重量轻量,是在加锁之后,考虑实际的锁开销
  • 正是应为这样的概念存在重合,争对一个具体的锁,可以把它叫做乐观锁,也可叫成轻量级锁(看待角度不同)

9.0 synchronized具体采用的锁策略

  • Synchronized即是悲观锁,又是乐观锁(自适应),Synchronized 初始使用乐观锁策略. 当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略.

  • Synchronized 不是读写锁

  • synchronized 开始是一个轻量级锁. 如果锁冲突比较严重, 就会变成重量级锁. 重量级锁是基于系统的互斥锁实现的 ,轻量级锁是基于自旋锁实现的

  • synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的.

  • synchronized 是非公平锁. (不会遵循先来后到,释放之后,哪个线程拿到锁,各凭本事)

  • synchronized 是可重入锁 (内部会记录哪些线程拿到了锁,记录引用计数)

10.0 synchronized 的原理

基本特点

结合上面的锁策略, 我们就可以总结出, Synchronized 具有以下特性(只考虑 JDK 1.8):

  1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
  2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
  3. 实现轻量级锁的时候大概率用到的自旋锁策略
  4. 是一种不公平锁
  5. 是一种可重入锁
  6. 不是读写锁

加锁工作过程

基本过程

  • JVM 将 synchronized 锁分为 无锁–>偏向锁–>轻量级锁–>重量级锁状态。会根据情况,进行依次升级。

偏向锁

  • 不是真的加锁,而只是做了一个"标记",如果有别的线程来竞争了,才会真的加锁,如果没有别的线程竞争,就自始至终都不会真的加锁了(加锁有一定开销),这样做开销小,轻量级

轻量级锁

  • synchronized 通过自旋锁的方式来实现轻量级锁,我这边把锁占据了,另一个线程就会按照自旋的方式,来反复查询当前的锁的状态是否被释放(while循环查看)(轻量级锁消耗cpu资源大,但也能快速拿到锁)

重量级锁

  • 但是后续如果锁竞争这把锁的线程越来越多(锁冲突),synchronized就会重轻量级锁进化成重量级锁(变成挂起等待,不会不断扫描了,等到相关信息才行动,原因是:随着线程变多而对锁的争取变强,即使前一个线程释放锁,也不应保证轻量级锁就能立马拿到锁,所以不如重量级锁挂起等待)

其他优化要点

锁消除

  • 编译器 + JVM 能判定这个代码是否有必要加锁.如果你写了加锁,但是实际上没必要加锁,就会把加锁的操作自动删除掉
  • 比如我们再单个线程中使用了StringBuffer(有锁的),此时编译器会分析出当前情况从而解除加锁
  • 但是编译器进行优化,是要保证优化之后的逻辑和之前的逻辑要一致的,这就会让有的优化变得保守起来

锁粗化

  • 即锁的粒度:粗和细 (如果加锁操作里面包含的实际要执行的代码越多,就会认为锁的粒度越大)
  • 一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化.
for(...){
    sychronized(this){
        cnt++;
    }
}//锁的粒度小(频繁加锁)

sychronized(this){
    for(...){
        cnt++;
    }
}//锁的粒度大,(多次for)
  • 有的时候,希望锁的粒度小比较好,原因是:并发程度高
  • 有的时候,也希望锁的粒度较大比较好(原因是加锁本身也有开销)

相较于cpp

  • jvm对锁做了相当多工作,cpp没有,赢
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

_Ap0stoL

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值