多线程(八)
目录
一 . 常见的锁策略
二 . 乐观锁与悲观锁
三 . 重量级锁与轻量级锁
四 . 挂起等待锁与自旋锁
五. 公平锁与非公平锁
六 . 可重入锁与不可重入锁
七 . 读写锁
八 . synchornized 原理
(1)synchornized 的特性
(2)synchornized 加锁的运行过程
(3)锁消除
(4)锁粗化
九 . synchornized 与 ReentrantLock 的区别
一 . 常见的锁策略
首先我们来了解一下常见的锁策略,这些分类将各式各样的锁分成了不同的类型,但值得注意的是这些类型并不是指的单一特定的锁,而是一个更广义的概念,所有的锁都可以往这些策略中套,我们根据锁的不同属性来将其定义为下列这些锁。
synchornized 也只是市面上五花八门的锁中一种典型的实现, Java 内置的、推荐使用的锁。
二 . 乐观锁与悲观锁
1 . 悲观锁:总是假设最坏的情况,就像我们的 “ 悲观主义者 ” 的思想一样,每次去拿数据的时候都会认为用户在后续进行修改,所以每次一拿到数据的时候就会对其进行上锁,这样别人想拿到这个数据就会阻塞直到它拿到锁。
2 . 乐观锁:假设数据一般情况下不会产生并发冲突,所以只有在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现了并发冲突,则让返回用户的错误信息,让用户自行做出判断。
我来举个例子帮助大家理解一下这两个锁的区别:
例如我们的两位同学 A、B 都想要问老师一个问题,A 同学认为,老师这时候应该是比较忙的,所以在去之前 A 同学就会先给老师发信息询问一下老师现在有空吗?(这一操作就相当于加锁操作),得到老师肯定的答复之后,那么此时 A 同学才会去找老师。这就相当于 “ 悲观锁 ” 。
B 同学就不一样了,他认为老师现在肯定是空闲的,就直接去找老师,如果老师确实比较闲,那么就直接解决问题了,但如果老师这会儿很忙,B 同学也不会打扰老师,就下次再来。(虽然没有加锁操作,但是能识别出数据访问冲突),这就相当于 “ 乐观锁 ” 。
悲观锁与乐观锁的区别就是 “ 预测锁冲突的概率是否更高 ” 。
这两种锁并没有谁优谁劣之分,而是我们要根据不同的场景去使用合适的锁。
如果当前明知道发生冲突的概率很高,我们就应该使用悲观锁,使用乐观锁就会导致 “ 白跑很多趟 ” ,额外消耗掉很多系统资源。
如果当前明知道发生冲突的概率很低,我们就应该使用乐观锁,毕竟,加锁也是需要消耗一定的系统资源的嘛。
我们的 synchornized 是一种 “ 自适应锁 ”(有关自适应锁我们马上就会讲到),它一开始使用的就是 “ 乐观锁策略 ”,当它发现锁竞争比较频繁的时候,就会自动切换成悲观锁策略。
在上面 “ 乐观锁与悲观锁 ” 例子的基础上,这就例如我们还有一个同学 C ,他也想问老师问题,他最开始就认为老师比较闲,就直接去找老师,但找了几次之后,都发现老师一直很忙,于是下次再来找老师之前,他也就发消息问一问,确定老师忙不忙,这就是 “ 自适应锁 ”。
三 . 重量级锁与轻量级锁
锁的核心特征就是 “ 原子性 ” ,这样的机制追根溯源是 CPU 这样的硬件设备提供的。
(2 . 1)CPU 提供了 “ 原子操作指令 ” 。
(2 . 2)操作系统基于 CPU 的原子指令,实现了 “ mutex ” 互斥锁。
(2 . 3)JVM 基于操作系统提供的互斥锁,实现了 “ synchornized ”、“ Reentrantlock ” 等关键字和类。
(注意:synchornized 并不仅仅是对 mutex 进行封装,synchornized 内部还做了很多其他工作)
1 . 重量级锁:加锁机制重度依赖了 OS 提供了 mutex。
重量级锁涉及到大量的内核态用户切换,很容易引发线程的调度,这两个操作成本比较高,一旦涉及到用户态与内核态的切换,就意味着 “ 沧海桑田 ”。简单来说就是重量级锁加锁的开销比较大,要做的工作更多。(这里的开销主要指的是时间上的开销,而非空间)
2 . 轻级锁:加锁机制尽可能不使用 mutex ,而是尽量在用户态代码完成,实在搞不定了,在使用 mutex。
轻量级锁则是少量的内核态用户切换,不容易引发线程的调度。简单来说就是轻量级锁加锁的开销比较小,要做的工作更少。(同上,这里的开销主要也指的是时间上的开销,而非空间)
我来举一个例子来帮助大家理解重量级锁与轻量级锁:
例如我们吃饭,我们可以选择自己在家做或者外出到饭馆吃饭。在家自己做饭,这就是 “ 用户态 ”,用户态的时间成本各方面是比较可控的;去到饭馆吃,这就是 “ 内核态 ”,内核态的时间成本各方面是不可控的。
重量级锁与轻量级锁的效果与乐观锁与悲观锁是几乎重叠的(但不能认为 100%等价,因为乐观、悲观锁是站在 “ 预估锁冲突 ” 的角度,而重量级、轻量级锁是站在 “ 加锁开销 ” 的角度),只是站在的角度不一样:因为往往我们的悲观锁需要做的工作更多更重,对应着重量级锁,乐观锁则相反。
注意:我们的 synchornized 一开始是一个轻量级锁,如果察觉到我们的锁冲突比较严重,此时我们的 synchornized 就会自动转变为重量级锁。
四 . 挂起等待锁与自旋锁
(3 . 1)挂起等待锁:属于是悲观锁 / 重量级锁的一种典型实现。
(3 . 2)自旋锁:属于是乐观锁 / 轻量级锁的一种典型实现。
咱们来举一个例子帮助大家理解这两个锁的区别:
例如我去给女神表白是吧,这一过程呢就是我尝试对女神 “ 加锁 ” 。
然后女神回我说你是个好人,但是我有男朋友了。这一过程就是女神表示 “ 她这个线程已经被别的锁加锁了 ” 。(以下两种等待方式在实际生活中都不建议大家这样昂)
这个时候我们就可以选择等待,比如我们依旧每天跟女神聊天问候买早餐等等,一直这样等,这样的操作就是 “ 自旋锁 ” ,这就是忙等:在等待的过程中并不会释放我们的 CPU 资源,并且不停地检测锁是否被释放,一旦发现锁被释放,就有机会能获取到锁。
我们还有一种等待方式就是先把女神拉黑,就先不联系了,好好搞我们的事业是吧,等到若干年后,从他人口中听说,女神分手了,这个时候觉得我们有机会了,再去联络女神,这样的操作就是我们的 “ 挂起等待锁 ” ,这就相当于让出 CPU 资源,缺点是当我们检测到锁被释放了,到我们去加锁,这一过程的时效性是不可预知的,可能到我们再想去加锁,又已经被加锁了。
自旋锁也就是我们假定锁冲突概率不高的情况下,才会忙等,如果锁冲突概率很高,咱们好几个线程同时竞争用一个锁,其他线程都在忙等,那么总的 CPU 消耗就会非常高。也就是适合 “ 乐观锁 ” 的场景;反之挂起等待锁也就适用于 “ 悲观锁 ” 的场景。
synchornized 的 “ 自适应 ” 的方式:它的轻量级锁就是基于 “ 自旋 ” 的方式实现的( JVM 内部,通过用户态代码实现的);重量级锁就是基于 “ 挂起等待 ” 的方式实现的(调用操作系统 API ,在内核中实现的)。
总体来说,我们一般认为 “ 自旋锁 ” 的效率更高,但是 CPU 的开销更大;“ 挂起等待锁 ” 的效率相对较低,但 CPU 的开销较小。
五. 公平锁与非公平锁
咱们用一张图就可以理清公平锁与非公平锁:
我们的 synchornized 就属于非公平锁:当 N 个线程在竞争同一个锁,其中有一个线程先拿到锁了,当后续该线程释放锁之后,剩下的 N - 1 个线程就需要重新竞争锁(当然,也不能保证这些线程竞争中获取锁的概率一定是数学意义上的严格均等),不管先来后到的顺序,这个时候谁能拿到锁那就不一定了,这就是非公平锁。
换言之,如果我们需要使用公平锁,那就需要做额外的操作(系统原生的是非公平锁),比如我们引入队列,记录每个线程加锁的顺序。
六 . 可重入锁与不可重入锁
可重入锁与不可重入锁一般出现在我们之前提到过的 “ 死锁问题 ” :如果一个线程,针对一把锁,连续加锁两次,就可能出现死锁,但如果我们把锁设定成 “ 可重入锁 ” ,就可以避免死锁了。
可重入锁:会记录当前是那个线程持有了锁;在加锁的时候进行判定:当前申请锁的线程是否就是锁的持有者的线程;在可重入加锁的时候,会实现一个计数器,记录加锁的次数,从而确定什么时候真正地释放锁。
七 . 读写锁
在我们多线程之间,数据的读取方之间不会产生线程安全问题,但是数据的写入方之间以及读、写之间都需要进行互斥,如果两种场景下都是用同一个锁,就会产生极大的性能消耗,因此 “ 读写锁 ” 应运而生。
读写锁在执行加锁操作的时候需要额外表明读写意图,多个读者之间并不互斥,而写者则是要求与任何人互斥。
所谓的读写锁就是把 “ 加锁操作 ” 分成两种情况:“ 读加锁 ” 、“ 写加锁 ”
读写锁提供了两种加锁的 API :“ 读加锁 ”、“ 写加锁 ”(解锁的 API 是一样的)
(6 . 1)如果两个线程都是按照读方式加锁,此时不会产生锁冲突,没有安全问题。
(6 . 2)如果两个线程都是加写锁,此时会产生锁冲突,有安全问题。
(6 . 3)如果是一个线程读一个线程写,此时也会产生锁冲突,有安全问题。
(在实际开发中,对于大部分场景下,读操作的频次比写操作的频次要高)
读写锁本身也是系统内置的锁。
在我们 Java 标准库中提供了 ReentrantReadWriteLock 类来实现读写锁(这是可重入锁)。
synchornized 不是读写锁。
八 . synchornized 原理
结合上面总结的这些锁策略,我们就可以总结出 synchornized 具有一下特性(只考虑 JDK 1.8 )
(1)synchornized 的特性
(1 . 1)初始是 “ 乐观锁 ” ,如果发现锁冲突频繁,就会自动转变为 “ 悲观锁 ” 。
(1 . 2)初始是 “ 轻量级锁 ” 实现,如果锁被持有的时间较长,则转换为 “ 重量级锁 ” 。
(1 . 3)实现 “ 轻量级锁 ” 的时候大概率用到的是 “ 自旋锁 ” 策略。
(1 . 4)synchornized 是一种不公平锁。
(1 . 5)synchornized 是一种可重入锁。
(1 . 6)synchornized 不是读写锁。
(2)synchornized 加锁的运行过程
JVM 将 synchornized 锁分为无锁,偏向锁,轻量级锁,重量级锁状态,而 synchornized 会根据实际情况进行依次升级。
在这之中,咱们重点来理解一下什么叫 “ 偏向锁 ” ?
偏向锁不是真的加锁,因为真的加锁开销比较大,而偏向锁只是做个标记(这一标记的过程,非常非常的轻量、高效)。
偏向锁本质上是在推迟加锁的时机,只是标记一下而已,能不加锁就不加锁(懒汉模式思想的体现),而一旦标记的该线程出现了锁竞争,那么此时就会真的对它进行加锁。
(3)锁消除
锁消除属于编译器的一种优化策略:编译器会对你所写的 synchornized 代码做出判定,判断这个地方是否真的需要加锁。如果这里实质上并没有必要加锁,就能自动把 synchornized 干掉。
例如我们单线程中的 Vector、StringBuffer(自带 synchornized ),此时就完全没必要加锁,编译器就会自动优化掉。
虽然锁消除存在,但是我们写代码的时候,也不能完全指望这个无脑加锁,它只是给我们兜底,我们程序员还是应该有自助判断应该加不加锁的能力。
(4)锁粗化
锁粗化也属于编译器的一种优化策略,那么这个粗化粗化的是什么呢?
首先我们来引入一个概念:锁的粒度。这个粒度就指的是咱们 synchornized { } 代码块中的代码个数(当然,是实际执行的代码个数,执行的代码个数越多,粒度越粗,反之,执行的代码越少,粒度越细)
锁粗化指的就是将多个 “ 细粒度 ” 的锁合并成 “ 粗粒度 ” 的锁。
粗化的好处和必要性:每次加锁都可能会涉及到阻塞等待,为了缩短我们整体等待的时间,提高执行效率,所以进行锁粗化。
九 . synchornized 与 ReentrantLock 的区别
(1)synchornized 是属于关键字,底层是通过 JVM 的 C++ 代码实现的。ReentrantLock 则是标准库中提供的类,通过 Java 代码实现。
(2)synchornized 通过代码块控制加锁 / 解锁,ReentrantLock 通过调用 lock / unlock 方法来实现。
(3)ReentrantLock 提供了 tryLock 这样的加锁风格,tryLock 在加锁失败的时候,不会阻塞,而是直接返回,通过返回值来反馈是加锁成功还是失败。
(4)ReentrantLock 还提供了公平锁的实现。(默认也是非公平的,可以在构造方法中,传入参数,设定成公平的)
(5)ReentrantLock还提供了功能更强大的 “ 等待通知机制 ” ,基于 Condition 类,能力要比 wait / notify 更强一些。
OKK,有关常见的锁策略咱们就讲这么多了,这一期都是概念型的知识,不过都还算简单,关于锁这一概念还是挺重要的,大家看一遍都能有个印象。多线程的内容我们也总结完了,当然,如果觉得我还有的部分没有总结到位的,也欢迎找我讨论私聊哦,觉得我总结的不错的呢,也希望诸君动动发财的小手,顺手点个赞点个关注吧,好啦,咱们下期再见吧。与诸君共勉!!!