AQS同步器

AQS(AbstractQueuedSynchronizer)是Java并发编程中的一个核心组件,用于构建锁和同步器。AQS使用CLH队列作为内部数据结构,实现线程的等待和唤醒。其提供了独占式和共享式的锁获取和释放方式,并支持线程中断。文章详细分析了AQS的获取和释放锁的源码,包括Node内部类、独占式和共享式的获取与释放过程,以及线程中断的处理机制。
摘要由CSDN通过智能技术生成

AQS(同步器)

AQS是AbstractQueuedSynchronizer的简称,是一个用来构建锁和同步器的框架。

原理概述

AQS核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。

CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS是将每条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node)来实现锁的分配。

AQS底层实现

AQS的底层数据结构是一个先进先出(FIFO)双向队列,队列的头节点为持有锁的线程,后续节点为等待获取锁的线程。通过volatile + CAS自旋 + lockSupport park + unsafe来保证高效性、原子性、可见性。

AQS基本简介

AQS提供两种锁,共享锁排它锁

核心变量

AQS中主要有三个核心变量

private transient volatile Node head;	// 头节点
private transient volatile Node tail;	// 尾节点
private volatile int state;		// 共享资源状态

暴露的自定义方法

isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。

Node静态内部类

在了解AQS之前我们先来了解下Node内部类,这个类是AQS等待队列中的节点包装类。

static final class Node {
    //表示共享模式(共享锁)
    static final Node SHARED = new Node();
    //表示独占模式(独占锁)
    static final Node EXCLUSIVE = null;

    //表示线程已取消
    static final int CANCELLED =  1;
    //表示当前结点的后继节点需要被唤醒
    static final int SIGNAL    = -1;
    //线程(处在Condition休眠状态)在等待Condition唤醒
    static final int CONDITION = -2;
    //表示锁的下一次获取可以无条件传播,在共享模式头结点有可能处于这种状态,传播共享锁
    static final int PROPAGATE = -3;

    //线程等待状态
    volatile int waitStatus;

    //当前节点的前一个节点
    volatile Node prev;

    //当前节点的下一个节点
    volatile Node next;

    //当前节点所代表的的线程
    volatile Thread thread;

    //可以理解为当前是独占模式还是共享模式
    Node nextWaiter;

    //如果节点在共享模式下等待,则返回true。
    final boolean isShared() {
        return nextWaiter == SHARED;
    }
    //获取前一个节点
    final Node predecessor() throws NullPointerException {
        Node p = prev;
        if (p == null)
            throw new NullPointerException();
        else
            return p;
    }
    ...
}

独占式

获取锁源码分析

acquire源码分析

public final void acquire(int arg) {
	// 尝试获取锁,获取锁失败则会调用acquireQueued方法加入队列去排队,如果获取锁成功!tryAcquire(arg) = false直接就跳出if条件了。
	// 注意tryAcquire前面的“!”
	// 如果获取不到锁,即tryAcquire返回false,才会执行if表达式后面的方法,将线程插入队列
	if (!tryAcquire(arg) &&
		acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
		selfInterrupt();
}

addWaiter(Node.EXCLUSIVE)源码分析

将节点入队,如果队列未初始化就初始化队列

// 当前线程获取锁失败,将其插入到队列中
private Node addWaiter(Node mode) {
	// 把当前线程封装成一个节点
	Node node = new Node(Thread.currentThread(), mode);
	Node pred = tail;
	// 如果尾节点不为空,表明队列已经被初始化,将上面创建的节点插入到队列尾部。
	if (pred != null) {
		node.prev = pred;
		if (compareAndSetTail(pred, node)) {
			pred.next = node;
			return node;
		}
	}
	// 执行到此处表明队列未初始化,所以初始化队列
	enq(node);
	return node;
}

private Node enq(final Node node) {
	// 死循环,因为CAS原子操作可能会失败,所以通过死循环不断自旋知道成功为止。
	for (;;) {
		Node t = tail;
		// 第一次循环,队列未初始化,头节点和尾节点肯定为null,所以创建一个空的头节点,并把头节点赋值给尾节点。
		// 注意第一次循环执行完并且CAS操作成功后并不会退出,只有当第二次或N次循环将当前线程插入到空的头节后面才会退出循环,此时队列才初始化完成。
		if (t == null) { // Must initialize
			if (compareAndSetHead(new Node()))
				tail = head;
		} else {
			// 第二次或N次循环,将node节点插入到空头节点后面,CAS操作成功后跳出循环。
			node.prev = t;
			if (compareAndSetTail(t, node)) {
				t.next = node;
				return t;
			}
		}
	}
}

在上面源码解析中我们说到了几个重要的点,下面我们一个个来分析。

1、enq初始化队列为什么要死循环?
因为我们初始化队列,修改头节点或尾节点的值时都是用的CAS进行修改的,CAS操作在被其它线程修改了head或tail节点的值时,会不执行,所以我们需要不停的循环直到CAS操作成功为止。从这点我们也可以看出初始化队列在竞争激烈的情况下可能需要循环多次才能初始化完成。

2、为什么队列的头节点是空的(此处空的指的是Node节点中线程的值为空)?
注意在AQS队列中的,队列的头节点表示的是获取到锁的线程,而不是正在等待获取锁的线程,因此头节点是空的,这点需要特别注意。队列中在头节点之后的才是等待获取锁的线程。

3、为什么要自旋两次?
上面我们说了头节点是空节点,之后头节点之后的节点才是表示等待获取锁的线程,因此在初始化队列的时候,第一次自旋是创建头节点(即正持有锁的线程),第二次自旋才是将当前线程的节点插入到队列中。

acquireQueued源码分析

// 等待队列中的线程尝试去获取锁
final boolean acquireQueued(final Node node, int arg) {
	boolean failed = true;
	try {
		boolean interrupted = false;
		// 死循环(自旋)
		for (;;) {
			// 获取当前node的前一个node
			final Node p = node.predecessor();
			// 第一个if分为两种情况,1、前一个节点是头节点;2、前一个节点不是头节点
            // 如果前一个节点是头节点就尝试获取锁,我们前面说过头节点是正在持有锁的线程,所以当前线程(头节点后的那个节点)是第一个队列中第一个等待获取锁的线程
            // 我们不知道持有锁的线程(头节点)是否已经释放锁,因此尝试去获取锁,如果获取成功就前一个节点已经执行完并释放了锁,当前节点不需要排队。
			if (p == head && tryAcquire(arg)) {
                // 获取锁成功将当前节点设置为头节点,并将线程设置为空。
				setHead(node);
				p.next = null; // help GC
				failed = false;
				return interrupted;
			}
            // 前一个节点不是头节点,或者获取锁失败,则当前节点需要排队等待锁,将当前节点的线程阻塞
            // 只有shouldParkAfterFailedAcquire返回true,表示前一个节点的状态改为-1了,才能执行parkAndCheckInterrupt去真正的阻塞当前线程
			if (shouldParkAfterFailedAcquire(p, node) &&
				parkAndCheckInterrupt())
				interrupted = true;
		}
	} finally {
        // failed等于false表示当前线程被删除。
		if (failed)
            // 从等待队列中删除获取请求
			cancelAcquire(node);
	}
}

在上面源码解析中我们说到了几个重要的点,下面我们一个个来分析。

1、为什么判断下当前的节点前一个节点是不是头节点,如果是则尝试获取下锁?
这里我们需要注意的是,队列中的头节点并不是等待获取锁的节点,头节点表示的是正在持有锁的节点,第二个节点开始才是等待节点。这里至于为什么要尝试获取下锁,因为我们不知道头节点是否已经释放了锁,所以尝试下去获取下锁,如果获取到了,就不需要排队等候。

2、为什么阻塞线程的时候是需要去改变上一个节点的状态?
因为我们只有在确定自己park(阻塞)了,才能改变自己的状态(只有这样才能保证安全),而我们是没办法在自己阻塞了之后在改变自己的状态的,所以我们需要后一个节点去改变上一个节点的状态,改变成功后在阻塞当前节点的线程。

shouldParkAfterFailedAcquire的源码分析

// 暂停线程获取锁失败后
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    // 注意这里比较的是当前节点前一个节点的状态
    int ws = pred.waitStatus;
    // 如果前一个节点的状态等于-1表示前一个节点的线程已经阻塞成功,返回。
    if (ws == Node.SIGNAL)
        return true;
    // 如果前一个节点的状态大于0,表示前一个节点的线程已经被删除,将其从等待队列中删除。
    if (ws > 0) {
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        // 前一个节点的状态等于0,则更新前一个节点的状态。
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

释放锁源码分析

release源码分析

public final boolean release(int arg) {
    // 尝试释放锁
    if (tryRelease(arg)) {
        Node h = head;
        // 如果尝试释放锁成功,并且头节点不为空且状态不等于0,则唤醒后继节点。
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

unparkSuccessor源码分析

private void unparkSuccessor(Node node) {
    
    int ws = node.waitStatus;
    // 如果节点的状态小于0,则修改节点的状态为0,为什么需要这样做,因为我们前面在获取锁的时候,后续等待节点会将前一个节点的状态改为-1
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);

    Node s = node.next;
    // 如果当前节点的下一个节点为空或者状态大于0(表示线程已删除),则for循环找到队列中排在第一个的节点(状态小于等于0)
    if (s == null || s.waitStatus > 0) {
        s = null;
        for (Node t = tail; t != null && t != node; t = t.prev)
            if (t.waitStatus <= 0)
                s = t;
    }
    if (s != null)
        // 唤醒节点
        LockSupport.unpark(s.thread);
}

共享式

获取锁原码分析

public final void acquireShared(int arg) {
    // tryAcquireShared返回值有三种情况,1、小于0,表示获取锁失败;2、等于0,表示成功但是没有剩余资源可用;3、大于0,表示成功并有剩余资源可用。
    if (tryAcquireShared(arg) < 0)
        // 如果获取共享锁失败就将线程加入队列
        doAcquireShared(arg);
}

doAcquireShared源码分析

private void doAcquireShared(int arg) {
    	// 添加一个共享状态的节点到队列中
        final Node node = addWaiter(Node.SHARED);
        boolean failed = true;
        try {
            boolean interrupted = false;
            // 死循环
            for (;;) {
                // 获取前一个节点
                final Node p = node.predecessor();
                // 如果前一个节点是头节点,就尝试获取锁,返回值大于等于0,表示成功,将当前节点设置为头节点,如果还有可用资源继续广播下去
                // 这里需要注意的是只有前一个节点是头节点才能有机会尝试获取锁,毕竟如果前面的线程都没有获取锁,后面的线程怎么能够比前面的线程先获取锁呢。
                if (p == head) {
                    int r = tryAcquireShared(arg);
                    if (r >= 0) {
                        // 将当前节点设置为头节点,如果还有可用资源继续广播下去
                        setHeadAndPropagate(node, r);
                        p.next = null; // help GC
                        if (interrupted)
                            selfInterrupt();
                        failed = false;
                        return;
                    }
                }
                // 这里和上面独占锁代码相同就不再复述
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

共享锁的获取和独占锁的获取逻辑大致相同,只不过共享模式,可能会有多个线程同时获取到锁,也可能同时释放线程,空出很多可用资源,因此当排队中的老二节点获取到锁后,如果还有可用资源会继续传播下去。

private void setHeadAndPropagate(Node node, int propagate) {
    Node h = head; // Record old head for check below
    setHead(node);
    // 可用资源大于0,原头节点为空,原头节点状态小于0,新头节点为空,新头节点状态小于0这些都会释放锁
    if (propagate > 0 || h == null || h.waitStatus < 0 ||
        (h = head) == null || h.waitStatus < 0) {
        Node s = node.next;
        if (s == null || s.isShared())
            // 这里是释放锁,下面再详细分析
            doReleaseShared();
    }
}

释放锁源码分析

public final boolean releaseShared(int arg) {
    // 尝试释放锁,如果返回大于0,表示释放锁成功,则开始真正的释放锁操作。
    if (tryReleaseShared(arg)) {
        doReleaseShared();
        return true;
    }
    return false;
}

doReleaseShared源码分析

private void doReleaseShared() {
    // 死循环
    for (;;) {
        Node h = head;
        // 头节点不为空并且不等于尾节点,说明队列中还有再等待获取锁的节点
        if (h != null && h != tail) {
            int ws = h.waitStatus;
            // 如果头节点的状态等于-1,则修改节点的状态为0,为什么这样做,因为我们前面在获取锁的时候,后续等待节点会将前一个节点的状态改为-1
            // 修改成功则唤醒头节点的后续的等待节点
            if (ws == Node.SIGNAL) {
                if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                    continue;            // loop to recheck cases
                unparkSuccessor(h);
            }
            // 如果头节点的状态等于0,则修改节点的状态为-3。
            // 头节点的状态等于0,说明队列中没有等待节点,则表示有剩余的可用资源,所以状态应该改为广播状态
            else if (ws == 0 &&
                     !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                continue;                // loop on failed CAS
        }
        // 如果上述条件不满足且队列没有发生改变则跳出循环
        if (h == head)                   // loop if head changed
            break;
    }
}

AQS中的线程中断

前面我们就AQS的源码进行了详细的分析,基本上都讲到了,但是对于线程中断这种情况没有讲到,下面我们来分析线程中断的情况。我们以独占式锁的源码为例进行分析。

首先我们看到acquireQueued方法,在方法中定义了一个interrupted变量默认为false,该方法最后将interrupted的值返回。

final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            final Node p = node.predecessor();
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            // 1、此处我们只需要关注parkAndCheckInterrupt方法,该方法在阻塞线程被唤醒后,
            // 会返回线程是否被中断,线程已中断返回true,此时将interrupted设置为true,未中断返回false。
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

接着我们再来看acquireQueued的上层方法,当acquireQueued返回true的时候,即表示线程被终止,调用selfInterrupt方法再次打断线程,为什么要这么做呢?因为在parkAndCheckInterrupt方法中调用了Thread.interrupted()方法,该方法会将线程的中断标志重置,此时线程的中断标志为false,显然是不对的,所以这里需要再次打断线程,重新设置中断标志。

public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

参考博客:

https://www.cnblogs.com/chengxiao/archive/2017/07/24/7141160.html#a3-3

https://blog.csdn.net/java_lyvee/article/details/98966684

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值