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

2024-10-29 16:02
13
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字段内。

4、ReadWriteLock读写锁

概念:

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

适用场景:

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

锁降级:

  • 指的是写锁降级成为读锁。持有写锁的同时,再获取读锁,随后释放写锁的过程。(即保证读的是刚刚释放的写锁中写的内容,而不会因为写锁释放而让其他线程抢到写锁把内存改了)
  • 写锁是线程独占,读锁是共享,所以写->读是降级。(读->写,是不能实现的)

问题:读锁用在什么时候?

为保证多个字段的一致性时要加读锁。如:读写都要去操作多个字段,为保证操作读的时候写已经完成了,即防止写到一半字段的时候去读,导致这多个字段的一致性被破坏。

 

 

 

 

全部评论