Java多线程之并发锁
Contents
本文先阐述 Java 中各种锁的概念。然后,介绍锁的核心实现 AQS。
- 互斥,即同一时刻只允许一个线程访问共享资源;
- 同步,即线程之间如何通信、协作。
一、并发锁简介
1.1 可重入锁
可重入锁,顾名思义,指的是线程可以重复获取同一把锁。 即同一个线程在外层方法获取了锁,在进入内层方法会自动获取锁。
可重入锁可以在一定程度上避免死锁。
- ReentrantLock 、ReentrantReadWriteLock 是可重入锁。这点,从其命名也不难看出。
- synchronized 也是一个可重入锁。
1.2 公平锁与非公平锁
- 公平锁 - 公平锁是指多线程按照申请锁的顺序来获取锁。
- 非公平锁 - 非公平锁是指多线程不按照申请锁的顺序来获取锁 。这就可能会出现优先级反转(后来者居上)或者饥饿现象(某线程总是抢不过别的线程,导致始终无法执行)。
synchronized
只支持非公平锁;ReentrantLock
、ReentrantReadWriteLock
,默认是非公平锁,但支持公平锁。
1.3 排它锁与共享锁
- 独享锁 - 独享锁是指 锁一次只能被一个线程所持有。
- 共享锁 - 共享锁是指 锁可被多个线程所持有。
synchronized
、ReentrantLock
只支持独享锁。ReentrantReadWriteLock
其写锁是独享锁,其读锁是共享锁。读锁是共享锁使得并发读是非常高效的,读写,写读,写写的过程是互斥的。
1.4 悲观锁与乐观锁
乐观锁与悲观锁不是指具体的什么类型的锁,而是处理并发同步的策略。
- 悲观锁 - 悲观锁对于并发采取悲观的态度,认为:不加锁的并发操作一定会出问题。悲观锁适合写操作频繁的场景。
- 乐观锁 - 乐观锁对于并发采取乐观的态度,认为:不加锁的并发操作也没什么问题。对于同一个数据的并发操作,是不会发生修改的。在更新数据的时候,会采用不断尝试更新的方式更新数据。乐观锁适合读多写少的场景。
悲观锁与乐观锁在 Java 中的典型实现:
-
悲观锁在 Java 中的应用就是通过使用 synchronized 和 Lock 显示加锁来进行互斥同步,这是一种阻塞同步。
-
乐观锁在 Java 中的应用就是采用 CAS 机制(CAS 操作通过 Unsafe 类提供,但这个类不直接暴露为 API,所以都是间接使用,如各种原子类)。
1.5 偏向锁、轻量级锁、重量级锁
所谓轻量级锁与重量级锁,指的是锁控制粒度的粗细。显然,控制粒度越细,阻塞开销越小,并发性也就越高。
Java 1.6 以后,针对 synchronized 做了大量优化,引入 4 种锁状态: 无锁状态、偏向锁、轻量级锁和重量级锁。锁可以单向的从偏向锁升级到轻量级锁,再从轻量级锁升级到重量级锁 。
1.6 分段锁
分段锁其实是一种锁的设计,并不是具体的一种锁。所谓分段锁,就是把锁的对象分成多段,每段独立控制,使得锁粒度更细,减少阻塞开销,从而提高并发性。这其实很好理解,就像高速公路上的收费站,如果只有一个收费口,那所有的车只能排成一条队缴费;如果有多个收费口,就可以分流了。
Java 1.7 以前的 ConcurrentHashMap 就是分段锁的典型案例。ConcurrentHashMap 维护了一个 Segment 数组,一般称为分段桶。
当有线程访问 ConcurrentHashMap 的数据时,ConcurrentHashMap 会先根据 hashCode 计算出数据在哪个桶(即哪个 Segment),然后锁住这个 Segment。
二、AQS
AQS 提供了对独享锁与共享锁的支持。AbstractQueueSychronizer
在 java.util.concurrent.locks
包中的相关锁(常用的有 ReentrantLock、 ReadWriteLock)都是基于 AQS 来实现。这些锁都没有直接继承 AQS,而是定义了一个 Sync 类去继承 AQS。为什么要这样呢?因为锁面向的是使用用户,而同步器面向的则是线程控制,那么在锁的实现中聚合同步器而不是直接继承 AQS 就可以很好的隔离二者所关注的事情。
2.1 应用
AQS 提供了对独享锁与共享锁的支持。
独享锁 API 获取、释放独享锁的主要 API 如下:
|
|
acquire
- 获取独占锁。acquireInterruptibly
- 获取可中断的独占锁。tryAcquireNanos
- 尝试在指定时间内获取可中断的独占锁。在以下三种情况下回返回:- 在超时时间内,当前线程成功获取了锁;
- 当前线程在超时时间内被中断;
- 超时时间结束,仍未获得锁返回 false。
release
- 释放独占锁。
共享锁 API 获取、释放共享锁的主要 API 如下:
|
|
- acquireShared - 获取共享锁。
- acquireSharedInterruptibly - 获取可中断的共享锁。
- tryAcquireSharedNanos - 尝试在指定时间内获取可中断的共享锁。
- release - 释放共享锁。
2.2 原理
ASQ 原理要点:
- AQS 使用一个整型的
volatile
变量来 维护同步状态。状态的意义由子类赋予。 - AQS 维护了一个 FIFO 的双链表,用来存储获取锁失败的线程。
AQS 围绕同步状态提供两种基本操作“获取”和“释放”,并提供一系列判断和处理方法,简单说几点:
- state 是独占的,还是共享的;
- state 被获取后,其他线程需要等待;
- state 被释放后,唤醒等待线程; 线程等不及时,如何退出等待。 至于线程是否可以获得 state,如何释放 state,就不是 AQS 关心的了,要由子类具体实现。
2.2.1 AQS 的数据结构
阅读 AQS 的源码,可以发现:AQS 继承自 AbstractOwnableSynchronize。
|
|
state
- AQS 使用一个整型的 volatile 变量来 维护同步状态。 这个整数状态的意义由子类来赋予,如ReentrantLock 中该状态值表示所有者线程已经重复获取该锁的次数,Semaphore 中该状态值表示剩余的许可数量。head
和tail
- AQS 维护了一个 Node 类型(AQS 的内部类)的双链表来完成同步状态的管理。 这个双链表是一个双向的 FIFO 队列,通过 head 和 tail 指针进行访问。当 有线程获取锁失败后,就被添加到队列末尾。 再来看一下 Node 的源码
|
|
很显然,Node 是一个双链表结构。
- waitStatus - Node 使用一个整型的 volatile 变量来 维护 AQS 同步队列中线程节点的状态。waitStatus 有五个状态值:
- CANCELLED(1) - 此状态表示:该节点的线程可能由于超时或被中断而 处于被取消(作废)状态,一旦处于这个状态,表示这个节点应该从等待队列中移除。
- SIGNAL(-1) - 此状态表示:后继节点会被挂起,因此在当前节点释放锁或被取消之后,必须唤醒(unparking)其后继结点。
- CONDITION(-2) - 此状态表示:该节点的线程处于等待条件状态,不会被当作是同步队列上的节点,直到被唤醒(signal),设置其值为 0,再重新进入阻塞状态。
- PROPAGATE(-3) - 此状态表示:下一个 acquireShared 应无条件传播。
- 0 - 非以上状态
2.2.2 独占锁的获取和释放
1、获取独占锁 AQS 中使用 acquire(int arg) 方法获取独占锁,其大致流程如下:
- 先尝试获取同步状态,如果获取同步状态成功,则结束方法,直接返回。
- 如果获取同步状态不成功,AQS 会不断尝试利用 CAS 操作将当前线程插入等待同步队列的队尾,直到成功为止。
- 接着,不断尝试为等待队列中的线程节点获取独占锁。
2、释放独占锁 AQS 中使用 release(int arg) 方法释放独占锁,其大致流程如下:
- 先尝试获取解锁线程的同步状态,如果获取同步状态不成功,则结束方法,直接返回。
- 如果获取同步状态成功,AQS 会尝试唤醒当前线程节点的后继节点。
3、获取可中断的独占锁
AQS 中使用 acquireInterruptibly(int arg)
方法获取可中断的独占锁。
acquireInterruptibly(int arg)
实现方式相较于获取独占锁方法( acquire)非常相似,区别仅在于它会通过 Thread.interrupted 检测当前线程是否被中断,如果是,则立即抛出中断异常(InterruptedException)。
4、获取超时等待式的独占锁
AQS 中使用 tryAcquireNanos(int arg)
方法获取超时等待的独占锁。
doAcquireNanos
的实现方式 相较于获取独占锁方法( acquire)非常相似,区别在于它会根据超时时间和当前时间计算出截止时间。在获取锁的流程中,会不断判断是否超时,如果超时,直接返回 false;如果没超时,则用 LockSupport.parkNanos
来阻塞当前线程。
2.2.4 共享锁的获取和释放
1、获取共享锁 AQS 中使用 acquireShared(int arg) 方法获取共享锁。
acquireShared
方法和 acquire
方法的逻辑很相似,区别仅在于自旋的条件以及节点出队的操作有所不同。
成功获得共享锁的条件如下:
tryAcquireShared(arg)
返回值大于等于 0 (这意味着共享锁的 permit 还没有用完)。- 当前节点的前驱节点是头结点。
2、释放共享锁
AQS 中使用
releaseShared(int arg)
方法释放共享锁。
releaseShared 首先会尝试释放同步状态,如果成功,则解锁一个或多个后继线程节点。释放共享锁和释放独享锁流程大体相似,区别在于:
对于独享模式,如果需要 SIGNAL,释放仅相当于调用头节点的 unparkSuccessor。
3、获取可中断的共享锁 AQS 中使用 acquireSharedInterruptibly(int arg) 方法获取可中断的共享锁。
acquireSharedInterruptibly
方法与 acquireInterruptibly 几乎一致,不再赘述。
4、获取超时等待式的共享锁 AQS 中使用 tryAcquireSharedNanos(int arg) 方法获取超时等待式的共享锁。
tryAcquireSharedNanos
方法与 tryAcquireNanos 几乎一致,不再赘述。
Author 拾光
LastMod 2022-04-14