JUC-锁

Lock接口

  • 在Lock接口出现之前,Java程序是靠synchronized关键字实现锁功能的,而Java SE 5之后,并发包中新增了Lock接口(以及相关实现类)用来实现锁功能,它提供了与synchronized关键字类似的同步功能,只是在使用时需要显式地获取和释放锁。
  • 虽然它缺少(通过synchronized块或者方法所提 供的)隐式获取释放锁的便捷性,但是却拥有了锁获取与释放的可操作性、可中断的获取锁以及超时获取锁等多种synchronized关键字所不具备的同步特性。 使用synchronized关键字将会隐式地获取锁,但是它将锁的获取和释放固化了,也就是先获取再释放。当然,这种方式简化了同步的管理,可是扩展性没有显示的锁获取和释放来的好。
  • 例如,针对一个场景,手把手进行锁获取和释放,先获得锁A,然后再获取锁B,当锁B获得 后,释放锁A同时获取锁C,当锁C获得后,再释放B同时获取锁D,以此类推。这种场景下, synchronized关键字就不那么容易实现了,而使用Lock却容易许多。

Lock的使用

Lock lock = new ReentrantLock(); 
lock.lock();
try { } 
finally { lock.unlock(); } 
复制代码
  • 在finally块中释放锁,目的是保证在获取到锁之后,最终能够被释放。 不要将获取锁的过程写在try块中,因为如果在获取锁(自定义锁的实现)时发生了异常, 异常抛出的同时,也会导致锁无故释放。

Lock的API

qq_pic_merged_1659856211112.jpg

Lock提供的synchronized不具备的特性

qq_pic_merged_1659856169785.jpg

AQS队列同步器

  • 队列同步器AbstractQueuedSynchronizer,是用来构建锁或者其他同步组 件的基础框架,它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作
  • 同步器的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态,在抽象方法的实现过程中免不了要对同步状态进行更改,这时就需要使用同步器提供的3 个方法(getState()、setState(int newState)和compareAndSetState(int expect,int update))来进行操作,因为它们能够保证状态的改变是安全的。
  • 子类推荐被定义为自定义同步组件的静态内部类,同步器自身没有实现任何同步接口,它仅仅是定义了若干同步状态获取和释放的方法来供自定义同步组件使用,同步器既可以支持独占式地获取同步状态,也可以支持共享式地获取同步状态,这样就可以方便实现不同类型的同步组件(ReentrantLock、 ReentrantReadWriteLock和CountDownLatch等)。
  • 同步器是实现锁(也可以是任意同步组件)的关键,在锁的实现中聚合同步器,利用同步 器实现锁的语义。可以这样理解二者之间的关系:锁是面向使用者的,它定义了使用者与锁交 互的接口(比如可以允许两个线程并行访问),隐藏了实现细节;同步器面向的是锁的实现者, 它简化了锁的实现方式,屏蔽了同步状态管理、线程的排队、等待与唤醒等底层操作。 锁和同 步器很好地隔离了使用者和实现者所需关注的领域。

模版方法模式

  • 同步器的设计是基于模板方法模式的, 也就是说, 使用者需要继承同步器并重写指定的方法, 随后将同步器组合在自定义同步组件的实现中, 并调用同步器提供的模板方法,而这些模板方法将会调用使用者重写的方法。
  • 重写同步器指定的方法时,需要使用同步器提供的如下3个方法来访问或修改同步状态。 ·getState():获取当前同步状态。 ·setState(int newState):设置当前同步状态。 ·compareAndSetState(int expect,int update):使用CAS设置当前状态,该方法能够保证状态设置的原子性。

同步器可以重写的方法

qq_pic_merged_1659858429312.jpg

同步器提供的模版方法

qq_pic_merged_1659858447112.jpg

同步器提供的模板方法基本上分为3类:独占式获取与释放同步状态、共享式获取与释放 同步状态和查询同步队列中的等待线程情况。自定义同步组件将使用同步器提供的模板方法 来实现自己的同步语义。

同步队列

  • 同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,当前线程获取 同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点(Node)并将其 加入同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态。
  • 节点是构成同步队列的基础,同步器拥有首节点(head) 和尾节点(tail),没有成功获取同步状态的线程将会成为节点加入该队列的尾部
  • 同步器包含了两个节点类型的引用,一个指向头节点,而另一个指向尾节点。 试想一下,当一个线程成功地获取了同步状态(或者锁),其他线程将无法获取到同步状态,转 而被构造成为节点并加入到同步队列中,而这个加入队列的过程必须要保证线程安全,因此同步器提供了一个基于CAS的设置尾节点的方法:compareAndSetTail(Node expect,Node update),它需要传递当前线程“认为”的尾节点和当前节点,只有设置成功后,当前节点才正式 与之前的尾节点建立关联。
  • 同步器加入尾节点过程 qq_pic_merged_1659859303722.jpg
  • 同步队列遵循FIFO,首节点是获取同步状态成功的节点,首节点的线程在释放同步状态 时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点 qq_pic_merged_1659859289797.jpg

独占式同步状态获取流程图

  • 在获取同步状态时,同步器维 护一个同步队列,获取状态失败的线程都会被加入到队列中并在队列中进行自旋;移出队列 (或停止自旋)的条件是前驱节点为头节点且成功获取了同步状态。在释放同步状态时,同步 器调用tryRelease(int arg)方法释放同步状态,然后唤醒头节点的后继节点。 qq_pic_merged_1659859733001.jpg

共享式

  • 共享式获取与独占式获取最主要的区别在于同一时刻能否有多个线程同时获取到同步状态。以文件的读写为例,如果一个程序在对文件进行读操作,那么这一时刻对于该文件的写操 作均被阻塞,而读操作能够同时进行。写操作要求对资源的独占式访问,而读操作可以是共享式访问,两种不同的访问模式在同一时刻对文件或资源的访问情况,
  • 在acquireShared(int arg)方法中,同步器调用tryAcquireShared(int arg)方法尝试获取同步状 态,tryAcquireShared(int arg)方法返回值为int类型,当返回值大于等于0时,表示能够获取到同 步状态。因此,在共享式获取的自旋过程中,成功获取到同步状态并退出自旋的条件就是 tryAcquireShared(int arg)方法返回值大于等于0。可以看到,在doAcquireShared(int arg)方法的自旋过程中,如果当前节点的前驱为头节点时,尝试获取同步状态,如果返回值大于等于0,表示 该次获取同步状态成功并从自旋过程中退出。

重入锁

重进入是指任意线程在获取到锁之后能够再次获取该锁而不会被锁所阻塞,该特性的实 现需要解决以下两个问题。

  • 线程再次获取锁。锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再 次成功获取。
  • 锁的最终释放。线程重复n次获取了锁,随后在第n次释放该锁后,其他线程能够获取到 该锁。锁的最终释放要求锁对于获取进行计数自增,计数表示当前锁被重复获取的次数,而锁 被释放时,计数自减,当计数等于0时表示锁已经成功释放。

可重入锁的获取和释放

ReentrantLock的nonfairTryAcquire方法

final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState(); 
if (c == 0) {
        if (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; 
}
复制代码

ReentrantLock的nonfairTryAcquire方法

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; 
 }
复制代码

如果该锁被获取了n次,那么前(n-1)次tryRelease(int releases)方法必须返回false,而只有同 步状态完全释放了,才能返回true。可以看到,该方法将同步状态是否为0作为最终释放的条 件,当同步状态为0时,将占有线程设置为null,并返回true,表示释放成功。

公平锁和非公平锁的区别

protected final boolean tryAcquire(int acquires) { 
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) { 
    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;
} 
复制代码
  • 该方法与nonfairTryAcquire(int acquires)比较,唯一不同的位置为判断条件多了 hasQueuedPredecessors()方法,即加入了同步队列中当前节点是否有前驱节点的判断,如果该 方法返回true,则表示有线程比当前线程更早地请求获取锁,因此需要等待前驱线程获取并释 放锁之后才能继续获取锁。

读写锁

  • 读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程均被阻塞。读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升。
  • 除了保证写操作对读操作的可见性以及并发性的提升之外,读写锁能够简化读写交互场景的编程方式。假设在程序中定义一个共享的用作缓存数据结构,它大部分时间提供读服务 (例如查询和搜索),而写操作占有的时间很少,但是写操作完成之后的更新需要对后续的读服务可见。
  • 在没有读写锁支持的(Java 5之前)时候,如果需要完成上述工作就要使用Java的等待通知机制,就是当写操作开始时,所有晚于写操作的读操作均会进入等待状态,只有写操作完成并 进行通知之后,所有等待的读操作才能继续执行(写操作之间依靠synchronized关键进行同 步),这样做的目的是使读操作能读取到正确的数据,不会出现脏读。改用读写锁实现上述功 能,只需要在读操作时获取读锁,写操作时获取写锁即可。当写锁被获取到时,后续(非当前写 操作线程)的读写操作都会被阻塞,写锁释放之后,所有操作继续执行,编程方式相对于使用 等待通知机制的实现方式而言,变得简单明了。

读写状态的设计

  • 读写锁的自定义同步器需要在同步状态(一个整型变量)上维护多个读线程和一个写线程的状 态,使得该状态的设计成为读写锁实现的关键。 如果在一个整型变量上维护多种状态,就一定需要“按位切割使用”这个变量,读写锁将 变量切分成了两个部分,高16位表示读,低16位表示写 qq_pic_merged_1659862251663.jpg
  • 当前同步状态表示一个线程已经获取了写锁,且重进入了两次,同时也连续获取了两次 读锁。读写锁是如何迅速确定读和写各自的状态呢?
  • 答案是通过位运算。假设当前同步状态 值为S,写状态等于S&0x0000FFFF(将高16位全部抹去),读状态等于S>>>16(无符号补0右移 16位)。当写状态增加1时,等于S+1,当读状态增加1时,等于S+(1<<16),也就是 S+0x00010000。
  • 根据状态的划分能得出一个推论:S不等于0时,当写状态(S&0x0000FFFF)等于0时,则读 状态(S>>>16)大于0,即读锁已被获取。

ReentrantReadWriteLock的tryAcquire方法(写锁获取)

protected final boolean tryAcquire(int acquires) { 
        Thread current = Thread.currentThread(); 
        int c = getState(); 
        int w = exclusiveCount(c);
        if (c != 0) { // 存在读锁或者当前获取线程不是已经获取写锁的线程 
            if (w == 0 || current != getExclusiveOwnerThread()) 
                return false;
            if (w + exclusiveCount(acquires) > MAX_COUNT) 
                throw new Error("Maximum lock count exceeded"); 
            setState(c + acquires);
            return true; 
       }
      if (writerShouldBlock() || !compareAndSetState(c, c + acquires)) {                 return false; 
      }
  setExclusiveOwnerThread(current); 
  return true;
  }
复制代码

该方法除了重入条件(当前线程为获取了写锁的线程)之外,增加了一个读锁是否存在的该方法除了重入条件(当前线程为获取了写锁的线程)之外,增加了一个读锁是否存在的判断。如果存在读锁,则写锁不能被获取,原因在于:读写锁要确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取,那么正在运行的其他读线程就无法感知到当 前写线程的操作。因此,只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞。写锁的释放与ReentrantLock的释放过程基本类似,每次释放均减少写状态,当写状态为0时表示写锁已被释放,从而等待的读写线程能够继续访问读写锁,同时前次写线程的修改对后续读写线程可见。

ReentrantReadWriteLock的tryAcquire方法(读锁获取)

protected final int tryAcquireShared(int unused) {
        for (;;) { 
            int c = getState();
            int nextc = c + (1 << 16); 
            if (nextc < c) 
            throw new Error("Maximum lock count exceeded"); 
            if (exclusiveCount(c) != 0 && owner != Thread.currentThread())
                return -1; 
            if (compareAndSetState(c, nextc)) 
                return 1; 
   } 
   }
  }
复制代码
  • 在tryAcquireShared(int unused)方法中,如果其他线程已经获取了写锁,则当前线程获取读 锁失败,进入等待状态。如果当前线程获取了写锁或者写锁未被获取,则当前线程(线程安全, 依靠CAS保证)增加读状态,成功获取读锁。 读锁的每次释放(线程安全的,可能有多个读线程同时释放读锁)均减少读状态,减少的 值是(1<<16)。

LockSupport工具

  • LockSupport定义了一组以park开头的方法用来阻塞当前线程,以及unpark(Thread thread) 方法来唤醒一个被阻塞的线程 qq_pic_merged_1659864114346.jpg

Condition接口

  • 任意一个Java对象,都拥有一组监视器方法(定义在java.lang.Object上),主要包括wait()、 wait(long timeout)、notify()以及notifyAll()方法,这些方法与synchronized同步关键字配合,可以 实现等待/通知模式。Condition接口也提供了类似Object的监视器方法,与Lock配合可以实现等 待/通知模式,但是这两者在使用方式以及功能特性上还是有差别的。
  • qq_pic_merged_1659863957360.jpg
  • qq_pic_merged_1659863946422.jpg

等待队列

  • 等待队列是一个FIFO的队列,在队列中的每个节点都包含了一个线程引用,该线程就是 在Condition对象上等待的线程,如果一个线程调用了Condition.await()方法,那么该线程将会 释放锁、构造成节点加入等待队列并进入等待状态。事实上,节点的定义复用了同步器中节点 的定义,也就是说,同步队列和等待队列中节点类型都是同步器的静态内部类
  • 如图所示,Condition拥有首尾节点的引用,而新增节点只需要将原有的尾节点nextWaiter 指向它,并且更新尾节点即可。上述节点引用更新的过程并没有使用CAS保证,原因在于调用 await()方法的线程必定是获取了锁的线程,也就是说该过程是由锁来保证线程安全的。 在Object的监视器模型上,一个对象拥有一个同步队列和等待队列,而并发包中的 Lock(更确切地说是同步器)拥有一个同步队列和多个等待队列
  • qq_pic_merged_1659863935234.jpg

等待

  • 调用Condition的await()方法(或者以await开头的方法),会使当前线程进入等待队列并释 放锁,同时线程状态变为等待状态。当从await()方法返回时,当前线程一定获取了Condition相 关联的锁。
  • 如果从队列(同步队列和等待队列)的角度看await()方法,当调用await()方法时,相当于同 步队列的首节点(获取了锁的节点)移动到Condition的等待队列中。
public final void await() throws InterruptedException { 
        if (Thread.interrupted()) 
        throw new InterruptedException(); // 当前线程加入等待队列 
        Node node = addConditionWaiter(); // 释放同步状态,也就是释放锁 
        int savedState = fullyRelease(node); 
        int interruptMode = 0; 
        while (!isOnSyncQueue(node)) { 
        LockSupport.park(this);
        if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
        break; 
        }
        if (acquireQueued(node, savedState) && interruptMode != THROW_IE) 
        interruptMode = REINTERRUPT; 
        if (node.nextWaiter != null) unlinkCancelledWaiters(); 
        if (interruptMode != 0) reportInterruptAfterWait(interruptMode);
 }
复制代码
  • 调用该方法的线程成功获取了锁的线程,也就是同步队列中的首节点,该方法会将当前 线程构造成节点并加入等待队列中,然后释放同步状态,唤醒同步队列中的后继节点,然后当 前线程会进入等待状态。 当等待队列中的节点被唤醒,则唤醒节点的线程开始尝试获取同步状态。如果不是通过 其他线程调用Condition.signal()方法唤醒,而是对等待线程进行中断,则会抛出 InterruptedException。 如果从队列的角度去看,当前线程加入Condition的等待队列,该过程如图5-11示。 如图所示,同步队列的首节点并不会直接加入等待队列,而是通过addConditionWaiter()方法把当前线程构造成一个新的节点并将其加入等待队列中。

通知

  • 调用Condition的signal()方法,将会唤醒在等待队列中等待时间最长的节点(首节点),在 唤醒节点之前,会将节点移到同步队列中。 qq_pic_merged_1659863926349.jpg
public final void signal() { 
    if (!isHeldExclusively()) 
        throw new IllegalMonitorStateException(); 
      Node first = firstWaiter;
    if (first != null) 
     doSignal(first); 
}
复制代码
  • 调用该方法的前置条件是当前线程必须获取了锁,可以看到signal()方法进行了 isHeldExclusively()检查,也就是当前线程必须是获取了锁的线程。接着获取等待队列的首节点,将其移动到同步队列并使用LockSupport唤醒节点中的线程。
  • 通过调用同步器的enq(Node node)方法,等待队列中的头节点线程安全地移动到同步队 列。当节点移动到同步队列后,当前线程再使用LockSupport唤醒该节点的线程。 被唤醒后的线程,将从await()方法中的while循环中退出(isOnSyncQueue(Node node)方法 返回true,节点已经在同步队列中),进而调用同步器的acquireQueued()方法加入到获取同步状 态的竞争中。 成功获取同步状态(或者说锁)之后,被唤醒的线程将从先前调用的await()方法返回,此时该线程已经成功地获取了锁。 Condition的signalAll()方法,相当于对等待队列中的每个节点均执行一次signal()方法,效 果就是将等待队列中所有节点全部移动到同步队列中,并唤醒每个节点的线程

猜你喜欢

转载自juejin.im/post/7129104012653101086