Java 中的锁

Lock 接口

  • 锁是用来控制多个线程访问共享资源的方式,一般来说,一个锁能够防止多个线程同时访问共享资源(但是有些锁可以允许多个线程并发访问共享资源,比如读写锁)。
  • Lock 接口提供了与 synchronized 关键字类似的同步功能,只是在使用时需要显式地获取和释放锁
  • Lock 接口拥有锁获取与释放的可操作性、可中断的获取锁以及超时获取锁等多种 synchronized 关键字所不具备的同步特性。
  • synchronized获取锁是一个原子操作,在线程获取锁的过程中是无法响应中断的。

Lock 接口
Lock 实现

队列同步器(AQS / AbstractQueuedSynchronizer)使用

  • AQS 用来构建锁或者其他同步组件的基础框架,它使用了一个 int 成员变量表示同步状态,通过内置的 FIFO 队列来完成资源获取线程的排队工作
  • 同步器自身没有实现任何同步接口,它仅仅是定义了若干同步状态获取和释放的方法来供自定义同步组件使用,同步器既可以支持独占式地获取同步状态,也可以支持共享式地获取同步状态
  • 方便实现 ReentrantLock、ReentrantReadWriteLock 和 CountDownLatch等同步组件
  • 同步器的设计是基于模板方法模式

AQS 设计
AQS 使用

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

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

队列同步器的实现分析

同步队列

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

队列节点
同步队列

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

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

  • 通过调用同步器的 acquire(int arg) 方法可以获取同步状态。

独占式同步状态获取

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

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

  • 通过调用同步器的 acquireShared(int arg) 方法可以共享式地获取同步状态。
  • 共享式获取与独占式获取的主要区别在于同一时刻能否有多个线程同时获取到同步状态。
  • 对于能够支持多个线程同时访问的并发组件(比如 Semaphore),它和独占式的主要区别在于 tryReleaseShared(int arg) 方法必须确保同步状态(或者资源数)线程安全释放,一般是通过循环和 CAS 来保证的,因为释放同步状态的操作会同时来自多个线程。

独占式超时获取同步状态

  • 通过调用同步器的 doAcquireNanos(int arg,long nanosTimeout) 方法可以超时获取同步状态,即在指定的时间段内获取同步状态,如果获取到同步状态则返回 true,否则,返回 false
  • 在 Java 5 之前,当一个线程获取不到锁而被阻塞在 synchronized 之外时,对该线程进行中断操作,此时该线程的中断标志位会被修改,但线程依旧会阻塞在 synchronized 上,等待着获取锁。
  • 在 Java 5 中,同步器提供了 acquireInterruptibly(int arg) 方法,这个方法在等待获取同步状态时,如果当前线程被中断,会立刻返回,并抛出 InterruptedException

独占式超时获取同步状态

重入锁

  • 重入锁 ReentrantLock,顾名思义,就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁。
    • ReentrantLock 虽然没能像 synchronized 关键字一样支持隐式的重进入,但是在调用 lock() 方法时,已经获取到锁的线程,能够再次调用 lock() 方法获取锁而不被阻塞。
  • synchronized 关键字隐式的支持重进入,比如 一个 synchronized 修饰的递归方法,在方法执行时,执行线程在获取了锁之后仍能连续多次地获得该锁。

锁获取的公平性问题

  • 如果在绝对时间上,先对锁进行获取的请求一定先被满足,那么这个锁是公平的,反之,是不公平的

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

  • 公平锁判断条件多了 hasQueuedPredecessors() 方法,即加入了同步队列中的当前节点是否有前驱节点的判断
  • 公平性锁保证了锁的获取按照 FIFO 原则,而代价是进行大量的线程切换。
  • 非公平性锁虽然可能造成线程“饥饿”,但极少的线程切换,保证了其更大的吞吐量

读写锁

  • 读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程均被阻塞
  • 读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升。
  • 一般情况下,读写锁的性能都会比排它锁好,因为大多数场景读是多于写的。在读多于写的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量。Java并发包提供读写锁的实现是 **ReentrantReadWriteLock**。

读写锁

读写锁的实现分析

  • 同步状态表示锁被一个线程重复获取的次数,而读写锁的自定义同步器需要在同步状态(一个整型变量)上维护多个读线程和一个写线程的状态,使得该状态的设计成为读写锁实现的关键
  • 如果在一个整型变量上维护多种状态,就一定需要“按位切割使用”这个变量,读写锁将变量切分成了两个部分,高 16 位表示读,低 16 位表示写

写锁的获取与释放

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

读锁的获取与释放

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

锁降级

  • 锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程

  • 锁降级中读锁的获取是否必要呢?答案是必要的

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

    • 保证数据可见性。
    • 如果读锁已被多个线程获取,其中任意线程成功获取了写锁并更新了数据,则其更新对其他获取到读锁的线程是不可见的

LockSupport 工具

  • LockSupport 定义了一组的公共静态方法,这些方法提供了最基本的线程阻塞和唤醒功能,而 LockSupport 也成为构建同步组件的基础工具。

LockSupport

Condition 接口

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

Condition 接口