5、Java中的锁


介绍Java并发包中与锁相关的API和组件,以及这些API和组件的使用方式和实现 细节。内容主要围绕两个方面:使用,实现

5.1 Lock接口

  • 锁是用来控制多个线程访问共享资源的方式,一般来说,一个锁能够防止多个线程同时 访问共享资源(但是有些锁可以允许多个线程并发的访问共享资源,比如读写锁)。
  • 在Lock接口出现之前,Java程序是靠synchronized关键字实现锁功能的,而Java SE 5之后,并发包中新增 了Lock接口(以及相关实现类)用来实现锁功能,它提供了与synchronized关键字类似的同步功 能,只是在使用时需要显式地获取和释放锁。虽然它缺少了(通过synchronized块或者方法所提 供的)隐式获取释放锁的便捷性,但是却拥有了锁获取与释放的可操作性、可中断的获取锁以 及超时获取锁等多种synchronized关键字所不具备的同步特性
  • 使用synchronized关键字将会隐式地获取锁,但是它将锁的获取和释放固化了,也就是先 获取再释放

Lock接口提供的synchronized关键字所不具备的主要特性
在这里插入图片描述
Lock是一个接口,它定义了锁获取和释放的基本操作
在这里插入图片描述
Lock接口的实现基本都是 通过聚合了一个同步器的子类来完成线程访问控制的

5.2 队列同步器

  • 队列同步器AbstractQueuedSynchronizer(简称同步器),是用来构建锁或者其他同步组件的基础框架,它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获 取线程的排队工作。

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

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

5.2.1 队列同步器的接口与示例

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

同步器可重写的方法与描述
在这里插入图片描述
同步器提供的模板方法
在这里插入图片描述
同步器提供的模板方法基本上分为3类

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

5.2.2 队列同步器的实现分析

  • 同步队列、独占式同 步状态获取与释放、共享式同步状态获取与释放以及超时获取同步状态等同步器的核心数据 结构与模板方法
  1. 同步队列
  • 同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,当前线程获取 同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点(Node)并将其 加入同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒,使其再 次尝试获取同步状态。
  • 同步队列中的节点(Node)用来保存获取同步状态失败的线程引用、等待状态以及前驱和 后继节点

节点的属性类型与名称以及描述
在这里插入图片描述

  • 同步器拥有首节点(head) 和尾节点(tail),没有成功获取同步状态的线程将会成为节点加入该队列的尾部
    同步队列的基本结构
    在这里插入图片描述

  • 同步器包含了两个节点类型的引用,一个指向头节点,而另一个指向尾节点。

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

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

首节点的设置
在这里插入图片描述

  • 设置首节点是通过获取同步状态成功的线程来完成的,由于只有一个线程能
    够成功获取到同步状态,因此设置头节点的方法并不需要使用CAS来保证,它只需要将首节 点设置成为原首节点的后继节点并断开原首节点的next引用即可。
  1. 独占式同步状态获取与释放
  • 通过调用同步器的acquire(int arg)方法可以获取同步状态,该方法对中断不敏感,也就是 由于线程获取同步状态失败后进入同步队列中,后续对线程进行中断操作时,线程不会从同 步队列中移出
  • 逻辑
    首先调用自定义同步器实现的tryAcquire(int arg)方法,该方法 保证线程安全的获取同步状态,如果同步状态获取失败,则构造同步节点(独占式 Node.EXCLUSIVE,同一时刻只能有一个线程成功获取同步状态)并通过addWaiter(Node node) 方法将该节点加入到同步队列的尾部,最后调用acquireQueued(Node node,int arg)方法,使得该 节点以“死循环”的方式获取同步状态。如果获取不到则阻塞节点中的线程,而被阻塞线程的 唤醒主要依靠前驱节点的出队或阻塞线程被中断来实现

独占式同步状态获取流程,也就是acquire(int arg)方法调用流程
在这里插入图片描述

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

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

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

  1. 共享式同步状态获取与释放
    共享式获取与独占式获取最主要的区别在于同一时刻能否有多个线程同时获取到同步状 态。以文件的读写为例,如果一个程序在对文件进行读操作,那么这一时刻对于该文件的写操 作均被阻塞,而读操作能够同时进行。写操作要求对资源的独占式访问,而读操作可以是共享 式访问,两种不同的访问模式在同一时刻对文件或资源的访问情况
  • 共享式访问资源时,其他共享式的访问均被允许,而独占式访问被 阻塞,右半部分是独占式访问资源时,同一时刻其他访问均被阻塞
  • 在共享式获取的自旋过程中,成功获取到同步状态并退出自旋的条件就是 tryAcquireShared(int arg)方法返回值大于等于0
  • 共享式获取也需要释放同步状态,通过调用releaseShared(int arg)方法可以 释放同步状态
  • 该方法在释放同步状态之后,将会唤醒后续处于等待状态的节点。对于能够支持多个线 程同时访问的并发组件(比如Semaphore),它和独占式主要区别在于tryReleaseShared(int arg) 方法必须确保同步状态(或者资源数)线程安全释放,一般是通过循环和CAS来保证的,因为 释放同步状态的操作会同时来自多个线程
  1. 独占式超时获取同步状态
  • 在指定的时间段内获取同步状态,如果获取到同步状态则返回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纳秒, 反之,表示已经超时

5.3 重入锁

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

  • ReentrantLock虽然没能像synchronized关键字一样支持隐式的重进入,但是在调用lock()方 法时,已经获取到锁的线程,能够再次调用lock()方法获取锁而不被阻塞
  • 锁获取的公平性
    如果在绝对时间上,先对锁进行获取的请求一定先 被满足,那么这个锁是公平的,反之,是不公平的。公平的获取锁,也就是等待时间最长的线 程最优先获取锁,也可以说锁获取是顺序的。ReentrantLock提供了一个构造函数,能够控制锁 是否是公平的

5.3.1 实现重进入

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

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

  • 成功获取锁的线程再次获取锁,只是增加了同步状态值,这也就要求ReentrantLock在释放 同步状态时减少同步状态值

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

5.3.2 公平与非公平获取锁的区别

公平性与否是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合 请求的绝对时间顺序,也就是FIFO

5.4 读写锁

读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读 线程和其他写线程均被阻塞。读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写 锁,使得并发性相比一般的排他锁有了很大提升

ReentrantReadWriteLock的特性
在这里插入图片描述

5.4.1 读写锁的接口与示例

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

ReentrantReadWriteLock展示内部工作状态的方法
在这里插入图片描述

5.4.2 读写锁的实现分析

读写状态的设计、写锁的获取与释 放、读锁的获取与释放以及锁降级

  1. 读写状态的设计
    读写锁同样依赖自定义同步器来实现同步功能,而读写状态就是其同步器的同步状态。读 写锁的自定义同步器需要在同步状态(一个整型变量)上维护多个读线程和一个写线程的状 态,使得该状态的设计成为读写锁实现的关键

  2. 写锁的获取与释放

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

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

读状态是所有线 程获取读锁次数的总和,而每个线程各自获取读锁的次数只能选择保存在ThreadLocal中,由 线程自身维护

  1. 锁降级

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

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

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

5.5 LockSupport工具

当需要阻塞或唤醒一个线程的时候,都会使用LockSupport工具类来完成相应 工作。LockSupport定义了一组的公共静态方法,这些方法提供了最基本的线程阻塞和唤醒功 能,而LockSupport也成为构建同步组件的基础工具。
LockSupport定义了一组以park开头的方法用来阻塞当前线程,以及unpark(Thread thread) 方法来唤醒一个被阻塞的线程。
LockSupport提供的阻塞和唤醒方法
在这里插入图片描述

5.6 Condition接口

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

Object的监视器方法与Condition接口的对比
在这里插入图片描述
Condition的(部分)方法以及描述
在这里插入图片描述
获取一个Condition必须通过Lock的newCondition()方法。

Condition的实现分析

ConditionObject是同步器AbstractQueuedSynchronizer的内部类,因为Condition的操作需要 获取相关联的锁,所以作为同步器的内部类也较为合理。每个Condition对象都包含着一个队 列(以下称为等待队列),该队列是Condition对象实现等待/通知功能的关键

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

等待队列的基本结构
在这里插入图片描述
Condition拥有首尾节点的引用,而新增节点只需要将原有的尾节点nextWaiter 指向它,并且更新尾节点即可。上述节点引用更新的过程并没有使用CAS保证,原因在于调用 await()方法的线程必定是获取了锁的线程,也就是说该过程是由锁来保证线程安全的

  1. 等待
  • 调用Condition的await()方法(或者以await开头的方法),会使当前线程进入等待队列并释 放锁,同时线程状态变为等待状态。当从await()方法返回时,当前线程一定获取了Condition相 关联的锁。
  • 如果从队列(同步队列和等待队列)的角度看await()方法,当调用await()方法时,相当于同 步队列的首节点(获取了锁的节点)移动到Condition的等待队列中。
  1. 通知
    调用Condition的signal()方法,将会唤醒在等待队列中等待时间最长的节点(首节点),在 唤醒节点之前,会将节点移到同步队列中。
  • 通过调用同步器的enq(Node node)方法,等待队列中的头节点线程安全地移动到同步队 列。当节点移动到同步队列后,当前线程再使用LockSupport唤醒该节点的线程。
  • 被唤醒后的线程,将从await()方法中的while循环中退出(isOnSyncQueue(Node node)方法 返回true,节点已经在同步队列中),进而调用同步器的acquireQueued()方法加入到获取同步状 态的竞争中。
  • 成功获取同步状态(或者说锁)之后,被唤醒的线程将从先前调用的await()方法返回,此 时该线程已经成功地获取了锁。
  • Condition的signalAll()方法,相当于对等待队列中的每个节点均执行一次signal()方法,效 果就是将等待队列中所有节点全部移动到同步队列中,并唤醒每个节点的线程。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值