Lock:
Lock没啥好说的,个个大神都写烂的东西,直接上链接:显式锁Lock的集大成之作,最细节教程_大将黄猿的博客-CSDN博客
Lock
在jdk1.5以后,增加了juc并发包且提供了Lock接口用来实现锁的功能,它除了提供了与synchroinzed关键字类似的同步功能,还提供了比synchronized更灵活api实现。
ReentrantLock
可重入锁。如果当前线程t1通过调用lock方法获取了锁之后,再次调用lock,是不会再阻塞去获取锁的,直接增加重入次数就行了。与每次lock对应的是unlock,unlock会减少重入次数,重入次数减为0才会释放锁。
ReentrantReadWriteLock
- 可重入读写锁。读写锁维护了一个读锁,一个写锁。
- 读锁同一时刻允许多个读线程访问。
- 写锁同一时刻只允许一个写线程,其他读/写线程都需要阻塞。
Lock和synchronized的简单对比
类别 | synchronized | Lock |
---|---|---|
存在层次 | 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是一个抽象类,所以大部分同步类都是继承于它,然后重写部分方法即可。
可以看到它是一个抽象类。之所以设计成抽象类,就是为了让子类去实现一部分方法,而自己提供的一些方法对这些子类的重写方法进行了整合。合并成了一个完整的功能。这就是AQS底层的设计模式——模板方法模式。
AQS的核心数据结构
下面我们来了解一下AQS的核心数据结构。
AQS是JUC下最核心的类,没有之一,所以我们先来分析一下这个类的数据结构。
AQS内部是使用了双向链表将等待线程链接起来(在CLH队列锁的基础上进行了改造。CLH队列锁仅仅有一个指向前驱节点的指针,而AQS中的同步队列每个节点都包含其前驱以及后继节点的指针),当发生并发竞争的时候,就会初始化该队列并让后续进入队列的线程睡眠等待被唤醒,同时每个节点会根据是否为共享锁标记状态为共享模式或独占模式。这个数据结构需要好好理解并牢牢记住,下面分析的组件都将基于此实现。
AQS的常见方法
因为大神的博客中没有列出AQS的方法,所以我自己整理了AQS常用方法如下。
CAS相关:
CAS没什么好说的,只要是任何修改值的操作,都可以用CAS的原子操作进行。
返回值 | 方法名 | 方法描述 |
boolean | compareAndSetState | CAS尝试修改锁状态 |
boolean | compareAndSetHead | CAS尝试修改队列头 |
boolean | compareAndSetTail | CAS尝试修改队队尾 |
boolean | compareAndSetWaitStatus | CAS尝试修改队节点状态 |
boolean | compareAndSetNext | CAS尝试设置下一个节点 |
加锁相关:
其中方法名中有shared的是共享锁,这些try起头的方法方法体里面直接是throw new UnsupportedOperationException()
,这些方法的细节都依靠于实现类去重写才有意义。
返回值 | 方法名 | 方法描述( *需要重写 ) |
---|---|---|
boolean | tryAcquire | 尝试获得锁* |
boolean | tryAcquireNanos | 尝试在给定时间获得锁* |
viod | acquire | 获得锁(封装了tryAcquire) |
void | acquireInterruptibly | 不断尝试获得锁 |
boolean | tryAcquireSharedNanos | 尝试获得共享锁 |
int | tryAcquireShared | 尝试在给定时间获得共享锁 |
void | acquireShared | 获得共享锁(封装了tryAcquireShared) |
void | acquireSharedInterruptibly | 不断尝试获得共享锁 |
解锁相关:
和加锁一样,try起头的方法里面都是抛异常,都需要子类去重写。
返回值 | 方法名 | 方法描述( *需要重写 ) |
---|---|---|
boolean | tryRelease | 尝试释放锁* |
boolean | tryReleaseShared | 尝试释放共享锁* |
boolean | release | 尝试释放锁(封装了tryRelease) |
boolean | releaseShared | 尝试释放共享锁(封装了tryReleaseShared) |
维护队列相关:
这部分的方法一般用于监控队列时使用,比如线程执行缓慢,可以查看等待队列是否有问题。
返回值 | 方法名 | 方法描述 |
---|---|---|
boolean | hasQueuedThreads | 是否有线程在排队 |
Thread | getFirstQueuedThread | 获取第一个排队线程 |
boolean | isQueued(Thread) | 判断线程是否在队列中 |
boolean | hasQueuedPredecessors | 判断一个线程前面是否有线程在排队 |
int | getQueueLength | 获取等待队列的长度 |
Collection | getQueuedThreads | 获取所有等待队列的线程的集合 |
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);
}
今天因为电脑的原因,只能直接上大神的博客链接了,以后会借鉴大神的经验来完善我的博客的。这三个博客的作者依旧是大神: 大将黄猿。 每一篇都是精品,看完深有启发。