并发编程05--Java中的锁(Lock接口和队列同步器)

Java中的锁

Lock接口

锁是用来控制多个线程控制访问共享资源的方式,一般来说,一个锁能限制多个线程同时访问共享资源(但是有些锁可以允许多个线程并发的访问共享资源,比如读写锁).在Lock接口出现之前,Java程序是靠synchronized关键字实现锁功能的.

而Java SE 5之后,并发包中新增了Lock接口(以及相关实现类)用来实现锁功能,它提供了与synchronized关键字类似的同步功能,只是在使用时需要显式地获取和释放锁。虽然它缺少了(通过synchronized块或者方法所提供的)隐式获取锁的便捷性,但是却拥有了获取锁与释放锁的可操作性,可中断性的获取锁以及超时获取锁等多种synchronized关键字不具备的同步特性.

使用synchronized关键字将会隐式地获取锁,但是它将锁的获取和释放固化了,也就是先获取再释放.当然,这种方式简化了同步的管理,可是拓展性没有显示的锁获取和释放来的好.
eg:针对一个场景,手把手进行锁获取和释放,先获得锁A,然后再获取锁B,当锁B获得后,释放锁A同时获取锁C,当锁C获得后,再释放B同时获取锁D,以此类推。这种场景下,synchronized关键字就不那么容易实现了,而使用Lock却容易许多。

LockUseCase.java

在finally块中释放锁,目的是保证在获取到锁之后,最终能够被释放。

注意:不要将获取锁的过程写在try块中,因为如果在获取锁(自定义锁的实现)时发生了异常,异常抛出的同时,也会导致锁无故释放。

Lock接口提供的synchronized关键字不具备的主要特性

Lock的API


队列同步器

队列同步器的接口与示例

同步器的设计是基于模板方法的,也就是说,使用者需要继承同步器并重写指定的方法,随后将同步器组合在自定义同步组件的实现中,并调用同步器提供的模板方法,而这些模板方法将会调用使用者重写的方法。

重写同步器指定的方法时,需要使用同步器提供如下的3个方法来访问或修改同步状态.

  • getState():获取当前同步状态
  • setState(int newState):设置当前同步状态。
  • compareAndSetState(int expect,int update):使用CAS设置当前状态,该方法能够保证状态设置 的原子性
同步器可重写方法

实现自定义同步组件时,将会调用同步器提供的模板方法

同步器提供的模板方法

同步器提供的模板方法基本上分为3类:

  • 独占式获取与释放同步状态
  • 共享式获取与释放同步状态
  • 查询同步队列中的等待情况

自定义同步组件将使用同步器提供的模板方法来实现自己的同步语义。

独占锁:在同一时刻只能有一个线程获取到锁,而其他获取锁的线程只能处于同步队列中等待,只有获取锁的线程释放了锁,后继的线程才能够获取锁.

Mutex.java

上述的示例中,独占锁是一个自定义同步组件,它在同一时刻只允许一个线程占有锁。Mutex中定义了一个静态内部类,该内部类继承了同步器并实现了独占式获取和释放同步状态。在tryAcquire(int acquires)方法中,如果经过CAS设置成功(同步状态设置为1),则代表获取了同步状态,而在tryRelease(int releases)方法中只是将同步状态重置为0。用户使用Mutex时并不会直接和内部同步器的实现打交道,而是调Mutex提供的方法,在Mutex的实现中,以获取锁的lock()方法为例,只需要在方法实现中调用同步器的模板方法acquire(int args)即可,当前线程调用该方法获取同步状态失败后会被加入到同步队列中等待,这样就大大降低了实现一个可靠自定义同步组件的门槛。

队列同步器的实现分析

同步队列

同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态管理.当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成一个节点(Node)并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态.

同步队列中的节点(Node)用来保存获取同步状态失败的线程引用,等待状态以及前驱和后继节点,节点属性名称以及描述如下.

节点是构成同步队列的基础,同步器拥有首节点(head)和尾节点(tail),没有成功获取同步状态的线程将会成为节点加入该队列的尾部.


同步队列的基本结构

同步器包含了两个节点类型的应用,一个指向头结点,而另一个指向尾节点.

当一个线程成功获取了同步状态或者锁的时候,其他线程将无法获取到同步状态,转而被构造成节点并加入同步队列中.而这个加入队列的过程必须保证线程安全,因此同步器提供了一个基于CAS的设置尾节点的方法:compareAndSetTail(Node expect,Node update),他需要传递当前线程"认为"的尾节点和当前节点,只有设置成功后,当前节点才正式与之前的尾节点建立关联.

节点加入到同步队列

同步队列遵循循FIFO,首节点是获取同步状态成功的节点,首节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点.


首节点的设置

首节点的设置是通过读取同步状态成功的线程来完成的,由于只有一个线程能够获取到同步状态,因此设置头结点的方法不需要CAS来保证,它只需要将首节点设置成为原首节点的后继节点并断开首节点的next引用即可.

独占式同步状态获取与释放

通过调用同步器的的acquire(int arg)方法可以获取同步状态,该方法对中断不敏感,也就是由于线程获取同步状态失败后进入同步队列中,后续对线程进行中断操作时,线程不会从同步队列中移出.

同步器的acquire方法

上述代码主要完成了同步状态获取,节点构造,介入同步队列以及在同步队列中自旋等待的相关工作,其主要逻辑是:首先调用自定义同步器实现的tryAcquire(int arg)方法,该方法保证线程安全的获取同步状态,如果同步状态获取失败,则构造同步节点(独占式Node.EXCLUSIVE,同一时刻只能有一个线程成功获取同步状态)并通过addWaiter(Node node)方法将该节点加入到同步队列的尾部,最后调用acquireQueued(Node node,int arg)方法,使得该节点以“死循环”的方式获取同步状态。如果获取不到则阻塞节点中的线程,而被阻塞线程的唤醒主要依靠前驱节点的出队或阻塞线程被中断来实现。

同步器的addWaiter和enq方法

上述代码通过使用compareAndSetTail(Node expect,Node update)方法来确保节点能够被线程安全添加.

在enq(final Node node)方法中,同步器通过“死循环”来保证节点的正确添加,在“死循环”中只有通过CAS将节点设置成为尾节点之后,当前线程才能从该方法返回,否则当前线程不断地尝试设置。可以看出,enq(final Node node)方法将并发添加节点的请求通过CAS变得“串行化”了。

节点进入同步队列之后,就进入了一个自旋的过程,每个节点(或者说每个线程)都在自省地观察,当条件满足,获取到了同步状态,就可以从这个自旋过程中退出,否则依旧留在这个自旋过程中(并会阻塞节点的线程)


同步器的acquireQueued方法

在acquireQueued(final Node node,int arg)方法中,当前线程在“死循环”中尝试获取同步状态,而只有前驱节点是头节点才能够尝试获取同步状态,这是为什么?原因有两个,如下。

1.头节点是成功获取到同步状态的节点,而头节点的线程释放了同步状态之后,将会唤醒其后继节点,后继节点的线程被唤醒后需要检查自己的前驱节点是否是头点。

2.维护同步队列的FIFO原则。

节点自旋获取同步状态

在上图中,由于非首节点线程前驱节点出队或者被中断从而等待状态返回,随后检查自己的前驱节点是否是头节点,如果是则尝试获取同步状态.看到节点和节点之间在循环检查的过程中基本不相互通信,而只是简单地判断自己的前驱是否为头结点,这样就使得节点的释放规则符合FIFO,并且也便于过早通知的处理(过早通知是指前驱节点不是头节点的线程由于中断而被唤醒)。

独占式同步状态获取流程

前驱节点为头结点且能够获取同步状态的判断条件和线程进入等待状态是获取同步状态的自旋过程.当同步状态获取成功之后,当前线程从acquire(int arg)方法返回,如果对于锁这种并发组件而言,代表当前线程获取了锁.

当前线程获取同步状态并执行了相应的逻辑之后,就需要释放同步状态,使得后续节点能够获取同步状态.通过调用同步器的release(int arg)方法可以释放同步状态,该方法在释放了状态之后,会唤醒其后集节点(进而使后继节点重新尝试获取同步状态)。


同步器的release方法

该方法执行时,会唤醒头结点的后集节点线程,unparkSuccessor(Node node)方法使用LockSupport来唤醒处于等待状态的线程。

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


共享式同步状态获取与释放

共享式获取和独占最主要的区别在于同一时刻是否能够有多个线程同时获取到同步状态.

以文件的读写为例,如果一个程序在对文件进行读操作,那么这一时刻对于该文件的写操作均被阻塞,而读操作能够同时进行。写操作要求对资源的独占式访问,而读操作可以是共享式访问,两种不同的访问模式在同一时刻对文件或资源的访问情况.


共享式与独占式访问资源的对比
  • 左半部分,共享式访问资源时,其他共享式的访问均被允许,而独占式访问被阻塞
  • 右半部分,独占式访问资源时,同一时刻其他访问均被阻塞。

通过调用同步器的acquireShared(int arg)方法可以共享式地获取同步状态


同步器的acquireShared和doAcquireShared方法

在acquireShared(int arg)方法中,同步器调用tryAcquireShared(int arg)方法尝试获取同步状态,tryAcquireShared(int arg)方法返回值为int类型,当返回值大于等于0时,表示能够获取到同步状态。因此,在共享式获取的自旋过程中,成功获取到同步状态并退出自旋的条件就是tryAcquireShared(int arg)方法返回值大于等于0。可以看到,在doAcquireShared(int arg)方法的自旋过程中,如果当前节点的前驱为头节点时,尝试获取同步状态,如果返回值大于等于0,表示该次获取同步状态成功并从自旋过程中退出。

与独占式一样,共享式获取也需要释放同步状态,通过调用releaseShared(int arg)方法可以释放同步状态

同步器的releaseShared方法

该方法在释放同步状态之后,将会唤醒处于等待状态的节点.对于能够支持多个线程同时访问的并发组件(比如Semaphore),它和独占式主要区别在于tryReleaseShared(int arg)方法必须确保同步状态(或者资源数)线程安全释放,一般是通过循环和CAS来保证的,因为释放同步状态的操作会同时来自多个线程.


$ 独占式超时获取同步状态

通过调用同步器的doAcquireNanos(int arg,long nanosTimeout)方法可以超时获取同步状态,即在指定的时间段内获取同步状态,如果获取到同步状态则返回true,否则,返回false。该方法提供了传统Java同步操作(比如synchronized关键字)所不具备的特性。


同步器的doAcquireNanos方法

该方法在自旋过程中,当节点的前驱节点为头节点时尝试获取同步状态,如果获取成功则从该方法返回,这个过程和独占式同步获取的过程类似,但是在同步状态获取失败的处理上有所不同。如果当前线程获取同步状态失败,则判断是否超时(nanosTimeout小于等于0表示已经超时),如果没有超时,重新计算超时间隔nanosTimeout,然后使当前线程等待nanosTimeout纳秒(当已到设置的超时时间,该线程会从LockSupport.parkNanos(Objectblocker,long nanos)方法返回)。

独占式超时获取同步状态的流程

独占式超时获取同步状态doAcquireNanos(int arg,long nanosTimeout)和独占式获取同步状态acquire(int args)在流程上非常相似,其主要区别在于未获取到同步状态时的处理逻辑

  • acquire(int args)在未获取到同步状态时,将会使当前线程一直处于等待状态
  • doAcquireNanos(int arg,long nanosTimeout):使当前线程等待nanosTimeout纳秒,如果当前线程在nanosTimeout纳秒内没有获取到同步状态,将会从等待逻辑中自动返回。

参考书籍:<<Java并发编程的艺术>>

推荐阅读更多精彩内容