并发编程之锁

什么是Lock

  • 锁是一种工具,用于控制对共享资源的访问。
  • Lock 和 synchronized 作用相同,都可以实现线程安全的目的。
  • Lock 不会像 synchronized 一样在异常时自动释放锁。
  • Lock 锁的是锁对象本身

synchronized 的不足之处

  1. 效率低:锁的释放情况少、试图获得锁时不能设定超时、不能中断一个正在试图获得锁的线程
  2. 不够灵活(读写锁更灵活):加锁和释放的时机单一,每个锁仅有单一的条件(某个对象),可能是不够的
  3. 无法知道是否成功获取到锁

lock() 、 tryLock() 、 tryLock(long time, TimeUnit unit) 和 lockInterruptibly()

  • lock() 方法不能被中断,这会带来很大的隐患:一旦陷入死锁,lock() 就会陷入永久等待。
  • tryLock() 用来尝试获取锁,如果当前锁没有被其他线程占用,便会获取成功,则返回 true,否则返回 false,代表获取锁失败(该方法会立即返回,即便在拿不到锁时不会一直在那等待获取锁)
  • tryLock(long time, TimeUnit unit):超时就放弃获取锁
  • lockInterruptibly():相当于 tryLock(long time, TimeUnit unit) 把超时时间设置为无限。在等待锁的过程中,线程可以被中断。

lockInterruptibly()和tryLock(time, unit)都拥有接收中断信号的能力,只是lockInterruptibly()是特殊的tryLock(time, unit),它的超时时间是无限大的,或者说它是具有被中断能力的tryLock();

注意点:不论是 Lock 还是 synchronized ,两者的可见性要求,如果下一个线程没有尝试获取锁,那么该线程是不能够看到前一个线程释放锁之前的操作的,也就是说下一个线程想要看到前一个线程释放锁之前的操作时,就必须先要获取这把锁,否则下一个线程得到的数据可能不是最新的。

乐观锁和悲观锁

乐观锁也成为非互斥同步锁,悲观锁也称为互斥同步锁。

互斥同步锁的劣势:

  • 堵塞和唤醒带来的性能劣势。悲观锁,当它锁住之后,便是独占的,其他线程想要获取该资源,就必须等待。
  • 永久堵塞:如果持有锁的线程被永久堵塞,比如遇到了无限循环、死锁等活跃性问题,那么等待该线程释放锁的那几个线程将无法得到执行。

两者的区别在于是否需要先拿到锁,再去操作。

悲观锁:悲观锁为了结果的正确性,会在每次获取并修改数据时,把数据锁住,让别人无法访问该数据,这样可以确保数据内容万无一失。synchronized 和 Lock 相关类就是悲观锁的实现。

乐观锁:认为自己在处理操作的时候不会有其他线程来干扰,所以不会锁住被操作对象。如果数据和一开始拿到的数据不一样,说明有其他线程在这段时间内改过数据,则直接放弃、报错、重试等策略。乐观锁的实现一般都是利用 CAS 算法来实现的。典型例子是原子类并发容器等。

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

开销对比:

  • 悲观锁的原始开销要高于乐观锁,但是特点是一劳永逸,临界区(每个进程中,访问临界资源的那段代码,临界资源:一次仅允许一个进程使用的资源)持锁时间就算越来越差,也不会对互斥锁的开销造成影响,因为开销是固定的。所以比较适合于并发写入多的场景。
  • 乐观锁刚开始的开销比悲观锁小,但是如果自旋的时间过长或者不停重试,那么消耗的资源也会越来越多。适合并发写入少,大部分是读取的场景。

可重入锁和非可重入锁(ReentrantLock)

什么是可重入?

再次去申请这个锁的时候,无需提前释放掉我这把锁,而是可以直接继续使用这把锁。

好处是避免死锁

公平锁和非公平锁

公平指的是按照线程请求的顺序,来分配锁;非公平指的是,不完全按照请求的顺序,在一定情况下,可以插队。

设计的初衷:避免线程从堵塞到唤醒期间带来的空档期,提高系统的吞吐量。

ReentrantLock 默认就是不公平锁。

  • 针对tryLock()方法,它是不遵守设定的公平的规则,即非公平
  • 例如,当有线程执行tryLock()的时候,一旦有线程释放了锁,那么这个正在tryLock的线程就能获取到锁,即使在它之前已经有其他的线程在等待队列里了。

优势 劣势
公平锁 各线程公平平等,每个线程在等待一段时间后,总有执行的机会 更慢,吞吐量小
非公平锁 更快,吞吐量大 有可能产生线程饥饿,也就是某些线程在长时间内,始终得不到执行
  1. 非公平锁的情况下,唤醒线程是按照队列顺序依次唤醒的还是同时唤醒一起抢锁? 按照队列顺序依次唤醒
  2. 如果是依次唤醒的,排在第一位的线程每次都抢不过新来的线程是不是它下次还在第一位还是首先唤醒它? 是的
  3. 在等待队列里面的是依次唤醒的,没在等待队列的是可以抢锁的是吧

共享锁和排它锁(ReentrantReadWriteLock)

  • 排他锁,又称为独占锁、独享锁
  • 共享锁,又称为读锁,获得共享锁之后,可以查看但无法修改和删除数据,其他线程此时也可以获取到共享锁,也可以查看但无法修改和删除数据
  • 共享锁和排它锁的典型是读写锁 ReentrantReadWriteLock ,其他读锁是共享锁,写锁是独享锁。

读写锁的规则

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

加读锁的作用就是为了保证并发安全,虽然看上去不加锁也可以读取,但是如果我们加了锁之后,就可以防止读写同时发生的情况发生,一旦我们在读取该值之前率先加锁,那么如果有其他线程想在我们读取期间修改该值的话,他是无法做到的,因为他无法在我们读取期间获取写锁。

自旋锁和阻塞锁

自旋锁:当前线程在不放弃CPU的情况下,不断地去尝试获取锁,当持有锁的线程释放掉锁之后,这样当前线程就不会陷入堵塞而是直接获取资源,从而避免线程切换的开销。适合于并发度不是特别高的情况下。

堵塞锁:如果当前线程没有拿到锁,就直接陷入堵塞状态。

可中断锁(可以响应的锁)

synchronized 就不是可中断锁,而Lock是可中断锁。因为在tryLock(time) 和 lockInterruptibly 都能响应中断。

即当某一线程A正在执行锁中的代码,另一线程B正在等待获取该锁,那么在这等待期间线程B是可以被中断的。

锁的优化

  1. 缩小同步代码块
  2. 减少请求锁的次数。(可以把多个请求封装成一个去统一请求)
  3. 锁中尽量不要再包含锁。(容易造成死锁)
  4. 选择合适的锁类型或者锁工具类。

posted @ 2022-11-03 00:19  追风少年潇歌  阅读(39)  评论(0编辑  收藏  举报