菜鸟笔记
提升您的技术认知

锁的分类总结

锁的分类是从不同角度去看的。同一个锁也可以同时属于多种类型。

一、乐观锁与悲观锁

1. 互斥同步锁的劣势

  • 阻塞和唤醒会带来性能的劣势
    • 用户态和核心态切换
    • 上下文切换
    • 检查是否有被阻塞线程需要被唤醒
    • 等等
  • 可能出现永久阻塞的问题:持有锁的线程永久阻塞了,比如遇到无限循环、死锁等活跃性问题,那么等待该线程释放锁的那几个线程将永远不能执行。
  • 优先级反转:如果我们为线程设置了优先级,想让优先级低的线程少运行,优先级高的线程多运行,但是一旦优先级低的线程拿到锁后释放得很慢,或者不释放, 就会导致优先级虽然很高,但实际执行起来很低的情况,导致优先级错乱

2. 悲观锁

每次悲观锁为了确保结果的正确性会在每次修改数据时把数据锁住,让别人无法访问该数据,这样就可以确保数据内容的万无一失。

3. 乐观锁

乐观锁认为自己在处理操作的时候,不会有其他线程来干扰,所以并不会锁住被操作对象。在更新的时候,去对比在我修改期间数据有没有被他人改变过,如果没有改变过,就说明只有自己操作过,那就正常去修改数据,如果被别人修改过,则不对数据进行修改了,选择放弃、报错重试等策略。
乐观锁的实现一般都是利用CAS算法

4. 典型例子

4.1 Git

Git 就是乐观锁的典型例子,当我们往远端仓库 push 的时候,git 会检查远端仓库的版本是不是领先于我们现在的版本,如果远程仓库的版本号和本地的不一样,就表示有其他人修改了远端代码了,我们的这次提交就失败;如果远端和本地版本号一致,我们就可以顺利提交版本到远端仓库

4.2 数据库

select for update 就是悲观锁

每次获取商品时,对该商品加排他锁。也就是在用户A获取获取 id=1 的商品信息时对该行记录加锁,期间其他用户阻塞等待访问该记录。悲观锁适合写入频繁的场景。

begin;
select * from table where id = 1 for update;
update set num = num - 1 where id = 1;
commit;

update goods set stock = stock - 1 where id = 1;

用version控制数据库就是乐观锁

添加一个字段 lock_ version
先查询这个更新语句的 version:

select * from goods where id = 1;

然后

begin;
update goods set num = 2, version = version + 1 where version = 1 and id = 1;
commit;

如果 version 被更新了等于 2,不一样就会更新出错,这就是乐观锁的原理。

5. 开销对比

悲观锁的原始开销要高于乐观锁,但是特点是一劳永逸,临界区持锁时间就算越来越长,也不会对互斥锁的开销造成影响。
相反,虽然乐观锁一开始的开销比悲观锁小,但是如果自旋时间很长或者不停重试,那么消耗的资源也会越来越多。

6. 各自的适用场景

悲观锁:适合并发写入多的情况,适用于临界区持锁时间比较长的情况,悲观锁可以避免大量的无用自旋等消耗。
典型情况:

  1. 临界区有I0操作。
  2. 临界区代码复杂或者循环量大。
  3. 临界区竞争非常激烈

乐观锁:适合并发写入少,大部分是读取的场景,不加锁的能让读取性能大幅提高。

二、可重入锁与非可重入锁

1. 什么是可重入?

可重入就是说某个线程已经获得某个锁,可以再次获取该锁。

2. 可重入的好处

  • 避免死锁
  • 提升了封装性

三、公平锁与非公平锁

1. 什么是公平与非公平

公平指的是按照线程请求的顺序,来分配锁;非公平指的是,不完全按照请求的顺序,在一定情况下,可以插队。
注意:非公平也同样不提倡“插队”行为,这里的非公平,指的是“在合适的时机”插队,而不是盲目插队。

2. 为什么要有非公平锁

提高效率,避免唤醒带来的空档期。
假设有A、B、C三个线程,此时A持有这把锁,B就要去等待休息。等A执行完以后释放锁, B就需要被唤醒然后占有这把锁,但是C突然来请求这把锁,C是本身处于唤醒状态,可以立刻执行的,C就很有可能在B被唤醒的过程中就可以占有并使用完释放这把锁了,这就形成了一个“双赢”的局面,是可以带来吞吐量的提升的。

3. 公平锁

在线程 1 执行 unlock(释放锁之后,由于此时线程 2 的等待时间最久,所以线程 2 先得到执行,然后是线程 3 和线程 4。

4. 非公平锁

如果在线程 1 释放锁的时候,线程 5 恰好去获取这把锁。
由于此时并没有线程持有这把锁(线程 2 还没来得及获取到,因为获取需要时间)
线程 5 可以插队,直接拿到这把锁。

5. 公平锁与非公平锁的优缺点

优势 劣势
公平锁 各线程公平平等,每个线程在等待一段时间后,总有执行的机会 更慢、吞吐量更小
非公平锁 更快、吞吐量更大 有可能产生线程饥饿,也就是某些线程在长时间内始终得不到执行

四、共享锁与排他锁

排他锁,又称为独占锁、独享锁。获取锁之后其他线程不能读或者写,只能由该线程读取或修改数据。

共享锁,又称为读锁,获得共享锁之后,可以查看但无法修改和删除数据,其他线程此时也可以获取到共享锁,也可以查看但无法修改和删除数据

1. 读写锁的规则

  • 多个线程只申请读锁,都可以申请到
  • 如果有一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待释放读锁。
  • 如果有一个线程已经占用了写锁,则此时其他线程如果申请写锁或者读锁,则申请的线程会一直等待释放写锁。

一句话总结:要么是一个或多个线程同时有读锁,要么是一个线程有写锁,但是两者不会同时出现(要么多读,要多一写)

2. 读锁插队策略

公平情况下: 不允许插队。

非公平的情况下:
假设线程 2 和线程 4 正在同时读取,线程 3 想要写入,拿不到锁,于是进入等待队列,线程 5 不在队列里,现在过来想要读取…

策略1:

读可以插队,线程5获取读锁。
优点 缺点
效率高 会造成饥饿

如果有大量的读线程进来,则可能读锁长时间不能释放,线程3长期得不到执行,产生饥饿。

策略2:

读不能插队,线程5进入等待队列。
优点 缺点
效率偏低 避免了饥饿

因为读锁可以同时被多个线程获取,所以插队能力非常强,要对读锁进行限制,最后策略如下:

  • 写锁可以正常插队
  • 读锁仅在等待队列头部不是写锁的时候可以插队

3. 锁的升级和降级

为什么需要升降级?

比如一个任务,一开始是写操作,然后是读操作, 这样一直占有读锁就会导致资源浪费,但是又不想释放锁重新去排队。这样我们就可以将写锁降级成读锁。

降级操作流程:

升级的操作流程:

读锁升级要求其他线程都释放读锁。如果两个线程同时升级还会导致死锁。
结论:读写锁降级容易,升级难。

五、自旋锁和阻塞锁

  • 阻塞或唤醒一个线程需要操作系统切换 CPU 状态来完成,这种状态转换需要耗费处理器时间。
  • 如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。
  • 在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。
  • 如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃 CPU 的执行时间,看看持有锁的线程是否很快就会释放锁。
  • 而为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁
  • 阻塞锁和自旋锁相反,阻塞锁如果遇到没拿到锁的情况,会直接把线程阻塞,直到被唤醒。

1. 自旋锁的缺点

  • 如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。
  • 在自旋的过程中,一直消耗 cpu,所 以虽然自旋锁的起始开销低于悲观锁,但是随着自旋时间的增长,开销也是线性增长的。

2. 自旋锁的应用场景

自旋锁一般用于多核的服务器,在并发度不是特别高的情况下,比阻塞锁的效率高。

另外,自旋锁适用于临界区比较短小的情况,否则如果临界区很大(线程一旦拿到锁,很久以后才会释放),那也是不合适的。

六、可中断锁

如果某一线程 A 正在执行锁中的代码,另一线程 B 正在等待获取该锁,可能由于等待时间过长,线程不想等待了,想先处理其他事情,我们可以中断它,这种就是可中断锁。