乐vs悲观锁,重vs轻量级锁,公vs非公平锁,不vs可重入锁,等等锁策略

这里讲的“乐观锁”“悲观锁”“轻量级锁”等等,都不是一个锁,而是一类锁。 

比如:我们班有“带眼镜”的同学,这里“带眼镜”并不是指一个人,而是指一类人。

并且这里的锁,并不局限于Java,而是只要关于有锁的话题,就会有下面的讨论。

目录

一、乐观锁vs悲观锁

二、轻量级锁vs重量级锁

三、公平锁vs非公平锁

四、自旋锁vs挂起等待锁

五、可重入锁vs不可重入锁

六、读写锁

七、Synchronized锁的原理

八、锁消除,锁粗化


一、乐观锁vs悲观锁

我们“ 预测这个锁冲突的概率高不高  ”这个角度

如果高,就是悲观锁;如果低,就是乐观锁。

乐观锁因为概率不高,做的工作可以轻松点;

悲观锁因为概率高,做的工作就是复杂点的。

这两种锁是站在:看加锁解锁的 “过程” 所干的活多还是少

乐观锁:就像一个乐观的人,总觉得别人都是好的,事件的发生都是往好的发展,所以它会认为别人不会经常修改这个数据。

悲观锁:就像一个悲观的人,总觉得别人是坏的,事情的发生都是往坏的发展,所以它会觉得别人老想修改这个数据。

他们应对的策略也不一样。 

 举个栗⼦: 同学 A 和 同学 B 想请教⽼师⼀个问题。

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

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

如果当前⽼师确实⽐较忙, 那么使⽤悲观锁的策略更合适, 使⽤乐观锁会导致 "⽩跑很多趟", 耗费额外的资源。

如果当前⽼师确实⽐较闲, 那么使⽤乐观锁的策略更合适, 使⽤悲观锁会让效率⽐较低。

二、轻量级锁vs重量级锁

这个站在 加锁解锁的开销大还是小 的角度

轻量级锁:加锁解锁开销小;重量级锁:加锁解锁开销大。

这两种锁是站在 “结果” 的角度去看最终加锁解锁的时间是多还是少。

为什么轻量级锁开销就小,重量级锁开销就大? 

锁的核⼼特性 "原⼦性", 这样的机制追根溯源是 CPU 这样的硬件设备提供的

  • CPU 提供了 "原⼦操作指令
  • 操作系统基于 CPU 的原⼦指令, 实现了 mutex 互斥锁
  • JVM 基于操作系统提供的互斥锁, 实现了 synchronized 和 ReentrantLock 等关键字和类

注意: synchronized 并不仅仅是对 mutex 进⾏封装, 在 synchronized 内部还做了很多其 他的⼯作

轻量级锁是加锁机制尽可能不使⽤ mutex, ⽽是尽量在⽤⼾态代码完成。 实在搞不定了, 再使⽤ mutex互斥锁;

  • 少量的内核态⽤⼾态切换.
  • 不太容易引发线程调度

而重量级锁是加锁机制重度依赖了 OS 提供了 mutex互斥锁。

  • ⼤量的内核态⽤⼾态切换
  • 很容易引发线程的调度

那啥是内核态,啥是用户态?


比如说:

1)在银行你要办业务,可能会需要身份证或者户口本复印件,这时候,你如果没带,那么银行可能会有复印机。

2)那么你有2种选择:第一种是去柜台让柜员帮你,第二种是自己去大厅的复印机复印。

3)内核态:如果让柜员帮你去后台复印,可能没这么快,柜员也许要帮别人也复印,也可能去摸鱼,也可能去上个厕所。我们知道操作系统是由内核和软件组成,有很多软件都需要内核管理,可能会没那么及时。

4)用户态:如果你是自己去大厅复印,自给自足,非常的快。

三、公平锁vs非公平锁

 大家可以思考一下:什么是公平

1)先到先得

2)虽然你先到,但是我们概率均等

在计算机中,我们的公平锁其实是遵循(1)先到先得

这就好⽐⼀群男⽣追同⼀个⼥神。

公平锁:当⼥神和前任分⼿之后, 先来追⼥神的男⽣上位

⾮公平锁:如果是⼥神不按先后顺序挑⼀个⾃⼰看的顺眼的

注意:

  • 操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是⾮公平锁. 如果要 想实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序
  • 公平锁和⾮公平锁没有好坏之分, 关键还是看适⽤场景 

四、自旋锁vs挂起等待锁

当一个线程释放锁了之后,另外的线程就会去抢这个锁,如果没抢到那就会进入堵塞状态,放弃CPU,但是其实没过多久就再能抢锁了,没必要放弃CPU。这时候就需要自旋锁了。

自旋锁:当争抢锁失败之后不气馁,不放弃CPU,继续等待争抢锁,其实没过多久就会有下一次尝试了。一直循环,直到抢到锁。

挂起等待锁:当争抢锁失败了之后,就放弃CPU,可能会等好久才能去竞争。

⾃旋锁是⼀种典型的 轻量级锁 的实现⽅式

  • 优点: 没有放弃 CPU, 不涉及线程阻塞和调度, ⼀旦锁被释放, 就能第⼀时间获取到锁
  • 缺点: 如果锁被其他线程持有的时间⽐较久, 那么就会持续的消耗 CPU 资源. (⽽挂起等待的时候是不消耗 CPU 的)

 举个例子:

两个人追女神,但是这时候女神已经有男朋友了。

自旋锁:看到女神有男朋友也不放弃,一直不断的追求女神,每天早安午安晚安问候女神,等女神分手了,就问女神处不处对象~

挂起等待锁:看到女神有男朋友了,直接暂时放弃追求女神,等听到或者知道女神分手了(这时候可能过了很久,失去最好的机会),才去问问女神处不处对象~ 

五、可重入锁vs不可重入锁

可重入锁:顾名思义,就是一个线程可以多次获取同一把锁。

不可重入锁:就是一个线程不可以多次获取同一把锁。

Java⾥只要以Reentrant开头命名的锁都是可重⼊锁,⽽且JDK提供的所有现成的Lock实现类,包括 synchronized关键字锁都是可重⼊的。⽽ Linux 系统提供的 mutex 是不可重⼊锁。

为什么会有不可重入锁?

其实可重入锁才是经过编写改进的,不可重入锁才是符合常态的。

里面的锁认为:我要拿到锁,就要外面的锁释放掉,我才能拿到,你不给我我就先堵塞

外面的锁认为:你在里面堵塞着我怎么才能先结束这个锁给你啊?

以上就是不可重入锁,也就是最基本的锁规则之一。

六、读写锁

在多线程中:

我们的读操作同时进行是不会有安全问题的,因为没有修改;

但是如果一个读一个写,那么就会有线程安全问题,因为修改了;

还有两个一起写也会有安全问题,因为都对数据进行了修改。

如果这时候都用来加锁,那就非常浪费资源,效率非常的低。因为我们更多时候都只需要读,如果两种场景下都⽤同⼀个锁,就会产⽣极⼤的性能损耗。所以读写锁因此⽽产⽣。

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

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

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

  • ReentrantReadWriteLock.ReadLock 类表⽰⼀个读锁。这个对象提供了 lock / unlock ⽅法进⾏加锁解锁
  • ReentrantReadWriteLock.WriteLock 类表⽰⼀个写锁。这个对象也提供了 lock / unlock ⽅法进⾏加锁解锁

我们规定:

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

值得注意的是:

只要是涉及到 "互斥", 就会产⽣线程的挂起等待。⼀旦线程挂起,再次被唤醒就不知道隔了多久了。因此尽可能减少 "互斥" 的机会, 就是提⾼效率的重要途径。

七、Synchronized锁的原理

 Synchronized的加锁过程:

JVM 将 synchronized 锁分为:⽆锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进⾏依次升级。

1) 偏向锁

第⼀个尝试加锁的线程, 优先进⼊偏向锁状态。

偏向锁不是真的 "加锁", 只是给对象头中做⼀个 "偏向锁的标记", 记录这个锁属于哪个线程。如果后续没有其他线程来竞争该锁, 那么就不⽤进⾏其他同步操作了(避免了加锁解锁的开销) 如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进⼊⼀般的轻量级锁状态。偏向锁本质上相当于 "延迟加锁" 。能不加锁就不加锁, 尽量来避免不必要的加锁开销. 但是该做的标记还是得做的, 否则⽆法区分何时需要真正加锁。

举个栗⼦理解偏向锁:

假设男主是⼀个锁,⼥主是⼀个线程。如果只有这⼀个线程来使⽤这个锁,那么男主⼥主即使不领证结婚(避免了⾼成本操作),也可以⼀直幸福的⽣活下去。但是⼥配出现了, 也尝试竞争男主, 此时不管领证结婚这个操作成本多⾼, ⼥主也势必要把这个动作完成了, 让⼥配死⼼。

2) 轻量级锁

随着其他线程进⼊竞争, 偏向锁状态被消除, 进⼊轻量级锁状态(⾃适应的⾃旋锁)。此处的轻量级锁就是通过 CAS 来实现

  • 通过 CAS 检查并更新⼀块内存 (⽐如 null => 该线程引⽤)
  • 如果更新成功, 则认为加锁成功
  • 如果更新失败, 则认为锁被占⽤, 继续⾃旋式的等待(并不放弃 CPU)

⾃旋操作是⼀直让 CPU 空转, ⽐较浪费 CPU 资源. 因此此处的⾃旋不会⼀直持续进⾏, ⽽是达到⼀定的时间/重试次数, 就不再⾃旋了. 也就是所谓的 "⾃适应" 

 3) 重量级锁

如果竞争进⼀步激烈, ⾃旋不能快速获取到锁状态, 就会膨胀为重量级锁 此处的重量级锁就是指⽤到内核提供的 mutex .

  • 执⾏加锁操作, 先进⼊内核态
  • 在内核态判定当前锁是否已经被占⽤
  • 如果该锁没有占⽤, 则加锁成功, 并切换回⽤⼾态
  • 如果该锁被占⽤, 则加锁失败. 此时线程进⼊锁的等待队列, 挂起. 等待被操作系统唤醒
  • 经历了⼀系列的沧海桑⽥, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒 这个线程, 尝试重新获取锁

八、锁消除,锁粗化

锁消除:编译器+JVM 判断锁是否可消除。如果可以,就直接消除。

比如,有的时候我们使用Synchronized,这个是锁,我们没有在多线程下使用,就会自动优化掉,提高效率(比如StringBuffer)

StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");

锁粗化⼀段逻辑中如果出现多次加锁解锁,编译器 + JVM 会⾃动进⾏锁的粗化。

(锁的粒度: 粗和细)粒度就是指锁括号括起来的代码。

实际开发过程中,使⽤细粒度锁,是期望释放锁的时候其他线程能使⽤锁。但是实际上可能并没有其他线程来抢占这个锁。这种情况 JVM 就会⾃动把锁粗化, 避免频繁申请释放锁。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值