Java多线程高并发系列:(六)线程安全-Look锁

2024-10-29 16:02
330
0

首先对锁的各种类型做个介绍:

  • 独占锁:同一时间,一把锁只能被一个线程获取;
  • 共享锁:同一时间,一把锁可以被多个线程获取。
  • 公平锁:按照申请锁的时间先后,进行锁的再分配工作,这种锁往往性能稍差,因为要保证申请时间上的顺序性。
  • 非公平锁: 锁被释放后,后续线程获得锁的可能性随机,或者按照设置的优先级进行抢占式获取锁。
  • 可重入锁:所谓可重入锁就是一个线程在获取到了一个对象锁后,线程内部再次获取该锁,依旧可以获得,即便持有的锁还没释放,仍然可以获得,不可重入锁这种情况下会发生死锁!可重入锁在使用时需要注意的是:由于锁会被获取 n 次,那么只有锁在被释放同样的 n 次之后,该锁才算是完全释放成功。
  • 可中断锁:在获取锁的过程中可以中断获取,不需要非得等到获取锁后再去执行其他逻辑。
  • 不可中断锁:一旦线程申请了锁,就必须等待获取锁后方能执行其他的逻辑处理。

一、looks包和Look接口

1.1、looks包介绍

如下所示,locks包里包含了锁的核心类和接口

其中包括三个同步器:

  • AbstractOwnableSynchronizer:可以实现线程独占的同步器,用来实现独占锁机制。其中就定义了一个字段exclusiveOwnerThread,用于保存当前持有锁的线程。
  • AbstractQueuedSynchronizer:抽象队列同步器简称AQS,继承于AbstractOwnableSynchronizer,它是实现同步器的基础组件。
  • AbstractQueuedLongSynchronizer:AQS的一个长整型版本,其中同步状态被维护为一个长整型。该类具有与AbstractQueuedSynchronizer完全相同的结构、属性和方法,不同之处在于,所有与状态相关的参数和结果都定义为long而不是int。在创建同步器(如需要64位状态的多级锁和屏障)时,这个类可能很有用。

一个条件接口Condition:条件接口,提供了线程之间的协调机制,优化了等待通知的机制,有效避免惊群效应带来的损耗。

一个LockSupport工具类:线程阻塞的工具类,实现了park/unpark机制

一些锁的接口和实现:

  • Lock:锁接口
  • ReadWriteLock:读写锁接口
  • ReentrantLock:可重入锁(使用场景一般在递归中)
  • ReentrantReadWriteLock:可重入读写锁
  • StampedLock: JDK8 版本新增的一个锁,是读写锁的一种具体实现,和ReentrantReadWriteLock 不同的是其不提供可重入性,不基于某个类似Lock或者ReadWriteLock接口实现,而是基于CLH锁思想实现这点这AQS有些类似,并且StampedLock不支持条件变量 Condition。

1.2、Lock接口的核心方法

  • void lock():获取锁,如果锁不可用,则进行等待。采用Lock,必须主动去释放锁,并且在发生异常时,不会自动释放锁。因此一般来说,使用Lock必须在try{}catch{}块中进行,并且将释放锁的操作放在finally块中进行,以保证锁一定被被释放,防止死锁的发生。
  • boolean tryLock():用来尝试获取锁,但是该方法是有返回值的,如果获取成功,则返回true,如果获取失败(即锁已被其他线程获取),则返回false,也就说这个方法无论如何都会立即返回,在拿不到锁时也不会一直在那等待。
  • boolean tryLock(long time, TimeUnit unit) throws InterruptedException:和tryLock()方法是类似的,只不过区别在于这个方法在拿不到锁时会等待一定的时间,在时间期限之内如果还拿不到锁,就返回false。如果如果一开始拿到锁或者在等待期间内拿到了锁,则返回true。
  • void lockInterruptibly() throws InterruptedException:当通过这个方法去获取锁时,如果线程正在等待获取锁,则这个线程能够响应中断,即中断线程的等待状态。也就说,当两个线程同时通过lock.lockInterruptibly()想获取某个锁时,假若此时线程A获取到了锁,而线程B只有在等待,那么对线程B调用threadB.interrupt()方法能够中断线程B的等待过程。
  • void unlock():释放锁
  • Condition newCondition():获取条件组件,该组件和当前的锁绑定,当前线程只有获得了锁,才能调用该组件的wait()方法,而调用后,当前线程将释放锁。

lock()最常用,lockInterruptibly()方法一般更昂贵。

有的impl可能没有实现lockInterruptibly(),只有真的需要效应中断时,才使用,使用之前看看impl对该方法的描述。

lock()方法不会对interrupt()方法响应,不会像Wait一样进行报错,要用lockInterruptibly方法。

二、AQS抽象队列同步器

AbstractQueuedSynchronizer抽象队列同步器简称AQS,定义了FIFO同步队列和同步状态的获取和释放方法,是构件其他同步组件的基础组件(如:ReentrantLockReentrantReadWriteLockSemaphoreCountDownLatch等)。其中类图如下:

2.1、核心逻辑

AQS的核心逻辑根据它的名字:“抽象队列同步器”,就已经总结的简单明了。

抽象:

其采用的模板方法设计模式,作为其他同步组件的基础组件。

队列:

定义了一个FIFO双向链表的同步队列,来完成线程获取锁的排队工作,线程获取锁失败后,会被添加至队尾。

还定义了一个条件队列ConditionObject供子类使用,用来精确的控制唤醒。参考ReentrantLock类的newCondition方法。

同步器:

通过维护了一个volatile修饰的state字段作为同步状态,当线程调用lock方法时,如果state=0,说明该资源没有被占有,可以获得锁;如果state>=1,说明该资源已被占用,需要加入同步队列等待。

为什么采用双向链表结构?

  1. 从双向列表的特性来看:双向链表有2个指针一个指向前置节点,一个指向后置节点,因此可以在时间复杂度O(1)的情况下找到前置节点,在插入和删除操作时比单向列表简单高效
  2. 没有竞争到锁线程在加入堵塞队列之前,判断前驱节点的状态是不是正常的。线程堵塞之前需要判断前置节点的状态,用双向链表就不需要从Head遍历
  3. lock接口存在允许在获取锁的过程中中断线程。在被中断的时候,需要将当前节点从链表中移除。需要将一个节点的指针指向下一个节点。使用双向链表,可以直接删除当前节点,而不需要遍历。而且,遍历的时候和解锁过程会产生竞争。
  4. 避免堵塞和唤醒的开销,记录头节点之后,直接判断前置节点是不是头节点,不是头节点就不用去竞争锁。(公平锁)

2.2、详细说明

AQS里面最重要的就是两个操作和一个状态:获取操作(acquire)、释放操作(release)、同步状态(state)。两个操作通过各种条件限制,如下:

  • acquire(int):独占模式的获取,忽略中断。
  • acquireInterruptibly(int):独占模式的获取,可中断
  • tryAcquireNanos(int, long):独占模式的获取,可中断,并且有超时时间。
  • release(int):独占模式的释放。
  • acquireShared(int):共享模式的获取,忽略中断。
  • acquireSharedInterruptibly(int):共享模式的获取,可中断
  • tryAcquireSharedNanos(int, long):共享模式的获取,可中断,并且有超时时间。
  • releaseShared(int):共享模式的释放。

AQS采用的是模板方法的设计模式,子类通过继承同步器并实现它的抽象方法来管理同步状态,可重写的方法tryAcquire(int arg)、tryRelease(int arg)、tryAcquireShared(int arg)、tryReleaseShared(int arg)、isHeldExclusively()。

内部获取操作如acquire方法会调用子类定义的tryAcquire来尝试获取锁,失败则通过CAS自旋加入同步队列,并调用park进行等待。

内部释放操作如release方法会调用子类定义的tryRelease来尝试释放锁,成功则调用unpark唤醒head节点的后继节点,失败返回false。

队列:AQS通过一个内置的FIFO双向队列来完成线程排队的工作。内部有head和tail字段用来记录队首和队尾元素。

/**
 * Head of the wait queue, lazily initialized.
 */
private transient volatile Node head;

/**
 * Tail of the wait queue. After initialization, modified only via casTail.
 */
private transient volatile Node tail;

其Node类中声明了waiter字段用来存放AQS队列中的线程引用。Node中的字段修改都是通过CAS操作来完成,保证其原子性。

持有锁的线程:存储在父类AbstractOwnableSynchronizer的exclusiveOwnerThread字段中,通过get/set方法赋值。保证其线程安全的方式是外层调用时通过判断state的cas操作来实现,以ReentrantLock类下的tryLock方法为例:

final boolean tryLock() {
    Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (compareAndSetState(0, 1)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    } else if (getExclusiveOwnerThread() == current) {
        if (++c < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        setState(c);
        return true;
    }
    return false;
}

状态:state=0,说明该资源没有被占有,可以获得锁;如果state!=0,说明该资源已被占用,如果是可重入锁,重入的次数其实就是对state+1。

三、ReentrantLock独占式可重入锁

ReentrantLock是一种同时拥有独占式、可重入、可中断、公平/非公平特性的同步器!

先来看看它的关系图谱:

image

其内部拥有三个内部类,分别为Sync、FairSync(公平锁)、NonfariSync(非公平锁),其中FairSync、NonfariSync继承父类Sync。Sync又继承了AQS(AbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在 Sync 中实现的。

3.1、公平锁和非公平锁

通过构造方法可以生成公平锁还是非公平锁,默认为非公平锁。

FairSync和NonfariSync的主要区别是公平锁要申请锁的先后顺序,也就是入队的先出队获得锁。源码中如何判断就在如下方法中,可以自行往下跟踪了解。

3.2、实现可重入

怎么实现的可重入?

先从owner说起:要想重入当然要判断是否是当前线程,当前线程才能再次进入。ReentrantLock中有个getOwner如下,其实调用的就是AQS中的exclusiveOwnerThread字段。

再说count:既然可重入多次那么当然要记录重入了多少次,程序中需要注意lock了多少次,后续要unlock多少次。如果没有释放相同次数就不会释放锁(即其他线程抢不到锁)。如果释放了超过的数量就会报IllegalMonitorStateException。这个重入次数其实就记录在AQS的state字段内。

四、ReadWriteLock读写锁

概念:

  • 维护一对关联锁,一个只用于读操作,一个只用于写操作;
  • 读锁可以由多个读线程同时持有,写锁是排他的。同一时间,两把锁不能被不同线程持有。(即两把锁在不同线程下是互斥的,但是同一个线程能获得读锁和写锁,这对锁降级很有用)

适用场景:

  • 读取操作多于写入操作的场景。
  • 改进互斥锁的性能,比如:集合的并发线程安全性改造、缓存组件。

锁降级:

  • 指的是写锁降级成为读锁。持有写锁的同时,再获取读锁,随后释放写锁的过程。
  • 线程持有读锁的情况下,该线程不能取得写锁。
  • 写锁是线程独占,读锁是共享,所以写->读是降级。
  • 为什么不支持读锁升级为写锁?一是因为持有读锁的线程可能有多个,如果能升级为写锁可能造成复杂的竞争关系。二是可能造成死锁。
  • 锁降级保障了一个可见性的问题:即刚刚释放的写锁中写的内容,不会因为写锁释放而让其他线程再次抢到写锁把刚刚写的内容又改了,导致读线程失去了对第一个写数据的读取。

为保证多个字段的一致性时要加读锁。利用读写锁互斥的关系,让读写顺利完成,而不是读到一半后面的字段被改了。

JDK8中引入的StampedLock提供了一种替代方案,允许读锁在一定条件下升级为写锁。

具体的读写锁的源码解析可以查看另一篇文章

五、StampedLock读写锁

StampedLock 是JUC并发包里面 JDK8 版本新增的一个锁,是读写锁的一种具体实现,它提供了更灵活的并发控制。与ReentrantReadWriteLock 不同的是其不提供可重入性,不是基于某个Lock或者ReadWriteLock接口,也没有引入AQS,而是基于CLH锁思想实现,并且StampedLock不支持条件变量 Condition。

如下是三个锁的并发度比较:

并发度
ReentrantLock 读读互斥,读写互斥,写写互斥
ReentrantReadWriteLock 读读不互斥、读写互斥、写写互斥
StampedLock 读读不互斥、读写不互斥、写写互斥

StampedLock 的读写不互斥是指 乐观读和写,而不是悲观读和写。

5.1、StampedLock 三个主要的锁模式

  • 写锁模式(writeLock()): 用于排他性地写操作,只允许一个写操作进行。在写锁模式下,其他线程既无法获取读锁,也无法获取写锁。
  • 读锁模式(readLock(),悲观读): 类似于 ReadWriteLock 的读锁,允许多个线程同时获取读锁,但无法与写锁共存。
  • 乐观读锁模式(tryOptimisticRead()): 允许线程进行读操作而不获取读锁,这种模式假设在读操作过程中数据不会被其他线程修改。如果发现数据被修改,可以重新获取悲观读锁以保证数据一致性。适用读多写少的环境,提高性能

读锁示例:

long stamp = lock.readLock();
  try {
      // 读操作
  } finally {
      lock.unlockRead(stamp);
  }

写锁示例:

  long stamp = lock.writeLock();
  try {
      // 写操作
  } finally {
      lock.unlockWrite(stamp);
  }

乐观读锁示例:

  long stamp = lock.tryOptimisticRead();
  // 读操作
  if (!lock.validate(stamp)) {
      // 乐观读失败,升级为读锁
      stamp = lock.readLock();
      try {
          // 重新读取数据
      } finally {
          lock.unlockRead(stamp);
      }
  }
  

注意:如示例代码所示,lock.tryOptimisticRead();获取悲观锁方法并不真正获取锁,而是假设在读操作期间数据不会被修改。使用戳记来验证数据是否在读操作期间被修改,必要时需要升级为悲观读锁来保证数据一致性。 

转换锁(锁的升降级)

  • long tryConvertToWriteLock(long stamp):尝试将当前持有的锁转换为写锁
  • long tryConvertToReadLock(long stamp):尝试将当前持有的写锁转换为读锁
  • long tryConvertToOptimisticRead(long stamp):尝试将当前持有的悲观读锁转换为乐观读锁

与ReentrantReadWriteLock不同,StampedLock是支持锁升级的。

 

六、Synchronized VS Lock

Synchronized优点:

  1. 使用简单,语义清晰,哪里需要点哪里。
  2. 由JVM提供,提供了多种优化方案(锁粗化、锁消除、偏向锁、轻量级锁)
  3. 锁的释放由虚拟机来完成,不用人工干预,也降低了死锁的可能性

Synchronized缺点:
      无法实现一些锁的高级功能如:公平锁、中断锁、超时锁、读写锁、共享锁等

Lock优点:

  1. 所有synchronized的缺点都能实现(自己实现)
  2. 可以实现更多的功能,让synchronized缺点更多

Lock缺点:

      需手动释放锁unlock,新手使用不当可能造成死锁

 

 

全部评论