并发编程的艺术--第五章-Java中的锁

内容主要围绕两个方面:

使用,通过示例演示这些组件的使用方法以及详细介绍与锁相关的API;

实现,通过分析源码来剖析实现细节,因为理解实现的细节方能更加得心应手且正确地使用这些组件。

Lock接口

锁是用来控制多个线程访问共享资源的方式,一般来说,一个锁能够防止多个线程同时

访问共享资源(但是有些锁可以允许多个线程并发的访问共享资源,比如读写锁)。

它提供了与synchronized关键字类似的同步功能,只是在使用时需要显式地获取和释放锁。

虽然它缺少了(通过synchronized块或者方法所提供的)隐式获取释放锁的便捷性,

但是却拥有了锁获取与释放的可操作性、可中断的获取锁以

及超时获取锁等多种synchronized关键字所不具备的同步特性。

使用synchronized关键字将会隐式地获取锁,但是它将锁的获取和释放固化了,也就是先
获取再释放。当然,这种方式简化了同步的管理,可是扩展性没有显示的锁获取和释放来的

好。

Lock的使用的方式:


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

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



Lock接口的实现基本都是通过聚合了一个同步器的子类来完成线程访问控制的。

队列同步器

队列同步器AbstractQueuedSynchronizer(以下简称同步器),是用来构建锁或者其他同步组
件的基础框架,它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获
取线程的排队工作,并发包的作者(Doug Lea)期望它能够成为实现大部分同步需求的基础。

同步器的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状
态,在抽象方法的实现过程中免不了要对同步状态进行更改,这时就需要使用同步器提供的3
个方法(getState()、setState(int newState)和compareAndSetState(int expect,int update))来进行操
作,因为它们能够保证状态的改变是安全的。子类推荐被定义为自定义同步组件的静态内部
类,同步器自身没有实现任何同步接口,它仅仅是定义了若干同步状态获取和释放的方法来
供自定义同步组件使用,同步器既可以支持独占式地获取同步状态,也可以支持共享式地获
取同步状态,这样就可以方便实现不同类型的同步组件(ReentrantLock、

ReentrantReadWriteLock和CountDownLatch等)。

同步器是实现锁(也可以是任意同步组件)的关键,在锁的实现中聚合同步器,利用同步
器实现锁的语义。可以这样理解二者之间的关系:锁是面向使用者的,它定义了使用者与锁交
互的接口(比如可以允许两个线程并行访问),隐藏了实现细节;同步器面向的是锁的实现者,
它简化了锁的实现方式,屏蔽了同步状态管理、线程的排队、等待与唤醒等底层操作。锁和同
步器很好地隔离了使用者和实现者所需关注的领域。

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

模板方法将会调用使用者重写的方法。

重写同步器指定的方法时,需要使用同步器提供的如下3个方法来访问或修改同步状态。
·getState():获取当前同步状态。
·setState(int newState):设置当前同步状态。
·compareAndSetState(int expect,int update):使用CAS设置当前状态,该方法能够保证状态

设置的原子性。



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

独占锁就是在同一时刻只能有一个线程获取到锁,而其他获取锁的线程只能

处于同步队列中等待,只有获取锁的线程释放了锁,后继的线程才能够获取锁。

接下来将从实现角度分析同步器是如何完成线程同步的,主要包括:同步队列、独占式同
步状态获取与释放、共享式同步状态获取与释放以及超时获取同步状态等同步器的核心数据
结构与模板方法。

1.同步队列

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

次尝试获取同步状态。





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

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

步队列中移出,该方法代码如代码清单5-3所示。


其主要逻辑是:首先调用自定义同步器实现的tryAcquire(int arg)方法,该方法
保证线程安全的获取同步状态,如果同步状态获取失败,则构造同步节点(独占式
Node.EXCLUSIVE,同一时刻只能有一个线程成功获取同步状态)并通过addWaiter(Node node)
方法将该节点加入到同步队列的尾部,最后调用acquireQueued(Node node,int arg)方法,使得该
节点以“死循环”的方式获取同步状态。如果获取不到则阻塞节点中的线程,而被阻塞线程的

唤醒主要依靠前驱节点的出队或阻塞线程被中断来实现。

在acquireQueued(final Node node,int arg)方法中,当前线程在“死循环”中尝试获取同步状
态,而只有前驱节点是头节点才能够尝试获取同步状态,这是为什么?原因有两个,如下。
第一,头节点是成功获取到同步状态的节点,而头节点的线程释放了同步状态之后,将会
唤醒其后继节点,后继节点的线程被唤醒后需要检查自己的前驱节点是否是头节点。

第二,维护同步队列的FIFO原则。


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

由于中断而被唤醒)。


前驱节点为头节点且能够获取同步状态的判断条件和线程进入等待状态是获
取同步状态的自旋过程。当同步状态获取成功之后,当前线程从acquire(int arg)方法返回,如果
对于锁这种并发组件而言,代表着当前线程获取了锁。
当前线程获取同步状态并执行了相应逻辑之后,就需要释放同步状态,使得后续节点能
够继续获取同步状态。通过调用同步器的release(int arg)方法可以释放同步状态,该方法在释

放了同步状态之后,会唤醒其后继节点(进而使后继节点重新尝试获取同步状态)。


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

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

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


左半部分,共享式访问资源时,其他共享式的访问均被允许,而独占式访问被

阻塞,右半部分是独占式访问资源时,同一时刻其他访问均被阻塞。

在共享式获取的自旋过程中,成功获取到同步状态并退出自旋的条件就是
tryAcquireShared(int arg)方法返回值大于等于0。

,在doAcquireShared(int arg)方法的自
旋过程中,如果当前节点的前驱为头节点时,尝试获取同步状态,如果返回值大于等于0,表示
该次获取同步状态成功并从自旋过程中退出。

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

释放同步状态的操作会同时来自多个线程。

4.独占式超时获取同步状态

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

先介绍一下响应中断的同步状态获取过程:

在Java 5之前,当一
个线程获取不到锁而被阻塞在synchronized之外时,对该线程进行中断操作,此时该线程的中
断标志位会被修改,但线程依旧会阻塞在synchronized上,等待着获取锁。在Java 5中,同步器
提供了acquireInterruptibly(int arg)方法,这个方法在等待获取同步状态时,如果当前线程被中
断,会立刻返回,并抛出InterruptedException。

超时获取同步状态过程可以被视作响应中断获取同步状态过程的“增强版”,
doAcquireNanos(int arg,long nanosTimeout)方法在支持响应中断的基础上,增加了超时获取的
特性。针对超时获取,主要需要计算出需要睡眠的时间间隔nanosTimeout,为了防止过早通知,
nanosTimeout计算公式为:nanosTimeout-=now-lastTime,其中now为当前唤醒时间,lastTime为上
次唤醒时间,如果nanosTimeout大于0则表示超时时间未到,需要继续睡眠nanosTimeout纳秒,

反之,表示已经超时。

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

blocker,long nanos)方法返回)。

如果nanosTimeout小于等于spinForTimeoutThreshold(1000纳秒)时,将不会使该线程进行
超时等待,而是进入快速的自旋过程。原因在于,非常短的超时等待无法做到十分精确,如果
这时再进行超时等待,相反会让nanosTimeout的超时从整体上表现得反而不精确。因此,在超

时非常短的场景下,同步器会进入无条件的快速自旋。


独占式超时获取同步状态doAcquireNanos(int arg,long nanosTimeout)
和独占式获取同步状态acquire(int args)在流程上非常相似,其主要区别在于未获取到同步状
态时的处理逻辑。acquire(int args)在未获取到同步状态时,将会使当前线程一直处于等待状
态,而doAcquireNanos(int arg,long nanosTimeout)会使当前线程等待nanosTimeout纳秒,如果当
前线程在nanosTimeout纳秒内没有获取到同步状态,将会从等待逻辑中自动返回。

5.自定义同步组件——TwinsLock

设计一个同步工具:该工具在同一时刻,只允许至多两个线程同时访问,超过两个线程的

访问将被阻塞,我们将这个同步工具命名为TwinsLock。

首先,确定访问模式。

其次,定义资源数。

最后,组合自定义同步器。



编写一个测试来验证TwinsLock是否能按照预期工作:


可以看到线程名称成对输出,也就是在同一时刻只有两个线程能够获
取到锁,这表明TwinsLock可以按照预期正确工作。

重入锁

重入锁ReentrantLock,顾名思义,就是支持重进入的锁,它表示该锁能够支持一个线程对
资源的重复加锁。除此之外,该锁的还支持获取锁时的公平和非公平性选择。

Mutex是一个不支持重进入的锁。而synchronized关键字隐式的支持重进入,比如一个synchronized修饰的递归方
法,在方法执行时,执行线程在获取了锁之后仍能连续多次地获得该锁,而不像Mutex由于获取了锁,而在下一次获取锁时出现阻塞自己的情况。

这里提到一个锁获取的公平性问题,如果在绝对时间上,先对锁进行获取的请求一定先
被满足,那么这个锁是公平的,反之,是不公平的。公平的获取锁,也就是等待时间最长的线
程最优先获取锁,也可以说锁获取是顺序的。ReentrantLock提供了一个构造函数,能够控制锁
是否是公平的。

1.实现重进入

重进入是指任意线程在获取到锁之后能够再次获取该锁而不会被锁所阻塞,该特性的实
现需要解决以下两个问题:
1)线程再次获取锁。锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再
次成功获取。
2)锁的最终释放。线程重复n次获取了锁,随后在第n次释放该锁后,其他线程能够获取到
该锁。锁的最终释放要求锁对于获取进行计数自增,计数表示当前锁被重复获取的次数,而锁
被释放时,计数自减,当计数等于0时表示锁已经成功释放。

ReentrantLock是通过组合自定义同步器来实现锁的获取与释放。

以非公平性(默认的)实现为例,获取同步状态:

该方法增加了再次获取同步状态的处理逻辑:通过判断当前线程是否为获取锁的线程来
决定获取操作是否成功,如果是获取锁的线程再次请求,则将同步状态值进行增加并返回
true,表示获取同步状态成功。

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

2.公平与非公平获取锁的区别

公平性锁每次都是从同步队列中的

第一个节点获取到锁,而非公平性锁出现了一个线程连续获取锁的情况。

公平性锁保证了锁的获取按照FIFO原则,而代价是进行大量的线程切换。非公平性锁虽
然可能造成线程“饥饿”,但极少的线程切换,保证了其更大的吞吐量。

为什么会出现线程连续获取锁的情况呢?

回顾nonfairTryAcquire(int acquires)方法,当一
个线程请求锁时,只要获取了同步状态即成功获取锁。在这个前提下,刚释放锁的线程再次获
取同步状态的几率会非常大,使得其他线程只能在同步队列中等待。

读写锁

读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读
线程和其他写线程均被阻塞。读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写

锁,使得并发性相比一般的排他锁有了很大提升。

读写锁能够简化读写交互场景的编程方式。

只需要在读操作时获取读锁,写操作时获取写锁即可。当写锁被获取到时,后续(非当前写
操作线程)的读写操作都会被阻塞,写锁释放之后,所有操作继续执行,编程方式相对于使用
等待通知机制的实现方式而言,变得简单明了。

等待通知机制:当写操作开始时,所有晚于写操作的读操作均会进入等待状态,只有写操作完成并
进行通知之后,所有等待的读操作才能继续执行(写操作之间依靠synchronized关键进行同
步),这样做的目的是使读操作能读取到正确的数据,不会出现脏读。

ReentrantReadWriteLock的特性


ReadWriteLock仅定义了获取读锁和写锁的两个方法,即readLock()方法和writeLock()方
法,而其实现——ReentrantReadWriteLock,除了接口方法之外,还提供了一些便于外界监控其

内部工作状态的方法。


1.读写状态的设计

读写锁同样依赖自定义同步器来实现同步功能,而读写状态就是其同步器的同步状态。

读写锁的自定义同步器需要在同步状态(一个整型变量)上维护多个读线程和一个写线程的状
态,使得该状态的设计成为读写锁实现的关键。

如果在一个整型变量上维护多种状态,就一定需要“按位切割使用”这个变量,读写锁将
变量切分成了两个部分,高16位表示读,低16位表示写,划分方式如图5-8所示。


读写锁是如何迅速确定读和写各自的状态呢?

答案是通过位运算。假设当前同步状态
值为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,即读锁已被获取。

2.写锁的获取与释放

写锁是一个支持重进入的排它锁。如果当前线程已经获取了写锁,则增加写状态。如果当
前线程在获取写锁时,读锁已经被获取(读状态不为0)或者该线程不是已经获取写锁的线程,

则当前线程进入等待状态。

该方法除了重入条件(当前线程为获取了写锁的线程)之外,增加了一个读锁是否存在的判断。如果存在读锁,则写锁不能被获取,原因在于:读写锁要确保写锁的操作对读锁可见,如
果允许读锁在已被获取的情况下对写锁的获取,那么正在运行的其他读线程就无法感知到当
前写线程的操作。因此,只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写
锁一旦被获取,则其他读写线程的后续访问均被阻塞。

3.读锁的获取与释放

读锁是一个支持重进入的共享锁,它能够被多个线程同时获取,在没有其他写线程访问
(或者写状态为0)时,读锁总会被成功地获取,而所做的也只是(线程安全的)增加读状态。如
果当前线程已经获取了读锁,则增加读状态。如果当前线程在获取读锁时,写锁已被其他线程

获取,则进入等待状态。

在tryAcquireShared(int unused)方法中,如果其他线程已经获取了写锁,则当前线程获取读
锁失败,进入等待状态。如果当前线程获取了写锁或者写锁未被获取,则当前线程(线程安全,
依靠CAS保证)增加读状态,成功获取读锁。
读锁的每次释放(线程安全的,可能有多个读线程同时释放读锁)均减少读状态,减少的
值是(1<<16)。

4.锁降级

锁降级指的是写锁降级成为读锁。如果当前线程拥有写锁,然后将其释放,最后再获取读
锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,再获取到
读锁,随后释放(先前拥有的)写锁的过程。

锁降级中读锁的获取是否必要呢?答案是必要的。主要是为了保证数据的可见性,如果
当前线程不获取读锁而是直接释放写锁,假设此刻另一个线程(记作线程T)获取了写锁并修
改了数据,那么当前线程无法感知线程T的数据更新。如果当前线程获取读锁,即遵循锁降级
的步骤,则线程T将会被阻塞,直到当前线程使用数据并释放读锁之后,线程T才能获取写锁进

行数据更新。

RentrantReadWriteLock不支持锁升级(把持读锁、获取写锁,最后释放读锁的过程)。目的
也是保证数据可见性,如果读锁已被多个线程获取,其中任意线程成功获取了写锁并更新了
数据,则其更新对其他获取到读锁的线程是不可见的。

LockSupport工具


Condition接口

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


Condition对象是由Lock对象(调用Lock对象的newCondition()方法)创
建出来的,换句话说,Condition是依赖Lock对象的。


下面通过一个有界队列的示例来
深入了解Condition的使用方式。有界队列是一种特殊的队列,当队列为空时,队列的获取操作
将会阻塞获取线程,直到队列中有新增元素,当队列已满时,队列的插入操作将会阻塞插入线
程,直到队列出现“空位”。

Condition的实现,主要包括:等待队列、等待和通知,下面提到的Condition如
果不加说明均指的是ConditionObject。

1.等待队列

等待队列是一个FIFO的队列,在队列中的每个节点都包含了一个线程引用,该线程就是
在Condition对象上等待的线程,如果一个线程调用了Condition.await()方法,那么该线程将会
释放锁、构造成节点加入等待队列并进入等待状态。事实上,节点的定义复用了同步器中节点
的定义,也就是说,同步队列和等待队列中节点类型都是同步器的静态内部类
AbstractQueuedSynchronizer.Node。

一个Condition包含一个等待队列,Condition拥有首节点(firstWaiter)和尾节点
(lastWaiter)。当前线程调用Condition.await()方法,将会以当前线程构造节点,并将节点从尾部

加入等待队列。


在Object的监视器模型上,一个对象拥有一个同步队列和等待队列,而并发包中的

Lock(更确切地说是同步器)拥有一个同步队列和多个等待队列。


Condition的实现是同步器的内部类,因此每个Condition实例都能够访问同步器
提供的方法,相当于每个Condition都拥有所属同步器的引用。

2.等待

调用Condition的await()方法(或者以await开头的方法),会使当前线程进入等待队列并释
放锁,同时线程状态变为等待状态。当从await()方法返回时,当前线程一定获取了Condition相

关联的锁。

如果从队列(同步队列和等待队列)的角度看await()方法,当调用await()方法时,相当于同
步队列的首节点(获取了锁的节点)移动到Condition的等待队列中。

调用该方法的线程成功获取了锁的线程,也就是同步队列中的首节点,该方法会将当前
线程构造成节点并加入等待队列中,然后释放同步状态,唤醒同步队列中的后继节点,然后当
前线程会进入等待状态。
当等待队列中的节点被唤醒,则唤醒节点的线程开始尝试获取同步状态。如果不是通过
其他线程调用Condition.signal()方法唤醒,而是对等待线程进行中断,则会抛出
InterruptedException。
如果从队列的角度去看,当前线程加入Condition的等待队列,该过程如图5-11示。
如图所示,同步队列的首节点并不会直接加入等待队列,而是通过addConditionWaiter()方法把当前线程构造成一个新的节点并将其加入等待队列中。

3.通知

调用Condition的signal()方法,将会唤醒在等待队列中等待时间最长的节点(首节点),在

唤醒节点之前,会将节点移到同步队列中。


调用该方法的前置条件是当前线程必须获取了锁,可以看到signal()方法进行了
isHeldExclusively()检查,也就是当前线程必须是获取了锁的线程。接着获取等待队列的首节点,将其移动到同步队列并使用LockSupport唤醒节点中的线程。


通过调用同步器的enq(Node node)方法,等待队列中的头节点线程安全地移动到同步队
列。当节点移动到同步队列后,当前线程再使用LockSupport唤醒该节点的线程。
被唤醒后的线程,将从await()方法中的while循环中退出(isOnSyncQueue(Node node)方法
返回true,节点已经在同步队列中),进而调用同步器的acquireQueued()方法加入到获取同步状
态的竞争中。
成功获取同步状态(或者说锁)之后,被唤醒的线程将从先前调用的await()方法返回,此
时该线程已经成功地获取了锁。
Condition的signalAll()方法,相当于对等待队列中的每个节点均执行一次signal()方法,效
果就是将等待队列中所有节点全部移动到同步队列中,并唤醒每个节点的线程。






猜你喜欢

转载自blog.csdn.net/qq_40722284/article/details/80475791