并发编程05_Lock/AQS

Lock:

Lock没啥好说的,个个大神都写烂的东西,直接上链接:显式锁Lock的集大成之作,最细节教程_大将黄猿的博客-CSDN博客

Lock

在jdk1.5以后,增加了juc并发包且提供了Lock接口用来实现锁的功能,它除了提供了与synchroinzed关键字类似的同步功能,还提供了比synchronized更灵活api实现。

ReentrantLock

可重入锁。如果当前线程t1通过调用lock方法获取了锁之后,再次调用lock,是不会再阻塞去获取锁的,直接增加重入次数就行了。与每次lock对应的是unlock,unlock会减少重入次数,重入次数减为0才会释放锁。

ReentrantReadWriteLock

  • 可重入读写锁。读写锁维护了一个读锁,一个写锁。
  • 读锁同一时刻允许多个读线程访问。
  • 写锁同一时刻只允许一个写线程,其他读/写线程都需要阻塞。

Lock和synchronized的简单对比

类别synchronizedLock
存在层次Java的关键字,在jvm层面上是一个接口
锁的释放

1、以获取锁的线程执行完同步代码,释放锁;

2、线程执行发生异常,jvm会让线程释放锁。

必须在finally中释放锁,不然容易造成线程死锁
锁的获取假设A线程获得锁,B线程等待。如果A线程阻塞,B线程会一直等待。Lock有多种获取锁的方式,如lock、tryLock
锁状态无法判断

可以判断;

tryLock();

tryLock(long time, TimeUnit unit);

可避免死锁。

锁类型可重入,非公平,不可中断

可重入,可公平(两者皆可)

可中断:lockInterruptibly();

功能功能单一

API丰富;

tryLock();

tryLock(long time, TimeUnit unit);

可避免死锁。

CLH:AQS的前菜—详解CLH队列锁_大将黄猿的博客-CSDN博客_aqs中的clh队列

什么是CLH队列锁

CLH锁其实就是一种基于逻辑队列非线程集合的一种自旋公平锁。当多线程竞争一把锁时,获取不到锁的线程,会排队进入CLH队列的队尾,然后自旋等待,直到其前驱线程释放锁。由于是 Craig、Landin 和 Hagersten三位大佬的发明,因此命名为CLH锁。

自旋锁和互斥锁的区别

CLH队列锁便是一种自旋锁。

什么是自旋锁呢,当一个线程没有抢到锁时,他不立刻进入阻塞状态,而是进行无限循环,再循环中不断不断的尝试获取锁,如果没有设定自旋时长,就会无穷尽的循环直到获取锁为止。这样的自旋如果不能快速的获取锁,就会浪费相当多的CPU资源。所以自旋锁更适合锁占用时间短,并且争抢锁强度比较低的情况。

因此我们可以总结出:CLH队列锁中如果等待执行队列排的过长,会对我们的CPU造成极大的压力,因此CLH的应用场景也被限制住了。

再说说互斥锁,互斥锁便是多个线程相互争抢锁,如果没有争抢到锁便进入到阻塞状态,直到抢到锁的线程执行完成任务释放掉锁,并唤醒其他呗阻塞的线程重新争抢锁,周而复始。互斥锁因为没抢到锁便进入到阻塞状态,重新唤醒线程的过程会切换线程,CPU要执行很多指令也需要一定的时间,如果说上一个线程执行任务的时间都没这个唤醒线程的时间长,那么互斥锁的效率还不如自旋锁。 因此互斥锁适合锁占用时间长的情况。

Java中ReentrantLock重入锁中的AQS队列排队策略,是基于CLH队列的一种变种实现。原始的CLH队列,一般用于实现自旋锁。而AQS队列的实现,是获取不到锁的线程,先进行一小段时间的自旋,然后进入Park挂起状态。【同样的,ZK中的分布式锁,也使用了类似方式,获取不到锁的线程值监听前一个节点】。因此AQS解决了CLH每个线程无限自旋的问题,应用场景也更加广泛。

CLH锁原理

首先有一个尾节点指针,通过这个尾结点指针来构建等待线程的逻辑队列,当有新的节点加入队列时,尾节点指针会指向这个新加入的节点,并将原本的尾节点变为当前新加入节点的前驱节点。因此能确保线程线程先到先服务的公平性,尾指针可以说是构建逻辑队列的桥梁;此外这个尾节点指针是原子引用类型,避免了多线程并发操作的线程安全性问题;
通过等待锁的每个线程在自己的某个变量上自旋等待,这个变量指向自己的前驱节点中的变量,通过不断地自旋,感知到前驱节点的变化后成功获取到锁。

CLH锁的优点

没有惊群效应。假设有1000个线程等待获取锁,锁释放后,只会通知队列中的第一个线程去竞争锁,避免了同时唤醒大量线程 在瞬间争抢CPU资源,避免了惊群效应。(此处仅仅是不会对锁过度的争抢,也就是公平锁的好处。但是自旋锁的实现方式依然消耗CPU)
CLH队列锁的长处是空间复杂度低(假设有n个线程。L个锁,每一个线程每次仅仅获取一个锁,那么须要的存储空间是O(L+n),n个线程有n个myNode。L个锁有L个tail)。

CLH锁的缺点

在NUMA系统结构下性能稍差。在这样的系统结构下,每一个线程有自己的内存,假设前趋结点的内存位置比較远。自旋推断前趋结点的locked域,性能将大打折扣,在SMP结构下还是非常有效的。【CLH自旋在前驱节点上,访问的是其他线程的变量值,在NUMA架构下,其他线程变量有可能是对端CPU的高速缓存,因此更适合SMP架构】

什么是AQS

AQS:最硬核的AQS解析,你啃的下来么?_大将黄猿的博客-CSDN博客

AbstractQuenedSynchronizer抽象的队列式同步器。是除了java自带的synchronized关键字之外的锁机制,定义了一套多线程访问共享资源的同步器框架。

AQS也是CLH队列锁的一个变种实现。

AQS定义了两种资源共享模式:

  • 独占式,每次只能有一个线程持有锁,例如ReentrantLock实现的就是独占式的锁资源。
  • 共享式,允许多个线程同时获取锁,并发访问共享资源,ReentrantReadWriteLock和CountDownLatch等就是实现的这种模式。
     

AQS是一个抽象类,所以大部分同步类都是继承于它,然后重写部分方法即可。

image.png

 可以看到它是一个抽象类。之所以设计成抽象类,就是为了让子类去实现一部分方法,而自己提供的一些方法对这些子类的重写方法进行了整合。合并成了一个完整的功能。这就是AQS底层的设计模式——模板方法模式。

AQS的核心数据结构

下面我们来了解一下AQS的核心数据结构。

AQS是JUC下最核心的类,没有之一,所以我们先来分析一下这个类的数据结构。
 

image.png

AQS内部是使用了双向链表将等待线程链接起来(在CLH队列锁的基础上进行了改造。CLH队列锁仅仅有一个指向前驱节点的指针,而AQS中的同步队列每个节点都包含其前驱以及后继节点的指针),当发生并发竞争的时候,就会初始化该队列并让后续进入队列的线程睡眠等待被唤醒,同时每个节点会根据是否为共享锁标记状态为共享模式或独占模式。这个数据结构需要好好理解并牢牢记住,下面分析的组件都将基于此实现。 

AQS的常见方法

因为大神的博客中没有列出AQS的方法,所以我自己整理了AQS常用方法如下。

CAS相关:

CAS没什么好说的,只要是任何修改值的操作,都可以用CAS的原子操作进行。

返回值     方法名方法描述
booleancompareAndSetStateCAS尝试修改锁状态
booleancompareAndSetHeadCAS尝试修改队列头
booleancompareAndSetTailCAS尝试修改队队尾
booleancompareAndSetWaitStatusCAS尝试修改队节点状态
booleancompareAndSetNextCAS尝试设置下一个节点

加锁相关:

其中方法名中有shared的是共享锁,这些try起头的方法方法体里面直接是throw new UnsupportedOperationException(),这些方法的细节都依靠于实现类去重写才有意义。

返回值方法名方法描述( *需要重写 )
booleantryAcquire尝试获得锁*
booleantryAcquireNanos尝试在给定时间获得锁*
viodacquire 获得锁(封装了tryAcquire)
voidacquireInterruptibly不断尝试获得锁
booleantryAcquireSharedNanos尝试获得共享锁
inttryAcquireShared尝试在给定时间获得共享锁
voidacquireShared获得共享锁(封装了tryAcquireShared)
voidacquireSharedInterruptibly不断尝试获得共享锁

解锁相关:

和加锁一样,try起头的方法里面都是抛异常,都需要子类去重写。

返回值方法名方法描述( *需要重写 )
booleantryRelease尝试释放锁*
booleantryReleaseShared尝试释放共享锁*
booleanrelease尝试释放锁(封装了tryRelease)
booleanreleaseShared尝试释放共享锁(封装了tryReleaseShared)

维护队列相关:

这部分的方法一般用于监控队列时使用,比如线程执行缓慢,可以查看等待队列是否有问题。

返回值方法名方法描述
booleanhasQueuedThreads是否有线程在排队
ThreadgetFirstQueuedThread获取第一个排队线程
booleanisQueued(Thread)判断线程是否在队列中
booleanhasQueuedPredecessors判断一个线程前面是否有线程在排队
intgetQueueLength获取等待队列的长度
CollectiongetQueuedThreads获取所有等待队列的线程的集合

AQS的内部实现

同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点(Node)并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态。

Node的主要属性:

static final class Node {
    //表示节点的状态,包含SIGNAL、CANCELLED、CONDITION、PROPAGATE、INITIAL
    volatile int waitStatus;
    //前继节点
    volatile Node prev;
    //后继节点
    volatile Node next;
    //当前线程
    volatile Thread thread;
    //存储在condition队列中的后继节点
    Node nextWaiter; 
}

waitStatus节点的几种状态:

  • CANCELLED,值为1,由于在同步队列中等待的线程等待超时或者被中断,需要从同步队列中取消等待,节点进入该状态将不会变化。
  • SIGNAL,值为-1,后继节点的线程处于等待状态,而当前节点的线程如果释放了同步状态或者被取消,将会通知后继节点,使后继节点得以运行。
  • CONDITION,值为-2,节点在等待队列中,节点线程等待在Condition上,当其他线程对Condition调用了signal()方法后,该节点将会从等待队列中转移到同步队列中,加入到对同步状态的获取中。
  • PROPAGATE,值为-3,表示下一次共享式同步状态获取将会无条件地被传播下去。
  • INITAL,值为0,初始状态。

设置尾节点:

当一个线程成功地获取了同步状态(或者锁),其他线程将无法获取,转而被构造成为节点并加入同步队列,而这个过程必须保证线程安全,因此同步器提供了一个基于CAS的设置尾节点的方法:compareAndSetTail(Node expect,Nodeupdate),它需要传递当前线程“认为”的尾节点和当前节点,只有设置成功后,当前节点才正式与之前的尾节点建立关联。

    /**
     * CAS tail field. Used only by enq.
     */
    private final boolean compareAndSetTail(Node expect, Node update) {
        return unsafe.compareAndSwapObject(this, tailOffset, expect, update);
    }

设置首节点:

同步队列遵循FIFO,首节点是获取同步状态成功的节点,首节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点。

设置首节点是通过获取同步状态成功的线程来完成的,由于只有一个线程能够成功获取到同步状态,因此设置头节点的方法并不需要使用CAS来保证,它只需要将首节点设置成为原首节点的后继节点并断开原首节点的next引用即可。

    /**
     * CAS head field. Used only by enq.
     */
    private final boolean compareAndSetHead(Node update) {
        return unsafe.compareAndSwapObject(this, headOffset, null, update);
    }

今天因为电脑的原因,只能直接上大神的博客链接了,以后会借鉴大神的经验来完善我的博客的。这三个博客的作者依旧是大神: 大将黄猿。 每一篇都是精品,看完深有启发。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值