目录
🚩synchronized锁特性详细解说
🚩加锁工作过程(锁升级)
🎈偏向锁
🎈轻量级锁(自适应的自旋锁)
🎈 重量级锁
🚩其他的优化操作
🎈锁消除
🎈锁粗化
🎈相关面试题
结合上上一篇的锁策略,我们可以了解到锁的特性:
🚩synchronized锁特性
- 一、开始时是乐观锁,如果锁冲突频繁,就转换为悲观锁。
- 就比如我去找导员有事,我害怕她不在,白跑一趟,所以我先发信息问问导员在不在办公室[这就相当于加锁],如果得到肯定的回复那么就去,得到否定的回复,那么就不去,等下次他在了,我再去问——这属于悲观锁。
- 还有一种就是我直接去,不发信息问导员在不在办公室了,如果他在正好,不在就相当于白跑了——这属于乐观锁。
悲观锁和乐观锁其实本质还是哪种方式做的多与少区别。
- synchronized开始是乐观锁,最后发现锁竞争比较频繁,会自动切换成悲观锁。(如果我之后去的每一次导员都不在,那么之后再去的话我就会先发信息问问导员在不在,以免我又白跑了一趟)。
- 二、开始时是轻量级锁,如果锁被持续的时间较长,就转换成重量级锁
轻量级锁是纯用户态的加锁解锁逻辑,重量级锁是系统内核态的加锁解锁逻辑。我们之前举例,我去银行取钱,必须得要身份复印件,此时我没有身份证复印件,银行内有个打印机,可以打印,或者去找柜员去打印。我自己去打印机自助打印,打印完就直接交给柜员了,但是如果我交给柜员去打印,我们不清楚柜员中间是否会进行别的操作,可能等的时间长又或者会做其他事情,所以我自己去打印机打印这属于纯用户态即轻量级锁,去找柜员打印则是系统内核态即重量级锁。如果打印机等待打印的时间太长了,我就直接找柜员去给我打印了,此时轻量级锁就转换成了重量级锁。
这两种锁是站在结果的角度看待最终加锁解锁消耗的时间是多还是少,和乐观锁与悲观锁并不一样通常情况下乐观锁比较轻量,悲观锁比较重量,但是也并不绝对。
- 三、实现轻量级锁的时候大概率用到的自旋锁策略
自旋锁:相当于是"忙等"的状态,大量消耗的CPU资源,反复询问当前锁是否就绪。
我们可以这样想,在我们自己去打印机打印的时候,排队的人很多,一直在等着去打印,这时候挺消耗人精力的,所以我们就一直等等,并且还会时不时看看前面还有多少人,看前面人少了就等待着去打印了。所以我们在实现轻量级锁的时候,大概率需要忙等。
如果获取锁失败 , 立即再尝试获取锁 , 无限循环 , 直到获取到锁为止 . 第一次获取锁失败 , 第二次的尝试会在极短的时间内到来. 一旦锁被其他线程释放, 就能第一时间获取到锁 .
- 是一种不公平锁
非公平锁:多个线程同时竞争一把锁,有一个线程是比较晚来的,却比其他先来的线程先拿到锁
synchronized锁的获取是非公平的。所谓非公平锁,指的是当多个线程同时请求锁时,锁的获取是随机的,没有任何公平性可言。这意味着,即使某个线程已经等待了很长时间,也不能保证它会在其他线程之前获取到锁。
为什么synchronized是非公平锁呢?这主要是由于synchronized的实现方式决定的。在Java中,synchronized是基于监视器锁(monitor)实现的。每个对象都有一个与之关联的监视器锁,当一个线程进入synchronized代码块时,它会尝试获取对象的监视器锁,如果锁已经被其他线程持有,则该线程会进入阻塞状态,直到锁被释放。
在非公平锁的情况下,当一个线程释放锁时,JVM并不会保证下一个获取锁的线程是等待时间最长的线程,而是随机选择一个等待的线程来获取锁。这样做的好处是可以减少线程切换的开销,提高系统的吞吐量。但同时也可能导致某些线程长时间等待,造成不公平性。
- 是一种可重入锁
- 可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。
之前我们在定义死锁的时候,我们说 一个线程,连续针对一把锁,加锁两次,不会出现死锁 这就是可重入锁。如果出现死锁的现象,那就是不可重入锁。
可重入锁也叫递归锁。
- 不是读写锁
多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。
而普通的锁就如synchronized锁不是读写锁,不管是读锁和读锁之间,读锁和写锁之间,写锁和写锁之间,都是会造成阻塞等待,而读写锁,只有在数据的写入方互相之间以及和读者之间都需要互斥,其余的在读取方之间不会产生线程安全问题,所以我们在频繁读,不频繁写的场景下,读写锁是很优化的,减少了锁之间的冲突。
🚩加锁工作过程(锁升级)
🎈偏向锁
偏向锁其实不是真正的加锁,只是给对象头中做一个 "偏向锁的标记", 记录这个锁属于哪个线程的,如果后续没有其他线程来竞争该锁,那么就不用进行同步操作了(避免了加锁解锁的消耗)
如果后续有其他线程来竞争该锁,(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别 当前申请锁的线程是不是之前记录的线程),那就取消原来偏向锁的状态,进入一般的轻量级锁状态。
举个栗子理解偏向锁假设男主是一个锁,女主是一个线程,如果只有一个线程来使用这个锁,那么男主女主即使不领证不结婚(避免高成本操作),也可以继续幸福下去。(我们前面也说到了,只有和对方在一起了成为情侣了那才是真正的加锁,但是现在他们并没有领证也没有结婚,只是打着有情侣之实,并没有情侣之名的操作,所以就没有真正的加锁,这只是偏向锁。)但是女配出现了,也尝试竞争男主,此时不管领证结婚这个操作成本多高,女主也势必要和这个男生结婚领证了。让女配死心。
我们想到之前说到的
- 线程池,是优化了"找下一任"的概率
- 偏向锁,是优化了"分手"的概率 (一旦有锁冲突,我可以直接丢弃,因为我并没有给线程加锁,所以我直接舍弃就行)
偏向锁的核心:
和我们之前所说的"懒汉模式"的另一种体现,需要的时候就再加锁,能不加锁就不加锁。加锁意味着开销。(懒汉模式就是非必要不创建对象)
🎈轻量级锁(自适应的自旋锁)
随着其他线程进行竞争,偏向锁状态被消除,进入轻量级状态(自适应的自旋锁).
一旦受到了锁竞争,那么就立刻和确认关系(确认关系就是加锁了)。这才是真正的加锁。
此处的轻量级锁就是通过 CAS 来实现.
- 通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)
- 如果更新成功, 则认为加锁成功
- 如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU).
自旋操作是一直让 CPU 空转, 比较浪费 CPU 资源.因此此处的自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋了.也就是所谓的 "自适应"。
🎈 重量级锁
- 执行加锁操作, 先进入内核态.
- 在内核态判定当前锁是否已经被占用
- 如果该锁没有占用, 则加锁成功, 并切换回用户态.
- 如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起. 等待被操作系统唤醒.
- 经历了一系列的沧海桑田, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒 这个线程, 尝试重新获取锁.
按照这样的加锁工作过程,这样就可以既能保证效率,又能够保证线程安全。
保证效率也就是我上述所说的,一旦遇到锁冲突,那么就立即加锁(轻量级锁)。
🚩其他的优化操作
🎈锁消除
也是一种编译器优化的手段,编译器会自动针对你当前写的加锁代码,做出判定,如果编译器觉得这个场景,不需要加锁,此时就会把你写的synchronized给优化。(编译器只会在自己非常有把握的时候,才会进行锁消除)
我们再javase中学到,StringBuilder和StringBuffer,我们当时说StringBuffer是安全的,StringBuilder是不安全的,因为StringBuffer是带有synchronized的,而StringBuilder是不带有synchronized的。
StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");
此时每个 append 的调用都会涉及加锁和解锁. 但如果只是在单线程中执行这个代码, 那么这些加
锁解锁操作是没有必要的, 白白浪费了一些资源开销.
(不是说写了synchronized就一定是安全的,因为再单例模式中,if和new必须同时加锁,因为俩者不能再多线程中实现,会造成线程不安全问题)
🎈锁粗化
锁的粒度: 粗和细synchronized里头,代码越多,就认为锁的粒度越粗,代码越少,锁的粒度越细。
举个例子:滑稽老哥当了领导, 给下属交代工作任务:方式一:打电话, 交代任务1, 挂电话.打电话, 交代任务2, 挂电话.打电话, 交代任务3, 挂电话.方式二:打电话, 交代任务1, 任务2, 任务3, 挂电话.显然, 方式二是更高效的方案所以在一段逻辑中多次进行加锁减锁,编译器就会自动的粗化代码,让只加一次锁完成后,就直接解锁。更加高效的完成任务。
可以看到 , synchronized 的策略比较复杂的 , 在背后做了很多事情 , 目的为了让程序猿哪怕啥都不懂 ,也不至于写出特别慢的程序.
🎈相关面试题
1) 什么是偏向锁?
偏向锁不是真正意义上的加锁,而只是在锁的对象头中记录一个标记(记录该锁所属的线程)。如果没有其他锁冲突的话 ,就不会执行真正的加锁操作,从而降低程序开销,一旦真的涉及到其他线程竞争,再取消锁偏向状态,进入轻量级状态。
我所有的野心,就是立刻出发。