常见的锁策略
锁策略就属于是实现锁的人要理解的。
以下指的不是某个具体的锁,而是描述锁的特性,描述的是“一类锁”
乐观锁 vs 悲观锁
乐观锁:预测该场景中,不太会出现锁冲突的情况。(后续做的工作会更少)
悲观锁:预测该场景中,非常容易出现锁冲突。(后续做的工作会更多)
锁冲突:两个线程尝试获取一把锁,一个线程获取成功,另一个线程阻塞等待
锁冲突的概率大还是小,对后续的工作是有一定影响的。
synchronized
初始使用乐观锁策略.当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略
重量级锁 VS 轻量级锁
重量级锁:加锁开销比较大(花的时间多、系统资源占的多),一个悲观锁很可能是个重量级锁(不绝对)
轻量级锁:加锁开销比较小(花的时间少、系统资源占的少),一个乐观锁很可能是个轻量级锁(不绝对)
synchronized
开始时是一个轻量级锁,如果锁冲突严重,就可能变成重量级锁
悲观乐观 是在加锁之前对锁冲突概率的预测,决定工作的多少
重量轻量 是在加锁之后考量实际的锁的开销
自旋锁 VS 挂起等待锁
自旋锁是轻量级锁的一种典型实现,在用户态下,通过自旋的方式(while循环),实现类似于加锁的效果
这种锁,会消耗一定CPU资源,但是可以做到最快速度拿到锁
挂起等待锁是重量级锁的一种典型实现,通过内核态,借助系统提供的锁机制,当出现锁冲突的时候,会牵扯到内核对于线程的调度,使冲突的线程出现挂起(阻塞等待)
这种锁,消耗的CPU资源是更少的,但也无法保证第一时间拿到锁
synchronized
中的轻量级锁策略大概率就是通过自旋锁方式实现的
读写锁 VS 互斥锁
读写锁,把读操作加锁和写操作加锁分开了(一个事实:多线程同时去读一个变量,不涉及线程安全问题)
- 两个线程都只是读一个数据, 此时并没有线程安全问题. 直接并发的读取即可.
- 两个线程都要写一个数据, 有线程安全问题.
- 一个线程读另外一个线程写, 也有线程安全问题.
读写锁就是把读操作和写操作区分对待. Java 标准库提供了 ReentrantReadWriteLock
类, 实现了读写锁
ReentrantReadWriteLock.ReadLock
类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁.
ReentrantReadWriteLock.WriteLock
类表示一个写锁. 这个对象也提供了 lock / unlock 方法进行加锁解锁.
其中,
- 读加锁和读加锁之间, 不互斥.
- 写加锁和写加锁之间, 互斥.
- 读加锁和写加锁之间, 互斥.
注意, 只要是涉及到 “互斥”, 就会产生线程的挂起等待. 一旦线程挂起, 再次被唤醒就不知道隔了多久了.
因此尽可能减少 “互斥” 的机会, 就是提高效率的重要途径.
公平锁 VS 非公平锁
此处定义:公平锁遵循先来后到
非公平锁:看似概率均等,实际上是不公平的,每个线程阻塞的时间不同
注意:
- 操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁. 如果要想实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序.
- 公平锁和非公平锁没有好坏之分, 关键还是看适用场景.
synchronized
是非公平锁
可重入锁 VS 不可重入锁
如果一个线程,针对一把锁,连续加锁两次,会出现死锁,就是不可重入锁;不会出现死锁,就是可重入锁。即允许同一个线程多次获取同一把锁。
Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized
关键字锁都是可重入的。
而 Linux 系统提供的 mutex 是不可重入锁.
可重入锁是锁记录了当前哪个线程持有了锁
死锁
死锁是什么
死锁是这样一种情形:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
举个例子理解死锁
男神和女神一起去饺子馆吃饺子. 吃饺子需要酱油和醋.
男神抄起了酱油瓶, 女神抄起了醋瓶.
男神: 你先把醋瓶给我, 我用完了就把酱油瓶给你.
女神: 你先把酱油瓶给我, 我用完了就把醋瓶给你.
如果这俩人彼此之间互不相让, 就构成了死锁.
酱油和醋相当于是两把锁, 这两个人就是两个线程.
死锁的三种典型情况:
- 一个线程一把锁,但是是不可重入锁,该线程针对这个锁连续加锁两次,就会出现死锁
- 两个线程两把锁,这两个线程先分别获取到一把锁,然后再同时尝试获取对方的锁
- N个线程M把锁,为了进一步阐述死锁的形成, 很多资料上也会谈论到 “哲学家就餐问题”.
- 有个桌子,围着一圈哲学家,桌子中间放着一盘意大利面,每个哲学家两两之间, 放着一根筷子.
- 哲学家吃面条的时候就会拿起左右两边的筷子(先拿起左边,再拿起右边).
- 如果哲学家发现筷子拿不起来了(被别人占用了),就会阻塞等待.
- [关键点在这] 假设同一时刻, 五个哲学家同时拿起左手边的筷子, 然后再尝试拿右手的筷子, 就会发现右手的筷子都被占用了. 由于哲学家们互不相让, 这个时候就形成了 死锁
死锁是严重的BUG!导致一个程序的线程池卡死,无法正常工作
如何避免死锁
- 死锁的四个必要条件:(缺一不可,能破坏其中任意一个条件,就可以避免出现死锁)
- 互斥使用,一个线程获取到一把锁以后,别的线程不能获取到这个锁。实际使用的锁,一般都是互斥的(锁的基本特性)
- 不可抢占,锁只能是被持有者主动释放,而不能是被其他线程直接抢走。也是锁的基本特性
- 请求和保持,一个线程去尝试获取多把锁,在获取第二把锁的过程中,会保持对第一把锁的获取状态。取决于代码结构**(很可能影响到需求)**
- 循环等待,t1尝试获取locker2,需要t2执行完然后释放locker2,t2尝试获取locker1,需要t1执行完然后释放locker1。取决于代码结构**(解决死锁问题的最关键要点)**
当上述四个条件都成立的时候,便形成死锁。当然,死锁的情况下如果打破上述任何一个条件,便可让死锁消失。
如何具体解决死锁问题,实际方法很多(银行家算法,可以解决,但不太接地气)
其中最容易破坏的就是 “循环等待”.
针对锁进行编号,并且规定加锁的顺序
比如,约定,每个线程如果要获取多把锁必须先获取编号小的锁后获取编号大的锁。只要所有线程加锁的顺序,都严格遵守上述顺序,就一定不会出现循环等待!
synchronized
具体采用了哪些锁策略呢?
- 既是悲观锁也是乐观锁
- 既是重量级锁也是轻量级锁
- 重量级锁部分是基于系统的互斥锁实现的,轻量级锁部分是基于自旋锁实现的
- 是非公平锁(不会遵循先来后到,锁释放之后,哪个线程拿到锁,各凭本事)
- 是可重入锁(内部会记录哪个线程拿到了锁,记录引用次数)
- 不是读写锁
以下是synchronized
内部实现策略(内部原理):代码中写了个synchronized
之后,这里可能产生一系列的“自适应的过程”,锁升级(锁膨胀)
依次从 无锁->偏向锁->轻量级锁->重量级锁
- 偏向锁不是真的加锁而只是做了一个"标记",如果有别的线程来竞争锁了,才会真的加如果没有别的线程竞争,就自始至终都不会真的加锁了。加锁本身,有一定开销。能不加,就不加,非得是有人来竞争了,才会真的加锁~
偏向锁在没有其他人竞争的时候,就仅仅是一个简单的标记(非常轻量),一旦有别的线程尝试进行加锁,就会立即把偏向锁,升级 成真正的加锁状态,让别人只能阻塞等待
- 轻量级锁,
sychronized
通过自旋锁的方式来实现轻量级锁。我这边把锁占据了,另一个线程就会按照自旋的方式,来反复查询当前的锁的状态是不是被释放了。但是,后续,如果竞争这把锁的线程越来越多了(锁冲突更激烈了),从轻量级锁升级成重量级锁 - 锁消除:编译器会智能的判定,当前这个代码是否需要加锁,如果你写了加锁,但实际上没有必要加锁,就会把加锁操作自动删除掉
- 锁粗化:关于:“锁的粒度”,如果加锁操作里包含的实际要执行的代码越多,就认为锁的粒度越大
for(...){ synchronized(this){ count++; } }//锁的粒度小 synchronized(this){ for(...){ count++; } }//锁的粒度大
有的时候希望锁的粒度小比较好,并发程度更高
有的时候,也希望锁的粒度大比较好(因为加锁解锁本身也有开销)