JUC源码学习笔记1——AQS独占模式和ReentrantLock

系列文章目录和关于我

 笔记主要参考《Java并发编程的艺术》并且基于JDK1.8的源码进行的刨析,此篇只分析独占模式,后续在ReentrantReadWriteLock和 CountDownLatch中 会重点分析AQS的共享模式 

一丶Lock

锁是用来控制多个线程访问共享资源的方式,一般来说,一个锁可以防止多个线程同时访问共享资源(这种锁称为独占锁,排他锁)但是有些锁可以允许多个线程并发访问共享资源,比如读写锁

1.Lock接口的方法:

方法 作用
void lock() 获取锁,调用该方法的线程将会获取锁,当锁获得之后从该方法返回
void lockInterruptibly() 可中断地获取锁,该方法会响应中断,在锁的获取途中可以中断当前线程,如果在获取锁之前设置了中断标志,or获取锁的中途被中断or其他线程中断该线程则抛出InterruptedException并清除当前线程的中断标识
boolean tryLock() 尝试非阻塞的获取锁,调用方法会立即返回,如果获取到锁返回true
boolean tryLock(long time, TimeUnit unit) throws InterruptedException 超时获取锁,从当前返回有三种情况
1.超时时间内获取到锁
2.当前线程在超时时间内被中断
3.超时间结束没有获取到锁,返回false
void unLock 释放锁
Condition newCondition() 获取等待通知的组件,该组件和当前锁绑定,只有获取到锁调用wait方法后当前线程将放弃锁,后续被其他线程signal继续争抢锁

2.Lock相比synchronized具备的特性

  • 尝试非阻塞的获取锁
  • 响应中断的获取锁
  • 超时获取锁
 synchronized相比于Lock 更加简单,更不容易犯错,但是不够灵活 

3.使用Lock的经典范式

获取锁的过程不要写在try中,避免获取锁失败最后finally释放其他线程持有的锁

二丶AbstractQueuedSynchronizer队列同步器

使用一个int成员变量state表示同步状态,内置的FIFO队列来完成资源的获取和线程的排队工作,支持独占也支持共享的获取同步状态。

三个变量(head队列头,tail队列尾,state同步状态)被volatile修饰,保证其线程可见性

1.队列同步器可以被重写的方法

方法 说明
protected boolean tryAcquire(int arg) 独占的获取锁,需要查询当前状态并判断同步状态是否符合预期,然后再进行CAS改变同步状态
protected boolean tryRelease(int arg) 独占式释放同步状态,等待获取同步状态的线程将有机会获取同步状态
protected int tryAcquireShared(int arg) 共享式的获取同步状态,返回大于等于0的值表示成功,反之失败
protected boolean tryReleaseShared(int arg) 共享式释放同步状态
protected boolean isHeldExclusively() 当前队列同步器释放再独占模式下被线程占用,一般表示当前线程是否独占

2.队列同步器提供的模板方法

方法 说明
void acquire(int arg) 独占式获取同步状态,如果获取成功那么直接返回,反之进入同步队列中等待,
void acquireInterruptibly(int arg) 和acquire,但是此方法支持在获取锁的过程中响应中断,如果当前线程被中断那么抛出InterruptedException
boolean tryAcquireNanos(int arg, long nanosTimeout) 在acquireInterruptibly的基础上增加了超时限制,如果在指定时间内没有获取到同步状态那么返回false反之true
void acquireShared(int arg) 共享式获取同步状态,如果没有获取到那么进入等待队列等待,和acquire不同的式支持同一个时刻多个线程获取同步状态
void acquireSharedInterruptibly(int arg) 和acquireShared类似但是支持响应中断
boolean tryAcquireSharedNanos(int arg, long nanosTimeout) 在acquireSharedInterruptibly新增了超时限制
boolean release(int arg) 独占式释放同步资源,在释放同步状态后唤醒后继线程
boolean releaseShared(int arg) 共享式释放同步状态
Collection
<Thread>
getQueuedThreads()
获取等待在同步队列上的线程们

3.同步队列的节点属性

属性 描述
int waitStatus 等待状态
Node pre 前驱节点
Node next 后继节点
Node nextWaiter 等待队列中的后继节点,如果当前节点式共享模式,那么这个节点是SHARED常量,也就是说节点类型和等待中后继节点是公用一个字段
Thread thread 获取同步状态的线程

等待状态是一个枚举,具备下列可选的值

  • CANCELLED(1)线程获取锁超时or被中断,需要从同步队列中取消中断,节点进入该状态后状态不再改变
  • SIGNAL(-1)后继节点线程处于等待状态,而当前节点的线程如果释放共享资源或者被取消会通知后继节点,使后继线程被唤醒继续执行
  • CONDITION(-2)节点在等待队列中,节点中的线程在Condition上进行等待,需要等待其他线程调用Condition的singal or singalAll进行唤醒,该节点会从等待队列移动到同步队列,进行共享资源的争夺
  • PROPAGATE(-3)表示下一次共享式同步状态的获取将无条件的传播下去
  • 0 初始状态,节点假如到同步队列时候的状态

4.AQS怎么维护同步队列

AQS中包含两个节点类型引用:头节点和尾节点。当一个线程获取到同步状态的时候,其他线程无法获取,将被放入到同步队列中,加入队列这个过程为了保证线程安全而采用CAS。同步队列遵守FIFO,头节点是获取到同步状态的线程,释放同步状态将会唤醒后继线程,后继节点获取到同步状态后将被设置为头节点

三丶ReentrantLock可重入锁

1.ReentrantLock简介

支持公平和非公平和重入的独占式锁

  • 重入表示已经获得锁的线程可以对共享资源重复加锁
  • 公平锁,支持先来后到,像在公司排队上厕所,先来的人肯定优先获取到茅坑,先来的线程肯定先获取到共享资源
  • 独占式,同一时间只允许一个线程操作共享资源

2.公平锁和非公平锁比较

公平锁在头节点释放同步资源的时候unpark后续节点,然后进行线程上下文切换由后继节点中的线程获取锁,导致效率并不如非公平锁(非公平锁也会唤醒后继节点,但是非公平锁接下来获取到锁的线程不一定是队列中被挂起的线程,也许是刚刚来到厕所门口的无赖小瘪三直接上来抢夺了厕所使用权,所以在当前线程唤醒后继线程的途中可能锁已经被小瘪三霸占了,提高了厕所的利用率),但是公平锁可以减少饥饿,因为非公平锁好像A在排队,A获取到共享资源需要进行唤醒和上下文切换,而导致需要更多时间,这时候流氓B刚好进厕所门,上来就是一个CAS,很快抢占了厕所这一共享资源,导致A处于

饥饿
——迟迟得不到厕所(共享资源)的操作资源。

3.ReentrantLock的可重入

实现可重入需要解决两个问题

  • 线程再次获得锁,锁需要去识别当前线程是否是当前占据锁的线程,如果是那么直接加锁成功
  • 锁的最终释放,加锁多少次,就需要释放多少次,完全解锁后其他线程才可以获取到锁。

第一个问题ReentrantLock通过获取当前线程和独占锁线程的

==
判断来实现,第二个问题ReentrantLock通过对AQS中的共享资源state增加和减少来实现

四丶结合ReentrantLock源码分析加锁解锁的流程

1.ReentranLock

ReentrantLock的公平和非公平就是由于sync引用指向了的不同实现,其lock unlock等操作也是一律交由到sync

2.ReentrantLock的非公平模式

2.1非公平加锁——lock方法

加锁的大致流程

  • 无论是非公平还是公平在加锁成功后都会通过setExclusiveOwnerThread设置当前线程为独占锁的线程,这个方法会记住当前线程,这是后面实现可重入的关键、
  • acquire 方法会调用tryAcquire方法,这个方法由AQS的子类实现,NonfairSync这里会调用nonfairTryAcquire方法
2.1.1不公平的尝试获取共享资源nonfairTryAcquire

  • 如果nonfairTryAcquire返回true表示当前线程获取到了锁,那么皆大欢喜,当前线程可以继续运行

  • 返回false的情况


    • 共享资源是0,但是同一个时间多个线程抢占,当前这个线程CAS失败了
    • 共享资源不是0,当前线程也不是独占的线程

    这两种情况都需要继续执行AQS的acquire方法

2.1.2AQS的acquire 方法

独占模式获取共享资源,对中断不敏感,或者说不响应中断——获取共享资源失败的线程将会进入到同步队列,后续对此线程进行中断操作,线程不会从同步队列中移出

1.执行流程

2.将当前线程包装成Node加入到队列尾addWaiter

  • 快速入队

    下面这段代码值得品一品

     Node pred = tail;
    if (pred != null) {
    //当前线程的前置设置为尾,多个线程执行这一步也是无关紧要的
    //只是把当前节点的前置改变了,不是改变pred的next指向,所以不存在线程安全问题
    node.prev = pred;
    //CAS设置尾节点 为当前节点,这个自旋操作compareAndSetTail是线程安全,同一时间只有一个线程可以设置自己为尾节点
    if (compareAndSetTail(pred, node)) {
    //注意 如果原尾节点是S,线程A设置成功 那么尾巴被修改为了A,
    //假如A执行下面一行的时候消耗完了时间片,线程B进来了,这时候线程B拿到的tail就是A,所以不会存在线程安全问题
    pred.next = node;
    return node;
    }
    }
  • 完整入队

    完整入队和快速入队差不多,就是多了一个初始化的逻辑

    那么为什么不直接完整入队,也许是for循环比if多更多的字节码需要执行?也许Doug Lea测试多次后发现快速入队后完整入队,比直接完整入队效率更高

3.尝试出队acquireQueued

  • 如何从自旋中退出

    前继节点是头节点(头节点是当前获取到共享资源的节点)并且获取共享资源tryAcquire成功

  • 挂起当前线程避免无休止的自旋

    自旋是cpu操作,无限制的自旋是很浪费cpu资源的

  • 是否挂起当前线程——shouldParkAfterFailedAcquire方法

    如果shouldParkAfterFailedAcquire返回true 表示当前线程需要被挂起,会继续执行parkAndCheckInterrupt,这个方法很简单只有两行

     private final boolean parkAndCheckInterrupt() {
    //挂起当前线程
    LockSupport.park(this);

    //返回中断状态,并且清除中断标识
    return Thread.interrupted();
    }

    如果parkAndCheckInterrupt 返回了true 表示当前线程被中断过,并且会让外层的acquireQueued返回true,会导致acquire执行当前线程的自我中断

    理解这一段代码需要对java中断机制具备一定理解


    java线程中断机制

    • 调用Thread的interrupt方法


      • 如果线程处于Running状态那么只是修改Thread内部的中断标识值为true
      • 如果线程由于sleep,wait,join等方法进入等待状态,会直接抛出中断异常并清楚中断标识
      • 如果线程由于LockSupport.park进入等待状态,调用该线程的interrupt方法只会让LockSupport.park返回
    • interrupt,interrupted,isInterrupted三个方法比较


      • interrupt 见上⬆

      • interrupted 返回当前线程的中断标识并且充值中断标识

      • isInterrupted返回中断标识


    我们继续说为什么当前线程在获取锁的途中被中断,需要自我中断一下

     acquire的"需求":
    独占模式获取共享资源,对中断不敏感,
    或者说不响应中断——获取共享资源失败的线程将会进入到同步队列,后续对此线程进行中断操作,线程不会从同步队列中移出

    线程获取同步状态的时候被中断会发生什么——从LockSupport.park(this)中返回继续拿锁,这就是为什么说acquire的对中断不敏感。

     LockSupport.park();不会抛出受检查异常,当出现被打断的情况下,线程被唤醒后,
    我们可以通过Interrupt的状态来判断,我们的线程是不是被interrupt的还是被unpark或者到达指定休眠时间

    哪为什么线程拿到锁退出后发现曾经被中断过后,会进行自我中断昵————假如我们写如下这样的代码执行

如果拿到锁返回的时候不进行自我中断,那么中断标识将会被复位,假如存在一个调度线程中断了上面的线程,但是上面的线程还在抢夺锁,并且被park了,这时候上面线程的park会返回,并且清除中断标识,如果不进行自我中断,那么下面while内容还是会进行,那么我们调度线程的中断就无效了。哪为什么doug lea在线程获取锁被中断之后要复位线程的中断标识昵————因为被中断的线程无法再次被park方法挂起,如果不复位那么线程将一直自旋尝试获取锁,浪费cpu资源。也就是说获取锁之后如果获取锁的途中被中断了进行自我中断的这个操作是在模拟中断是在获取到锁之后来的(无论是在抢锁之前,还是抢锁之后的中断,对于acquire来说都是如此)

3.ReentrantLock的公平模式

 使用此构造方法,传入true获取一个公平锁 

3.1公平的获取锁——lock方法

公平锁的lock方法直接调用AQS的acquire方法,上面我们分析的acquire方法它会先去调用tryAcquire,这个tryAcquire被FairSync重写

  • FairSync的tryAcquire方法
 protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
//共享状态当前空闲
if (c == 0) {
//前面没有节点 这就是公平是怎么实现的
//且cas成功 那么拿到锁
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
//实现重入 和 公平锁一样
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
}

源码没什么很难的点,就是通过判断前面时候还有节点(标识是否由线程比当前线程先到)如果没有那么再去拿锁,如果共享状态不是0且当前线程不是独占的线程那么就会执行acquireQueued方法,在acquireQueued里面自选获取锁会判断前一个节点是否是头节点且调用tryAcquire

4.释放锁

释放锁直接调用AQS的release方法,其中tryRelease方法由ReentrantLock中Sync自己实现(公平or非公平都一样)

 public final boolean release(int arg) {
//完全的释放资源
if (tryRelease(arg)) {
Node h = head;
//头节点初始化的时候才为0,但是后面如果由节点加入到同步队列会把前置节点的状态设置为Singnal
if (h != null && h.waitStatus != 0)
//唤醒后继节点
unparkSuccessor(h);
return true;
}
return false;
}

4.1 tryRelease

 protected final boolean tryRelease(int releases) {
//重入了n次,当前释放m次 c=n-m
int c = getState() - releases;
//如果不是独占锁的线程 那么抛出异常
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
//是否完全的释放了锁
boolean free = false;
//只有剩下的为0 才是完全释放锁
if (c == 0) {
//置为true
free = true;
//独占线程设置为null
setExclusiveOwnerThread(null);
}
//修改state
setState(c);
return free;
}

需要注意的是只有完全的释放了共享资源(在ReentrantLock里就是加锁n次解锁n次)才返回true,才会去唤醒后继节点

4.2 unparkSuccessor

 private void unparkSuccessor(Node node) {
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
Node s = node.next;
if (s == null || s.waitStatus > 0) {
s = null;
//从尾巴开始找到队列最前面的且需要通知的节点 为什么要从尾巴开始找?
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)//大于0代表放弃了
s = t;
}
if (s != null)
//唤醒
LockSupport.unpark(s.thread);
}

使用 LockSupport.unpark(s.thread)唤醒线程,这里需要品一品 Doug Lea 他为什么要从尾部开始唤醒

  • 再品入队

     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;
    }

    • 快速入队

      快速入队要求尾节点不为空,如果尾节点为空那么说明


      1. 当前没有线程竞争,锁只有一个线程再使用,直接tryAcquire就成功了,所以头和尾都没有初始化
    • 完整入队


      • 进入完整入队的条件
        • 队列头和尾没有初始化
        • CAS失败,也就是说存在比较多的线程在执行快速入队

       private Node enq(final Node node) {
      for (;;) {
      Node t = tail;
      if (t == null) { // Must initialize
      if (compareAndSetHead(new Node()))
      tail = head;
      } else {
      //假设AB两个线程现在正在抢锁
      node.prev = t;
      //CAS设置尾 当前线程A被设置为了尾
      if (compareAndSetTail(t, node)) {
      //假如A执行这一行的时候用完了时间片,轮到了B
      //B把自己设置了尾并且B的前置是A,此时A的前置还没来得及设置
      //如果这个时候进行唤醒,从头开始遍历的话会发现没有后面的节点了
      //所以需要从尾开始,找到B,B继续往前找到A
      //Doug Lea 永远的神
      t.next = node;
      return t;
      }
      }
      }
      }
  • 为什么要从尾开始遍历

5.其他

5.1独占式尝试获取锁—— tryLock方法

这部分都是调用的nonfairTryAcquire方法,也就是是说无论是公平还是非公平都是直接不公平的获取资源。tryLock方法是直接尝试,只有当前共享资源没有被占用的时候返回true,否则false 并且是立即返回所以无论是公平还是非公平,调用这个方法都是一样的逻辑——有人占着厕所那就直接回去继续工作

5.2 独占式响应中断的获取锁——lockInterruptibly

这个方法直接调用了AQS的acquireInterruptibly(1)

 public final void acquireInterruptibly(int arg)
throws InterruptedException {
//如果已经中断了那么抛出中断异常
//Thread.interrupted() 会清除中断标识,因为抛出InterruptedException就是响应了中断,
if (Thread.interrupted())
throw new InterruptedException();
//调用公平or非公平自己复写的方法
if (!tryAcquire(arg))
//如果尝试获取共享资源失败了 那么入队,自旋的共享资源
doAcquireInterruptibly(arg);
}
5.2.1 doAcquireInterruptibly

基本上和acquireQueued差不多,就是自旋时发现中断了那么抛出中断异常,注意parkAndCheckInterrupt是调用的Thread.interrupted(),会清除中断标识

 private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
//不是interrupted 而是 isInterrupted
return Thread.isInterrupted();
}
使用interrupted会重置中断标识,因为若中断表示没有重置,
线程再次获取锁进行自旋将无法被park方法挂起导致无限自选耗费cpu资源,
且如果不重置中断那么用户的业务逻辑会收到影响(while(当前线程没有中断){执行业务代码})
【2022-7-11 评论区一名博主指出,这是他的主页https://www.cnblogs.com/sunankang/】
5.2.2 cancelAcquire

放弃共享资源的争抢,一般是等待超时,或者被中断后响应中断

 private void cancelAcquire(Node node) {
// Ignore if node doesn't exist
if (node == null)
return;
node.thread = null;
// 删除放弃的节点
Node pred = node.prev;
while (pred.waitStatus > 0)
node.prev = pred = pred.prev;
//当前节点的前置节点
Node predNext = pred.next;
//设置当前节点状态为取消
node.waitStatus = Node.CANCELLED;

// 如果当前节点是尾节点,且CAS设置前置节点为尾节点成功
//在共享模式中可能存在当前线程放弃,但是后续存在线程入队的情况
//所以这里的CAS可能失败
if (node == tail && compareAndSetTail(node, pred)) {
//CAS设置前置节点的next为null 相当于删除自己
compareAndSetNext(pred, predNext, null);
} else {
//当前节点不是尾节点,
//或者说在共享模式下,上面一步的compareAndSetTail失败
int ws;
//前置不是头,头节点释放后自然会唤醒后继
//且 前置节点已经是SIGNAL 或者CAS设置前置节点状态为SIGNAL成功
if (pred != head &&
((ws = pred.waitStatus) == Node.SIGNAL ||
(ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
pred.thread != null) {
Node next = node.next;
//CAS设置前置的next是当前节点的后继 删除自己
if (next != null && next.waitStatus <= 0)
compareAndSetNext(pred, predNext, next);
} else {
//前置是头 或者前置不是SIGNAL或者CAS设置SIGNAL(说明前置也放弃)
//直接唤醒后继节点,后继节点会继续自旋获取锁
unparkSuccessor(node);
}

node.next = node; // help GC
}
}

总体上就是,如果当前节点前面存在节点可以唤醒当前节点的后继节点,那么为二者建立联系,否则直接唤醒后面的节点,最终都会把自己从队列移除

5.3超时并响应中断的获取锁——tryLock(long timeout, TimeUnit unit)

直接调用了AQS的tryAcquireNanos方法

 public final boolean tryAcquireNanos(int arg, long nanosTimeout)
throws InterruptedException {
//上来就判断下是否中断了
if (Thread.interrupted())
throw new InterruptedException();
//尝试获取锁 (调用对应公平和非公平的方法)or doAcquireNanos
//所以返回true的情况是 上来tryAcquire尝试CAS成功 或者调用doAcquireNanos超时等待获取锁成功
return tryAcquire(arg) ||
doAcquireNanos(arg, nanosTimeout);
}
5.3.1doAcquireNanos

大致逻辑还是那些,需要注意的是,nanosTimeout > spinForTimeoutThreshold,剩余时间大于阈值(1000)才会挂起,如果小于那么还是进行自旋,因为非常短的超时时间无法做到十分精确(挂起和唤醒也是需要时间的)进行超时等待反而会表现得不精确

标签: Java

添加新评论