JUC 中的重入锁-ReentrantLock

88 阅读4分钟

JUC 中的重入锁-ReentrantLock

作者:沈自在

1 Lock API

1.1 Lock API 有哪些提供方法?

位置: java.util.locks.Lock

Lock接口的作用其实与 synchronized类似,均是为了达到线程安全所提出的解决方案,在Lock中有以下几个方法:

public interface Lock {
 
 void lock();
 
 // 获取锁过程中可被中断 中断抛出 InterruptedException
 void lockInterruptibly() throws InterruptedException;
 
 boolean tryLock();
 
 boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
​
 void unlock();
 
 // 返回绑定当前锁的 Condition 实例
 Condition newCondition();
​
}

1.2 有哪些实现?

主要有以下几个:

  • ReentrantLock 可重入锁的典范,支持公平与非公平锁(默认非公平),内部核心类Sync 继承自AQS
  • ReentrantReadWriteLock 可重入的读写锁,该类并没有实现Lock,其内部的ReadLockWriteLock才是实现源(默认非公平
  • StampedLockReentrantReadWriteLock 的增强,在原先读写锁的基础上新增了乐观读模式,因此提供了更高的性能

2 ReentrantLock

2.1 简单实践一下

2.1.1 可重入锁的使用范式

public class ReentrantLockTest {
   static Lock lock = new ReentrantLock();
   public void testLock(){
       lock.lock();
       try {
           // omitted code..
      }finally {
           lock.unlock();
      }
  }
}

2.1.2 Java自增原子吗?

众所周知,Java中的自增操作并不是原子操作

public void getNext();  
     Code:  
:   aload_0   
:   dup                 // 将当前栈顶的对象引用复制一份  
:   getfield        #2; // 获取id的值,并将其值压入栈顶  
:   iconst_1            // 将int型的值1压入栈顶  
:   iadd                // 将栈顶两个int类型的元素相加,并将其值压入栈顶  
:   putfield        #2; // 将栈顶的值赋值给id  
:  return

所以Java的自增操作其实是分为三步的:取值 -> 加一 ->赋值 ,因此并非原子

2.1.3 一个一定出错的案例

那么这里就要引入一个老生常谈的案例——多线程自增出错,这个案例在后续的文章中也会频繁出现。

// 开启 1000 个线程,每个线程自增1000次, 理论值 1000000
public class ReentrantLockTest {
   int value;
​
   int getValue() {
       return value;
  }
​
   void increment() {
       ++value;
  }
​
   @Test
   void reentrant_lock_test() {
       ReentrantLockTest reentrantLockTest = new ReentrantLockTest();
       for (int i = 0; i < 1000; ++i) {
           new Thread(() -> {
               for (int j = 0; j < 1000; ++j) {
                   reentrantLockTest.increment();
              }
          }).start();
​
      }
       // 等待 7s
       LockSupport.parkNanos(7000000000L);
       System.out.println("reentrantLockTest.getValue() = "
               + reentrantLockTest.getValue());
  }
}

上面这个简单的案例的结果不用多说肯定是小于理论值的,接下来我们就用ReentrantLock来实践去解决这个问题

2.1.4 可重入锁改造代码

我们对increment()方法进行简单的加锁改造(套用2.1.1的使用范式)

void increment() {
 lock.lock();
 try {
   ++value;
}finally {
   lock.unlock();
}
}

我们不难得到一个稳如老狗的值

image-20230918134602978

2.2 这玩意咋实现的?

2.1.1 结构起手

ReentrantLock结构图

简单介绍一下:

可重入锁ReentrantLock其核心点在于内部的Sync(其实是AbstractQueuedSynchronizer的派生),同时基于Sync 提供了公平和非公平俩种实现。

所以从这里可以知道该锁的核心点其实就是 AQS,接下来,我们就接着探讨一下什么是AQS

2.1.2 啥玩意儿是AQS呢?

AbstractQueuedSynchronizer(简称AQS)是ReentrantLock实现锁同步的核心类,实际上在J.U.C中大部分组件都依赖于AbstractQueuedSynchronizer

来自何方?

AbstractQueuedSynchronizer 继承自AbstractOwnableSynchronizer,在该类中提供了基础的信息维护操作。

如:

public abstract class AbstractOwnableSynchronizer
   implements java.io.Serializable {
   private static final long serialVersionUID = 3737899427754241961L;
​
   protected AbstractOwnableSynchronizer() { }
​
   private transient Thread exclusiveOwnerThread;
​
   protected final void setExclusiveOwnerThread(Thread thread) {
       exclusiveOwnerThread = thread;
  }
​
   protected final Thread getExclusiveOwnerThread() {
       return exclusiveOwnerThread;
  }
}

那么对于AQS,它究竟提供了什么?其实是俩种锁的实现:

  • 独占锁:同一时刻只允许有一个线程获得锁。
  • 共享锁,同一时刻允许多个线程同时获得锁。

这里比较关键的一点是 state的值。其中 state = 0 表示当前锁资源空闲。如果state > 0 表示已有锁占用。需要注意的是:

线程重入了多少次就需要释放多少次,这样才能 保证state的值最终为0

当然对于共享锁和独占锁这里也有着不同的实现:

   protected boolean tryAcquire(int arg) {
       throw new UnsupportedOperationException();
  }
​
   protected boolean tryRelease(int arg) {
       throw new UnsupportedOperationException();
  }
​
   protected int tryAcquireShared(int arg) {
       throw new UnsupportedOperationException();
  }
​
   protected boolean tryReleaseShared(int arg) {
       throw new UnsupportedOperationException();
  }

那么AQS是如何完成这样一个锁机制的呢?

竞争流程

2.1.3 Sync 的具体工作

Sync具有以下俩种实现

NonfairSync,非公平锁,非公平锁的特点是允许在不排队的情况下直接尝试抢占锁, 默认使用非公平锁。

FairSync,公平锁,必须按照FIFO的规则来访问锁资源。

// 下面是公平锁的获取方法,必须满足等待队列中没有线程才能获取锁
protected final boolean tryAcquire(int acquires) {
 if (getState() == 0 && !hasQueuedPredecessors() &&
     compareAndSetState(0, acquires)) {
   setExclusiveOwnerThread(Thread.currentThread());
   return true;
}
 return false;
}
2.1.3.1 上锁

下面是一段 ReentrantLock 上锁代码:

隐含: state初始为0

@ReservedStackAccess
final boolean tryLock() {
  Thread current = Thread.currentThread();
  int c = getState();
  if (c == 0) {
    if (compareAndSetState(0, 1)) {
      setExclusiveOwnerThread(current);
      return true;
    }
  } else if (getExclusiveOwnerThread() == current) {
    if (++c < 0) // overflow
      throw new Error("Maximum lock count exceeded");
    setState(c);
    return true;
  }
  return false;
}

Sync上锁流程

可以简单的看出来,如果当前锁为空闲状态,那么直接会利用CAS进行赋值上锁,如果已经被上锁了,但是上锁的线程是自己就对state 加1赋值。

2.1.3.2 释放锁
protected final boolean tryRelease(int releases) {
  int c = getState() - releases;
  if (getExclusiveOwnerThread() != Thread.currentThread())
    throw new IllegalMonitorStateException();
  boolean free = (c == 0);
  if (free)
    setExclusiveOwnerThread(null);
  setState(c);
  return free;
}

其实这个理念都一样啦,如果state = 0释放,否则减去一个release放回去。

2.1.4 整体来看 ReentrantLock

image-20230918155845231

2.1.4.1 ReentrantLock中的lock()
public void lock() {
  sync.lock();
}
2.1.4.2 如何给State赋值

这里用到的是Unsafe类的CAS操作 :

// AQS 中
// 这段代码的意思是,通过CAS乐观锁的方式来做比较并替换,如果当前内存中state的值 和预期值expect相等,则更新为update。如果更新成功则返回true,否则返回false。
protected final boolean compareAndSetState(int expect, int update) {
  return U.compareAndSetInt(this, STATE, expect, update);
}

// Unsafe 中
public final native boolean compareAndSetInt(Object o, long offset,
                                             int expected,
                                             int x);

2.1.5 核心的上锁方法是什么?

最核心的上锁方法是 AQS中的

final int acquire(Node node, int arg, boolean shared,boolean interruptible, boolean timed, long time)方法

它,非常的长!!

参数解释:

  • node:除非有重新获取条件,否则为null
  • arg:获取参数
  • shared:是否为共享模式
  • timed:是否超时等待
  • time:如果超市等待,那么超时时间是?

jdk8

2.1.5.1 获取?入队?
// acquire失败后尝试入队
public final void acquire(int arg) {
  // Sync实现的tryAcquire
  if (!tryAcquire(arg) &&
      // 入队后反复阻塞和恢复进行tryAcquire直到成功 (EXCLUSIVE为独占状态)
      acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
    selfInterrupt();
}
//入队
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) {
    // 当前节点只想tail
    node.prev = pred;
    // cas 入队
    if (compareAndSetTail(pred, node)) {
      pred.next = node;
      return node;
    }
  }
  // 初始化队列
  enq(node);
  return node;
}
2.1.5.2 入队之后怎么办?
// 通过自旋去抢占锁
// 当抢不到锁时,不能让线程一直自旋重试,如果竞争失败就调用 parkAndCheckInterrupt()方法阻塞当前线程。
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;
      }
      if (shouldParkAfterFailedAcquire(p, node) &&
          parkAndCheckInterrupt())
        interrupted = true;
    }
  } finally {
    if (failed)
      cancelAcquire(node);
  }
}
2.1.5.3 抢不到怎么办?

Node 具有五种状态:

  • CANCELLED(1)如果在同步队列中等待的线程等待超时或被中断,那么需要从同步队 列中取消该Node的结点,其结点的waitStatus为CANCELLED,即结束状态,进入该状态后 的结点将不会再变化。
  • SIGNAL(-1)只要前置节点释放锁,就会通知标识为SIGNAL状态的后续节点的线程
  • CONDITION(-2)
  • PROPAGATE(-3) 在共享模式下,PROPAGATE状态的线程处于可运行状态。
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
  int ws = pred.waitStatus;
  if (ws == Node.SIGNAL)
    return true;
  if (ws > 0) {
    do {
      node.prev = pred = pred.prev;
    } while (pred.waitStatus > 0);
    pred.next = node;
  } else {
    compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
  }
  return false;
}

shouldParkAfterFiledAcquired()方法的作用是检查当前节点的前置节点状态,如果是 SIGNAL,则表示可以放心地阻塞,否则需要通过compareAndSetWaitStatus修改前置节点的状态为SIGNAL。这么做的目的是确保在同步队列中每个等待的线程状态是正常的,否则就需要把非正常状态的节点移除。

如果shouldParkAfterFailedAcquire()方法返回true,则调用parkAndCheckInterrupt()方法挂 起当前线程

private final boolean parkAndCheckInterrupt() {
  LockSupport.park(this);
  return Thread.interrupted();
}

2.1.6 一个完成的释放锁的流程

// 1. 你所调用的释放锁 API
public void unlock() {
  sync.release(1);
}

// 2. AQS中的释放锁操作
public final boolean release(int arg) {
  if (tryRelease(arg)) {
    Node h = head;
    // 释放成功,唤醒节点
    if (h != null && h.waitStatus != 0)
      unparkSuccessor(h);
    return true;
  }
  return false;
}
// 3. ReentrantLock的释放操作
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;
}

// 4. 唤醒
private void unparkSuccessor(Node node) {

  int ws = node.waitStatus;
  if (ws < 0)
    compareAndSetWaitStatus(node, ws, 0);
	
  Node s = node.next;
  // 如果下一个节点被取消则找最近的 waitStatus <= 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);
}

那么为什么这里一定要从队尾开始检索呢?

这和enq()方法有关系,在enq()方法的逻辑中,把一个新节点添加到链表中的逻辑如 下。

  • 将新节点的prev指向tail。
  • 通过CAS将tail设置为新节点,因为CAS是原子操作,所以能够保证线程的安全性。
  • t.next=node,目的是设置原tail的next节点指向新节点。

如果在CAS操作之后、t.next=node操作之前,存在其他线程调用unlock()方法从head开始往后遍历,由于t.next=node还没执行,所以链表的关系还没有建立完整,就会导致遍历到t节点的时候被中断。而如果从tail往前遍历,就一定不会出现这个问题。