前面我们学习过线程不安全问题,我们通过给代码加锁来解决线程不安全问题,在生活中我们也知道有很多种类型的锁,同时在代码的世界当中,也对应着很多类型的锁,今天我们对锁一探究竟!
1. 常见的锁策略
注意: 接下来介绍的锁策略不仅仅是局限于Java.任何和"锁"相关的话题,都可能会涉及到以下内容.这些特性主要是给锁的实现者来参考的.
我们普通的程序猿也需要了解⼀些,对于合理的使用锁也是有很大帮助的.
1.1 乐观锁vs悲观锁
乐观锁: 在执行任务之前预期竞争不激烈,那就可以先不加锁,等后面如果真实发生了锁竞争再加锁
举个例子: 我很喜欢我的女友,在学校时,我们天天在一起,我不担心有冲突别人会占用她(并没有对她上锁),但是偶尔她会和她的好朋友出去,此时,我感觉到有人在和我竞争她,我就加锁,不让她离开我
悲观锁: 在执行任务之前预期竞争非常激烈(表示在执行任务的时候会发生锁冲突),必须先加锁再执行任务
举个例子: 我很喜欢我的女友,为了和她天天在一起,我对她上锁,这样当别人想要和她玩的时候,就会和我竞争,但是我持有锁,别人就会得不到她,显示她一直被我持有,哈哈哈
1.2 重量级锁vs轻量级锁
锁的核心特性"原子性",这样的机制追根溯源是CPU这样的硬件设备提供的.
- CPU提供了"原子操作指令".
- 操作系统基于CPU的原子指令,实现了
mutex 互斥锁
. - JVM基于操作系统提供的互斥锁,实现了
synchronized
和ReentrantLock
等关键字和类.
轻量级锁: 加锁机制尽可能不使用mutex,而是尽量在用户态代码完成.实在搞不定了,再使用mutex.加锁的过程比较简单,用到的资源比较少,典型就是用户态
的一些操作(JAVA 层面就可以完成加锁)
举个例子: 有对象的男同胞,都会遇到的问题,就是和女友出去玩的时候,会等待女友的精心打扮(化妆),轻量级锁就是不停的自旋,一会问一下“宝宝,化好妆了吗”(只是一昧的催促女友,不干别的事情,消耗资源较少),可以第一时间知道女友啥时候化好妆可以出发
重量级锁: 加锁机制重度依赖了OS提供了mutex,加锁的过程比较复杂,用到的资源比较多,典型的就是内核态
的一些操作
举个例子: 当女友在化妆的时候,我们不去过问,而是做好自己的事情,准备好出去玩的所有东西(一直在帮忙做事,消耗了很多资源),等待女友的召唤(唤醒),不能第一时间知道女友啥时候化好妆
理解用户态和内核态:
想象去银行办业务.
在窗口外,自己做,这是用户态.用户态的时间成本是比较可控的.
在窗口内,工作人员做,这是内核态.内核态的时间成本是不太可控的. 如果办业务的时候反复和工作人员沟通,还需要重新排队,这时效率是很低的.
乐观锁是能不加锁就不加锁,从而导致他干活少,消耗资源也少,所以可以说乐观锁就是一种轻量级锁
悲观锁是任何时候都加锁,从而导致他干活多,消耗资源也多,所以可以说悲观锁就是一种重量级锁
1.3 自旋锁vs挂起等待锁
自旋锁: 不停的检查锁是否被释放,如果一旦锁被释放就可以直接获取锁资源
挂起等待锁: 阻塞等待,等待到被唤醒
举个例子: 每周末,我要和我对象一起吃饭,我到了她宿舍楼下打电话问她啥时候下来,她说等一会,我就不停的打电话(一直在自旋,不停的检查锁的状态,她下楼了,我会第一时间发现)——>
自旋锁
,我不打电话了,然后去小亭子坐下来,等待(我就不能第一时间发现她下楼如果她下楼之后就需要喊我一声相当于通知我(唤醒),获取锁资源)——>阻塞:挂起等待锁
优缺点:
- 自旋锁:
纯用户态的操作
,可以第一时间获取到锁,
有自旋次数和时间的限制,通过这个限制可以控制对系统资源的消耗,可以第一时间获取到锁 - 挂起等待锁:
内核态的操作
,会生成对应的加锁指令,要等待唤醒,在等待的过程中会释放CPU资源
自旋锁详情请看后续CAS详细介绍
1.4 公平锁vs非公平锁
公平锁: 先来后到,先排队的线程先拿到锁,后排队的线程后拿到锁,JAVA的JUC中有一个类专门实现了公平锁
非公平锁: 大家去抢,谁先抢到是谁的,synchronized是一个非公平锁
注意:
- 一般情况下,大多数锁都是非公平锁!
- 操作系统内部的线程调度就可以视为是随机的.如果不做任何额外的限制,锁就是非公平锁.如果要想实现公平锁,就需要依赖额外的数据结构,来记录线程们的先后顺序.
- 公平锁和非公平锁没有好坏之分,关键还是看适用场景.
举个例子: 现实生活中如果要真正的公平:立法、执法、教育、环境都要发挥作用消耗更大的资源,实现公平锁的过程也是一样,需要用额外的逻辑去管理线程,做到先来后到
1.5.读写锁(readers-writerlock)
多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同⼀个锁,就会产生极大的性能损耗。所以读写锁因此而产生。
⼀个线程对于数据的访问,主要存在两种操作:读数据和写数据.
- 两个线程都只是读⼀个数据,此时并没有线程安全问题.直接并发的读取即可.
- 两个线程都要写⼀个数据,有线程安全问题.
- ⼀个线程读另外⼀个线程写,也有线程安全问题.
读写锁就是把读操作和写操作区分对待.Java标准库提供了ReentrantReadWriteLock 类
,实现了读写锁.
ReentrantReadWriteLock.ReadLock 类
表示⼀个读锁.这个对象提供了lock/unlock方法
进行加锁解锁.ReentrantReadWriteLock.WriteLock 类
表示⼀个写锁.这个对象也提供了lock/unlock方法
进行加锁解锁.
其中,
- 读加锁和读加锁之间,不互斥.
- 写加锁和写加锁之间,互斥.
- 读加锁和写加锁之间,互斥.
注意,只要是涉及到"互斥",就会产⽣线程的挂起等待.⼀旦线程挂起,再次被唤醒就不知道隔了多久了.因此尽可能减少"互斥"的机会,就是提高效率的重要途径.
适用场景:
读写锁特别适合于"频繁读,不频繁写"的场景中.(这样的场景其实也是非常广泛存在的).
举个例子: 大学生必备学习通,老师会经常上课点名(读操作),发布学习资料(写操作),同学们看详细资料(读操作),读操作会涉及很多次,但是写操作偶尔几周一次,所以很适合读写锁
1.6可重入锁VS不可重入锁
可重入锁: 对一把锁可以连续加多次,多次加锁也要多次解锁,不造成死锁
不可重入锁: 对一把锁可以连续加多次,造成死锁
2. 相关面试题
2.1 你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?
- 悲观锁认为多个线程访问同⼀个共享变量冲突的概率较大,会在每次访问共享变量之前都去真正加锁.
- 乐观锁认为多个线程访问同⼀个共享变量冲突的概率不大.并不会真的加锁,而是直接尝试访问数据.在访问的同时识别当前的数据是否出现访问冲突.
- 悲观锁的实现就是先加锁(比如借助操作系统提供的mutex),获取到锁再操作数据.获取不到锁就等待.
- 乐观锁的实现可以引入⼀个版本号.借助版本号识别出当前的数据访问是否冲突.
2.2 介绍下读写锁?
- 读写锁就是把读操作和写操作分别进行加锁.
- 读锁和读锁之间不互斥.
- 写锁和写锁之间互斥.
- 写锁和读锁之间互斥.
- 读写锁最主要用在"频繁读,不频繁写"的场景中
2.3 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?
- 如果获取锁失败,立即再尝试获取锁,无限循环,直到获取到锁为止.第⼀次获取锁失败,第二次的尝试会在极短的时间内到来.⼀旦锁被其他线程释放,就能第⼀时间获取到锁.
相比于挂起等待锁,
- 优点:没有放弃CPU资源,⼀旦锁被释放就能第⼀时间获取到锁,更高效.在锁持有时间比较短的场景下非常有用.
- 缺点:如果锁的持有时间较长,就会浪费CPU资源.
2.4 synchronized 是可重入锁么?
是可重入锁.
可重入锁指的就是连续两次加锁不会导致死锁.
实现的方式是在锁中记录该锁持有的线程身份,以及⼀个计数器(记录加锁次数).如果发现当前加锁的线程就是持有锁的线程,则直接计数自增.