Reentrantlock和AQS的原理及应用

1,079 阅读6分钟

特性

Reentantlock是lock的实现类,他的特性如下 reentrantlock的锁机制实现依赖于aqs,所以我们主要讲解aqs

cas机制

cas也就是乐观锁,是一种异步策略。cas机制当中使用了三个基本操作数:

  • 内存地址 V
  • 旧的预期值 A
  • 要修改的新值B

要更新一个变量时,只有当内存地址v当中的实际值和预期值a相同的时候,才会将内存地址v对应的值修改为b。而这个比较的过程会不断的进行遍历操作,也就是自旋

cas的缺点

  • cpu开销大

  • 不能保证代码块的原子性,cas机制所能保证的只是一个变的原子性操作,而不能保证整个代码块的原子性。比如保证三个变量共同进行原子性的更新,就不得不使用synchronized了

  • ABA问题 aba问题可以用一个举例来了解

    ABA问题带来的危害: 小明在提款机,提取了50元,因为提款机问题,有两个线程,同时把余额从100变为50 线程1(提款机):获取当前值100,期望更新为50, 线程2(提款机):获取当前值100,期望更新为50, 线程1成功执行,线程2某种原因block了,这时,某人给小明汇款50 线程3(默认):获取当前值50,期望更新为100, 这时候线程3成功执行,余额变为100, 线程2从Block中恢复,获取到的也是100,compare之后,继续更新余额为50!!! 此时可以看到,实际余额应该为100(100-50+50),但是实际上变为了50(100-50+50-50)这就是ABA问题带来的成功提交。 解决方法: 在变量前面加上版本号,每次变量更新的时候变量的版本号都+1,即A->B->A就变成了1A->2B->3A。

Reentrantlock与aqs的联系

static final class NonfairSync extends Sync {
	...
	final void lock() {
		if (compareAndSetState(0, 1))
			setExclusiveOwnerThread(Thread.currentThread());
		else
			acquire(1);
		}
  ...
}

这是一个非公平锁的源码,在加锁过程中的处理策略,可以看到如果通过cas设置变量state(同步状态)成功,也就是获取锁成功,则将当前线程设置为独占锁。而如果通过cas设置变量失败,也就是获取锁失败,则进入aqs处理流程也就是acquire()方法image

通过ReentrantLock理解AQS

线程加入等待队列

在执行acquire之后,通过tryacquire获得锁。在这种情况下,如果获取锁失效,就会调用addwaiter加入到等待队列当中去。那么如何加入到队列里呢?

private Node addWaiter(Node mode) {
	Node node = new Node(Thread.currentThread(), mode);
	// Try the fast path of enq; backup to full enq on failure
	Node pred = tail;
	if (pred != null) {
		node.prev = pred;
		if (compareAndSetTail(pred, node)) {
			pred.next = node;
			return node;
		}
	}
	enq(node);
	return node;
}
private final boolean compareAndSetTail(Node expect, Node update) {
	return unsafe.compareAndSwapObject(this, tailOffset, expect, update);
}

虽然这是一个双向链表,采用的仍是尾插法。在这个链表中,head是一个固定的null值,在插入进来之后,尾指针向后移动

等待队列中的线程出队列的时机

final boolean acquireQueued(final Node node, int arg) {
	// 标记是否成功拿到资源
	boolean failed = true;
	try {
		// 标记等待过程中是否中断过
		boolean interrupted = false;
		// 开始自旋,要么获取锁,要么中断
		for (;;) {
			// 获取当前节点的前驱节点
			final Node p = node.predecessor();
			// 如果p是头结点,说明当前节点在真实数据队列的首部,就尝试获取锁(别忘了头结点是虚节点)
			if (p == head && tryAcquire(arg)) {
				// 获取锁成功,头指针移动到当前node
				setHead(node);
				p.next = null; // help GC
				failed = false;
				return interrupted;
			}
			// 说明p为头节点且当前没有获取到锁(可能是非公平锁被抢占了)或者是p不为头结点,这个时候就要判断当前node是否要被阻塞(被阻塞条件:前驱节点的waitStatus为-1),防止无限循环浪费资源。具体两个方法下面细细分析
			if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
				interrupted = true;
		}
	} finally {
		if (failed)
			cancelAcquire(node);
	}
}

image 从上面可以知道,跳出循环的条件是必须位于头节点之后且当前node没有被阻塞

中断取消

在上面那一串代码中,最后会finally执行的是

finally {
		if (failed)
			cancelAcquire(node);
	}

我们详细看一下cancelAcquire()代码

private void cancelAcquire(Node node) {
  // 将无效节点过滤
	if (node == null)
		return;
  // 设置该节点不关联任何线程,也就是虚节点
	node.thread = null;
	Node pred = node.prev;
  // 通过前驱节点,跳过取消状态的node
	while (pred.waitStatus > 0)
		node.prev = pred = pred.prev;
  // 获取过滤后的前驱节点的后继节点
	Node predNext = pred.next;
  // 把当前node的状态设置为CANCELLED
	node.waitStatus = Node.CANCELLED;
  // 如果当前节点是尾节点,将从后往前的第一个非取消状态的节点设置为尾节点
  // 更新失败的话,则进入else,如果更新成功,将tail的后继节点设置为null
	if (node == tail && compareAndSetTail(node, pred)) {
		compareAndSetNext(pred, predNext, null);
	} else {
		int ws;
    // 如果当前节点不是head的后继节点,1:判断当前节点前驱节点的是否为SIGNAL,2:如果不是,则把前驱节点设置为SINGAL看是否成功
    // 如果1和2中有一个为true,再判断当前节点的线程是否为null
    // 如果上述条件都满足,把当前节点的前驱节点的后继指针指向当前节点的后继节点
		if (pred != head && ((ws = pred.waitStatus) == Node.SIGNAL || (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) && pred.thread != null) {
			Node next = node.next;
			if (next != null && next.waitStatus <= 0)
				compareAndSetNext(pred, predNext, next);
		} else {
      // 如果当前节点是head的后继节点,或者上述条件不满足,那就唤醒当前节点的后继节点
			unparkSuccessor(node);
		}
		node.next = node; // help GC
	}
}

这段代码的流程是:获取当前节点的前驱节点,如果前驱节点的状态是CANCELLED,那就一直往前遍历,找到第一个waitStatus <= 0的节点,将找到的Pred节点和当前Node关联,将当前Node设置为CANCELLED。然后再根据当前节点的位置,考虑以下三种情况:

  • (1) 当前节点是尾节点。

  • (2) 当前节点是Head的后继节点。

  • (3) 当前节点不是Head的后继节点,也不是尾节点。

而在这里要说一下删除节点的规则,那便是只动next指针,不动prev指针。这是为什么呢?

原因:执行cancelAcquire的时候,当前节点的前置节点可能已经从队列中出去了(已经执行过Try代码块中的shouldParkAfterFailedAcquire方法了),如果此时修改Prev指针,有可能会导致Prev指向另一个已经移除队列的Node,因此这块变化Prev指针不安全。 shouldParkAfterFailedAcquire方法中,会执行下面的代码,其实就是在处理Prev指针。shouldParkAfterFailedAcquire是获取锁失败的情况下才会执行,进入该方法后,说明共享资源已被获取,当前节点之前的节点都不会出现变化,因此这个时候变更Prev指针比较安全。

那我们可以继续来看一下这三种情况:
image 就删除最后一个节点同时尾指针前移 image 让节点指向自己,实现中断 image 删除该节点同时指向自己实现种断

释放锁

public void unlock() {
	sync.release(1);
}

在释放锁是通过框架来实现的

public final boolean release(int arg) {
	if (tryRelease(arg)) {
		Node h = head;
		if (h != null && h.waitStatus != 0)
			unparkSuccessor(h);
		return true;
	}
	return false;
}