Java 并发编程 --ReentrantLock

用户头像
Java收录阁
关注
发布于: 2020 年 05 月 09 日

J.U.C简介

Java.util.concurrent是在并发编程中比较常用的工具类,里面包含很多用来在并发场景中使用的组件,比如线程池、阻塞队列、计时器、同步器、并发集合等等。接下来我们会了解一下经典的比较常用组件的设计思想



Lock

Lock在J.U.C中是最核心的组件,如果我们去看J.U.C包中的所有组件,我们可以发现绝大多数组件都有用到了Lock。在Lock接口出现之前,Java中的应用程序对于多线程的并发安全处理只能基于synchronized关键字来解决。但是synchronized关键字在有些场景中会存在一些短板,也就是它并不适用于所有的并发场景,但是Java 5之后Lock的出现可以解决synchronized在某些场景中的短板,它比synchronized更加灵活。



Lock本质上是一个接口,它定义了释放锁和获得锁的方法,定义成接口就意味着它定义了锁的一个标准规范,也意味着锁的不同实现。实现Lock接口的类有很多,以下为几个常见的实现:



ReentrantLock: 表示重入锁,它是唯一一个实现了Lock接口的类。重入锁指的是线程获得锁后,再次获得该锁不需要阻塞,而是直接关联一次计数器增加重入次数。



ReentrantReadWriteLock: 冲入读写锁,它实现了ReadWriteLock接口,在这个类中维护了两个锁,一个是ReadLock,一个是WriteLock;他们都分别实现了Lock接口。读写锁是一种适合读多写少的场景下解决线程安全问题的工具,基本原则是:读和读不互斥、读和写互斥、写和写互斥。也就是说涉及到影响数据变化的操作都存在互斥。



StampedLock: stampedLock是JDK 8引入的新的锁机制,可以简单认为是读写锁的一个改进版,读写锁虽然通过分离读和写的功能使得读和读之间可以完全并发,但是读和写是有冲突的,如果大量的读线程存在,可能会引起写线程的饥饿。stampedLock是一种乐观的读策略,使得乐观锁完全不会阻塞写线程。



Lock的类关系图



ReentrantLock重入锁

重入锁,表示支持重新进入的锁,也就是说如果当前线程t1通过调用lock方法获取了锁之后,再次调用lock,是不会再阻塞去获取锁的,直接增加重试次数就可以了。synchronized和ReentrantLock都是可重入锁,比如下面这类场景中存在多个加锁方法的相互调用,其实就是一种重入特性的场景。

重入锁设计的目的

比如下面代码通过调用demo方法获得了当前的对象锁,然后在这个方法中再去调用demo2,demo2中也存在同一个实例锁,这个时候当前线程会因为无法获得demo2的对象锁而阻塞,就会产生死锁。重入锁的设计目的就是为了避免死锁。

public class ReentrantLockDemo {
public synchronized void demo() {
System.out.println("begin demo");
demo2();
}
public void demo2() {
System.out.println("begin demo1");
synchronized (this) {
}
}
public static void main(String[] args) {
ReentrantLockDemo rl = new ReentrantLockDemo();
new Thread(rl::demo).start();
}
}



ReentractLock的使用案例:

public class AtomicDemo {
private static int count = 0;
static Lock lock = new ReentrantLock();
public static void incr() {
lock.lock();
try {
Thread.sleep(1);
count++;
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
public static void main(String[] args) throws Exception {
for (int i = 0; i < 1000; i++) {
new Thread(() -> {AtomicDemo.incr();}).start();
}
Thread.sleep(3000);
System.out.println(count);
}
}



ReentrantReadWriteLock

我们以前理解的锁基本都是排他锁,也就是这些锁在同一时刻只允许一个线程进行访问,而读写锁在同一时刻允许多个线程访问,但是在写线程访问时,所有的读线程和其他线程读会被阻塞。读写锁维护了一对锁,一个读锁,一个写锁;一般情况下,读写锁的性能都比排它锁好,因为大多数场景是读多余写的。在读多余写的情况下,读写锁能提供比排它锁更好的并发性和吞吐量。

public class LockDemo {
static Map<String, Object> map = new HashMap<String, Object>();
static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
static Lock read = rwl.readLock();
static Lock write = rwl.writeLock();
public static final Object get(String key) {
System.out.println("开始读取数据");
read.lock();
try {
return map.get(key);
} finally {
read.unlock();
}
}
public static final void put(String key, Object obj) {
System.out.println("开始写入数据");
write.lock();
try {
map.put(key, obj);
} finally {
write.unlock();
}
}
}



在这个案例中,通过HashMap来模拟了一个内存缓存,然后使用读写锁来保证这个内存缓存的线程安全性。当执行读操作的时候,需要获取读锁,在并发访问的时候读锁不会被阻塞,因为读操作不会影响执行结果。



在执行写操作的时候,线程必须要获取写锁,当已经有线程持有写锁的情况下,当前线程会被阻塞,只有当写锁释放后,其他读写操作才能执行。使用读写锁可提升读操作的并发性,也保证每次写操作对所有的读写操作的可见性。

  1. 读锁与读锁可以共享

  2. 读锁与写锁不可以共享

  3. 写锁与写锁不可以共享



ReentrantLock的实现原理

我们知道锁的基本原理是基于将多线程并行任务通过某一种机制实现线程的串行执行,从而达到线程安全的目的。在之前的synchronized中,我们了解了偏向锁、轻量级锁、乐观锁,基于乐观锁以及自旋锁来优化了synchronized的加锁开销,同时在重量级锁阶段,通过线程的阻塞和唤醒来达到线程竞争和同步的目的。

那么在ReentrantLock中,也一定会存在这样的需要去解决的问题,就是在多线程竞争重入锁时,竞争失败的线程时如何实现阻塞以及被唤醒的呢?



在Lock中,用到了一个同步队列AQS,全程AbstractQueuedSynchronizer,它是一个同步工具也是Lock用来实现线程同步的核心组件。如果搞懂了AQS,那么J.U.C中绝大部分工具都能轻松掌握。



AQS的两种功能

从使用层面来说,AQS的功能分为两种:独占和共享

独占锁:每次只能有一个线程持有锁,比如前面演示的ReentrantLock就是以独占方式实现的互斥锁

共享锁:允许多个线程同时获得锁,并发访问共享资源,比如ReentrantReadWriteLock



AQS的内部实现

AQS队列内部维护的是一个FIFO的双向链表,这种结构的特点是每个数据结构都有两个指针,分别指向直接的后继节点和直接的前驱节点,所以双向链表可以从任意一个节点开始可以很方便的访问前驱和后继节点。每个Node其实都是由线程封装的,当前线程争抢锁失败后会封装成Node加入到AQS队列中去;当获得锁的线程释放锁以后,会从队列中唤醒一个阻塞的节点



Node的组成:

static final class Node {
static final Node SHARED = new Node();
static final Node EXCLUSIVE = null;
/** waitStatus value to indicate thread has cancelled */
static final int CANCELLED = 1;
/** waitStatus value to indicate successor's thread needs unparking */
static final int SIGNAL = -1;
/** waitStatus value to indicate thread is waiting on condition */
static final int CONDITION = -2;
/**
* waitStatus value to indicate the next acquireShared should
* unconditionally propagate
*/
static final int PROPAGATE = -3;
/**
* Status field, taking on only the values:
* SIGNAL: The successor of this node is (or will soon be)
* blocked (via park), so the current node must
* unpark its successor when it releases or
* cancels. To avoid races, acquire methods must
* first indicate they need a signal,
* then retry the atomic acquire, and then,
* on failure, block.
* CANCELLED: This node is cancelled due to timeout or interrupt.
* Nodes never leave this state. In particular,
* a thread with cancelled node never again blocks.
* CONDITION: This node is currently on a condition queue.
* It will not be used as a sync queue node
* until transferred, at which time the status
* will be set to 0. (Use of this value here has
* nothing to do with the other uses of the
* field, but simplifies mechanics.)
* PROPAGATE: A releaseShared should be propagated to other
* nodes. This is set (for head node only) in
* doReleaseShared to ensure propagation
* continues, even if other operations have
* since intervened.
* 0: None of the above
*
* The values are arranged numerically to simplify use.
* Non-negative values mean that a node doesn't need to
* signal. So, most code doesn't need to check for particular
* values, just for sign.
*
* The field is initialized to 0 for normal sync nodes, and
* CONDITION for condition nodes. It is modified using CAS
* (or when possible, unconditional volatile writes).
*/
volatile int waitStatus;
/**
* Link to predecessor node that current node/thread relies on
* for checking waitStatus. Assigned during enqueuing, and nulled
* out (for sake of GC) only upon dequeuing. Also, upon
* cancellation of a predecessor, we short-circuit while
* finding a non-cancelled one, which will always exist
* because the head node is never cancelled: A node becomes
* head only as a result of successful acquire. A
* cancelled thread never succeeds in acquiring, and a thread only
* cancels itself, not any other node.
*/
volatile Node prev; // 前驱节点
/**
* Link to the successor node that the current node/thread
* unparks upon release. Assigned during enqueuing, adjusted
* when bypassing cancelled predecessors, and nulled out (for
* sake of GC) when dequeued. The enq operation does not
* assign next field of a predecessor until after attachment,
* so seeing a null next field does not necessarily mean that
* node is at end of queue. However, if a next field appears
* to be null, we can scan prev's from the tail to
* double-check. The next field of cancelled nodes is set to
* point to the node itself instead of null, to make life
* easier for isOnSyncQueue.
*/
volatile Node next; // 后继节点
/**
* The thread that enqueued this node. Initialized on
* construction and nulled out after use.
*/
volatile Thread thread; // 当前线程
/**
* Link to next node waiting on condition, or the special
* value SHARED. Because condition queues are accessed only
* when holding in exclusive mode, we just need a simple
* linked queue to hold nodes while they are waiting on
* conditions. They are then transferred to the queue to
* re-acquire. And because conditions can only be exclusive,
* we save a field by using special value to indicate shared
* mode.
*/
Node nextWaiter; // 存储在Condition队列中的后继节点
/**
* Returns true if node is waiting in shared mode.
*/
final boolean isShared() {
return nextWaiter == SHARED;
}
/**
* Returns previous node, or throws NullPointerException if null.
* Use when predecessor cannot be null. The null check could
* be elided, but is present to help the VM.
*
* @return the predecessor of this node
*/
final Node predecessor() throws NullPointerException {
Node p = prev;
if (p == null)
throw new NullPointerException();
else
return p;
}
Node() { // Used to establish initial head or SHARED marker
}
Node(Thread thread, Node mode) { // Used by addWaiter
this.nextWaiter = mode;
this.thread = thread;
}
Node(Thread thread, int waitStatus) { // Used by Condition
this.waitStatus = waitStatus;
this.thread = thread;
}
}



释放锁以及添加线程对于队列的变化

当出现锁竞争以及释放锁的时候,AQS同步队列中的节点会发生变化,首先看一下添加节点的场景



这个场景会涉及两个变化:

  1. 新的线程封装成Node添加到同步队列中,设置prev节点以及修改当前节点的前置节点的next指向自己

  2. 通过CAS将tail重新指向新的尾部节点



head节点表示获取锁成功的节点,当头节点释放同步状态时会唤醒后继节点,如果后继节点获得锁成功,会把自己设置成头节点,节点的变化过程如下:



这个过程也是涉及两个变化:

  1. 修改head节点指向下一个获得锁的节点

  2. 新获得锁的节点,将prev的指针指向null



设置head节点不需要CAS,原因是设置head节点是由获得锁的线程来完成的,而同步锁只能有一个线程获得,所以不需要CAS保证,只需要把head节点设置为原首节点的后继节点,并断开原首节点的next引用即可。



ReentrantLock的源码分析

接下来以ReentrantLock为切入点,看看在这个场景中是如何是用AQS来实现线程同步的:

调用ReentrantLock中的lock方法,源码的调用过程如下面的时序图所示:



这个方式是ReentrantLock获取锁的入口:

public void lock() {
sync.lock();
}



sync实际上是一个抽象的静态内部类,它继承了AQS来实现重入锁的逻辑,我们前面说过AQS是一个同步队列,它能够实现线程的阻塞以及唤醒,但它并不具备业务功能,所以在不同的同步场景中,会继承AQS来实现对应场景的功能:



Sync有两个具体的实现类,分别是:

NofairSync: 表示可以存在抢占锁的功能,也就是说不管当前队列上是否存在其他线程等待,新线程都有机会抢占锁

FairSync: 表示所有线程都严格按照FIFO来获取锁



NofairSync.lock

以非公平锁为例子,来看看lock中的实现:

  1. 非公平锁和公平锁最大的区别在于,在非公平锁中我抢占锁的逻辑是不管有没有线程排队,我先上来CAS去抢占一下

  2. CAS成功,就表示成功获得了锁

  3. CAS失败,调用acquire(1)走锁竞争逻辑

static final class NonfairSync extends Sync {
private static final long serialVersionUID = 7316153563782823691L;
/**
* Performs lock. Try immediate barge, backing up to normal
* acquire on failure.
*/
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}



CAS实现原理

protected final boolean compareAndSetState(int expect, int update) {
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}



通过CAS乐观锁的方式来做比较并替换,这段代码的意思是如果当前内存中的state的值和预期值expect相等,则替换为update。更新成功返回true,否则返回false。

这个操作是原子的,不会出现线程安全问题,这里面涉及到Unsafe这个类的操作,以及涉及到state属性的意义。



State是AQS中的一个属性,它在不同的实现中表达的含义不一样,对于同步锁的实现来说,表示一个同步状态,有两个含义的表示:

  1. 当state=0时,表示无锁状态

  2. 当state>0时,表示已经有线程获得了锁,也就是state=1;但因为ReentrantLock允许重入,所以同一个线程多次获的同步锁的时候,state会递增,比如重入5次,那么state=5。而在释放锁的时候,同样需要释放5次,直到state=0其他线程才有资格获得锁。



AQS.acquire

acquire是AQS中的方法,如果AQS操作未能成功,说明state已经不为0,此时继续acquire(1)操作。这个方法的主要逻辑是:

  1. 通过tryAcquire尝试获得独占锁,如果成功返回true,失败返回false

  2. 如果tryAcquire失败,则会通过addWaiter方法将当前线程封装成Node添加到AQS队列尾部

  3. acquireQueued,将Node作为参数通过自旋去尝试获取锁



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



NonfairSync.tryAcquire

这个方法的作用是尝试获取锁,如果成功返回true,不成功返回false;它是重写AQS类中的tryAcquire方法,这个方法中调用了ReentrantLock类内部类Sync中nonfairTryAcquire方法

protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}



final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread(); // 获取当前执行的线程
int c = getState(); // 获取state的值
if (c == 0) { // 表示无锁状态
if (compareAndSetState(0, acquires)) { // cas替换state的值,cas成功表示获取锁成功
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;
}



当tryAcquire方法获取锁失败以后,则会先调用addWaiter将当前线程封装成Node。入参mode表示当前节点的状态,传递的参数是Node.EXCLUSIVE,表示独占状态,意味着重入锁用到了AQS的独占锁功能。

  1. 将当前线程封装成Node

  2. 当前链表中的tai'l节点是否为空,如果不为空则通过cas操作把当前线程的node添加到AQS队列

  3. 如果为空或者AQS失败,调用enq将节点添加到AQS队列



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; // tail是AQS中表示同步队列队尾的属性,默认是null
if (pred != null) { // tail不为空的情况下,说明队列中存在节点
node.prev = pred; // 把当前线程的Node的prev指向tail
if (compareAndSetTail(pred, node)) { // 通过cas把当前Node加入到AQS队列
pred.next = node;
return node;
}
}
enq(node); // 把node添加到同步队列
return node;
}



enq方法就是通过自旋操作把当前节点加入到队列中:

private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) { // Must initialize
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}



图解分析

假设3个线程来争抢锁,那么截至到enq方法运行结束后,或者调用addWaiter方法结束后,AQS中的链表结构图:



AQS.acquireQueued

通过addWaiter方法把线程添加到链表后,接着会把Node作为参数传递给acquireQueued方法,去竞争锁。

  1. 获取当前节点的prev节点

  2. 如果prev节点为head节点,那么就有资格去竞争锁,调用tryAcquire去抢占锁

  3. 抢占锁成功后,把获得锁的节点设置成head,并且移除原来的初始化head节点

  4. 如果获取锁失败,则根据waitStatus决定是否需要挂起线程

  5. 最后,通过cancelAcquire取消获得锁的操作



final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor(); // 获取当前节点的prev节点
if (p == head && tryAcquire(arg)) { // 如果prev节点为head,说明有资格去争抢锁
setHead(node); // 获取锁成功,也就是ThreadA释放了锁,然后设置head为ThreadB
p.next = null; // help GC 把原head节点从链表中删除
failed = false;
return interrupted;
}
// 线程A可能还没释放锁,使得ThreadB在执行tryAcquire时返回false
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}



shouldParkAfterFailedAcquire

如果ThreadA的锁还没有被释放的情况下,ThreadB和Thread C来争抢锁肯定是会失败的,那么失败以后会调用shouldParkAfterFailedAcquire 方法;Node中有5中状态,分别是CANCELLED(1)、SIGNAL(-1)、CONDITION(-2)、PROPAGATE(-3)、默认状态(0)

CANCELLED: 在同步队列中等待的线程等待超时或者被中断,需要从同步队列中取消该Node节点,其节点的waitStatus为CANCELLED,即结束状态,进入该状态后的节点将不会再变化

SIGNAL:只要前置节点释放锁,就会通知标识为SIGNAL状态的后续节点的线程

CONDITION:和Condition有关

PROPAGATE:共享模式下,PROPAGATE状态的线程处于可运行状态

这个方法的作用是通过Node的状态的判断,Thread A竞争锁失败后是否被挂起。

  1. 如果Thread A的prev节点状态为SIGNAL,那就表示可以放心挂起当前线程

  2. 通过循环扫描链表把CANCELLED 状态的节点移除

  3. 修改prev节点的状态为SIGNAL,返回false



返回false时,也就是不需要挂起;返回true,则需要调用parkAndCheckInterrupt挂起当前线程

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
if (ws == Node.SIGNAL)
/*
* This node has already set status asking a release
* to signal it, so it can safely park.
*/
return true;
if (ws > 0) {
/*
* Predecessor was cancelled. Skip over predecessors and
* indicate retry.
*/
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
/*
* waitStatus must be 0 or PROPAGATE. Indicate that we
* need a signal, but don't park yet. Caller will need to
* retry to make sure it cannot acquire before parking.
*/
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}



发布于: 2020 年 05 月 09 日 阅读数: 64
用户头像

Java收录阁

关注

士不可以不弘毅,任重而道远 2020.04.30 加入

喜欢收集整理Java相关技术文档的程序员,欢迎关注同名微信公众号 Java收录 阁获取更多文章

评论 (1 条评论)

发布
用户头像
如果文章加一张头图,阅读效果会更好哦
2020 年 05 月 09 日 15:18
回复
没有更多了
Java并发编程--ReentrantLock