常见锁策略(乐观锁悲观锁/读写锁/重量级锁轻量级锁/自旋锁挂起等待锁/公平锁非公平所/可重入锁不可重入锁)

常见的锁策略

1.1 乐观锁 vs 悲观锁

乐观锁: 预测该场景中, 不太会出现锁冲突的情况. 假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做.

悲观锁: 预测该场景中, 容易出现锁冲突. 总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁.

举个例子: 同学A和同学B想请教老师一个问题.

同学 A 认为 “老师是比较忙的, 我来问问题, 老师不一定有空解答”. 因此同学 A 会先给老师发消息: “老师你忙嘛? 我下午两点能来找你问个问题嘛?” (相当于加锁操作) 得到肯定的答复之后, 才会真的来问问题. 如果得到了否定的答复, 那就等一段时间, 下次再来和老师确定时间. 这个是悲观锁.

同学 B 认为 “老师是比较闲的, 我来问问题, 老师大概率是有空解答的”. 因此同学 B 直接就来找老师.(没加锁, 直接访问资源) 如果老师确实比较闲, 那么直接问题就解决了. 如果老师这会确实很忙, 那么同学 B 也不会打扰老师, 就下次再来(虽然没加锁, 但是能识别出数据访问冲突). 这个是乐观锁.

这两种思路不能说谁优谁劣, 而是看当前的场景是否合适.

如果当前老师确实比较忙, 那么使用悲观锁的策略更合适, 使用乐观锁会导致 “白跑很多趟”, 耗费额外的资源.

如果当前老师确实比较闲, 那么使用乐观锁的策略更合适, 使用悲观锁会让效率比较低.

1.2 读写锁

多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。

一个线程对于数据的访问, 主要存在两种操作: 读数据 和 写数据.

  • 两个线程都只是读一个数据, 此时并没有线程安全问题. 直接并发的读取即可.
  • 两个线程都要写一个数据, 有线程安全问题, 需要加锁.
  • 一个线程读另外一个线程写, 也有线程安全问题, 需要加锁.

读写锁, 就是把读操作和写操作区分对待. Java标准库提供了ReentrantReadWriteLock 类, 实现了读写锁.

  • ReentrantReadWriteLock.ReadLock类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁.
  • ReentrantReadWriteLock.WriteLock类表示一个写锁. 这个对象也提供了 lock / unlock 方法进行加锁解锁.

其中,

  • 读加锁和读加锁之间, 不互斥.
  • 写加锁和写加锁之间, 互斥.
  • 读加锁和写加锁之间, 互斥.

在实际开发中, “频繁读, 不频繁写” 的场景是非常广泛存在的.所以这样做, 可以提高多线程的并发执行效率.

1.3 重量级锁 vs 轻量级锁

重量级锁: 加锁开销比较大(花的时间多, 占用资源多), 一个悲观锁很可能是一个重量级锁.

轻量级锁: 加锁开销比较小(花的时间少, 占用资源少), 一个乐观锁很可能是一个轻量级锁.

悲观乐观, 是在枷锁之前, 对锁冲突概率的预测, 决定工作的多少.

重量轻量, 是在加锁之后, 考量实际的锁开销.

1.4 自旋锁(Spin Lock) vs 挂起等待锁

自旋锁, 是轻量级锁的典型实现.

  • 在用户态下, 通过自旋的方式(例如while循环), 实现类似于加锁的效果.
  • 如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来.
  • 这样可以在锁释放的时候第一时间拿到锁, 也会消耗一定的CPU资源.

挂起等待锁, 是重量级锁的典型实现

  • 通过内核态, 借助系统提供的锁机制, 当出现锁冲突时, 会牵扯到内核对于线程的调度, 使冲突的线程出现挂起(阻塞)等待.
  • 如果获取锁失败, 那么该线程会被阻塞, 内核去调度其他线程了, 直到有内核重新调度该线程, 再去尝试获取锁.
  • 这样的锁会消耗较少的CPU资源

1.5 公平锁 vs 非公平锁

假设三个线程 A, B, C. A先尝试获取锁, 获取成功. 然后B再尝试获取锁, 获取失败, 阻塞等待; 然后C也尝试获取锁, C也获取失败, 也阻塞等待.

当线程 A 释放锁的时候, 会发生啥呢?

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

非公平: 不遵守 “先来后到”. B和C都有可能获取到锁.

系统自带的锁是非公平锁. 如果想实现公平锁, 就需要有一些额外的数据结构来支持(比如需要有方法记录每个线程的阻塞等待时间)

1.6 可重入锁 vs 不可重入锁

如果一个线程没有释放锁, 然后又尝试加锁, 若没有产生死锁, 那么该锁就是可重入锁; 如果产生死锁, 那么该锁就是不可重入锁.

理解死锁:

public synchronized void increase() {
	synchronized(this) {
	    count++;
	}
}

这样一个代码运行的时候会出现什么问题呢?

  1. 调用方法, 先对this加锁, 假设加锁成功了.
  2. 执行到下面的synchronized, 此时, 还是针对this进行加锁. 此时, 线程就会阻塞, 一直阻塞到锁被释放, 才有机会拿到锁. 但是this上的锁要在increase方法执行完毕后才能释放, 但要想让increase方法执行完, 就得需要第二次加锁成功, 方法才能继续执行… 这样就会陷入无限循环, 永远卡在这里.

这就是死锁的一种表现形式.

如果是一个不可重入锁, 这把锁不会保存是哪个线程加的, 只要它处于加锁状态时, 收到了"加锁"请求, 就会拒绝当前加锁, 而不管请求的是哪个线程, 就会产生死锁.

如果是一个重入锁, 他就会保存是哪个线程加的锁, 后续收到加锁请求时, 就会先对比一下, 看看请求加锁的线程是不是当前已经加锁的线程, 这就不会产生死锁.

synchronized是一个可重入锁, 所以上面的代码不会出现死锁的情况.

还有一种情况:

synchronized(this){
 synchronized(this){
     synchronized(this){
         ....
     }
 }
}

第一个synchronized真正加了锁, 下面的那些, 判定了持有线程就是当前线程, 就并没有真的加锁.

一直执行到第五行, 出了这个代码块后, 是否要释放锁?

如果释放, 那么再执行下面的代码时, 就没有锁了, 也就不能保证线程安全了.

所以真正释放锁的地方是第七行.

如果加锁了n层, 在遇到’}‘时, JVM如何知晓当前这个’}'是不是最后一层呢?

让锁持有一个计数器就行了, 让锁对象不光记录是哪个线程持有的锁, 同时在通过一个整型变量记录当前线程加了几次锁. 每遇到一个加锁操作, 计数器+1, 每遇到一个解锁操作, 计数器-1. 当计数器减为0时, 才真正释放锁.

这样的计数器, 被称为"引用计数"


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

是布谷阿

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

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

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

打赏作者

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

抵扣说明:

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

余额充值