14. 多线程(进阶1) --- 常见的锁策略和锁的特性
文章目录
- 前言
- 一. 常见的锁策略
- 1.1. 悲观锁 VS 乐观锁
- 1.2. 重量级锁 VS 轻量级锁
- 1.3. 挂起等待锁 VS 自旋锁
- 1.4. 普通互斥锁 VS 读写锁
- 1.5. 可重入锁 VS 不可重入锁
- 1.6. 公平锁 VS 非公平锁
- 二. 锁的特性
- 2.1. 锁升级
- 2.2. 锁清除
- 2.3. 锁粒度
前言
知识回顾:我们之前学习了 多线程 初阶,讲解了:
- 线程原理,进程和线程的关系。
- 多线程 Thread 类的使用
- 线程安全问题
- 等待通知机制 (wait notify)
- 多线程代码案例 — 单例模式,生产者-消费者模型,定时器以及线程池
从这篇博客开始,我们主要要讲解 多线程进阶的学习了,这个内容会更加的丰富,主要包含 面试题,咱们开始吧!
一. 常见的锁策略
如果自己要实现一把锁,认为 标准库中提供的锁 不够用,此时就需要关注锁策略。
此处的“锁”策略 不是和 Java 强相关的,但凡涉及到并发编程,涉及到锁,都可以谈到此处的 锁策略。
锁策略主要探讨的就是,不同锁之间在加锁的时候,有什么行为,有什么特点。
虽然 synchronized 的功能和用法已经非常好,可以满足大部分场景的需求,但是架不住 面试官要问。
1.1. 悲观锁 VS 乐观锁
此处的 “乐观” 和 “悲观” 可不是 人的所谓 “乐天派” 或者 “苦瓜脸”,而是针对不同的场景。并且 不是针对某一种具体的锁,而是某个具体的锁具有 “悲观” 特性 或者 “乐观” 特性。
悲观:加锁的时候,预测接下来的锁竞争的情况 非常激烈,就需要针对这样的 激励情况 额外 做一些工作。
乐观:加锁的时候,预测接下来的锁竞争情况 不激烈,就不需要做额外的工作。
有两个例子:
- 悲观锁:有一把锁,有二十个线程尝试获取锁,每个线程加锁的频率都很高,一个线程加锁的时候,很可能被另一个线程占用的。
乐观锁:有一把锁,只有两个线程尝试获取到这个锁,每个线程加锁的频率都很低,一个线程加锁的时候,大概率另一个线程没有和他竞争。 - 现在 同学A 和 同学B 想请教老师一个问题:
同学 A 认为 “老师现在应该比较忙,我先发个微信,看看老师的回应”,然后就给老师说:“老师,你下午忙吗,我下午2点钟想问个问题” (相当于加锁操作)。在得到肯定答复之后,同学A在去办公室找老师;可能老师上午没有看消息,同学A隔了一会,在向老师发出请求。这个就是 悲观锁。
同学 B 认为 “老师现在在休息,现在可以直接去办公室找他问问题”,因此同学 B 直接就去找老师了 (没加锁,直接访问资源)如果老师没有事,那么问题就能立马解决,如果老师确实是很忙,那么同学 B 就不打扰老师,下次再来 (虽然没加锁,但是能识别出数据访问冲突)。这个是个 乐观锁。
而在 synchronized中 ,初始使用乐观锁策略,当发现锁竞争比较频繁的时候,就会自动切换成悲观锁策略。
这就好比 有一个 同学 C,他也想问问题,直接去找老师(乐观锁的策略),但是找了两三次之后,发现去了两三次,老师仍然很忙,于是下次再问问题的时候,先发个消息问问老师忙不忙,然后再决定是否来问问题。
1.2. 重量级锁 VS 轻量级锁
遇到场景之后的解决方案
重量级锁,当悲观的场景下,此时就要付出更多的代价 --> 更低效。
轻量级锁,应对乐观的场景下,此时付出的代价就会更小 —> 更高效
这个重量级锁 和 轻量级锁的先搁置,等把 常见的锁策略 讲完之后,再讲这个内容。
1.3. 挂起等待锁 VS 自旋锁
挂起等待锁 --> 重量级锁的典型实现 --> 操作系统内核级别的,加锁的时候发生了锁竞争,就会使线程进入到阻塞状态,后续就需要 内核 来唤醒。
自旋锁 --> 轻量级锁的典型实现 --> 应用程序级别的,加锁的时候发生竞争,一般也不是进入到阻塞,而是通过 忙等 来等待。
忙等,在乐观的时候,本来遇到锁竞争的概率就小,真的遇到竞争,在短时间内能拿到锁。
用两个例子来解释一下:
- 追女神,心里的感受是:女神,是大家心中的女神 --> 悲观的态度。
在挂起等待锁的情况下,你去问女神,做我女朋友好不好,女神说,你是个好人。你表示,“没关系,我愿意去等”,等的过程中,不再联系女神,安心敲代码,几个月过去了。偶然间,听说,女神单身了(可能在你听到这个消息的时候,人家都谈了好几次了),此时再去找女神,你愿意做我的女朋友吗?此时女神说,愿意~。
那么在这个过程中,我在敲代码的时候,我并没有在女神身上消耗精力。
还有一种感受,就是女神,只是我心中的女神 --> 乐观的态度。
在自旋锁方式,今天问问女神,做我的女朋友好不,女神说,你是个好人。此时,你表示,没事,我愿意等 (甘当备胎),等的过程中,隔三差五的,联系一下,嘘寒问暖,很快,有一天,得知女神和她的男朋友分手了,此时机会来了,你就乘机上位,就得到了女神。
在这个过程中,并没有消耗很长时间,但是消耗了一定量的 CPU资源的。 - 在实际的代码场景下,挂起等待锁:在操作系统中,让线程阻塞,后续由系统唤醒,比如首,线程1 在 14:00的时候,因为加锁失败,进入到阻塞状态,14:05 的时候,对应的锁就释放了。14:10的时候,操作系统才唤醒线程1,线程1才拿到锁。
在 14:00~14:10 的这个过程中,线程1 全程阻塞,不消耗 CPU资源。
自旋锁,不涉及到内核操作。线程2 在14:00 的时候,因为加锁失败,等待,并不会放弃 CPU资源。
14:01 问问,锁是否可以加,还是不能加。
14:02 问问,锁是否可以加,还是不能加。
14:03 问问,锁是否可以加,还是不能加。
…
14:05,其他线程释放了锁,当前线程2 就可以加锁了。
不涉及到系统内核中的线程调度,第一时间拿到锁。
挂起等待锁的特点:
- 获取锁的周期很长,很难做到及时获取
- 获取锁的过程不必一直消耗 CPU 资源
- 竞争激烈
- 操作内核操作
自旋锁的特点:
- 整个锁等待的时间,不会很长
- 获取锁的周期很短
- 这个过程会一直消耗 CPU 资源
- 锁竞争不激烈
- 应用程序内的
悲观锁 --> 重量级锁 --> 挂起等待锁
乐观锁—> 轻量级锁 --> 自旋锁
synchronized 是挂起等待锁 还是 自旋锁?
其实是,既是 挂起等待锁 又是自旋锁。
设计 JVM 的大佬们,为程序员操碎了心。大佬的初心是,别让程序员操作这些事情,都在 JVM 内部做好。
JVM 内部,会统计每个锁竞争的激烈程度,
如果竞争不激烈,那么此时 synchronized 就会按照 轻量级锁 (自旋),如果竞争激烈,那么此时 synchronized 就会按照 重量级锁 (挂起等待锁)。
1.4. 普通互斥锁 VS 读写锁
读锁就是在读取内容的时候,进行加锁;
写锁就是在往内存中写入数据的时候,进行加锁。
我们知道,多个线程读取一个数据,线程是安全的,多个线程读取一个内容,一个线程修改同一个内容,肯定会涉及到线程安全的问题。
大部分操作在 读,少数操作是在写。
如果把 每个读和写 都加上普通的互斥锁,意味着锁冲突非常严重。
这是个读多,写少的情况,在服务器开发中,是非常常见的情况,或者在教务系统中,通常都是老师布置一项作业,一个人在写的时候,几十个同学在读。
读写锁,确保,读锁和读锁之间,不是互斥的(不会产生阻塞),读锁和写锁之间,才产生互斥。写锁和 写锁之间,也会产生互斥。
读写锁,就是为了降低锁冲突的概率,提高运行效率提出来的。
多线程读一个变量,本身就是线程安全的,不需要加锁,也不会互斥。但是写操作,为了避免写操作对读操作的影响,于是给读操作也加上锁。
synchronized是个 普通互斥锁,Java的标准库中实现了读写锁,这个我们再下一个博客中讲解。
1.5. 可重入锁 VS 不可重入锁
可重入锁的字面意思是:“可以重新进入的锁”,即** 允许同一个线程多次获取同一把锁**,一个线程,一把锁,连续加锁多次,看是否会构成死锁。
核心要义:
- 锁要记录当前是哪个线程 拿到了这把锁
- 使用计数器,记录当前加锁了多少次,在合适的时候进行解锁
synchronized 是个 可重入锁,我们可以看一下下面的代码,就知道了。
public class Demo46 {public static void main(String[] args) {Object locker = new Object();int i = 0;synchronized (locker){synchronized (locker){synchronized (locker){System.out.println(i);}}}}
}
这个代码中,System.out.println(i);这个操作,被同一个 locker 锁了三次,到最后还是执行了这个代码,因此是个可重入锁。
1.6. 公平锁 VS 非公平锁
当女神和男朋友分手之后,此时应该谁上位呢?
- 按照先来后到的顺序,谁先追女神,谁先上位,这是公平锁。
- 概率均等,这是 非公平锁。
要想实现公平锁,需要付出额外的东西,比如,需要记录一下,各个线程获取锁的顺序。
synchronized 是个 非公平锁。
二. 锁的特性
2.1. 锁升级
JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁和重量级锁 状态。会根据情况,进行依次升级。
我们先解释一下什么是 偏向锁?
偏向锁是 JVM 中针对 无竞争场景的锁优化机制,核心思想是:
如果一个锁被某个线程获得,后续该线程再次获取锁时无需任何同步操作(如 CAS 或 操作系统互斥)。他通过“偏向”第一次获得锁的线程,消除重复加锁的开销,适合单线程重复访问的场景。
举个例子,现在有个妹子(锁),要谈个男朋友(线程)。
- 有个小哥哥 (线程A),喜欢了这个妹子,有了联系方式(获取对方的id)
- 然后这个妹子喜欢上另一个小哥哥(线程2),那么此时直接分手(消除对方的id)
- 此时,分手之后,两个哥哥都想得到这个妹子,那么就要各种哄,然后强大表白(CAS自旋)。谁先令妹子心动,那么就上位,此时妹子(锁)就找到心仪的男生(线程),升级为 自旋锁。
- 那么还有一些喜欢妹子的男生,人数挺多的,妹子该怎么做呢?如果妹子还没有找到心仪的男朋友,那么追妹子的男生太多(达到了阈值)那就全部清空;如果妹子已经有心仪的男朋友了,还有人喜欢妹子,那么直接升级为重量级锁。
上述的过程就是 偏向锁的过程,以及 重偏向技术。
进行 synchronized,刚一上来,不是真加锁,而是做一个标记(获取对方的id),这个标记非常轻量,相比于加锁解锁来说,效率很高。如果没有其他线程来竞争这个锁,最终当前线程执行到解释带阿米,也就是清除上述的标记即可(不涉及到真加锁,真解锁)。如果有其他线程来竞争,就抢先一步,在另一个线程拿到锁之前,抢先拿到锁。
无锁 --> 偏向锁:代码进入 synchronized的代码块
偏向锁 --> 轻量级锁:拿到偏向锁的线程运行过程中,遇到了其他线程尝试竞争这个锁
轻量级锁 --> 重量级锁:JVM发现,当前竞争锁的情况非常激烈。就会升级到 重量级锁。
这个就是synchronized的锁变化的过程,还有就是,当前 JVM 中,只提供 锁升级 而不提供 锁降级。
2.2. 锁清除
锁清除是编译器优化的一种体现。
编译器会判定,当前这个代码逻辑是否是真的需要枷锁,如果确实不需要加锁,但是你写了 synchronized 就会自动把 synchronized 给去掉。
可能大家会有以下问题:
- 会出现逻辑错误,导致线程安全吗?
是比较保守的,100%确定这个代码是单线程的时候,才会真正触发消除。
例如下面的这个代码:
public class Demo47 {public static void main(String[] args) {StringBuffer content = new StringBuffer();content.append('a');content.append('b');content.append('c');content.append('d');System.out.println(content.toString());}
}
像这种单一线程中,使用 synchronized,在调用 append 的时候都会涉及到加锁和解锁,其实都没有必要,白白消耗一些资源。
- 如果到处都是 synchronized呢?
意味着优化机制,只能把其中一部分,能明确判定的给优化掉。还会有很多不应该使用的,但是编译器也优化不掉。
2.3. 锁粒度
加锁和解锁之间,包含的代码越多,就认为锁的粒度就很粗。
如果包含的代码越少, 就认为锁的粒度就越细 (不是代码的行数,而是实际执行的指令 / 时间)。
一个代码中,针对细粒度的代码枷锁,就可能被优化成粗粒度的加锁。
上面黑色的是细粒度的写法,红色的是粗粒度的写法。本来是执行多次加锁解锁的,优化成一次加锁解锁、每次解锁之后,重新加锁,都会增加竞争。
锁是越粗越优秀吗?
还是需要具体问题具体分析的
锁粗了,就会影响线程的并发操作。
例如: 确实有三个线程,每一个都要加锁,粗化成一把锁,合理的
如果是有三个线程,其中两个线程需要加锁,一个线程不需要,粗化成一把锁使得本来不需要加锁的
能够并行执行的事情,也变成串行,那肯定是不可能的
下个博客我们讲解 CAS 和一些多线程常用的类,我们不见不散!