Java学习笔记(多线程):ConditionObject 源码分析

本文是自己的学习笔记,主要参考资料如下
JavaSE文档

1. ConditionObject的应用

1.1、park()和unpark()

ConditionObject本质上是通过这两个方法将线程挂起暂停操作和唤醒线程继续执行。在源码中可以看到这两个方法的执行。

这两个方法和wait()notify类似,但也有不同。下面是park()unpark()的特点。

  • park()挂起的线程不会自动释放锁,这点和wait()不同。
  • park()线程后,如果有其他线程执行了被挂起线程的interrupt()中断方法,那被挂起的线程会被自动唤醒。这是比较重要的特性,很多时候线程的中断都是没有实际作用的,这里不太一样。

1.1、await()和signal() ```synchronized```里提供了```wait()```和```notify```的方法来实现线程挂起和唤醒的功能。

ReentrantLock也提供了await()signal()来实现类似的事。

  • await()会挂起当前线程并释放锁。当有其他的线程再这个锁上执行了signal()方式唤醒该线程,并且该线程争取到了锁,那线程才会开始继续执行await()后面的代码。
  • signal()会唤醒一个因执行await()被挂起的线程,这里并不意味着被唤醒的线程能拿到锁,它需要竞争成功后才能拿到锁。执行signal()的线程不会释放锁资源。要通过unlock()才会释放锁。

上面的这两个方法本质是是通过park()unpark()将线程挂起暂停操作和唤醒线程继续执行。在源码中可以看到这两个方法的执行。

只有获得锁资源的线程才能执行这两个方法。而执行了await()后,被挂起的线程会自动释放锁资源。

下面来看一段代码示例来更好地理解await()signal()

1.2、代码示例

java 复制代码
public static void main(String[] args) throws InterruptedException, IOException {
    ReentrantLock lock = new ReentrantLock();
    Condition condition = lock.newCondition();

    new Thread(() -> {
        lock.lock();
        System.out.println("1.子线程获取锁资源并await挂起线程");
        try {
        	// sleep不释放锁资源
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        try {
        	// 子线程挂起,释放锁。
            condition.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("4.子线程挂起后被唤醒!持有锁资源");

    }).start();
    // 主线程沉睡,保证子线程能开始执行并获取到锁。
    Thread.sleep(100);
    // lock会阻塞挂起主线程,因为子线程已经拿到锁了。
    lock.lock();
    // 子线程因signal()释放锁,主线程拿到锁继续执行
    System.out.println("2.主线程等待5s拿到锁资源,子线程执行了await方法");
    condition.signal();
    // 前面只是唤醒子线程,但还没释放锁,所以主线程会继续打印3.
    System.out.println("3.主线程唤醒了await挂起的子线程");
    // 释放锁后子线程才能拿到锁继续执行打印4.
    lock.unlock();
}

结果会按序打印。

  1. 一开始子线程启动,并且主线程睡眠,所以子线程一定会拿到锁并打印1。
  2. 主线程执行lock()但因为锁在子线程手里所以主线程陷入阻塞。
  3. 子线程沉睡5s后执行await()主动释放锁并阻塞挂起自己。于是主线程能获取到锁继续执行打印2.
  4. 主线程执行signal()唤醒子线程,但因为主线程没有释放锁,所以子线程拿不到锁不会执行。主线程会继续打印3。
  5. 主线程释放锁之后,被唤醒的子线程拿到锁开始执行,打印4。

1.3、ConditionObject原理简介

Condition是接口,ConditionObject是其实现类。文中有时候会说Condition,有时候ConditionObject,但我指的是同一个东西。

Condition是依附于ReentrantLock存在的,提供控制想获取这个锁的线程的方法。

AQS类似,Condition内部有一个队列,但是是单向的,用于封装想获取锁的线程。

2、ConditionObject源码分析

2.1、ConditionObject的构造方法

ConditionOject实例通过下面的方式获取。

java 复制代码
ReentrantLock lock = new ReentrantLock();
Condition condition = lock.newCondition();

进入newCondition()内部。发现它只是直接new了一个ConditionObject

java 复制代码
public Condition newCondition() {
   	return sync.newCondition();
}
final ConditionObject newCondition() {
    return new ConditionObject();
}

所以一个lock可以创建出不止一个ConditionObject

在使用的时候要注意同一个锁上的不同的ConditionObject理论上是互不影响的。比如一个lock上创建了两个Condition实例,condition1signal()是不会唤醒condition2的线程。

2.2、await()的前置分析

await()方法很复杂,这里先分析前面的部分,即线程挂起前的操作。

java 复制代码
public final void await() throws InterruptedException {
   //判断中断
   if (Thread.interrupted())
        throw new InterruptedException();
    // 把封装成Node的线程放到Condition的单向队列中,中间会删除状态是Cancel的节点
    Node node = addConditionWaiter();
    // 充分释放锁资源。因为是可重入锁,用该方法将state直接变成0,同时返回重入次数保存。
    // 如果线程没有持有锁会在其中抛出异常。同时该Node的状态会置为Cancel。
    int savedState = fullyRelease(node);
    int interruptMode = 0;
    // 检查当前Node是否在AQS中,只有不在了才需要挂起当前线程。
    // 因为前面线程已经通过fullyRelease()释放了锁,所以其他在AQS中的线程是有可能立刻获取锁并执行signal()方法唤醒该线程。
    // 所谓的singal()就是从Condition的队列把一个Node拿出来放到AQS的队列中。
    // 所以如果发生了上面的情况,即当前线程在AQS中,意味着线程已经被唤醒,无需再挂起。
    while (!isOnSyncQueue(node)) {
        LockSupport.park(this);
        // 省略
    }
    // 省略
}
  1. 判断中断,如果线程中断了就抛出异常。
  2. 将线程封装成Node放入Condition的单向队列中。放入前会清除队列中状态为Cancel的节点。
  3. 将锁完全释放,status = 0
  4. 如果线程释放锁后因其他获得锁的线程执行的signal()方法唤醒,那就无需再挂起当前线程。

2.2.1、addConditionWaiter()

该方法会将封装成Node的线程加到Condition自己的单向队列队尾。在加入之前会删除队列中状态为Cancel的节点。

java 复制代码
private Node addConditionWaiter() {
    if (!isHeldExclusively())
        throw new IllegalMonitorStateException();
    // 拿到尾结点
    Node t = lastWaiter;
    // 如果尾结点不是null,说明有其他线程在队列中。
    // 状态不是Condiiton,那该节点状态不正常。
    // 所以需要执行unlinkCancelledWaiters()去除取消的节点后再把Node加入到队列中。
    if (t != null && t.waitStatus != Node.CONDITION) {
    	// 去除队列中状态是Cancel的节点
        unlinkCancelledWaiters();
        t = lastWaiter;
    }
	// 构建当前线程的Node
    Node node = new Node(Node.CONDITION);
	// 把当前线程放到队尾
    if (t == null)
        firstWaiter = node;
    else
        t.nextWaiter = node;
    lastWaiter = node;
    return node;
}
  1. 首先中断状态判断,常规操作了,中断直接抛异常。
  2. 执行unlinkCanceledWaiters(),去除状态为Cancel的节点。
  3. 最后将线程封装成Node放入队尾。

2.2.2、fullyRelease()

该方法会充分释放锁资源。因为是可重入锁,用该方法将state直接变成0,同时返回重入次数保存。

如果线程没有锁会在这里抛出异常。所以await()方法必须是线程持有锁时才能执行。这种情况下该Node不是个正常的节点,状态会被置为Cancel

java 复制代码
final int fullyRelease(Node node) {
    try {
        int savedState = getState();
		// 失败则表示当前线程不持有锁,抛出异常
        if (release(savedState))
            return savedState;
        throw new IllegalMonitorStateException();
    } catch (Throwable t) {
    	// 该节点状态变为Cancel,后续会被删除
        node.waitStatus = Node.CANCELLED;
        throw t;
    }
}

2.3、signal()方法分析

signal()方法比较简单,检查是否持有线程,然后有节点在等待的话才执行唤醒操作。

具体唤醒的逻辑在doSignal()

java 复制代码
public final void signal() {
	// 不持有锁的话就抛异常
    if (!isHeldExclusively())
        throw new IllegalMonitorStateException();
    // 拿到排队的第一个节点
    Node first = firstWaiter;
    // 有节点才需要唤醒,没有就结束。
    if (first != null)
        doSignal(first);
}

下面看看doSignal()的逻辑。

java 复制代码
private void doSignal(Node first) {
    do {
    	// 将firstWaiter指向第二个节点,因为第一个节点要被唤醒了
        if ( (firstWaiter = first.nextWaiter) == null)
            lastWaiter = null;
        first.nextWaiter = null;
    // 唤醒Node,将Condition的Node移到AQS
    } while (!transferForSignal(first) &&
             (first = firstWaiter) != null);
}
java 复制代码
final boolean transferForSignal(Node node) {
    // 需要改变Node的状态,从CONDITION改成SIGNAL
    if (!node.compareAndSetWaitStatus(Node.CONDITION, 0))
        return false;
    // enq()将node放到AQS中,这个方法返回的是node在AQS中的上一个节点
    Node p = enq(node);
    int ws = p.waitStatus;
    // 如果ws>0,那上一个节点的状态是CANCEL,
    if (ws > 0 || !p.compareAndSetWaitStatus(ws, Node.SIGNAL))
    	// 如果前置节点状态是取消,继续在这个节点后面会导致当前节点永远无法被唤醒。所以执行unpark()方法直接唤醒线程。
    	// 线程会基于acquireQueued方法再AQS中找到一个正常的前置节点挂在后面。
    	// 如果状态改变失败,那可能是并发导致的。为了防止节点无法正常唤醒,也需要直接唤醒当前线程。
        LockSupport.unpark(node.thread);
    return true;
}

所以signal()主要做了下面几件事。

  • 确保执行signal()的线程持有锁。
  • 脱离Condition队列。
  • Node状态改成Signal后插入到AQS队列。
  • 为了避免Node无法在AQS中正常唤醒做了一些判断和操作。

2.4、await()后置分析

前面的前置分析只分析到了线程挂起前的操作,即循环isOnSyncQueue()的第一句。这里会对之后的代码分析。

做完前面的操作,包括进入Condition队列和释放锁,如果没有其他线程唤醒该线程,那该线程应该执行park()方法将自己挂起。

过一段时间后线程被唤醒,park()之后的代码才能继续执行,这部分的代码比较重要,可以更好理解NodeCondition队列和AQS队列之间的转换和中断对他们的影响。

线程会因为下面三个原因被唤醒。

  1. 被其他线程的signal()唤醒,这属于正常操作,后续不需要额外做处理。
  2. 被中断唤醒。这是异常情况,需要抛出异常,还需要移除在Condition队列中的Node
  3. signal()唤醒,但是在执行unpark()之前就遇到了中断。这种情况也算是被正常唤醒,但需要做一些额外的操作。
    这时候NodeCondition队列到AQS的处理可能还没完成线程就被唤醒。那需要保证Node如预期一样从Condition脱离并顺利进入AQS。同时需要需要将中段位重置,以免后续因中段位发生错误。

while (!isOnSyncQueue(node)) {之后的代码就是围绕着这三种情况做处理。

java 复制代码
public final void await() throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
    Node node = addConditionWaiter();
    int savedState = fullyRelease(node);
    int interruptMode = 0;
    while (!isOnSyncQueue(node)) {
    	// Node不在AQS队列中,还在Condition队列中,所以需要挂起线程。
        LockSupport.park(this);
        // 上一行执行了park()方法线程已经被挂起,如果执行到了这一行说明线程被唤醒了。
        // 线程被唤醒有三种情况,对应的操作也不同。
        // 1. 被signal()唤醒,那Node应该在AQS中。一切正常可以退出循环
        // 2. 中断唤醒,Node还在Condition队列中。需要手动将Node移到AQS中。
        // 3. 被signal()唤醒,紧接着遇到中断。
        if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
            break;
    }
    // 此时Node一定在AQS中,但如果是被中断唤醒那Node也存在于Condition中
    if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
        interruptMode = REINTERRUPT;
    // 对于第二种情况,Node此时还是Condition队列中,需要从Condition队列中移除
    if (node.nextWaiter != null) // clean up if cancelled
        unlinkCancelledWaiters();
    // 如果等于0,线程再signal()以及持有锁的过程中没有被中断过。
    // 如果被中断过,第二种情况需要抛出异常,第三种情况则只需要将中段位重置即可。
    if (interruptMode != 0)
        reportInterruptAfterWait(interruptMode);
}

2.4.1、checkInterruptWhileWaiting()

需要判断被唤醒属于下面的那种情况。

  1. 被signal()唤醒,那Node应该在AQS中。一切正常可以退出循环
  2. 中断唤醒,Node还在Condition队列中。需要手动将Node移到AQS中。因为是非正常唤醒,需要抛出异常。
  3. 被signal()唤醒,紧接着遇到中断。

直接通过中断位判断,无中断则第一种情况,有中断则继续。

这里因为使用的是Thread.interrupted(),该方法读取了标志位后会重置中断标志位为false。所以对于第三种情况,如果线程后续还会执signal()或者await()方法,不需要担心这两个方法会因为中断位抛异常。

java 复制代码
private int checkInterruptWhileWaiting(Node node) {
	// 没有中断则返回0,一切正常。有中断则看具体情况。
	// 第二种情况,需要抛出异常。
	// 第三种情况,不需要抛出异常。
    return Thread.interrupted() ?
        (transferAfterCancelledWait(node) ? THROW_IE : REINTERRUPT) :
        0;
}

如果通过CAS成功将状态从Condititon换成0,那节点原来的状态是Condition,即节点仍在Condiiton的队列中。

可以推断这不属于第三种情况,所以需要手动将NodeCondition移到AQS。但是需要注意,Node还没从Condition移除。

java 复制代码
final boolean transferAfterCancelledWait(Node node) {
	//属于第二种情况,将Node从Condition移到AQS
    if (node.compareAndSetWaitStatus(Node.CONDITION, 0)) {
        enq(node);
        return true;
    }
    
    // 第三种情况下,signal()方法可能还没执行到unpark(),线程就被中断唤醒了。
    // 但这属于正常情况,所以只需要让线程一直自旋,直到Node完全从Condition移到AQS
    while (!isOnSyncQueue(node))
        Thread.yield();
    return false;
}
2.4.1.1、总结

线程执行await()后,会因park()方法被挂起。过一段时间后线程会唤醒,但需要确定被唤醒的原因。总共有三种情况。

  1. 被signal()唤醒,那Node应该在AQS中。一切正常可以退出循环
  2. 中断唤醒,Node还在Condition队列中。需要手动将Node移到AQS中。因为是非正常唤醒,需要抛出异常。
  3. 被signal()唤醒,紧接着遇到中断。本质上是正常被唤醒,但需要做一些操作以免后续有错误。

如果中断位是false那一定是第一种情况。

之后则需checkInterruptWhileWaiting()方法就是用于判断具体是第二种还是第三种。

相关推荐
chao_7893 分钟前
手撕算法(定制整理版2)
笔记·算法
醉殇姒若梦遗年1 小时前
怎么用idea打jar包
java·intellij-idea·jar
林九生1 小时前
【Docker】Docker环境下快速部署Ollama与Open-WebUI:详细指南
java·docker·eureka
灰原A1 小时前
摆脱拖延症的详细计划示例
笔记
虾球xz2 小时前
游戏引擎学习第276天:调整身体动画
c++·学习·游戏引擎
Aric_Jones2 小时前
lua入门语法,包含安装,注释,变量,循环等
java·开发语言·git·elasticsearch·junit·lua
Akiiiira2 小时前
【日撸 Java 三百行】Day 12(顺序表(二))
java·开发语言
虾球xz2 小时前
游戏引擎学习第275天:将旋转和剪切传递给渲染器
c++·学习·游戏引擎
qq_386322693 小时前
华为网路设备学习-21 IGP路由专题-路由过滤(filter-policy)
前端·网络·学习
J先生x3 小时前
【IP101】图像处理进阶:从直方图均衡化到伽马变换,全面掌握图像增强技术
图像处理·人工智能·学习·算法·计算机视觉