Skip to content

Lock 与锁机制

5.9.2 Lock接口

基本信息
Package java.util.concurrent.locks

public interface Lock

用于控制对共享资源的访问。通常情况下 Lock 仅允许一个线程来访问共享资源,但也有一些特殊的实现类允许并发访问。
Lock 加解锁和 synchronized 有相同的内存语义,下一个线程加锁后可以看到前一个线程解锁前所有操作。
由于 synchronized 具有以下缺点,我们需要 Lock 来强化功能:

  • 效率低。锁释放情况少,获得锁不能设置超时或者中断。
  • 不够灵活。加减锁的时机单一,每个锁只有单一条件。
  • 无法知道是否成功获取到了锁。

分类
根据线程是否锁住同步资源可以分为:

  • 悲观锁、互斥同步锁(锁)
    • 缺点:阻塞和唤醒带来性能劣势;可能造成永久阻塞;可能造成优先级反转。
    • 优点:就算临界区持锁时间越来越差,对锁的开销影响不大
    • 认为如果不对资源进行上锁,就可能会有其他线程争抢而造成数据错误。所以每次获取和修改数据时都先把资源锁住。
    • 适合并发写入多的情况;临界区持锁时间长的情况。例如:临界区有 IO 操作;临界区代码复杂循环量大;临界区竞争激烈。
    • 例子:synchronized Lock
  • 乐观锁、非互斥同步锁(不锁)
    • 缺点:如果自旋时间长或者不停重试,会造成消耗的资源越来越多。
    • 认为在线程处理数据时不会有其他线程干扰而不提前对资源锁定。在更新数据时对比修改期间是否有其他线程改动,如果没有则正常操作;如果发现数据不一致则选择放弃、报错、重试等策略。
    • 一般利用 CAS 算法实现。
    • 适合并发写入少,大部分操作是读取的场景。不加锁可以让读的性能大幅提高。
    • 例子:原子类、并发容器

根据多线程能否公用一把锁可以分为:

  • 共享锁(可)
  • 独占锁(不可)

根据多线程竞争是否需要排队可以分为:

  • 公平锁(排队)
    优点:每个线程公平处理,在等待一段时间后总有执行机会。
    缺点:慢,吞吐量小。
  • 非公平锁(先插队,失败再排队)
    优点:避免唤醒期间的空档期而提高效率。
    缺点:可能产生线程饥饿(长时间内始终得不到执行)。

根据同一个线程是否可以重复获得同一把锁可以分为:

  • 可重入锁
    • 优点:避免死锁;提高封装性。
  • 不可重入锁

根据是否可以中断可分为:

  • 可中断锁
  • 非可中断锁

根据等待锁的过程可以分为:

  • 自旋锁(自旋)
    当同步代码内容简单,线程状态转换时间开销比代码执行时间还要长,设计出自旋锁。
    让线程状态不变的前提下进行自旋,如果自旋完成后之前锁定的资源已经释放锁,则不必切换状态直接获取资源,避免了切换线程状态的开销。
    缺点:如果锁被占用时间过长,自旋线程会浪费处理器资源。虽然自旋开销低于悲观锁,但随着自旋时间的增增长开销也线性增长。
    适用于多核处理器的服务器,并发程度不是特别高,临界区小。
  • 非自旋锁(阻塞)
    在没有拿到锁的情况下直接阻塞线程。

重要方法

  • lock()
    获取锁。如果锁被其他线程获取则等待。
    它不会在遇到异常时自动释放锁,所以我们一般要求在上锁后把业务用 try-finally 包裹,并在 finally 中释放锁。
    lock() 不能被中断,如果死锁发生则可能永久等待。
  • tryLock()
    尝试获取锁,如果当前锁没有被其他线程占用则成功获取,返回 true,否则返回 false.
    可以根据是否能获取到锁来决定后续程序行为。
    方法会立刻返回,不会在拿不到锁时等待(无视公平策略)。
  • tryLock(long time, TimeUnit unit)
    带有超时时间的 tryLock(). 在等待设定时间内拿不到锁则放弃。
  • lockInterruptibly()
    相当于等待时间无限长的 tryLock(), 但是它可以被中断。
  • unlock()
    解锁。

具体实现类
ReentrantLock

  • 默认为非公平策略,可设置为公平策略
  • 互斥锁、可重入锁、可中断锁
  • 类似 synchronized 机制,但更加灵活
  • 利用 AQS 算法实现
  • isHeldByCurrentThread() 查看锁是否被当前线程持有
  • getQueueLength() 返回正在等待当前锁的队列长度

ReentrantReadWriteLock

  • 使用时允许多个线程同时使用统一资源,但只允许一个线程对资源进行写操作
  • 读期间不允许进行写操作,写期间不允许进行读操作
  • 读锁为共享锁,写锁为独享锁
  • 适用于读多写少的场景
  • 插队策略
    设置公平策略:private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(true);
    不公平策略避免解饿:写锁可以随时尝试插队。读线程可以插队,但需要在等待队列头结点的线程不是写线程时可以插队。
  • 升降级策略
    支持降级但不支持升级。若支持升级容易操作死锁,因为假设两个写线程想升级,都需要对方先释放锁,造成死锁。
java
private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
private static ReentrantReadWriteLock.readLock = reentrantReadWriteLock.readLock();
private static ReentrantReadWriteLock.writeLock = reentrantReadWriteLock.writeLock();

锁优化
JVM 对锁进行了优化:

  • 自旋锁的自适应:在自旋尝试一段时间后自动转为阻塞所来防止资源消耗过大。
  • 锁消除:对于无需加锁但是加锁的场景,消除掉锁。
  • 锁粗化:频繁对一些资源加锁解锁时,合并相邻的操作进入一个锁中。

使用锁时的启发规则:

  • 缩小同步代码块
  • 尽量不要锁定方法
  • 减少锁使用次数
  • 避免人为制造热点数据
  • 避免锁中包含锁
  • 选择适合的锁和工具类