Java EE:3.多线程-进阶(第一弹):常见的锁策略+synchronized原理
目录
书接上文:Java EE:2.多线程-初阶(第九弹)~~
阶段性小结
多线程初阶
1.线程的原理、进程和线程的关系
2.多线程的使用、Thread类的用法
3.线程安全机制
4.等待通知机制
5.多线程的代码案例
定时器
1)定义类描述任务
2)使用优先级队列
3)schedule方法
4)扫描线程,复杂执行队列中的任务
以上问题还涉及到“忙等”→用 wait / notify 优化
多动手敲一敲~~学编程,不是学知识,而是学手艺~~
多线程
初阶:贴合实际工作情况
进阶:贴合面试
当前面试的环境,非常卷
经常会出现一些,工作中用不到,但面试还要考的东西=>八股文(背)
1.常见的锁策略
如果你自己需要实现一把锁(你认为 标准库 给你提供的锁不够用),你需要关注锁策略
其实 synchronized 已经非常好用了,足矣覆盖绝大多数的使用场景
此处的“锁策略”不是和 Java 强相关的,其他语言,但凡涉及到并发编程、涉及到锁,都可以谈到这样的锁策略
这个锁在加锁的时候,有啥特点、有啥行为~
1.1乐观锁vs悲观锁
不是针对某一种具体的锁~~
而是某个具体锁具有“悲观”特性 或者 “乐观”特性~~
悲观锁:
悲观:加锁的时候,预测接下来的锁竞争的情况非常激烈,就需要针对这样的激烈情况额外做一些工作
比如说有一把锁,有20个线程尝试获取锁,每个线程加锁的概率都很高,一个线程加锁的时候,很可能锁被另一个线程占用着,此时就要用到悲观锁的策略
乐观锁:
乐观:加锁的时候,预测接下来的锁竞争的情况不激烈,就不需要做额外工作
比如说有一把锁,只有两个线程尝试获取这个锁,每个线程加锁的概率都很低,一个线程加锁的时候,大概率另一个线程没有和它竞争,此时就要用到乐观锁的策略
闲聊:比如说 offer 的竞争~~
大厂的 offer 好不好拿??
秋招的大厂 offer ,非常难
实习的大厂 offer ,相对简单很多,出手越早越简单
答疑:
Q1:我大三上学期就去找实习??我感觉我还没复习好,我再看看,等几个月再投??
这个跟你复不复习没关系,主要看竞争的激烈程度(供需关系)
秋招,一个大厂 offer ,通常几百人,上千人在竞争
比如网易,一场秋招的笔试,往往就是几万人一起参加,最终网易也就招聘几百人~~
实习,大三下学期三四月投实习,意味着大部分同学都没有反应过来
只有少数,先飞的鸟和你竞争
一个岗位可能就只有几个人和你竞争
如果你是大三上学期,9月、10月、11月投实习,意味着正常大学生都没有这个意识
企业那边的情况:放出的招聘信息,压根没人投
如果人家需要紧急招聘5个实习生,结果很可能,0份简历
如果你投了,哪怕你就会 hello word ,人家也能要~~
Q2:大三学校会放行吗?
大家要能够认清,正常的学校是不会放人的~~
学校领导无法区分你是去正经公司实习,还是被骗到传销窝点~~
如果大家以后真的有好的实习 offer ,只有“溜”这一个途径~~
Q3:那叫家里人和学校请假呢?
请长假会判定留级
留级是你整个职业生涯的污点
因为在中国,大学生4年按时毕业,是再正常不过的了
如果你5年毕业,HR 眼中会认为你大概率是存在严重问题的
1.2重量级锁vs轻量级锁
重量级锁+轻量级锁 和 悲观锁+乐观锁 在很大程度上是重合的
悲观锁和乐观锁描述的是加锁时候遇到的场景
那么重量级锁和轻量级锁就是遇到场景之后的解决方案
重量级锁:
在悲观的场景下,就要付出更多的代价=>更低效
轻量级锁:
在乐观的场景下,付出的代价就会更小=>更高效
课件内容(了解即可)
锁的核心特性“原子性”,这样的机制追根溯源是CPU这样的硬件设备提供的
CPU提供了“原子操作指令”
操作系统基于CPU的原子指令,实现了 mytex 互斥锁
JVM 基于操作系统提供的互斥锁,实现了 synchronized 和 ReentrantLock 等关键字和类
注意,synchronized 并不仅仅是对 mutex 进行封装,在 synchronized 内部还做了很多其他的工作
重量级锁
重量级锁:加锁机制重度依赖了 OS 提供的 mutex
大量的内核态用户切换
很容易引发线程的调度
这两个操作,成本比较高,一旦涉及到用户态和内核态的切换,就意味着“沧海桑田”
轻量级锁
轻量级锁:加锁机制尽可能不使用 mutex ,而是尽量在用户态代码完成,实在搞不定了,再使用 mutex
少量的内核态用户态切换
不太容易引发线程调度
理解用户态vs内核态
用户态=配套的应用程序
内核态=内核
1.3挂起等待锁vs自旋锁(Spin Lock)
这就相当于在重量级锁+轻量级锁的基础上做了进一步的细化
挂起等待锁:
挂起等待锁=>重量级锁的典型实现=>操作系统内核级别的,加锁的时候发现竞争,就会使该线程进入阻塞状态,后续就需要内核进行唤醒了
自旋锁:
自旋锁=>轻量级锁的典型实现=>应用程序级别的,加锁的时候发现竞争,一般也不是进入阻塞,而是通过“忙等”的形式来进行等待
答疑:
Q1:之前不是讲“忙等”不好吗??
这个是应对乐观锁的场景,本身遇到锁竞争的概率就很小
而且真的遇到锁竞争,在短时间内就能拿到锁,所以问题不大~~
Q2:自旋锁和定时器那个是不是差不多?
其实定时器和自旋锁不太相关,只不过这两者都涉及到忙等而已~~
因为定时器这个东西并不涉及到去竞争某个资源~~所以这俩东西联系到一起还是有点牵强~~
自旋锁伪代码👇
while (抢锁(lock) == 失败) {}拿追女神举🌰:
①自旋的方式:女神,只是你眼中的女神=>乐观
今天来问问女神,你做我女票好不好
女神表示,她有男朋友,你是个好人~~
你表示,没关系,我愿意等~~(甘当备胎)
等的过程中,三天两头的,“联络”一下女神~~
很快,有一天,你发现女神和男票吵架了
机会来了!!
在你的关心和煽风点火之下,女神成功和男票分手
你就趁机上位!
其实也消耗不了多久,很快就能有机会,只要发现机会,就能立即上位
整个锁的等待时间,其实并不长~~
这种就适合锁竞争不激烈的场景,获取锁的周期更短,能够及时获取锁,这个过程会一直消耗CPU~~
②挂起等待锁:女神,是大家眼中的女神=>悲观
你去问女神,做我女票好不好
女神表示,她有男朋友,你是个好人~~
你表示,没关系,我愿意等~~
等的过程中,不再联系女神~~
于是你安心认真学习敲代码~~
几个月过去了、甚至几年过去~~
偶然间,听说了,女神单身了的消息(当你听到这个消息的时候,女神可能都已经分手过几次了~~)
你再去找到女神,你做我女票好不好~~
好就成了,不好,那就接着以上述的方式等~~
这个过程中,你这边不再继续在女神身上消耗精力了~~
这种就适合锁竞争激烈的场景,获取锁的周期更长,很难做到及时获取,但是这个过程就不必一直消耗CPU,把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就可以拿到锁了
不涉及到系统内核的线程调度,第一时间能拿到锁
以上过程,都是在锁的内部实现的,咱们使用的时候无非就是一个 lock / synchronized 操作,咱们也无法在应用程序这里获取到 synchronized 的底层状态~~
synchronized 是 悲观锁 / 乐观锁 呢??
既是又是~~自适应~~
设计 JVM 的大佬为咱们操碎了心~~
大佬的初心,就是别让程序员操心这些事情,都在 JVM 内部做好了~~
JVM 内部,会统计每个锁 竞争的激烈程度
如果竞争不激烈,此时 synchronized 就会按照轻量级锁(自旋)方式处理
如果竞争激烈,此时 synchronized 就回去按照重量级锁(挂起等待)方式处理
闲聊:学C++的同学就要操心了~~
std::mutex 就是重量级锁~~
加锁,就会挂起等待
不太适合乐观的场景
乐观的情况还得程序员自己实现(标准库没有提供)
虽然大佬们是希望咱们不关注这些细节,但遗憾的是,面试官没有放过咱们~~
1.4普通互斥锁vs读写锁
普通互斥锁:
synchronized 就是一个普通互斥锁,只有加锁和解锁两种方式
读写锁:
有三种方式:读方式加锁、写方式加锁、解锁
我们知道:
多个线程读取一个数据,本身就是线程安全的~~
多个线程读取,一个线程修改,肯定会涉及到线程安全问题
而读写锁就适合处理这种“读多写少”的情况:
大部分操作在读,少数操作在写
如果你把读和写都加上普通的互斥锁,意味着锁冲突非常严重
读写锁,就能够确保:读锁和读锁之间,不是互斥的(不会产生阻塞,线程安全)
写锁和读锁之间 以及 写锁和写锁 之间,才会互斥
因此,读写锁能够在保证线程安全的前提下,降低锁冲突的概率,提高效率
读多写少这种情况,在服务器开发中,是非常常见的场景~~
比如说,教务系统~~布置作业~~
老师会通过教务系统,往数据库中写一个记录(给作业表,添加一个记录,班级和一个具体的作业内容)=>写操作,一个人写
同学们也会通过教务系统,查看作业=>读操作,几十号人读
再或者说当时学MySQL时的索引~~查询操作~~也是类似道理
Java标准库提供了 ReentrantReadWriteLock 类,实现了读写锁
ReentrantReadWriteLock是外部类,ReadLock 和 WriteLock 是内部类
ReentrantReadWriteLock.ReadLock 类表示一个读锁,这个对象提供了 lock / unlock 方法进行加锁解锁
ReentrantReadWriteLock.WriteLock 类表示一个写锁,这个对象也提供了 lock / unlock 方法进行加锁解锁
1.5可重入锁vs不可重入锁
synchronized 是“可重入锁”
可重入锁:
一个线程,一把锁,连续加锁多次,不会死锁
实现的核心要点:
1.锁要记录当前是哪个线程拿到的这把锁
2.使用计数器,记录当前加锁了多少次,在合适的适合进行解锁
不可重入锁:
一个线程,一把锁,连续加锁多次,会死锁
此处关于可重入锁和不可重入锁不再过多赘述,大家可参考之前讲过的内容进行理解👇
1.6公平锁vs非公平锁
当女神和男朋友分手之后~~
谁应该上位呢??怎样做才是公平呢??
1.按照先来后到,谁最先追女神,谁上位
2.概率均等
这两种都可以定义成公平~~
但最终的公平,是由Java大佬决定的~~把第1种,先来后到,定义成了公平~~
公平锁:
非公平锁:
注意:
1.锁默认情况下是非公平锁,就相当于“概率公平”,因为操作系统针对线程的调度是随机的
2.如果要想实现公平锁,就需要依赖额外的数据结构,比如说队列,记录一下各个线程获取锁的先后顺序
1.7相关面试题
1.悲观vs乐观
2.重量vs轻量
3.挂起等待vs自旋
4.互斥vs读写
5.可重入vs不可重入
6.公平vs非公平
对于以上6种类型的锁,在面试题中,也就两种方式考察:
1.一般就是问概念
2.面试官问到某个问题的时候,用到上述术语
其他的锁,同样也能套入到上述的这些词中
课件内容
Q1:你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?
悲观锁认为多个线程访问同一个共享变量冲突的概率较大,会在每次访问共享变量之前都去真正加锁
乐观锁认为多个线程访问同一个共享变量冲突的概率不大,并不会真的加锁,而是直接尝试访问数据,在访问的同时识别当前的数据是否出现访问冲突
悲观锁的实现就是先加锁(比如借助操作系统提供的 mutex),获取到锁再操作数据,获取不到锁就等待
乐观锁的实现可以引入一个版本号,借助版本号识别出当前的数据访问是否冲突
Q2:介绍下读写锁?
读写锁就是把读操作和写操作分别进行加锁
读锁和读锁之间不互斥
写锁和写锁之间互斥
写锁和读锁之间互斥
读写锁最主要用在“频繁读,不频繁写”的场景中
Q3:什么是自旋锁,为什么要使用自旋锁策略呢?缺点是什么?
如果获取锁失败,立即再尝试获取锁,无限循环,直到获取到锁为止
第一次获取锁失败,第二次的尝试会在极短的时间内到来,一旦锁被其他线程释放,就能第一时间获取到锁
相比于挂起等待锁:
优点:没有放弃CPU资源,一旦锁被释放就能第一时间获取到锁,更高效,在锁持有时间比较短的场景下非常有用
缺点:如果锁的持有时间较长,就会浪费CPU资源
Q4:synchronized 是可重入锁吗?
是可重入锁
可重入锁指的是连续两次加锁不会导致死锁
实现方式:
1.记录该锁持有的线程身份
2.计数器记录加锁次数
2.synchronized原理
2.1基本特点
synchronized 是自适应的,不是读写锁,是可重入锁、非公平锁
2.2加锁工作过程
接下来讲一下 synchronized 自适应的过程(也叫“锁升级”)
JVM 将 synchronized 的锁分为 无锁、偏向锁、轻量级锁、重量级锁 四个状态,会根据情况,进行依次升级👇
1)偏向锁
还是拿出之前讲线程池时的例子:小美(茶化之前),要谈男朋友,又希望把男朋友更换的快一点~~
1.和当前男朋友分手
当时说的是,小美需要zuo一zuo,打一打拳,逐渐消耗他的耐心~~,再谈分手,而且一定要哭的梨花带雨,让他认为都是他的问题~~
2.和下一个小哥哥培养感情(通过 池 ,优化这一步的性能)
那第一步能不能优化呢??当然可以!!😏😏当小美最开始谈这个男朋友的时候,就和他,做各种情侣之间做的事情,但是从来不和他确认关系=>就是搞暧昧
这就使得他能够很好的满足小美对于 男朋友 的需求~~
有一天,小美 对他厌烦了~~,这个时候就不必麻烦了~~直接和他说,咱们不要再见面了~~
他问,就说“咱们只是普通朋友呀~~”,直接让他闭嘴
当然,这种搞暧昧的手段,也有副作用~~
万一,有别的妹子,也接近这个小哥哥~~这个小哥哥也可以瞬间把小美踹掉,然后用同样的话“咱们只是普通朋友”来应对小美~~
于是小美就想了个办法,她只要发现,有妹子试图接近小哥哥,只要一有这样的苗头,小美就立即和小哥哥确认关系,并且朋友圈官宣~~让其他妹子可以滚远了~~
上述过程,就是偏向锁的过程~~,本质上也是懒汉模式思想的体现,不到万不得已不去加锁,尽量省下加锁的开销
1.进行 synchronized 时,刚一上来,不是真加锁,而是只是简单做一个标记(搞暧昧),这个标记,非常轻量,相比于加锁解锁来说,效率高很多~~
2.如果没有其他线程来竞争这个锁,最终当前线程执行到解锁代码,也就只是简单清除上述标记即可~~(不涉及真加锁,真解锁)(搞暧昧,不真确立关系,后续分手很快)
3.如果有其他线程来竞争,就抢先一步,在另一个线程拿到锁之前,抢先拿到锁,真加锁,此时 偏向锁 就升级到=>轻量级锁,其他线程只能阻塞等待
①无锁=>偏向锁:代码进入 synchronized 的代码块
②偏向锁=>轻量级锁:拿到偏向锁的线程运行过程中,遇到了其他线程尝试竞争这个锁
③轻量级锁=>重量级锁:JVM发现,当前竞争锁的情况非常激烈
当前 JVM 中,只提供了“锁升级”不能“锁降级”,个人猜测,可能是,实现了锁降级,收益不大,使 JVM 相关代码的复杂度更高~~
2)轻量级锁(课件内容)
随着其他线程进入竞争,偏向锁状态被消除,进入轻量级锁状态(自适应的自旋锁)
此处的轻量级锁就是通过 CAS 来实现
①通过 CAS 检查并更新一块内存(比如 null=>该线程引用)
②如果更新成功,则认为加锁成功
③如果更新失败,则认为锁被占用,继续自旋式的等待(并不放弃CPU)
自旋操作是一直让 CPU 空转,比较浪费 CPU 资源
因此此处的自旋不会一直持续进行,而是达到一定的时间 / 重试次数,就不再自旋了,也就是所谓的“自适应”
3)重量级锁(课件内容)
如果竞争进一步激化,自旋不能快速获取到锁状态,就会膨胀为重量级锁
此处的重量级锁就是指用到内核提供的 mutex
①执行加锁操作,先进入内核态
②在内核态判定当前锁是否已经被占用
③如果该锁没有被占用,则加锁成功,并切换回用户态
④如果该锁被占用,则加锁失败,此时线程进入锁的等待队列,挂起,等待被操作系统唤醒
⑤经历了一系列的沧海桑田,这个锁被其他线程释放了,操作系统也会想起这个挂起的线程,于是唤醒这个线程,尝试重新获取锁
2.3其他的优化操作
锁消除
也是编译器优化的一种体现
编译器会判定,当前代码逻辑是否真的需要加锁,不需要,但你写了 synchronized ,就会把 synchronized 给消除掉
答疑:
Q1:会出现逻辑错误导致线程安全吗?
这种消除是比较保守的,100%确认你这个代码确实是单线程操作的时候,才会真正触发消除
像一些判定不清楚的情况,不会触发的
Q2:那直接到处 synchronized?
到处 synchronized ,意味着优化机制,只能把其中一部分,它能明确判定的给优化掉,还会有很多不应该使用,但是编译器也优化不掉
所以目前还是不能完全依赖编译器优化
Q3:优化后效率会提高?
即使只是一个偏向锁,肯定也不如完全不加锁~~
锁粗化
锁的粒度:粗和细~~
加锁和解锁之间:
包含的代码越多=>粒度越粗
包含的代码越少=>粒度越细
这个粗和细,不是代码行数,而是实际执行的指令/时间
一个代码中,反复针对细粒度的代码加锁,就可能被优化成更粗粒度的加锁(避免每次解锁之后再加锁而增加锁竞争)
比如,有一天,你的领导给你安排3个工作,当你把这几个工作都做完之后,你要给领导汇报~~
那你肯定不能做完一个汇报一个(第一次第二次也就罢了,第三次领导不呲儿你算他素质高~~),咱们肯定是都做完了之后一次性给领导全面汇报~~
闲聊:
在以后的工作中,一定要及时汇报!!!
产出的结果,一定要及时让领导看见~~(刷存在感),未来的升职加薪,都是领导主观感受~~
还有就是汇报工作:“能见面则见面,能电话不邮件~~”,这是重要的原则~~
因为你给领导打电话,相当于对领导加锁,此时领导需要全心全意应付你,没法做别的事情了~~对于整体的效率必然造成影响~~
很多同学,实习的时候,不了解这些,经常自己一个人吭哧吭哧研究一些东西,结果搞岔~~
积极保持沟通,尤其是有的领导比较忙,不一定能顾得上你,你一定要积极主动,当面的积极沟通~~
如果你的领导特别忙,就趁着一起吃饭的时候,或者下班一起出门的时候……赶紧跟上去说几句~~
其次,你问的问题,一定是深度思考过的、有价值的问题~~
别问那种,百度一搜就能搜到的问题~~
不能转正一般都不是因为你菜,而是因为其他的原因,最主要的原因就是沟通费劲~~
比如说像下面的代码,synchronized 就不应该加到 for 外面,因为 for 中的 i <50000 ; i++ ,希望能够并发执行的,只是 count++ 不能并发~~
而下述这种情况就可以优化👇
synchronized (cur){ count++; } synchronized (cur){ count++; } synchronized (cur){ count++; }可以看到,synchronized 的策略是比较复杂的,在背后做了很多事情,目的就是为了让咱哪怕啥也不懂,也不至于写出特别慢的程序,Java大佬为了咱可真是操碎了心~~
2.4相关面试题(课件内容)
Q1:什么是偏向锁??
偏向锁不是真的加锁,而只是在锁的对象头中记录一个标记(记录该锁所属的线程),如果没有其他线程参与竞争锁,那么就不会真正执行加锁操作,从而降低程序开销
一旦真的涉及到其他的线程竞争,再取消偏向锁状态,进入轻量级锁状态
Q2:synchronized 实现原理是什么??
参考上述 synchronized 原理全部内容
更多推荐










所有评论(0)