Java中的锁
1.Lock 接口
锁是用来控制多个线程访问共享资源的方式,一般来说, 一个锁能够防止多个线程同时访问共享资源(但是有些锁可以允许多个线程并发的访问共享资源, 比如读写锁)。在Lock接口出现之前,Java程序是靠synchronized关键字实现锁功能的, 而Java SE 5之后,并发包中新增了Lock接口(以及相关实现类)用来实现锁功能,它提供了与synchronized关键字类似的同步功能, 只是在使用时需要显式地获取和释放锁。虽然它缺少了(通过synchronized块或者方法所提供的)隐式获取释放锁的便捷性,但是却拥有了锁获取与释放的可操作性、 可中断的获取锁以及超时获取锁等多种synchronized关键字所不具备的同步特性。
使用synchronized关键字将会隐式地获取锁,但是 它将锁的获取和释放固化了,也就是先获取再释放。当然,这种方式简化了同步的管理,可是扩展性没有显示的锁获取和释放来的好。
不要将获取锁的过程写在try块中, 因为如果在获取锁(自定义锁的实现)时发生了异常, 异常抛出的同时, 也会导致锁无故释放。
Lock 接口提供的 synchronized 关键字所不具备的主要特性如下表所示。
Lock 是一个接口,它定义了锁获取和释放的基本操作,Lock的API如下表:
Lock的API.jpg
Lock接口的实现基本都是能过聚合了一个同步器的子类完成线程访问控制的。
2.队列同步器
队列同步器AbstractQueuedSynchronizer (以下简称同步器), 是用来构建锁或者其他同步组件的基础框架, 它使用了一个int成员变量表示同步状态, 通过内置的FIFO队列来完成资源获取线程的排队工作, 并发包的作者(Doug Lea )期望它能够成为实现大部分同步需求 的基础。
同步器的主要使用方式是继承, 子类通过继承同步器并实现它的抽象方法来管理同步状态, 在抽象方法的实现过程中免不了要对同步状态进行更改, 这时就需要使用同步器提供 的3个 方法(getState()、setState(int newState)和compareAndSetState(int expect, int update)) 来进行操作,因为它们能够保证状态的改变是安全的。 子类推荐被定义为自定义同步组件 的静态内部类, 同步器自身没有实现任何同步接口, 它仅仅是定义了若干同步状态获取和释放的方法来供自定义同步组件使用, 同步器既可以支持独占式地获取同步状态, 也可以支持共享式地获取同步状态, 这样就可以方便实现不同类型的同步组件(ReentrantLock、 ReentrantReadWriteLock和CountDownLatch等) 。
同步器是实现锁(也可以是任意同步组件) 的关键, 在锁的实现中聚合同步器, 利用同步器实现锁的语义。 可以这样理解二者之间的关系:锁是面向使用者的, 它定义了使用者与 锁交互的接口(比如可以允许两个线程并行访问), 隐藏了实现细节;同步器面向的是锁的 实现者, 它简化了锁的实现方式, 屏蔽了同步状态管理、 线程的排队、等待与唤醒等底层操作。 锁和同步器很好地隔离了使用者和实现者所需关注的领域。
2.1 队列同步器的接口与示例
同步器的设计是基于模板方法模式的,使用者需要继承同步器并重写指定的方法,随后将同步器组合在自定义同步组件的实现中,并调用同步器提供的模板方法,而这些模板方法将会调用使用者重写的方法。
重写同步器指定的方法时,需要使用同步器提供的如下3个方法来访问或修改同步状态。
- getState(); 获取当前同步状态。
- setState(int newState):设置当前同步状态。
- compareAndSetState(int expect, int update):使用CAS设置当前状态,该方法能够保证状态设置的原子性。
同步器方法-2.jpg
实现自定义同步组件时,将会调用同步器提供的模板方法,这些(部分)模板方法与描述如下:
同步器提供的模板方法.jpg
同步器提供的模板方法基本上分为3类:独占式获取与释放同步状态、 共享式获取与释放同步状态和查询同步队列中的等待线程情况。 自定义同步组件将使用同步器提供的模板方法来实现自己的同步语义。
只有掌握了同步器的工作原理才能更加深入地理解并发包中其他的并发组件, 所以下面通过一个独占锁的示例来深入了解一下同步器的工作原理。
顾名思义, 独占锁就是在同一时刻只能有一个线程获取到锁, 而其他获取锁的线程只能处于同步队列中等待, 只有获取锁的线程释放了锁, 后继的线程才能够获取锁。
2.2 队列同步器的实现分析
从实现角度分析同步器是如何完成线程同步的,主要包括:同步队列、独占式 同步状态获取与释放、共享式同步状态获取与释放以及超时获取同步状态等同步器的核心数据结构与模板方法。
1.同步队列
同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点(Node) 并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态。
同步队列中的节点(Node)用来保存获取同步状态失败的线程引用、等待状态以及前驱和后继节点,节点的属性类型与名称以及描述如下表所示。
节点的属性类型与名称以及描述.jpg
节点是构成同步队列(等待队列) 的基础,同步器拥有首节点 ( head)和尾节点(tail),没有成功获取同步状态的线程将会成为节点加入该队列的尾部,同步队列的基本结构如下图所示。
compareAndSetTail(Node expect, Node update).jpg
同步器包含了两个节点类型的引用, 一个指向头节点, 而另一个指向尾节点。 试想一下, 当一个线程成功地获取了同步状态(或者锁), 其他线程将无法获取到同步状态, 转而被构造成为节点并加入到同步队列中, 而这个加入队列的过程必须要保证线程安全,因此同步器提供了一个基于CAS的设置尾节点的方法:compareAndSetTail(Node expect, Node update), 它需要传递当前线程 “认为” 的尾节点和当前节点, 只有设置成功后, 当前节点才正式与之前的尾节点建立关联。
同步器将节点加入到同步队列的过程如图所示。
节点加入同步队列的过程.png
同步队列遵循FIFO, 首节点是获取同步状态成功的节点, 首节点的线程在释放同步状态时, 将会唤醒后继节点, 而后继节点将会在获取同步状态成功时将自己设置为首节点, 该过程如图所示。
首接点的设置.png
设置首节点是通过获取同步状态成功的线程来完成的, 由于只有一个线程能够成功获取到同步状态, 因此设置头节点的方法并不需要使用CAS来保证, 它只需要将首节点设置成为原首节点的后继节点并断开原首节点的 next引用即可。
&emsp 2.独占式同步状态获取与释放
通过调用同步器的 acquire(int arg)方法可以获取同步状态, 该方法对中断不敏感, 也就是由于线程获取同步状态失败后进入同步队列中, 后续对线程进行中断操作时, 线程不会从 同步队列中移出。
3.共享式同步状态获取与释放
共享式获取与独占式获取最主要的区别在于同一时刻能否有多个线程同时 获取到同步状态。 以文件的读写为例,如果一个程序在对文件进行读操作, 那么这一时刻对于该文件的写操作均被阻塞, 而读操作能够同时进行。 写操作要求对资源的独占式访问, 而读操作可以是共享式访问, 两种不同的访问模式在 同一时刻对文件或资源的访问情况, 如下图所示。
共享和独占.png
通过调用同步器的acquireShared(int arg)方法可以共享式地获取同步状态。tryAcquireShared(int arg)方法返回值为 int 类型, 当返回值大于等于 0 时, 表示能够获取到同步状态。 因此, 在共享式获取的自旋过程中, 成功获取到同步状态并退出自旋的条件就是 tryAcquireShared(int arg)方法返回值大于等于 0。
与独占式一样, 共享式获取也需要释放同步状态, 通过调用 releaseShared(int arg)方法可以释放同步状态。
4. 独占式超时获取同步状态
通过调用同步器的 doAcquireNanos(int arg, long nanosTimeout)方法可以超时获取同步状态, 即在指定的时间段内获取同步状态, 如果获取到同步状态则返回 true,否则, 返回 false。该方法提供了传统 Java 同步操作(比如 synchronized 关键字)所不具备的特性。
在分析该方法的实现前, 先介绍一下响应中断的同步状态获取过程。在 Java 5 之前, 当一个线程获取不到锁而被阻塞在 synchronized 之外时, 对该线程进行中断操作, 此时该线程的中断标志位会被修改, 但线程依旧会阻塞在 synchronized 上, 等待着获取锁。在 Java 5 中, 同步器提供了 acquirelnterruptibly(int arg)方法, 这个方法在等待获取同步状态时, 如果 当前线程被中断, 会立刻返回, 并抛出 InterruptedException。
超时获取同步状态过程可以被视作响应中断获取同步状态过程的 “增强版”, doAcquireNanos(int arg, long nanosTimeout)方法在支持响应中断的基础上, 增加了超时获取 的特性。针对超时获取, 主要需要计算出需要睡眠的时间间隔 nanosTimeout,为了防止过早通知, nanosTimeout 计算公式为: nanosTimeout -= now - lastTime,其中 now 为当前唤醒时间, lastTime 为上次唤醒时间, 如果 nanosTimeout 大于 0 则表示超时时间未到, 需要继续睡眠 nanosTimeout 纳秒, 反之, 表示已经超时。
3.重入锁
重人锁 ReentrantLock,顾名思义, 就是支持重进入的锁, 它表示该锁能够支持一个线程对资源的重复加锁。 除此之外, 该锁的还支持获取锁时的公平和非公平性选择。
回忆在同步器一节中的示例( Mutex), 同时考虑如下场景: 当一个线程调用 Mutex 的lock()方法获取锁之后, 如果再次调用 lock()方法, 则该线程将会被自己所阻塞, 原因是 Mutex 在实现町rAcquire(int acquires)方法时没有考虑占有锁的线程再次获取锁的场景, 而在调用 tryAcquire(int acquires)方法时返回了 false, 导致该线程被阻塞。简单地说, Mutex 是一个不支持重进入的锁。 而 synchronized 关键字隐式的支持重进入, 比如一个 synchronized 修饰的递归方法, 在方法执行时, 执行线程在获取了锁之后仍能连续多次地获得该锁, 而不像Mutex 由于获取了锁, 而在下一次获取锁时出现阻塞自己的情况。
ReentrantLock 虽然没能像 synchronized 关键字一样支持隐式的重进入, 但是在调用lock()方法时, 已经获取到锁的线程, 能够再次调用 lock()方法获取锁而不被阻塞。
这里提到一个锁获取的公平性问题 如果在绝对时间上, 先对锁进行获取的请求一定先被满足, 那么这个锁是公平的, 反之, 是不公平的。 公平的获取锁, 也就是等待时间最长的线程最优先获取锁, 也可以说锁获取是顺序的。 ReentrantLock 提供了一个构造函数, 能够控制锁是否是公平的。
事实上, 公平的锁机制往往没有非公平的效率高, 但是, 并不是任何场景都是以TPS作
为唯一的指标, 公平锁能够减少 ‘饥饿’ 发生的概率, 等待越久的请求越是能够得到优先满足。
1. 实现重进入
重进入是指任意线程在获取到锁之后能够再次获取该锁而不会被锁所阻塞, 该特性的实现需要解决以下两个问题。
1 )钱程再次获取锁。 锁需要去识别获取锁的线程是否为当前占据锁的线程, 如果是,则再次成功获取。
2 )锁的最终释煎。 线程重复n次获取了锁, 随后在第n次释放该锁后, 其他线程能够获取到该锁。 锁的最终释放要求锁对于获取进行计数自增, 计数表示当前锁被重复获取的次数, 而锁被释放时, 计数自减, 当计数等于0时表示锁已经成功释放。
ReentrantLock 是通过组合自定义同步器来实现锁的获取与释放, 以非公平性(默认的) 实现。
2.公平与非公平获取锁的区别
公平锁是按FIFO的原则。而非公平锁不按照这个原则。非公平锁的效率要高,因为它不需要做那么多上下文切换。
4.读写锁
只需要在读操作时获取读锁, 写操作时获取写锁即可。 当写锁被获取到时, 后续(非当前写操作线程)的读写操作都会被阻塞, 写锁释放之后, 所有操作继续执行, 编程方式相对于使用等待通知机制的实现方式而言, 变得简单明了。
一般情况下, 读写锁的性能都会比排它锁好, 因为大多数场景读是多于写的。 在读多于写的情况下, 读写锁能够提供比排它锁更好的并发性和吞吐量。Java并发包提供读写锁的实现是 ReentrantReadWriteLock。特性如下:
4.1 读写锁的接口
ReadWriteLock仅定义了获取读锁和写锁的两个方法, 即readLock()方法和writeLock()方法,而其实现一- ReentrantReadWriteLock, 除了接口方法之外, 还提供了一些便于外界监方法, 而其实现
控其内部工作状态的方法。如下图:
ReentrantReadWriteLock展示内部工作状态的方法.png
LockSupport 工具
LockSupport 定义了一组以park开头的方法用来阻塞当前线程,以及unpark(Thread thread)方法来唤醒一个被阻塞的线程。
LockSupport提供的阻塞和唤醒.png
6 Condition 接口
任意一个 Java 对象, 都拥有一组监视器方法(定义在 java.lang.Object 上), 主要包括 wait()、 wait(long timeout)、 notify()以及 notifyAll()方法, 这些方法与 synchronized 同步关键字配合, 可以实现等待/通知模式。 Condition 接口也提供了类似 Object 的监视器方法, 与Lock 配合可以实现等待/通知模式 但是这两者在使用方式以及功能特性上还是有差别的。
通过对比 Object 的监视器方法和 Condition 接口, 可以更详细地了解 Condition 的特性,对比项与结果如表所示。
6.1 Condition 接口
Condition 定义了等待/通知两种类型的方法, 当前线程调用这些方法时, 需要提前获取到 Condition 对象关联的锁。 Condition 对象是由 Lock 对象(调用 Lock 对象的 newCondition() 方法)创建出来的, 换句话说, Condition 是依赖 Lock 对象的。
Condition 的使用方式比较简单, 需要注意在调用方法前获取锁。
一般都会将 Condition 对象作为成员变量。 当调用await()方法后, 当前线程会释放锁并在此等待, 而其他线程调用 Condition 对象的 signal()方法, 通知当前线程后,当前线程才从 await()方法返回, 并且在返回前已经获取了锁。
Condition定义的(部分)方法以及描述如下图所示。
Condition方法和描述1.png
Condition方法和描述2.png
获取一个 Condition 必须通过 Lock 的 newCondition() 方法。
6.2 Condition 的实现分析
Condition Object是同步器AbstractQueuedSynchronizer的内部类, 因为Condit_ion的操作需要获取相关联的锁, 所以作为同步器的内部类也较为合理。 每个Condition对象都包含着 一个队列(以下称为 等待队列), 该队列是Condition对象实现等待/通知功能的关键。
下面将分析Condition的实现, 主要包括: 等待队列、 等待和通知, 下面提到的Condition如果不加说明均指的是ConditionObject。
1.等待队列
等待队列是一个FIFO的队列,在队列中的每个节点都包含了一个线程引用,该线程就是在Condition对象上等待的线程,如果一个线程调用了Condition.await()方法 ,那么该线 程将会释放锁、 构造成节点加入等待队列 并进入等待状态。 事实上, 节点的定义复用了同
步器中节点的定义, 也就是说, 同步队列和 等待队列中节点类型都是同步器的静态内部类AbstractQueuedS ynchronizer.Node。
一个Condition 包含 一个等待队列, Condition拥有首节点( firstWaiter) 和尾节点( last Waiter)。 当前线程调用 Condition.await()方法, 将会以当前线程构造节点, 并将节点从尾部加入等待队列。
2.等待
调用 Condition 的 await()方法(或者以 await 开头的方法), 会使当前线程进入等待队列并释放锁, 同时线程状态变为等待状态。 当从 await()方法返回时, 当前线程一定获取了Condition 相关联的锁。
如果从队列(同步队列和等待队列) 的角度看 await()方法, 当调用await()方法时, 相当于同步队列的首节点(获取了锁的节点)移动到 Condition 的等待队列中。
3. 通知
调用Condition 的 signal()方法, 将会唤醒在等待队列中等待时间最长的节点(首节点),在唤醒节点之前,会将节点移到同步队列中。
调用该方法的前置条件是当前线程必须获取了锁, 可以看到 signal()方法进行了 isHeldExclusively()检查, 也就是当前线程必须是获取了锁的线程。 接着获取等待队列的首节点,将其移动到同步队列并使用 LockSupport 唤醒节点中的线程。
通过调用同步器的 enq (Node node)方法, 等待队列中的头节点线程安全地移动到同步队 列。 当节点移动到同步队列后, 当前线程再使用 LockSupport 唤醒该节点的线程。
被唤醒后的线程, 将从await()方法中的while循环中退出( isOnSyncQueue (Node node) 方法返回true,节点已经在同步队列中), 进而调用同步器的acquir eQueued()方法加入到获取同步状态的竞争中。
成功获取同步状态(或者说锁)之后, 被唤醒的线程将从先前调用的await()方法返回, 此时该线程已经成功 地获取了锁。
Condition的signa!All()方法, 相当于对等待队列中的每个节点均执行一次signal()方法,效果就是将等待队列中所有节点全部移动到同步队列中, 并唤醒 每个节点的线程。