常见的锁策略你了解多少?

1 乐观锁和悲观锁

悲观锁:
  • 悲观锁假设数据访问会发生冲突,因此在访问数据之前会立即对其进行锁定,以防止其他用户同时访问。
  • 当一个用户获得了悲观锁并且正在访问数据时,其他用户需要等待直到锁被释放才能访问相同的数据。
  • 悲观锁适用于写操作频繁,读操作较少的情况,以确保数据的一致性和完整性。

乐观锁:

  • 乐观锁是一种乐观的假设,它认为在大多数情况下,数据不会发生冲突,因此不会立即锁定数据。
  • 当多个用户尝试同时访问相同的数据时,系统会首先允许它们同时访问,并在数据更新时进行冲突检测。
  • 通常,乐观锁会在数据的版本或时间戳上添加一个标记,每次更新时都会检查这个标记。如果标记没有发生变化,说明没有冲突,更新会成功。如果标记发生了变化,说明有冲突,更新会失败,需要进行冲突解决。
  • 乐观锁适用于读操作频繁,写操作相对较少的情况,因为它减少了数据的锁定时间,提高了并发性能。

举个例子:

乐观锁:

假设有一家电影院,多个人可以在网上购买电影票。电影票的库存是有限的,但是电影院希望尽量减少用户之间的竞争。在这种情况下,可以使用乐观锁来管理电影票库存。

  1. 用户A和用户B同时尝试购买最后一张电影票。
  2. 系统读取电影票的库存数量(1),并标记版本为0,然后允许用户A和用户B继续购买。
  3. 用户A首先完成购买,电影票库存减少到0,同时更新库存的版本标记为1。
  4. 当用户B尝试购买时,系统会检查版本标记,发现与读取时不同(标记为1而不是0),并且会阻止用户B的购买,因为库存已经售罄。

在这个示例中,乐观锁允许多个用户同时查看电影票的库存,但在实际购买时会检查并发冲突,以避免超售。

 悲观锁:

考虑一个公共自行车租赁系统,多个人可以在不同的站点租赁自行车。每个站点有一定数量的自行车,但不能让多个用户同时租赁同一辆自行车。在这种情况下,可以使用悲观锁来管理自行车租赁。

  1. 用户A和用户B同时到达同一个站点,都想租赁同一辆自行车。
  2. 当用户A选择一辆自行车并尝试租赁时,系统立即对该自行车应用悲观锁,阻止其他用户租赁同一辆自行车。
  3. 用户A完成租赁后,该自行车锁被释放,用户B可以租赁。

在这个示例中,悲观锁确保一次只有一个用户可以租赁同一辆自行车,以避免竞争和冲突。

2 读写锁

读写锁(Read-Write Lock)是一种用于多线程编程的同步机制,旨在提高并发性能。它允许多个线程同时读取共享资源,但在写入资源时会独占锁,以确保数据的一致性和完整性。读写锁通常分为两个部分:读锁和写锁。

  1. 读锁(Read Lock):

    • 读锁允许多个线程同时获取锁并访问共享资源。多个线程可以同时读取数据,因为读操作通常不会对数据造成破坏或冲突。
    • 当没有写锁被持有时,任意数量的线程可以同时获取读锁。
    • 读锁是共享锁,不会阻塞其他持有读锁的线程,但会阻塞写锁的获取。
  2. 写锁(Write Lock):

    • 写锁是独占锁,只有一个线程可以获取写锁并修改共享资源。当一个线程持有写锁时,其他线程无法同时持有读锁或写锁。
    • 写锁通常用于更新或修改共享资源,以确保在写入时不会发生竞争和数据破坏。
Java 标准库提供了 ReentrantReadWriteLock , 实现了读写 .
  • ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁.
  • ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进行加锁解锁

同样的,读锁与读锁加锁之间是没有互斥的,但是写锁与写锁、写锁与读锁之间加锁是有互斥的。只要有互斥就会产生线程的挂起等待,一旦线程被挂起在此被唤醒就不知道过了多久时间了,所以应该减少“互斥”的可能,来提高效率。

 读写锁特别适合于 "频繁读, 不频繁写" 的场景中. (这样的场景其实也是非常广泛存在的).

比如教务系统 .
每节课老师都要使用教务系统点名 , 点名就需要查看班级的同学列表 ( 读操作 ). 这个操作可能要每周 执行好几次 . 而什么时候修改同学列表呢 ( 写操作 )? 就新同学加入的时候 . 可能一个月都不必改一次 .
同学们使用教务系统查看作业 ( 读操作 ), 一个班级的同学很多 , 读操作一天就要进行几十次或者上百次。但是一节课的作业老师只需要布置一次(写操作)。

3 重量级锁和轻量级锁

重量级锁 : 加锁机制重度依赖了 OS 提供了 mutex
  • 大量的内核态用户态切换
  • 很容易引发线程的调度
轻量级锁 : 加锁机制尽可能不使用 mutex, 而是尽量在用户态代码完成 . 实在搞不定了 , 再使用 mutex.
  • 少量的内核态用户态切换.
  • 不太容易引发线程调度

3.1 自旋锁

按之前的方式,线程在抢锁失败后进入阻塞状态,放弃 CPU ,需要过很久才能再次被调度 .
但实际上 , 大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃 CPU. 这个时候就可以使用自旋锁来处理这样的问题 . 自旋锁是轻量级锁
适用情况:
  1. 短期等待:自旋锁适用于锁的竞争时间较短,当线程尝试获取锁时,其他线程会很快释放锁。在这种情况下,自旋等待的开销较低,因为线程不需要进入阻塞状态和重新调度。

  2. 高并发:自旋锁在高并发的情况下表现出色,因为它避免了线程切换的开销。当线程不能立即获取锁时,它会反复尝试获取锁,而不是进入等待队列。

  3. 多核处理器:自旋锁在多核处理器上效果更好,因为线程可以在一个核上自旋等待,而不会浪费过多的上下文切换开销。

3.2 挂起等待锁

挂起等待锁是一种更传统的同步机制,它会让线程进入阻塞状态,直到某个条件满足后才被唤醒。挂起等待锁通常与条件变量和锁结合使用。当条件不满足时,线程调用条件变量的await方法进入等待状态,等待其他线程通过signalsignalAll来唤醒它们。

适用情况:

  1. 挂起等待锁适用于需要等待某些条件满足的情况,例如,线程A等待线程B完成某个任务后才能继续执行。
  2. 它适用于长时间的锁竞争,当线程需要等待较长时间才能满足条件时,不会浪费大量的CPU资源。

自旋vs挂起等待 总结:

  • 自旋锁适用于短期的锁竞争,它不会让线程进入阻塞状态,但需要注意自旋时间不宜过长,以免浪费CPU资源。
  • 挂起等待锁适用于需要等待特定条件满足的情况,它会让线程进入阻塞状态,等待条件满足后才会被唤醒。
  • 选择自旋锁还是挂起等待锁取决于具体的多线程编程场景和性能需求。自旋锁通常适用于高并发、短期竞争的情况,而挂起等待锁适用于需要等待条件满足的长期竞争情况。

 4 公平锁和非公平锁

假设三个线程 A, B, C。A 先尝试获取锁 , 获取成功 然后 B 再尝试获取锁 , 获取失败 , 阻塞等待 ; 然后 C 也尝试获取锁 , C 也获取失败 , 也阻塞等待 .
当线程 A 释放锁的时候 , 会发生啥呢 ?
公平锁 : 遵守 " 先来后到 ". B C 先来的 . A 释放锁的之后 , B 就能先于 C 获取到锁 .
非公平锁 : 不遵守 " 先来后到 ". B C 都有可能获取到锁 .
注意:
  • 操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁. 如果要想实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序.
  • 公平锁和非公平锁没有好坏之分, 关键还是看适用场景

5 可重入锁和不可重入锁

可重入锁(Reentrant Lock)

  • 可重入锁允许同一个线程在持有锁的情况下再次尝试获取相同的锁,而不会造成死锁。这意味着线程可以多次获得同一个锁,每次获得锁后需要相同数量的释放操作来释放锁。
  • 可重入锁通常通过维护一个计数器来实现,该计数器记录了锁被获得的次数。只有当计数器降为零时,锁才会完全释放,其他线程才能获得锁。

不可重入锁(Non-Reentrant Lock)

  • 不可重入锁不允许同一个线程多次获得相同的锁。如果一个线程已经获得了不可重入锁,再次尝试获取锁会导致死锁或锁定。
  • 不可重入锁通常是一种较简单的锁实现,它不维护锁的持有者信息,因此不具备可重入性。

在前面的  线程安全博客  中的可重入小结 介绍了可重入的代码。

猜你喜欢

转载自blog.csdn.net/qq_45875349/article/details/133656552