文章目录
本文第一章基于 Java并发之AQS详解对队列同步器AQS进行深入学习,是用来构建锁或者其他同步组件的基础框架,它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。
第二章学习重入锁ReentrantLock
第三章介绍读写锁,读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和写线程均被阻塞。
一. AQS 原理
关于AQS 原理,这篇文章 Java并发之AQS详解讲的很透彻,我也是认真看了几遍,这节只对这篇博客做一些笔记。
AbstractQueuedSynchronizer(AQS)
抽象的队列式的同步器,AQS定义了一套多线程访问共享资源的同步器框架,许多同步类实现都依赖于它,如常用的ReentrantLock/Semaphore/CountDownLatch.等。
1.1 addWaiter()是如何保证多线程运行下入队操作的正确性?
对应博客中的3.1.2
addWaiter() 源码:
private Node addWaiter(Node mode) {
//以给定模式构造结点。mode有两种:EXCLUSIVE(独占)和SHARED(共享)
Node node = new Node(Thread.currentThread(), mode);
//尝试快速方式直接放到队尾。
Node pred = tail;
if (pred != null) {
node.prev = pred;
//将新建的node加入到队尾
if (compareAndSetTail(pred, node)) {
//调用CAS(CompareAndSet)重新设置tail
pred.next = node;
return node;
}
}
//上一步失败则通过enq入队。
enq(node);
return node;
}
可以看出,实现正确性的关键在于原子性方法compareAndSetTail()。
compareAndSetTail(pred, node)会比较pred和tail是否指向同一个节点,如果是,才将tail更新为node。
为何不是直接赋值,而要多做一步比较操作呢?那是因为虽然当前线程在声明pred时,为pred赋值了tail,但tail可能会被其他线程改变,而当前线程的本地变量pred是不会感知到这个改变的。
入队的同步关键在于原子性的compareAndSetTail()方法。它保证了每个线程能够完整的执行下面两个操作:
- 设置prev,将自己链接到队尾;
- 将tail更新为自己。
这使得队列中的tail和prev指针总是可靠的,用户在任何时候都可以使用tail和prev去访问队列。
1.2 enq(Node) 的CAS自旋volatile变量
此部分内容引自 AQS的原理浅析
enq(Node)源码:
private Node enq(final Node node) {
//CAS"自旋",直到成功加入队尾
for (;;) {
Node t = tail;
if (t == null) {
// 队列为空,创建一个空的标志结点作为head结点,并将tail也指向它。
Node h = new Node();
h.next = node;
node.prev = h;
if (compareAndSetHead(h))
tail = node;
return h;
} else {//正常流程,放入队尾
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
首先这个是一个死循环,而且本身没有锁,因此可以有多个线程进来,假如某个线程进入方法,此时head、tail都是null,自然会进入if(t == null)所在的代码区域,这部分代码会创建一个Node出来名字叫h,这个Node没有像开始那样给予类型和线程,很明显是一个空的Node对象,而传入的Node对象首先被它的next引用所指向,此时传入的node和某一个线程创建的h对象如下图所示。
{% asset_img 1.jpg ConcurrentHashMap %}
刚才我们很理想的认为只有一个线程会出现这种情况,如果有多个线程并发进入这个if判定区域,可能就会同时存在多个这样的数据结构,在各自形成数据结构后,多个线程都会去做compareAndSetHead(h)的动作,也就是尝试将这个临时h节点设置为head,显然并发时只有一个线程会成功,因此成功的那个线程会执行tail = node的操作,整个AQS的链表就成为:
{% asset_img 2.jpg ConcurrentHashMap %}
有一个线程会成功修改head和tail的值,其它的线程会继续循环,再次循环就不会进入if (t == null)的逻辑了,而会进入else语句的逻辑中。
else逻辑和 addWaiter() 是一样的。
1.3 acquire(int)方法总结
此方法是独占模式下线程获取共享资源的顶层入口。如果获取到资源,线程直接返回,否则进入等待队列,直到获取到资源为止,且整个过程忽略中断的影响。这也正是lock()的语义,当然不仅仅只限于lock()。获取到资源后,线程就可以去执行其临界区代码了。下面是acquire()的源码:
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
流程:
- 调用自定义同步器的tryAcquire()尝试直接去获取资源,如果成功则直接返回;
- 没成功,则addWaiter()将该线程加入等待队列的尾部,并标记为独占模式;
- acquireQueued()使线程在等待队列中休息,有机会时(轮到自己,会被unpark())会去尝试获取资源。获取到资源后才返回。如果在整个等待过程中被中断过,则返回true,否则返回false。
- 如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断selfInterrupt(),将中断补上。
{% asset_img 3.png ConcurrentHashMap %}
1.4 release(int) 方法总结
上一小节已经把acquire()说完了,这一小节就来讲讲它的反操作release()吧。此方法是独占模式下线程释放共享资源的顶层入口。它会释放指定量的资源,如果彻底释放了(即state=0),它会唤醒等待队列里的其他线程来获取资源。这也正是unlock()的语义,当然不仅仅只限于unlock()。下面是release()的源码:
1 public final boolean release(int arg) {
2 if (tryRelease(arg)) {
3 Node h = head;//找到头结点
4 if (h != null && h.waitStatus != 0)
5 unparkSuccessor(h);//唤醒等待队列里的下一个线程
6 return true;
7 }
8 return false;
9 }
1.5 AQS应用注意点
不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。自定义同步器实现时主要实现以下几种方法:
isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。
同步类在实现时一般都将自定义同步器(sync)定义为内部类,供自己使用;而同步类自己(Mutex)则实现某个接口,对外服务。当然,接口的实现要直接依赖sync,它们在语义上也存在某种对应关系!!而sync只用实现资源state的获取-释放方式tryAcquire-tryRelelase,至于线程的排队、等待、唤醒等,上层的AQS都已经实现好了,我们不用关心。
二. 重入锁 ReentrantLock
ReentrantLock 支持线程对资源的重复加锁,同时,该锁还支持获取锁的公平性和非公平性选择。(公平锁即等待时间最长的线程优先获得锁)
2.1 互斥锁 Mutex
在 Java并发之AQS详解的文末,介绍了互斥锁Mutex的源码,当一个线程调用lock()方法获取锁之后,如果在调用lock()方法,将会使得线程阻塞,这是因为第二次lock()方法在调用tryAcquire()时,会返回false,导致线程阻塞。
class Mutex implements Lock, java.io.Serializable {
// 自定义同步器
private static class Sync extends AbstractQueuedSynchronizer {
// 判断是否锁定状态
protected boolean isHeldExclusively() {
return getState() == 1;
}
// 尝试获取资源,立即返回。成功则返回true,否则false。
public boolean tryAcquire(int acquires) {
assert acquires == 1; // 这里限定只能为1个量
if (compareAndSetState(0, 1)) {//state为0才设置为1,不可重入!
//设置为当前线程独占资源
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
// 尝试释放资源,立即返回。成功则为true,否则false。
protected boolean tryRelease(int releases) {
assert releases == 1; // 限定为1个量
//既然来释放,那肯定就是已占有状态了。只是为了保险,多层判断!
if (getState() == 0)
throw new IllegalMonitorStateException();
setExclusiveOwnerThread(null);
setState(0);//释放资源,放弃占有状态
return true;
}
}
// 真正同步类的实现都依赖继承于AQS的自定义同步器!
private final Sync sync = new Sync();
//lock<-->acquire。两者语义一样:获取资源,即便等待,直到成功才返回。
public void lock() {
sync.acquire(1);
}
//tryLock<-->tryAcquire。两者语义一样:尝试获取资源,要求立即返回。成功则为true,失败则为false。
public boolean tryLock() {
return sync.tryAcquire(1);
}
//unlock<-->release。两者语文一样:释放资源。
public void unlock() {
sync.release(1);
}
//锁是否占有状态
public boolean isLocked() {
return sync.isHeldExclusively();
}
}
2.2 ReentrantLock 重进入的源码分析(非公平性)
通过组合自定义同步容器实现锁的获取与释放。
- 尝试获取资源的 nonfairTryAcquire()
final boolean nonfairTryAcquire(int acquires) { //acquires 其实就是1
final Thread current = Thread.currentThread();
int c = getState(); // 同步状态值
if (c == 0) { // 当c=0 时,表示当前线程第一次获取锁,0表示资源未被锁定
if (compareAndSetState(0, acquires)) {
//当 c= state =0 时,才表示当前线程可以占用该资源
setExclusiveOwnerThread(current); //设置资源的持有者为该线程
return true;
}
}
//当获取锁的线程再次请求时,将同步状态值进行增加并返回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;
}
该方法增加了再次获取同步状态的处理逻辑:通过判断当前线程是否为获取锁的线程来
决定获取操作是否成功,如果是获取锁的线程再次请求,则将同步状态值进行增加并返回
true,表示获取同步状态成功。
- 释放资源的 tryRelease()
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
如果该锁被获取了n次,那么前(n-1)次tryRelease(int releases)方法必须返回false,而只有同
步状态完全释放了,才能返回true。可以看到,该方法将同步状态是否为0作为最终释放的条
件,当同步状态为0时,将占有线程设置为null,并返回true,表示释放成功。
2.3 公平与非公平获取锁的源码分析
如果一个锁是公平的,那么锁的获取顺序就应该符合请求的绝对时间顺序,即FIFO(这种公平性通常效率不高)
- 公平性的尝试获取资源的 tryAcquire()
final boolean nonfairTryAcquire(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) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
该方法与nonfairTryAcquire(int acquires)比较,唯一不同的位置为判断条件多了hasQueuedPredecessors()方法,即加入了同步队列中当前节点是否有前驱节点的判断,如果该方法返回true,则表示有线程比当前线程更早地请求获取锁,因此需要等待前驱线程获取并释放锁之后才能继续获取锁。
2.4 重入锁的相关性质与应用
-
与synchronized的区别
可重入性:
两者的锁都是可重入的,差别不大,有线程进入锁,计数器自增1,等下降为0时才可以释放锁
锁的实现:
synchronized是基于JVM实现的(用户很难见到,无法了解其实现),ReentrantLock是JDK实现的。
性能区别:
在最初的时候,二者的性能差别差很多,当synchronized引入了偏向锁、轻量级锁(自选锁)后,二者的性能差别不大,官方推荐synchronized(写法更容易、在优化时其实是借用了ReentrantLock的CAS技术,试图在用户态就把问题解决,避免进入内核态造成线程阻塞)
功能区别:
(1)便利性:synchronized更便利,它是由编译器保证加锁与释放。ReentrantLock是需要手动释放锁,所以为了避免忘记手工释放锁造成死锁,所以最好在finally中声明释放锁。
(2)锁的细粒度和灵活度,ReentrantLock优于synchronized -
重入锁独有的功能
- 可以指定是公平锁还是非公平锁,sync只能是非公平锁。(所谓公平锁就是先等待的线程先获得锁)
- 提供了一个Condition类,可以分组唤醒需要唤醒的线程。不像是synchronized要么随机唤醒一个线程,要么全部唤醒
- 提供能够中断等待锁的线程的机制,通过lock.lockInterruptibly()实现,这种机制 ReentrantLock是一种自选锁,通过循环调用CAS操作来实现加锁。性能比较好的原因是避免了进入内核态的阻塞状态。
- 使用ReentrantLock
//创建锁:使用Lock对象声明,使用ReentrantLock接口创建
private final static Lock lock = new ReentrantLock();
//使用锁:在需要被加锁的方法中使用
private static void add() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
2.5 重入锁的使用之 condition
以下内容引自慕课网实战·高并发探索(十二):并发容器J.U.C – AQS组件 锁:ReentrantLock、ReentrantReadWriteLock、StempedLock
Condition可以非常灵活的操作线程的唤醒,下面是一个线程等待与唤醒的例子,其中用1234序号标出了日志输出顺序
public static void main(String[] args) {
ReentrantLock reentrantLock = new ReentrantLock();
Condition condition = reentrantLock.newCondition();//创建condition
//线程1
new Thread(() -> {
try {
reentrantLock.lock();
log.info("wait signal"); // 1
//使获取锁的线程进入等待队列,必须先执行lock()方法获得锁
condition.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
log.info("get signal"); // 4
reentrantLock.unlock();
}).start();
//线程2
new Thread(() -> {
reentrantLock.lock();
log.info("get lock"); // 2
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//唤醒所有等待的线程,但是由于还未释放锁,其他线程还不能得到资源。
condition.signalAll();//发送信号
log.info("send signal"); // 3
reentrantLock.unlock();
}).start();
}
输出过程讲解:
1、线程1调用了reentrantLock.lock(),线程进入AQS等待队列,输出1号log
2、接着调用了awiat方法,线程从AQS队列中移除,锁释放,直接加入condition的等待队列中
3、线程2因为线程1释放了锁,拿到了锁,输出2号log 4、线程2执行condition.signalAll()发送信号,输出3号log
5、condition队列中线程1的节点接收到信号,从condition队列中拿出来放入到了AQS的等待队列,这时线程1并没有被唤醒。
6、线程2调用unlock释放锁,因为AQS队列中只有线程1,因此AQS释放锁按照从头到尾的顺序,唤醒线程1
7、线程1继续执行,输出4号log,并进行unlock操作。
三. 读写锁
之前提到的锁(Mutex和 ReentrantLock)都是排他锁,这些锁在同一时刻只允许一个线程进行访问。而读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和写线程均被阻塞。
“读取锁” 用于只读操作,它是“共享锁”,能同时被多个线程获取。
“写入锁”用于写入操作,它是“独占锁”,写入锁只能被一个线程锁获取。
注意:不能同时存在读取锁和写入锁! #E91E63
ReadWriteLock是一个接口。ReentrantReadWriteLock是它的实现类,ReentrantReadWriteLock包括子类ReadLock和WriteLock。
备注:在没有任何读写锁的时候才可以取得写入锁(悲观读取,容易写线程饥饿),也就是说如果一直存在读操作,那么写锁一直在等待没有读的情况出现,这样我的写锁就永远也获取不到,就会造成等待获取写锁的线程饥饿。平时使用的场景并不多。
3.1 读写锁的接口与示例
以下部分内容引自 《java并发编程的艺术》
- ReadWriteLock接口简单说明
ReadWriteLock接口只定义了两个方法:
// 返回用于读取操作的锁。
Lock readLock()
// 返回用于写入操作的锁。
Lock writeLock()
通过调用相应方法获取读锁或写锁,获取的读锁及写锁都是Lock接口的实现,可以如同使用Lock接口一样使用。
- 读写锁的示例
public class Cache {
private static final Map<String, Object> map = new HashMap<String, Object>();
private static final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock(); // 读写锁
private static final Lock r = rwl.readLock(); //读锁
private static final Lock w = rwl.writeLock(); //写锁
// 读操作 用读锁
public static final Object get(String key) {
r.lock();
try {
return map.get(key);
} finally {
r.unlock();
}
}
// 写操作,用写锁,在获取写锁后,所有的读线程和其他的写线程均被阻塞,防止读入脏数据
public static final Object put(String key, Object value) {
w.lock();
try {
return map.put(key, value);
} finally {
w.unlock();
}
}
// clear 操作同时更新 HashMap ,也需要获取写锁
public static final void clear() {
w.lock();
try {
map.clear();
} finally {
w.unlock();
}
}
}
上述示例中,Cache组合一个非线程安全的HashMap作为缓存的实现,同时使用读写锁的读锁和写锁来保证Cache是线程安全的。在读操作get(String key)方法中,需要获取读锁,这使得并发访问该方法时不会被阻塞。写操作put(String key,Object value)方法和clear()方法,在更新HashMap时必须提前获取写锁,当获取写锁后,其他线程对于读锁和写锁的获取均被阻塞,而只有写锁被释放之后,其他读写操作才能继续。
参考: