Java并发编程总结3——AQS、ReentrantLock、ReentrantReadWriteLock

时间:2021-08-11 22:56:11

本文内容主要总结自《Java并发编程的艺术》第5章——Java中的锁。

一、AQS

AbstractQueuedSynchronizer(简称AQS),队列同步器,是用来构建锁或者其他同步组建的基础框架。该类主要包括:

1、模式,分为共享和独占。

2、volatile int state,用来表示锁的状态。

3、FIFO双向队列,用来维护等待获取锁的线程。

AQS部分代码及说明如下:

public abstract class AbstractQueuedSynchronizer
extends AbstractOwnableSynchronizer
implements java.io.Serializable { static final class Node {
/** 共享模式,表示可以多个线程获取锁,比如读写锁中的读锁 */
static final Node SHARED = new Node();
/** 独占模式,表示同一时刻只能一个线程获取锁,比如读写锁中的写锁 */
static final Node EXCLUSIVE = null; volatile Node prev;
volatile Node next;
volatile Thread thread;
} /** AQS类内部维护一个FIFO的双向队列,负责同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等
构造成一个节点Node并加入同步队列;当同步状态释放时,会把首节点中线程唤醒,使其再次尝试同步状态 */

private transient volatile Node head;
private transient volatile Node tail; /** 状态,主要用来确定lock是否已经被占用;在ReentrantLock中,state=0表示锁空闲,>0表示锁已被占用;可以自定义,改写tryAcquire(int acquires)等方法即可 */
private volatile int state;
}

这里主要说明下双向队列,通过查看源码分析,队列是这个样子的:

head -> node1 -> node2 -> node3(tail)

注意:head初始时是一个空节点(所谓的空节点意思是节点中没有具体的线程信息),之后表示的是获取了锁的节点。因此实际上head->next(即node1)才是同步队列中第一个可用节点。

AQS的设计基于模版方法模式,使用者通过继承AQS类并重写指定的方法,可以实现不同功能的锁。可重写的方法主要包括:

Java并发编程总结3——AQS、ReentrantLock、ReentrantReadWriteLock

二、通过ReentrantLock学习AQS的使用

1、公平锁的获取

/**
* Sync object for fair locks
*/
static final class FairSync extends Sync {
private static final long serialVersionUID = -3000897897090466540L; final void lock() {
acquire(1);
} /**
* 首先尝试获取锁,如果tryAcquire(arg)返回true,获取锁成功;
* 如果失败,则调用acquireQueued(addWaiter(Node.EXCLUSIVE), arg),将当前线程封装成Node节点加入到同步队列队尾,之后阻塞当前线程
*/

public final void acquire(int arg) {
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
} /**
* 获取state的值,如果等于0表示锁空闲,可以尝试获取;
* 查看当前线程是否是FIFO队列中的第一个可用节点,如果是第一个,则尝试通过CAS方式获取锁, 这保证了等待时间最长的必定先获取锁
*/

protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0
, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
  
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
/**
* 如果发现当前节点的前一个节点为head,那么尝试获取锁,成功之后删除head节点并将自己设置为head,退出循环;
* 如果当前节点为阻塞状态,需要unpark()唤醒,release()方法会执行唤醒操作
*/
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
/**
* 为了避免无意义的自旋,同步队列中的线程会通过park(this)方法用于阻塞当前线程
*/
if (shouldParkAfterFailedAcquire(p, node) &&

parkAndCheckInterrupt())

interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
}

2、公平锁的释放

更新状态值state,之后唤醒同步队列中的第一个等待节点,unparkSuccessor(Node node)。

三、公平锁和非公平锁

ReentrantLock默认的锁为非公平锁,其主要原因在于:与公平锁相比,可以避免大量的线程切换,极大的提高性能。

先看一个非公平锁的例子:

public class AQS2 {
private ReentrantLock lock = new ReentrantLock(false);
private Thread[] threads = new Thread[3]; public AQS2() {
for (int i = 0; i < 3 ; i++) {
threads[i] = new Thread(new Runnable() {
public void run() {
for (int i = 0; i < 2; i++) {
try {
lock.lock();
Thread.sleep(100);
System.out.println(Thread.currentThread().getName());
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
});
}
} public void startThreads() {
for (Thread thread : threads) {
thread.start();
}
} public static void main(String[] args) {
AQS2 aqs2 = new AQS2();
aqs2.startThreads();
}
}

运行结果为:

Java并发编程总结3——AQS、ReentrantLock、ReentrantReadWriteLock

这段代码(每个线程2次获取锁/释放锁)的运行结果我一开始没有想清楚,之前我是这么想的:

Thread0先获取锁,之后sleep 100ms,那么等待获取锁的同步队列为:

head -> thread1 -> thread2 -> thread0 -> thread1 -> thread2。

从运行结果可知,第二次获取锁的还是thread0,但是锁的释放release(int args)却总是从同步队列的第一个可用节点开始,那就把thread1从队列中移除了,逻辑明显不对了。

后来重新看了代码,比较了非公平锁和公平锁之间的不同时,才终于明白。

非公平锁获取锁最大的不一样的地方在于:线程可以无视sync同步队列插队!一旦插队成功,获得了锁,那么该线程当然也就不用在排队了。所以以上程序的同步队列应该为:

head -> thread1 -> thread2。

非公平锁源代码主要的不同点有2点:

static final class NonfairSync extends Sync {
private static final long serialVersionUID = 7316153563782823691L; //不同点1
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
} protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
} final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) { //不同点2
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
}

thread0第一次释放锁之后,会立刻通过lock.lock()操作继续尝试获取锁。非公平锁的lock()方法会直接尝试获取锁,无视同步队列,因此很大概率会再次获得锁;如果失败了,那么执行nonfairTryAcquire(int acquires)方法,该方法和tryAcquire(int acquires)最大的不同在于,缺少了hasQueuedPredecessors()的判断,即不需要判断当前线程是否是同步队列的第一个可用节点,甚至也不需要判断当前线程是否在同步队列中,直接尝试获取锁即可。

四、ReentrantReadWriteLnock

理解了AQS的原理后,读写锁也就不难理解了。读写锁分为2个锁,读锁和写锁。读锁在同一时刻允许多个线程访问,通过改写int tryAcquireShared(int arg)以及boolean tryReleaseShared(int arg)方法即可;写锁为独占锁,通过改写boolean tryAcquire(int arg)以及boolean tryRelease(int arg)方法即可。

由于AQS中只提供了一个int state来表示锁的状态,那么如何表示读和写2个锁呢?解决办法是前16位表示读锁,后16位表示写锁。由于锁的状态只有16位,因此无论是对于读锁或者是写锁,其state最大值均为65535,即所有获得了锁的线程的拿到锁的总次数(由于是重进入锁,因此每个线程可以拿到n个锁)不超过65536。由于读写锁主要的应用场景为多读少写,所以如果感觉读锁的65535不够用,可以自己改写读写锁即可,比如分配int state的前24位为读锁,后8位为写锁。

读写锁还提供了一些新的方法,比如final int getReadHoldCount(),返回当前线程获取读锁的次数。由于读状态保存的是所有获取读锁的线程读锁次数的总和,因此每个线程自己的读锁次数需要单独保存,引入了ThreadLocal,由线程自身维护。