多线程进阶:各种锁策略

常见面试题:

  1. 你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?
  2. 有了解什么读写锁么?
  3. 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?
  4. 什么是死锁,死锁的产生以及解决办法?
  5. synchronized 是可重入锁么?

乐观锁 VS 悲观锁

乐观锁:乐观锁假设认为数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。
悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
悲观锁的问题:总是需要竞争锁,进而导致发生线程切换,挂起其他线程;所以性能不高
乐观锁的问题:并不总是能处理所有问题,所以会引入一定的系统复杂度。

两种锁没有优略之分,要根据具体使用场景进行使用。

读写锁

多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。
读写锁(readers-writer lock),看英文可以顾名思义,在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求与任何人互斥。

重量级锁 VS 轻量级锁

加锁需要保证原子性,原子性功能来自硬件。(硬件提供了相关的原子操作指令,操作系统封装一下成为原子操作的接口,应用程序才能使用这样的操作)。
重量级锁:在加锁过程中,如果整个加锁逻辑都是依赖操作系统内核,此时就是重量级锁。(代码在内核中的执行开销会比较大);
轻量级锁:对应的,如果大部分操作都是用户自己完成,少数操作是内核完成的话,这就是轻量级锁。

挂起等待锁 VS 自旋锁

挂起等待锁表示当获取锁失败之后,对应的线程就要在内核中挂起等待(放弃CPU ,进入等待队列),需要在锁被释放之后由操作系统唤醒。【通常都是重量级锁】
自旋锁表示在获取锁失败后,不会立即释放 CPU, 而是快速频繁的再次询问锁的持有状态,一旦被释放了,就能够立刻获取到锁。【通常都是轻量级锁】
可以看看下面的代码:

while (抢锁(lock) == 失败) {}

只要没抢到锁,就死等。
自旋锁的效率更高,但是会浪费一些 CPU资源(自选过程相当于 CPU 在空转)。为什么说效率会更高呢,是因为线程能够获取到 CPU 资源是一件来之不易的事情,一旦线程被挂起,下次什么时候被调度就是不可预期的。(时间可能会很久,能过达到 ms 级,极端情况下可能是 s 级)线程调度过程没有那么快的,调度也是不可预期的。

公平锁 VS 非公平锁

场景:如果多个线程都在等待一把锁的释放,当锁释放之后,恰好又来了一个新的锁也要获取锁。
公平锁:能够保证之前先来的线程优先获取到锁;
非公平锁: 新来的线程直接获取到锁,之前的线程还得接着等。
当然想实现公平锁,就需要付出一些额外的代价。

可重入锁

可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。比如一个递归函数里有加锁操
作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁(因为这个原因可重入锁也叫做递归
锁)。
Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized
关键字锁都是可重入的。
简单来说就是,一个线程针对一把锁,连续加锁两次,不会死锁,这种就是可重入锁。如果锁记录自己是被谁持有的,就可以进行特殊的判定了,当锁的持有者正好就是新的锁的申请者,此时就进行特殊处理一下让加锁成功即可。
在这里就涉及到:死锁
死锁的典型场景:
1、一个线程针对一把锁连续加两次
2、两个线程针对两把锁分别加锁
3、N 个线程针对 N 把锁分别加锁。

什么是线程死锁?

这部分参考公众号:LeetCode力扣
线程死锁是指由于两个或者多个线程互相持有对方所需要的资源,导致这些线程处于等待状态,无法前往执行。当线程进入对象的 synchronized 代码块时,便占有了资源,直到它退出该代码块或者调用 wait 方法,才释放资源,在此期间,其他线程将不能进入该代码块。当线程互相持有对方所需要的资源时,会互相等待对方释放资源,如果线程都不主动释放所占有的资源,将产生死锁。
多线程进阶:各种锁策略_第1张图片

死锁如何产生

场景一

星期日早上十点半,你在公路上开车,这是一条窄路,只能容纳一辆车。这时,迎面又驶来一辆车,你们都走到一半,谁也不想倒回去,于是各不相让,陷入无尽的等待。

场景二

你和她吵架了,谁也不理谁,甚至晚饭时间都各自煮饭。你在炒京酱肉丝,她在做葱烤鲫鱼。炒到一半你发现小葱被她全部拿走了,于是你默默等待她做好菜后再去拿,殊不知她也在等待你炒完菜后来拿酱油。

场景三

你和四个好朋友坐在圆形餐桌旁,你们只做两件事情:吃饭,或者思考。吃饭的时候,你们就停止思考。思考的时候,也停止吃饭。每个人面前有一碗兰州拉面,并且每个人左右两边各有一根筷子。你们必须要拿到两根筷子才能开始吃面。吃完后再放下筷子,让别人可以使用。吃了一会之后,每个人都拿起了自己左手边的筷子,导致每个人都只有一根筷子,并且等待别人吃完放下筷子给自己。可惜,没有人吃到面,所以没有人会放下筷子。(著名的哲学家就餐问题)

场景四

你有两个线程 A 和 B ,各自在加锁的状态下运行。A 持有一部分资源,并且等待 B 线程中的资源以完成自己的工作,而此时 B 线程也在等待 A 中的资源以完成自己的工作。由于他们都是锁定状态,所以他们必须完成了自己的工作后,自己持有的资源才能释放。于是线程无休止地等待,导致死锁。
代码如下:

public class JavaTest {
    @Test
    public void test() {
        final Object lockA = new Object();
        final Object lockB = new Object();
        new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (lockA) {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    synchronized (lockB) {
                    }
                    System.out.println("finish A");
                }
            }
        }).start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (lockB) {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    synchronized (lockA) {
                    }
                    System.out.println("finish B");
                }
            }
        }).start();
        try {
            Thread.sleep(10000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

此程序中,线程 A 持有 lockA 对象,并请求 lockB 对象;线程 B 持有 lockB 对象,并请求 lockA 对象。由于他们都在等待对方释放资源,所以会产生死锁。运行程序,将发现控制台无法打印出 “finish A” 和 “finish B” 消息。
产生死锁的的四个条件如下:
1、互斥条件:一个资源每次只能被一个进程使用;
2、请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放;
3、不剥夺条件:进程已获得的资源,在没使用完之前,不能强行剥夺;
4、循环等待条件:多个进程之间形成一种互相循环等待资源的关系。

如何避免线程死锁!!!!

方案一

你想起书中所言:退一步海阔天空。但你也深知公平好过忍让。正值周赛时间,你摇下车窗,对对面的兄弟喊道:咱来比赛一场力扣周赛,谁输了谁倒出去让另一个人过吧!于是你们打开力扣,开始答题。半小时后,你凭借高超的代码水平 AC 了全部题目。对面司机对你拱手道:技不如人,甘拜下风。于是他倒了回去,让出了自己的一半路。最终你们都得以顺利通行。

方案二

你在炒菜时发现没有小葱,于是你换位思考,想到她会不会也缺少自己用着的材料。虽然她还在和你冷战,但你劝解自己一个大老爷们不应该和女孩子置气,于是你主动把自己用着的所有材料拿给了她。她感受到你设身处地为她着想,大为感动,你们和好如初。之后她为你们两个人一起炒了京酱肉丝和葱烤鲫鱼。

方案三

你和你的朋友们决定给筷子编上号:1~5。规定每个人拿筷子时必须先拿到自己两边的筷子中号码小的那一根,再去拿号码大的那一根。如果小的那一根没有拿到,不能先拿大的。当你们开始吃饭时,由于数字 5 不可能被一个人单独拿到。因为他旁边的另一根筷子编号必定比 5 小,所以不会再出现每个人都拿着一根的无限等待情形。

方案四

你在运行两个线程前,预先将线程 A 和 B 中的资源拷贝一份,让他们不需互相等待对方的资源,于是两个线程都得以顺利运行。
这四种方案分别破坏了上述四个条件之一。
解决死锁问题的四种方法:
1、破坏不剥夺条件:让对面的司机放弃了自己已有的资源。
2、破坏请求与保持条件:在自己需要的材料缺少时,主动放弃自己持有的资源,防止出现互相等待。
3、破坏循环等待条件:由于筷子指定了编号和获取规则,所以每个锁定状态都将按照顺序执行,于是便杜绝了环路等待条件。
4、破坏互斥条件:由于每次使用时都拷贝一份,所以一个资源可以被多个进程使用。

一定要注意面试中一定不要一上来就背四个必要条件,而是从上述几个场景入手来回答

但解决方案并不止以上列出的四种。事实上,使用预先拷贝资源解决死锁问题的方案一般并不常用。这是由于拷贝的成本往往很大,并且影响效率。实际工作中较常采用的是第三种方案,通过控制加锁顺序解决死锁:

  • 加锁顺序:当多个线程需要相同的一些锁,但是按照不同的顺序加锁,死锁就很容易发生。如果能确保所有的线程都是按照相同的顺序获得锁,那么死锁就不会发生。当然这种方式需要你事先知道所有可能会用到的锁,然而总有些时候是无法预知的。

除此之外,我们还可以通过设置加锁时限或添加死锁检测避免死锁:

  • 加锁时限:加上一个超时时间,若一个线程没有在给定的时限内成功获得所有需要的锁,则会进行回退并释放所有已经获得的锁,然后等待一段随机的时间再重试。但是如果有非常多的线程同一时间去竞争同一批资源,就算有超时和回退机制,还是可能会导致这些线程重复地尝试但却始终得不到锁。
  • 死锁检测:死锁检测即每当一个线程获得了锁,会在线程和锁相关的数据结构中( map 、 graph等)将其记下。除此之外,每当有线程请求锁,也需要记录在这个数据结构中。死锁检测是一个更好的死锁预防机制,它主要是针对那些不可能实现按序加锁并且锁超时也不可行的场景。

synchronized 是可重入锁么?

JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级。
针对synchronized:
1、开始是乐观锁,一定条件下转换为悲观锁;
2、开始是轻量级锁,一定条件下膨胀为重量级锁;
3、实现锁的时候使用的是自旋锁;
4、是不公平锁;
5、是一个可重入锁;
6、不是读写锁。
后边讲到锁优化在具体说一下。

你可能感兴趣的:(操作系统,多线程,java,面试)