一、概念
1、什么是锁
在 Java 多线程环境中,锁是确保共享资源线程安全的重要手段。
当线程要操作共享资源时,需先获取对应的锁,以此保证在操作过程中,该资源不会被其他线程访问。待操作结束后,线程释放锁,使其他线程有机会获取并操作该资源。
2、为什么需要锁
锁可以确保多个线程之间对共享资源的访问是互斥的,也就是同一时刻只有一个线程能够访问被保护的共享资源,从而避免并发访问带来的数据不一致性和竞态条件等问题,是解决线程安全问题常用手段之一。
二、锁的分类
名称 | 实现 |
内置锁 | 1、使用关键字synchronized实现。 2、可以对方法或代码块进行同步,被同步的代码同一时刻只有一个线程可以执行其中的代码。 |
显式锁 | 1、使用java.util.concurrent.locks包下锁机制实现,比如ReentrantLock。 2、提供了更加灵活的控制,需要显式的用lock()方法加锁和unlock()方法释放锁。 |
条件锁 | 1、使用java.util.concurrent.locks包下的Condition接口和ReentrantLock实现 2、允许线程在某个特定条件满足时等待或唤醒 |
读写锁 | 1、使用java.util.concurrent.locks包下的ReentrantReadWriteLock实现。 2、允许多个线程同时读共享资源,但只允许一个线程进行写操作。 |
StampedLock | 1、在Java8中引入的新型锁机制,也是在java.util.concurrent.locks包下。 2、提供了三种模式:写锁、悲观读锁和乐观读锁。 |
无锁 | 1、也就是我们常说的乐观锁,基于原子操作实现的锁机制,比如CAS算法。 2、避免了传统锁机制的线程阻塞和切换开销。 |
上面是很多锁的名词,这些分类并不是全是指锁的状态,有的指锁的特性,有的指锁的设计,下面总结的内容是对每个锁的名词进行一定的解释。
序号 | 锁名称 | 应用 |
1 | 乐观锁 | CAS |
2 | 悲观锁 | synchronized、vector、hashtable |
3 | 自旋锁 | CAS |
4 | 可重入锁 | synchronized、Reentrantlock、Lock |
5 | 读写锁 | ReentrantReadWriteLock,CopyOnWriteArrayList、CopyOnWriteArraySet |
6 | 公平锁 | Reentrantlock(true) |
7 | 非公平锁 | synchronized、reentrantlock(false) |
8 | 共享锁 | ReentrantReadWriteLock中读锁 |
9 | 独占锁 | synchronized、vector、hashtable、ReentrantReadWriteLock中写锁 |
10 | 重量级锁 | synchronized |
11 | 轻量级锁 | 锁优化技术 |
12 | 偏向锁 | 锁优化技术 |
13 | 分段锁 | concurrentHashMap |
14 | 互斥锁 | synchronized |
15 | 同步锁 | synchronized |
16 | 死锁 | 相互请求对方的资源 |
17 | 锁粗化 | 锁优化技术 |
18 | 锁消除 | 锁优化技术 |
1、悲观锁
核心思想:悲观锁假设每次访问共享资源时,都可能被其他线程修改,因此在操作资源前必须加锁,确保同一时刻只有一个线程访问资源。其他线程若想获取该资源,需阻塞等待锁释放,体现了对资源竞争的保守策略。
是一种悲观思想,即认为写多读少,遇到并发写的可能性高,每次去拿数据的时候都认为其他线程会修改,所以每次读写数据都会认为其他线程会修改,所以每次读写数据时都会上锁。其他线程想要读写这个数据时,会被这个线程block,直到这个线程释放锁然后其他线程获取到锁。
传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。
1.1、Java 中悲观锁的实现方式
Java 悲观锁主要通过 synchronized
关键字 和 基于 AQS 的同步工具类 实现,两者在实现机制和使用场景上各有特点。
1.1.1 基于 synchronized
关键字(JVM 内置锁)
synchronized
是 JVM 层面实现的同步机制,通过字节码指令 MONITORENTER
(加锁)和 MONITOREXIT
(释放锁)实现,无需手动管理锁的释放(JVM 保证异常时自动释放)。
三种使用形式:
- 修饰实例方法
-
- 锁对象:当前实例(
this
) - 作用范围:同一实例的方法调用互斥,不同实例的方法调用互不影响。
- 锁对象:当前实例(
public synchronized void instanceMethod() {
// 操作共享资源
}
- 修饰静态方法
-
- 锁对象:类的
Class
对象(全局唯一) - 作用范围:该类的所有实例共享同一把锁,所有静态方法调用互斥。
- 锁对象:类的
public static synchronized void staticMethod() {
// 操作类级共享资源
}
- 修饰代码块
-
- 锁对象:显式指定(可以是任意对象)
- 作用范围:仅对代码块内的资源加锁,细粒度控制同步范围,提升效率。j
// 实例代码块:锁当前实例
public void instanceBlock() {
synchronized (this) {
// 同步逻辑
}
}
// 静态代码块:锁 Class 对象
public static void staticBlock() {
synchronized (MyClass.class) {
// 同步逻辑
}
}
2.1.2 基于 AQS(AbstractQueuedSynchronizer)的同步工具类
AQS 是 Java 并发包(java.util.concurrent
)的核心框架,通过一个 volatile int state
变量表示锁状态,并利用 Unsafe
类实现原子操作(如 CAS)来管理线程竞争。基于 AQS 实现的常用悲观锁及工具类如下:
1. ReentrantLock(可重入互斥锁)
- 特性:
-
- 显式锁:需手动调用
lock()
加锁、unlock()
释放锁(需在finally
中释放,避免死锁)。 - 可重入性:允许同一线程多次获取同一把锁(通过计数器实现)。
- 公平与非公平模式:默认非公平(提升吞吐量),可通过构造函数设置为公平锁(按等待队列顺序分配锁)。
- 显式锁:需手动调用
- 适用场景:细粒度控制锁逻辑,需要更灵活的锁释放或条件等待(配合
Condition
)。
2. ReentrantReadWriteLock(可重入读写锁)
- 特性:
-
- 分离读锁与写锁:
-
-
- 读锁(共享锁):多个线程可同时获取,适用于只读操作。
- 写锁(独占锁):同一时间仅一个线程持有,读写、写写操作互斥。
-
-
- 比普通互斥锁(如
synchronized
)并发度更高,适合 “多读少写” 场景。
- 比普通互斥锁(如
- 注意:读锁不保证数据实时性(可能读到脏数据),需根据业务选择。
3. StampedLock(优化版读写锁)
- 特性:
-
- 在读锁基础上增加 “乐观读” 模式:通过
tryOptimisticRead()
尝试无锁读取,提升读性能。 - 支持写锁降级为读锁,但不支持读锁升级为写锁,使用复杂度较高。
- 在读锁基础上增加 “乐观读” 模式:通过
- 适用场景:对读性能要求极高且逻辑允许短暂脏读的场景(使用较少)。
4. 同步辅助工具类(非严格锁,基于 AQS 状态控制)
- Semaphore(信号量):控制同时访问资源的线程数,用于限流(如限制数据库连接数)。
- CountDownLatch:允许一个或多个线程等待其他线程完成操作(如主线程等待所有子线程计算完毕)。
1.2、核心对比与选择建议
实现方式 | 锁类型 | 加锁方式 | 释放方式 | 适用场景 |
| 内置锁 | 自动(JVM 管理) | 自动(异常时释放) | 简单场景、粗粒度同步 |
| 显式互斥锁 | 手动 | 手动 | 复杂逻辑、需条件等待或公平锁 |
| 读写分离锁 | 手动获取读写锁 | 手动释放 | 多读少写、提升并发度 |
总结:悲观锁通过强制互斥保证数据一致性,适用于资源竞争激烈或对一致性要求极高的场景。选择时需平衡代码复杂度与性能,简单场景优先使用 synchronized
,复杂逻辑可基于 AQS 实现更细粒度的控制。
2、乐观锁
核心思想:乐观锁基于 “资源访问冲突概率低” 的假设,认为线程在执行过程中无需加锁等待,仅在提交修改时验证资源是否被其他线程修改。其通过 版本号机制 或 CAS(比较与交换)算法 实现,避免了锁竞争带来的性能开销,适用于读多写少的场景。
是一种乐观思想,假定当前环境是读多写少,遇到并发写的概率比较低,读数据时认为别的线程不会正在进行修改(所以没有上锁)。写数据时,判断当前 与期望值是否相同,如果相同则进行更新(更新期间加锁,保证是原子性的)。
像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。
2.1、Java 中乐观锁的实现方式
Java 未提供直接的乐观锁 API,但提供了基于乐观锁思想实现的工具类,主要集中在 java.util.concurrent.atomic
包中,通过底层硬件指令(如 CAS)实现高效并发控制。
2.1.1、基于版本号机制实现
原理:在数据对象(如数据库表记录)中添加一个 版本号(version)字段,用于记录数据的修改次数。每次数据更新时,版本号自增 1。线程更新数据前,先读取版本号,提交更新时需验证当前版本号是否与数据库中的版本号一致:
- 一致:说明数据未被其他线程修改,允许更新并递增版本号。
- 不一致:表示数据已被修改,需重试更新操作(通常通过循环实现)。
示例(以数据库操作为例):
假设数据库表 account
包含 balance
(余额)和 version
(版本号)字段:
- 初始状态:
balance=100
,version=1
。 - 线程 A 操作:
-
- 读取数据:
balance=100
,version=1
。 - 执行修改:
balance=50
,version=1
。 - 提交更新:验证
version
匹配,更新后balance=50
,version=2
。
- 读取数据:
- 线程 B 操作:
-
- 读取数据:
balance=100
,version=1
(线程 A 更新前)。 - 执行修改:
balance=80
,version=1
。 - 提交更新:验证
version
不匹配(数据库中version=2
),更新失败,需重试。
- 读取数据:
2.1.2、基于 CAS 机制实现
全称:Compare And Swap(比较与交换)。
核心逻辑:
- 预期值(expected):线程读取变量时记录的初始值。
- 更新值(update):线程计算后的新值。
- 当前值(current):变量的实际内存值。
线程尝试更新变量时,仅当 当前值等于预期值 时,才将变量替换为更新值,否则不做修改并返回失败。
示例(以 AtomicInteger
为例):
import java.util.concurrent.atomic.AtomicInteger;
public class CASExample {
public static void main(String[] args) {
AtomicInteger atomicInt = new AtomicInteger(100);
// 预期值 100,更新值 101
boolean success = atomicInt.compareAndSet(100, 101);
if (success) {
System.out.println("CAS update successful");
} else {
System.out.println("CAS update failed");
}
}
}
底层实现:CAS 依赖 CPU 指令(如 x86 平台的 cmpxchg
),通过硬件原子性保证操作的线程安全,避免锁竞争。
2.2、乐观锁与悲观锁的对比
特性 | 乐观锁 | 悲观锁 |
核心假设 | 冲突概率低,先操作后验证 | 冲突概率高,先加锁后操作 |
实现方式 | 版本号、CAS |
、 |
性能开销 | 无锁竞争,低开销;冲突时重试 | 锁竞争导致线程阻塞,开销固定 |
适用场景 | 读多写少、冲突少的场景 | 写多读少、冲突频繁的场景 |
典型问题 | 冲突频繁时重试导致 CPU 飙升 | 死锁、线程阻塞导致吞吐量下降 |
优化方案:针对乐观锁的重试性能问题,可通过 分段锁(如 LongAdder
) 或 延迟重试策略 减少冲突概率,平衡性能与资源消耗。在实际应用中,需根据读写比例、冲突频率等因素选择合适的锁策略。
3、公平锁/非公平锁(多线程竞争时是否排队)
公平锁
公平锁是一种遵循严格顺序规则的锁机制,其核心思想是让多个线程按照申请锁的先后顺序依次获取锁,确保线程获取锁的公平性,避免出现部分线程长时间等待(饥饿)的情况。
在并发环境下,当线程尝试获取公平锁时,会先检查锁维护的等待队列状态:
- 若等待队列为空,说明当前没有其他线程等待,该线程可直接获取锁;
- 若等待队列不为空,则线程会加入队列末尾,后续按照先进先出(FIFO)的原则,从队列头部依次唤醒线程获取锁,确保先申请的线程优先获得锁资源。这种方式如同现实中的排队机制,每个线程都能得到公平的锁获取机会,保障了多线程环境下资源访问的有序性 。
非公平锁
非公平锁是一种不遵循线程申请顺序获取锁的机制,线程尝试获取锁时,即使存在等待队列,也不会强制按先来后到的规则分配锁。这意味着后申请的线程可能优先于先申请的线程获取锁,从而导致优先级反转或线程饥饿(即某些线程长时间无法获取锁资源)的情况发生。
在 Java 中,非公平锁的实现与应用体现在以下方面:
- ReentrantLock:作为
java.util.concurrent
包中的常用锁,ReentrantLock
可通过构造函数显式指定锁的公平性。默认情况下,ReentrantLock
采用非公平锁模式。由于非公平锁减少了线程排队等待的开销,在高并发场景下,其吞吐量通常优于公平锁,能更高效地利用系统资源。 - synchronized:作为 Java 内置的同步关键字,
synchronized
属于非公平锁。其底层依赖 JVM 实现,不基于 AQS(抽象队列同步器)进行线程调度,因此无法通过修改代码或配置将其转变为公平锁。
非公平锁的特性总结:
- 优势:减少线程排队的开销,在高并发场景下能提升系统吞吐量,提高资源利用率。
- 不足:由于锁获取顺序的随机性,可能导致部分线程长时间无法获取锁,出现线程饥饿问题,影响程序执行的公平性和稳定性。
4、可重入锁与不可重入锁(同一个线程能否获取同一把锁)
可重入锁
广义来讲,可重入锁允许线程在已持有锁的情况下,再次递归调用或在内层代码中继续使用该锁,且不会引发死锁(需针对同一对象或类) 。在 Java 中,ReentrantLock
和 synchronized
均属于可重入锁。
核心原理
可重入锁通过 计数机制 实现多次获取与释放。当线程首次获取锁时,锁会记录当前线程标识并将持有计数设为 1;后续该线程再次获取锁,只需判断持有者为自身,即可直接成功获取并递增计数。释放锁时,计数相应递减,直至计数归零才真正释放锁资源。
Java 中的实现
ReentrantLock
:基于 AQS(抽象队列同步器)实现,通过内部类记录锁的持有线程和重入次数,支持显式加锁(lock()
)与释放(unlock()
)。synchronized
:JVM 层面自动管理,当线程进入synchronized
修饰的方法或代码块时,自动获取对象锁;若方法内再次调用同对象的synchronized
方法,因锁的可重入特性,不会触发阻塞。例如:
synchronized void setA() throws Exception{
Thread.sleep(1000);
setB(); // 因synchronized的可重入性,可正常调用
}
synchronized void setB() throws Exception{
Thread.sleep(1000);
}
在此代码中,若不是可重入锁,线程在调用 setA
持有锁后,再调用 setB
时可能无法执行,甚至导致死锁。但因为 synchronized
是可重入锁,所以能正常运行。
注意
- 加两把锁,仅释放一把会如何?
线程会因锁计数无法归零而持续持有锁资源,导致后续其他线程无法获取该锁,程序出现死锁状态。 - 仅加一把锁,释放两次会怎样?
对于ReentrantLock
,重复调用unlock()
会抛出java.lang.IllegalMonitorStateException
,因为释放次数超过持有次数;对于synchronized
,JVM 会检测到非法的锁释放操作,同样抛出异常,提示锁状态不一致。
不可重入锁
不可重入锁与可重入锁相反,不允许线程递归调用。一旦递归调用,就会产生死锁。以下是通过自旋锁模拟不可重入锁的示例:
import java.util.concurrent.atomic.AtomicReference;
public class UnreentrantLock {
private AtomicReference<Thread> owner = new AtomicReference<Thread>();
public void lock() {
Thread current = Thread.currentThread();
for (;;) {
if (!owner.compareAndSet(null, current)) {
return;
}
}
}
public void unlock() {
Thread current = Thread.currentThread();
owner.compareAndSet(current, null);
}
}
上述代码使用原子引用来记录持有锁的线程。若同一线程在未释放锁的情况下,再次调用 lock
方法,由于自旋机制,会陷入死循环,导致死锁,这体现了不可重入锁的特性。实际上,同一线程没必要每次都释放锁后再获取,频繁的线程调度切换会消耗大量资源。
若将上述模拟的不可重入锁改造为可重入锁:
import java.util.concurrent.atomic.AtomicReference;
public class UnreentrantLock {
private AtomicReference<Thread> owner = new AtomicReference<Thread>();
private int state = 0;
public void lock() {
Thread current = Thread.currentThread();
if (current == owner.get()) {
state++;
return;
}
for (;;) {
if (!owner.compareAndSet(null, current)) {
return;
}
}
}
public void unlock() {
Thread current = Thread.currentThread();
if (current == owner.get()) {
if (state != 0) {
state--;
} else {
owner.compareAndSet(current, null);
}
}
}
}
改造后的代码通过增加 state
变量进行计数,在每次操作前判断当前锁持有者是否为当前线程。若为当前线程,则递增 state
计数,无需每次都释放锁再获取,避免了频繁的线程调度开销。
ReentrantLock 中可重入锁的实现
以 ReentrantLock
的非公平锁获取方法为例:
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
在 AQS(AbstractQueuedSynchronizer,抽象队列同步器) 中,通过维护 private volatile int state
变量来记录重入次数。当线程再次获取锁时,若判断为当前持有锁的线程,就递增 state
计数,避免了频繁的锁持有和释放操作,既提升了性能,又防止了死锁的发生。
5、独占锁 / 共享锁(多个线程能否共享同一把锁)
5.1、共享锁
共享锁是一种允许多个线程以共享方式持有锁的机制,其核心思想是支持多个线程同时访问共享资源,从而提升并发性能。共享锁常与乐观锁、读写锁的概念相关联,但并不完全同义(乐观锁侧重于无锁竞争的更新策略,读写锁是共享锁与独占锁的结合实现) 。
在 Java 中,ReentrantReadWriteLock
是共享锁的典型实现:
- 读锁特性:
ReentrantReadWriteLock
的读锁允许多个线程同时获取并持有,适用于并发读操作场景。例如,多个线程可同时读取缓存数据,提升读操作的并行度。 - 写锁特性:虽然读锁支持共享,但写锁具有独占性,同一时刻仅允许一个线程持有写锁,且写锁与读锁、其他写锁均为互斥关系,确保写操作的原子性和数据一致性。
共享锁基于 AQS(抽象队列同步器)实现,通过特定的方法(如读锁的共享式获取与释放逻辑),控制多个线程对锁资源的共享访问。
5.2、独占锁
独占锁是指在同一时刻只能由一个线程获取并持有的锁,具有排他性。该机制与悲观锁、互斥锁的概念高度重合,强调对共享资源的独占访问,以保证数据的安全性和一致性。
在 Java 中,常见的独占锁实现包括:
- synchronized:作为 Java 内置的同步关键字,
synchronized
修饰的方法或代码块会为对象或类添加独占锁,同一时刻仅允许一个线程进入同步区域。 - ReentrantLock:
java.util.concurrent
包中的ReentrantLock
同样实现了独占锁功能,支持可重入、锁中断等特性。默认情况下,ReentrantLock
采用非公平模式,但也可通过构造函数设置为公平锁。
独占锁同样基于 AQS 实现,通过独占式的获取与释放逻辑,确保在任意时刻只有一个线程能够访问被保护的资源。
5.3、二者的关系与应用场景
- 实现差异:独占锁和共享锁均基于 AQS 构建,但通过不同的方法实现线程访问控制。独占锁采用严格的排他机制,而共享锁则在写操作时保持排他性,在读操作时支持并发访问。
- 应用场景:独占锁适用于写操作频繁或对数据一致性要求极高的场景;共享锁则更适合读多写少的场景,如缓存读取、配置文件加载等,可有效减少锁竞争,提升系统吞吐量。
6、互斥锁 / 读写锁(读写操作的互斥性与共享性,根据写操作互斥、读操作是否共享来区分)
6.1、读写锁
读写锁是一种用于提升并发性能的锁机制,在 Java 中通过ReentrantReadWriteLock
类实现,该类实现了ReadWriteLock
接口。其将锁分为读锁和写锁,通过差异化的访问控制策略,在保证线程安全的同时提高程序执行效率。
6.1.1、读写锁的实现与使用
// 创建读写锁实例
private ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
// 获取读锁并使用
rwLock.readLock().lock();
try {
// 执行读操作逻辑
} finally {
rwLock.readLock().unlock(); // 释放读锁
}
// 获取写锁并使用
rwLock.writeLock().lock();
try {
// 执行写操作逻辑
} finally {
rwLock.writeLock().unlock(); // 释放写锁
}
6.1.2、读写锁的特性
- 读锁(共享锁):允许多个线程同时获取,支持并发读操作。多个线程可同时持有读锁,共同访问同一资源,提升读操作的并行度。
- 写锁(互斥锁):同一时刻仅允许一个线程获取,具有排他性。持有写锁的线程独占资源,其他线程(无论是读线程还是写线程)都必须等待写锁释放后才能访问资源。
6.1.3、读写锁的互斥规则
- 读 - 读:无互斥限制,支持并发访问,提升读性能。
- 读 - 写、写 - 读、写 - 写:均为互斥关系,确保写操作的原子性和数据一致性。
6.1.4、读写锁的适用场景
当读写锁感知到写锁请求时,会阻塞后续读锁请求,避免写线程饥饿。因此,读写锁特别适用于读多写少的场景,如缓存读取、配置文件读取等高频读场景,可显著减少锁竞争带来的性能损耗。
6.2、互斥锁
互斥锁(也称为独占锁、排他锁)的核心特性就是同一时刻只允许一个线程获取锁并访问共享资源,其他线程必须等待锁释放。在这种机制下:
- 读 - 读互斥:即便只是进行读操作,也不允许多个线程同时获取锁。例如,多个线程同时读取数据库某条记录,在互斥锁保护下,也必须排队依次进行读取,避免了可能因并发读引发的潜在问题(如数据在读取过程中被修改导致读取不一致) 。
- 读 - 写互斥:当有线程进行写操作时,其他线程无论是读操作还是写操作,都不能获取锁,需等待写操作完成并释放锁后才能进行。例如,一个线程正在修改文件内容,其他线程既不能读文件也不能写文件。
- 写 - 读互斥:与读 - 写互斥类似,一旦有线程持有写锁进行写操作,其他读操作线程只能等待。
- 写 - 写互斥:多个写操作之间必须严格互斥,确保同一时刻只有一个线程能够修改共享资源,防止数据冲突和不一致 。
6.2.1、互斥锁的工作原理
在访问共享资源前进行加锁,访问结束后解锁。加锁后,其他尝试加锁的线程会被阻塞,直至当前线程解锁。若解锁时有多个线程阻塞,这些线程都会变为就绪状态,第一个变为就绪状态的线程执行加锁操作后,其他线程又将进入等待状态。如此一来,同一时刻只有一个线程能访问被互斥锁保护的资源 。
6.2.2、互斥锁的适用场景
所有对资源的操作(无论读写)均需互斥进行,适用于写操作频繁或对数据一致性要求极高的场景。
6.3、互斥锁与读写锁的关系
读写锁是互斥锁的优化变体,二者的核心区别在于对读操作的处理:
- 互斥锁:最基础的排他性锁,不区分读写操作,所有操作均互斥执行。
- 读写锁:通过分离读、写操作的锁策略,在保证数据安全的前提下提升并发性能。读模式下实现共享锁机制,允许多线程同时读取;写模式下保持互斥性,确保写操作原子性 。在高并发读场景下,读写锁相比互斥锁可提供更高的吞吐量和效率。
7、分段锁
分段锁并非某一具体类型的锁,而是一种用于提升并发程序性能的锁设计策略。其核心思想是在容器中设置多把锁,每把锁负责锁定容器中的一部分数据,以此降低锁的竞争程度,让多个线程在访问容器中不同数据段时可实现真正的并行,进而提高并发访问效率。
以 ConcurrentHashMap 为例理解分段锁
ConcurrentHashMap 是运用分段锁机制的典型例子。它内部将数据细分到若干个小的 HashMap 中,这些小的 HashMap 被称为段(Segment)。默认情况下,一个 ConcurrentHashMap 会被细分为 16 个段,这 16 个段也就代表了锁的并发度。
当需要在 ConcurrentHashMap 中添加一个键值对(key - value)时,并非对整个 HashMap 加锁,而是先根据键的 hashcode 确定该键值对应存放在哪个段中,然后对该段加锁并完成 put 操作。在多线程环境下,只要多个线程要加入的键值对不存放在同一个段中,这些线程就可以真正并行地进行 put 操作。
ConcurrentHashMap 的线程安全实现
ConcurrentHashMap 是一个 Segment 数组,其中每个 Segment 都继承自 ReentrantLock 来进行加锁操作。这意味着每次加锁操作锁住的只是一个 segment,只要保证每个 Segment 是线程安全的,就能实现整个 ConcurrentHashMap 的全局线程安全。
降低锁竞争程度的方法
在并发程序中,串行操作会降低系统的可伸缩性,频繁的上下文切换也会影响性能。当多个线程竞争同一把锁时,就容易出现这些问题。为降低锁的竞争程度,通常有以下三种方法:
- 减少锁的持有时间:尽量缩短线程持有锁的时长,让其他线程能更快地获取锁。
- 降低锁的请求频率:减少线程对锁的请求次数,从而降低锁竞争的概率。
- 使用带有协调机制的独占锁:通过特殊的协调机制,让锁支持更高的并发度。
分段锁的优势体现
ConcurrentHashMap 采用的锁分段技术,是对锁分解技术的进一步拓展。它先将数据划分为多个段进行存储,再为每段数据分配一把锁。当一个线程持有某把锁并访问对应段的数据时,其他线程仍可访问其他段的数据。
例如,ConcurrentHashMap 内部使用了一个包含 16 个锁的数组,每个锁负责保护所有散列桶的 1/16。具体来说,第 N 个散列桶由第(N mod 16)个锁来保护。如果采用合理的散列算法使关键字均匀分布,那么锁的竞争程度大约能降低至原来的 1/16。这使得 ConcurrentHashMap 能够支持多达 16 个并发的写入线程,极大地提升了并发性能。
8、偏向锁、轻量级锁与重量级锁(JVM 的锁优化机制)
在 Java 中,为优化synchronized
关键字的性能,JVM 设计了无锁、偏向锁、轻量级锁、重量级锁四种状态,这些状态由对象头中的监视器字段标识,并基于竞争程度逐步升级,且锁状态只能单向升级,不能降级。它们并非 Java 语言层面的锁类型,而是 JVM 层面的优化机制。
锁状态 | 存储内容 | 标志位 | 优点 | 缺点 | 适用场景 |
无锁 | 对象的hashCode、对象分代年龄、是否是偏向锁(0) | 01 | |||
偏向锁 | 偏向线程ID、偏向时间戳、对象分代年龄、是否是偏向锁(1) | 01 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块场景 |
轻量级锁 | 指向栈中锁记录的指针 | 00 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到索竞争的线程,使用自旋会消耗CPU | 追求响应速度,同步块执行速度非常快 |
重量级锁 | 指向互斥量的指针 | 11 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量,同步块执行速度较慢 |
锁升级与性能开销对比
锁类型 | 竞争强度 | 性能开销 | 核心机制 |
偏向锁 | 微竞争 | 极低(仅首次CAS | 线程ID记 |
轻量级锁 | 弱竞争 | 中(自旋消耗CPU | CAS自旋 |
重量级锁 | 强竞争 | 高(线程阻塞/切换 | 操作系统互斥量 |
8.1、无锁状态
无锁状态是对象锁的初始状态。此时,对象未被任何线程锁定,对象头的锁标识位为01
,偏向锁标识位为0
。当第一个线程访问synchronized
修饰的同步代码块时,锁状态开始发生变化,根据竞争情况逐步升级。
8.2、偏向锁
偏向锁是 JDK 6 引入的 JVM 锁优化机制,旨在通过减少无竞争场景下的同步开销,提升程序性能。其核心逻辑是让锁 "偏向" 于首个获取它的线程,从而避免重复的加锁、解锁操作。
核心实现原理
当线程首次访问synchronized
同步代码块时,JVM 通过 CAS(Compare And Swap)操作将当前线程 ID 写入对象头的 Mark Word 中,此时该对象进入偏向锁状态。后续同一线程再次进入该同步区域时,JVM 只需检查对象头中的线程 ID 是否与当前线程一致:若匹配,则直接进入同步块,无需执行任何加锁操作;若不一致,则说明有其他线程竞争,锁状态将升级为轻量级锁。
适用场景与生命周期
- 适用场景:适用于同步代码块仅被单个线程反复访问的场景,如单线程长时间运行的程序模块、频繁调用的同步方法等。
- 升级条件:当有其他线程尝试获取同一把锁时,偏向锁立即失效,锁状态升级为轻量级锁,通过自旋(CAS 循环重试)机制处理竞争。
- 锁撤销:在少数情况下(如持有偏向锁的线程结束或锁竞争加剧),JVM 会撤销偏向锁,将对象恢复至无锁或升级为轻量级锁。
性能特点
- 优点:在无竞争场景下,偏向锁能消除几乎所有同步操作开销,甚至无需执行 CAS 操作,相比传统锁机制性能显著提升。
- 缺点:若锁频繁被多个不同线程交替访问,频繁的锁升级和撤销操作会带来额外开销,反而降低性能。因此,偏向锁不适合高并发、多线程竞争的场景。
通过偏向锁,JVM 实现了对单线程场景的精准优化,在保障线程安全的同时,最大限度减少了性能损耗,尤其适用于锁竞争较少的程序模块。
8.3、轻量级锁
轻量级锁是 JVM 在 JDK 6 引入的锁优化机制,旨在降低无竞争或低竞争场景下的锁开销。当多个线程交替执行(非竞争)时,JVM 在栈帧中创建Lock Record,并通过 CAS 操作将对象头的 Mark Word 指向该记录,完成加锁过程。
触发与升级机制:当偏向锁出现竞争(如其他线程尝试获取同一把锁),锁状态会升级为轻量级锁。此时,竞争线程采用 ** 自旋(CAS 循环重试)** 的方式尝试获取锁,避免线程阻塞带来的用户态与内核态切换开销。若自旋成功,线程直接进入同步代码块;若自旋超过一定次数仍未获取锁,或竞争进一步加剧,则锁会升级为重量级锁。
性能特点:
- 优点:在无竞争或低竞争场景下,通过 CAS 操作替代操作系统互斥量,大幅减少性能损耗。
- 缺点:在高竞争场景下,频繁的 CAS 操作会产生额外开销,加上互斥量本身的消耗,导致性能低于重量级锁。
8.4、重量级锁
- 实现与升级条件:重量级锁依赖操作系统底层的 Mutex Lock(互斥锁)实现。当多个线程竞争激烈,轻量级锁自旋失败(比如自旋次数超过限定值),无法通过自旋方式获取锁时,锁就会升级为重量级锁。此时,对象的 Mark Word 指向操作系统维护的 Monitor 对象,竞争的线程会进入阻塞队列。线程被阻塞后,需要等待持有锁的线程释放锁,然后由操作系统唤醒,这个过程涉及用户态与内核态的切换,会带来较高的性能开销。
- 在 Java 中的体现:Java 里的 synchronized 关键字在高竞争场景下最终会运用重量级锁机制。它是通过对象内部的监视器锁(monitor)来实现同步的,而监视器锁依赖于操作系统的 Mutex Lock,这就是 synchronized 在高竞争场景下被称为 “重量级锁” 的原因。
8.5、锁升级总结
- 状态不可逆:锁状态严格遵循 无锁 → 偏向锁 → 轻量级锁 → 重量级锁 的单向升级路径,避免因频繁状态切换带来的额外开销。
- 性能优化策略:JVM 通过这种逐步升级的锁策略,在低竞争场景下利用偏向锁和轻量级锁减少开销,仅在高竞争场景下启用重量级锁来保证线程安全,从而平衡效率与安全性。
8.6、锁内存变化
- 偏向锁撤销 内存变化:
Mark Word中线程ID被清除,升级为轻量级锁时生成Lock Record;
- 轻量级锁膨胀 内存变化:
Mark Word从指向Lock Record变为指向Monitor,同时释放原Lock Record空间;
- 重量级锁 内存变化 内存开销:
Monitor对象包含EntryList、WaitSet等数据结构,占用额外堆内存。
8.7、为什么内置锁存在多种状态?
在JDK1.6版本之前,所有的Java内置锁都是重量级锁。重量级锁会造成CPU在用户态和核心态之间频繁切换,所以代价高、效率低。
JDK1.6版本为了减少获得锁和释放锁所带来的性能消耗,引入了“偏向锁”和“轻量级锁”实现。
所以,在JDK1.6版本里内置锁一共有四种状态:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,这些状态随着竞争情况逐渐升级。
内置锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种能升级却不能降级的策略,其目的是为了提高获得锁和释放锁的效率。
8.8、为什么会存在锁升级现象?
在保证线程安全的前提下,通过动态调整锁的粒度(偏向锁→轻量级锁→重量级锁)来平衡性能开销与并发效率:
无竞争时用低开销锁, 减少同步损耗(如偏向锁仅需一次CAS),
竞争加剧时逐步升级锁机制以匹配实际并发强度(如重量级锁通过阻塞避免CPU空转)
8.9、锁的内存变化及膨胀流程图
9、自旋锁
什么是自旋锁
自旋锁(spinlock)是一种为保护共享资源而设计的锁机制。当一个线程尝试获取锁时,如果该锁已被其他线程持有,此线程将进入循环等待状态,持续判断是否能够成功获取锁,直至获取到锁才会退出循环。
自旋锁与互斥锁类似,都是为了解决资源的互斥使用问题,在任何时刻,最多只有一个执行单元能够获得锁。不过,它们在调度机制上有所不同。对于互斥锁,若资源已被占用,资源申请者会进入睡眠状态;而自旋锁不会使调用者睡眠,若自旋锁已被其他执行单元持有,调用者会持续循环检查该自旋锁是否已被释放,“自旋” 一词由此而来。
Java 中自旋锁的实现
以下是一个简单的 Java 自旋锁实现示例:
public class SpinLock {
private AtomicReference<Thread> cas = new AtomicReference<Thread>();
public void lock() {
Thread current = Thread.currentThread();
// 利用 CAS
while (!cas.compareAndSet(null, current)) {
// DO nothing
}
}
public void unlock() {
Thread current = Thread.currentThread();
cas.compareAndSet(current, null);
}
}
在上述代码中,lock()
方法使用了 CAS 操作。当第一个线程 A 获取锁时,能够成功获取,不会进入 while
循环。若线程 A 未释放锁,线程 B 来获取锁,由于不满足 CAS 条件,线程 B 会进入 while
循环,不断判断是否满足 CAS 条件,直到线程 A 调用 unlock()
方法释放锁。
自旋锁存在的问题
- CPU 资源消耗:若某个线程持有锁的时间过长,会使其他等待获取锁的线程进入循环等待,持续消耗 CPU 资源,使用不当可能导致 CPU 使用率极高。
- 公平性问题:上述 Java 实现的自旋锁不具备公平性,无法保证等待时间最长的线程优先获取锁,可能会出现 “线程饥饿” 问题。
- 适用场景局限:自旋等待必须控制时间限度。若自旋超过一定次数仍未获取到锁,应切换为传统的线程挂起方式。否则,自旋锁的优势会被过度消耗的 CPU 资源抵消,得不偿失。
自旋锁的优点
- 避免上下文切换:自旋锁不会使线程状态发生切换,线程始终处于用户态,保持活跃状态,不会进入阻塞状态,减少了不必要的上下文切换,执行速度较快。
- 性能优势:非自旋锁在获取不到锁时会进入阻塞状态,进而进入内核态,获取到锁时又需从内核态恢复,涉及线程上下文切换。而线程被阻塞后进入内核(Linux)调度状态,会导致系统在用户态与内核态之间频繁切换,严重影响锁的性能。
可重入的自旋锁和不可重入的自旋锁
前面给出的简单自旋锁实现是不可重入的。当一个线程第一次获取到锁后,在锁释放之前再次尝试获取该锁,由于不满足 CAS 条件,第二次获取会进入 while
循环等待。而可重入锁应允许线程在锁释放前再次成功获取锁。
为实现可重入锁,我们引入一个计数器来记录获取锁的线程数,以下是实现代码:
public class ReentrantSpinLock {
private AtomicReference<Thread> cas = new AtomicReference<Thread>();
private int count;
public void lock() {
Thread current = Thread.currentThread();
if (current == cas.get()) { // 如果当前线程已经获取到了锁,线程数增加一,然后返回
count++;
return;
}
// 如果没获取到锁,则通过 CAS 自旋
while (!cas.compareAndSet(null, current)) {
// DO nothing
}
}
public void unlock() {
Thread cur = Thread.currentThread();
if (cur == cas.get()) {
if (count > 0) { // 如果大于 0,表示当前线程多次获取了该锁,释放锁通过 count 减一来模拟
count--;
} else { // 如果 count == 0,可以将锁释放,这样就能保证获取锁的次数与释放锁的次数是一致的了。
cas.compareAndSet(cur, null);
}
}
}
}
自旋锁与互斥锁的比较
- 相同点:自旋锁和互斥锁都是为实现资源共享保护而设计的机制,在任意时刻,最多只有一个执行单元能够持有锁。
- 不同点:获取互斥锁的线程,若锁已被占用,会进入睡眠状态;而获取自旋锁的线程不会睡眠,而是持续循环等待锁释放。
自旋锁总结
- 基本概念:线程获取锁时,若锁被其他线程持有,当前线程会循环等待,直至获取到锁。
- 线程状态:自旋锁等待期间,线程状态保持不变,始终处于用户态且保持活跃。
- 性能影响:若持有锁的时间过长,会导致其他等待获取锁的线程耗尽 CPU 资源。
- 特性局限:自旋锁本身无法保证公平性和可重入性。
- 功能拓展:基于自旋锁,可以实现具备公平性和可重入性的锁。
10、同步锁
同步锁是一种用于协调多线程并发访问共享资源的机制,其核心目的是确保共享资源在同一时刻仅能被一个线程访问或操作,以此避免数据竞争、不一致等问题,保证程序执行的正确性和稳定性。同步锁不仅实现了资源访问的互斥性(即同一时刻只有一个线程持有锁并访问资源),还常涉及线程间的协作,例如线程的等待与唤醒,从而满足更复杂的多线程同步需求。从广义上理解,同步锁是多线程同步机制的统称,它涵盖了多种具体的锁实现方式。
Java中的同步锁: synchronized
11、死锁
死锁是一种在多线程环境下可能出现的现象。假设有线程 A 持有资源 x,而线程 B 持有资源 y,此时线程 A 需要获取线程 B 所持有的资源 y 才能继续执行,于是它等待线程 B 释放资源 y;与此同时,线程 B 也需要获取线程 A 所持有的资源 x 才能继续运行,进而等待线程 A 释放资源 x。然而,由于两个线程都不会主动释放自己持有的资源,这就导致它们相互等待,都无法获取到对方的资源,最终造成死锁。
在 Java 中,死锁一旦发生,是不能自行打破的。处于死锁状态的线程无法继续执行,也不能对其他操作进行响应,这会使程序的部分功能甚至整个程序陷入停滞。因此,在编写 Java 程序时,尤其是涉及到多线程并发的场景,必须格外注意资源的分配和线程的调度,以避免死锁的发生。
public class DeadlockExample {
// 定义两个资源对象
private static final Object resource1 = new Object();
private static final Object resource2 = new Object();
public static void main(String[] args) {
// 线程1
Thread thread1 = new Thread(() -> {
synchronized (resource1) {
System.out.println("Thread 1 acquired resource 1");
try {
// 让线程1休眠一段时间,确保线程2获取resource2
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (resource2) {
System.out.println("Thread 1 acquired resource 2");
}
}
});
// 线程2
Thread thread2 = new Thread(() -> {
synchronized (resource2) {
System.out.println("Thread 2 acquired resource 2");
synchronized (resource1) {
System.out.println("Thread 2 acquired resource 1");
}
}
});
// 启动两个线程
thread1.start();
thread2.start();
}
}
在上述代码中,thread1
先获取resource1
,然后试图获取resource2
;thread2
先获取resource2
,然后试图获取resource1
,这样就形成了死锁。运行这段代码,会发现两个线程都停留在获取对方资源的等待状态,程序无法正常结束。
12、锁粗化(JVM性能优化策略)
锁粗化是 JVM 为提升多线程程序性能而采用的一种优化技术,主要针对同一对象频繁加锁与解锁的场景。当一系列连续操作(甚至包含循环体内的操作)都围绕同一对象反复进行加锁、解锁时,即便不存在线程竞争,频繁的锁操作也会带来额外的性能开销。例如,每次循环迭代都进行一次锁获取和释放,会导致大量时间消耗在上下文切换和锁状态检查上。
锁粗化通过扩大锁的作用范围来解决这一问题:将原本分散的多个锁操作合并为一个,把加锁范围扩展到整个操作序列的外部。这样,锁的获取与释放次数大幅减少,从而降低了性能损耗。例如,将循环体内的锁操作移至循环外部,使整个循环仅需进行一次加锁和解锁,显著提升执行效率。
这种优化策略由 JVM 自动执行,开发者无需手动干预。它在减少锁竞争、提升程序性能的同时,也避免了因频繁锁操作导致的资源浪费,尤其适用于连续、紧密相关的同步操作场景。
13、锁消除(JVM 的深度性能优化技术)
锁消除是 JVM(Java 虚拟机)在即时编译(JIT)阶段执行的一种高级优化策略,旨在通过消除不必要的锁操作来提升程序性能。其核心逻辑是识别并移除那些在运行时不会发生线程竞争的锁,避免因锁机制带来的额外开销。
锁消除的实现原理
JVM 借助逃逸分析(Escape Analysis)技术判断对象的作用域与访问范围:
- 方法逃逸:若一个对象在某个方法中创建后,作为参数传递到其他方法,或被其他方法引用,即发生方法逃逸。例如,在方法 A 中创建对象 obj 并返回,被方法 B 接收使用。
- 线程逃逸:当对象被其他线程访问时,即发生线程逃逸。例如,将对象放入共享集合,或在多线程环境中作为共享变量传递。
若 JVM 通过逃逸分析确定某个对象仅在单个线程内使用,且不会被其他线程访问(即无方法逃逸和线程逃逸),则会将该对象视为线程私有数据。此时,针对该对象的同步加锁操作被判定为冗余,JVM 会直接消除相关锁代码,避免锁竞争和同步开销。
示例说明
以下代码中,局部变量str
仅在concatenateStrings
方法内使用,不会被其他线程访问:
public class LockEliminationExample {
public String concatenateStrings() {
String str1 = "Hello";
String str2 = "World";
StringBuilder sb = new StringBuilder();
// 虽然StringBuilder的append方法是同步的,但sb不会发生线程逃逸
sb.append(str1).append(str2);
return sb.toString();
}
}
在这种情况下,JVM 会通过锁消除优化,移除StringBuilder
内部同步方法(如append
)的锁操作,将其视为单线程安全的普通操作,从而提升执行效率。
锁消除技术体现了 JVM 在运行时的智能优化能力,通过精准分析对象的作用域和线程访问情况,自动消除不必要的锁,在保证线程安全的前提下显著提升程序性能。
14、synchronized 关键字
14.1 synchronized 介绍
synchronized
是 Java 中用于实现线程同步的核心关键字,通过确保同一时刻仅有一个线程执行被修饰的方法或代码块,有效解决多线程环境下的资源竞争与数据一致性问题。
在 Java 早期版本中,synchronized
因底层依赖操作系统的 Mutex Lock(互斥锁)实现,被视为 “重量级锁”。由于 Java 线程需映射到操作系统原生线程,线程的挂起与唤醒涉及用户态到内核态的切换,导致锁操作存在较高性能开销。
自 Java 6 起,JVM 对synchronized
进行了深度优化,引入自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术,显著降低了锁竞争带来的性能损耗。这些优化使synchronized
在 JDK 源码及众多开源框架中广泛应用,成为多线程同步的可靠选择。
synchronized 保证原子性的原理
synchronized
关键字通过获取对象的监视器锁(monitor
)来实现同步。当一个线程进入被synchronized
修饰的方法或者代码块时,它会先尝试获取对应的锁。如果锁已经被其他线程持有,该线程就会被阻塞,直到持有锁的线程释放锁。
一旦线程获取到锁,它就可以执行synchronized
修饰的代码,并且在执行期间不会被其他线程干扰。这就保证了在同一时刻只有一个线程能够执行这段代码,从而使得这段代码中的操作具有原子性。因为在该线程执行完毕并释放锁之前,其他线程无法进入这个同步区域来修改共享数据。
synchronized 保证可见性的原理
synchronized
能够保证可见性,这是因为线程在进入synchronized
块之前,会清空工作内存中共享变量的值,从主内存中重新读取最新的值;而在退出synchronized
块时,会将工作内存中对共享变量的修改刷新到主内存中。这样一来,当一个线程修改了共享变量的值并退出同步块后,其他线程进入同步块时就能看到这个修改。
synchronized 保证有序性的原理
synchronized
能够保证有序性,因为它会形成一个 “临界区”,同一时刻只有一个线程能够进入临界区执行代码。在临界区内,指令不会被重排序到临界区之外,也就是说,synchronized
块内的代码会按照顺序执行,不会被其他线程干扰。
14.2 synchronized 的使用
synchronized
有三种使用方式,根据修饰目标不同,锁定范围和效果也有所区别:
- 修饰实例方法:锁定当前对象实例(
this
),进入同步代码前需获取该实例的锁。同一时刻,只有一个线程能进入被修饰的实例方法。
synchronized void method() {
// 业务代码
}
- 修饰静态方法:锁定当前类(
Class
对象),由于静态成员归属于类,所有对象实例共享该锁,会阻塞所有调用该方法的线程。
synchronized static void method() {
// 业务代码
}
- 修饰代码块:可自定义锁定对象,分为两种情况:
-
synchronized(object)
:锁定给定对象,进入同步代码前需获取该对象的锁,适用于缩小同步范围。synchronized(类.class)
:锁定给定类,常用于控制类级别的资源访问。
synchronized(this) {
// 业务代码
}
使用演示:
public class Phone {
public synchronized void sendEmail() {
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("------sendEmail");
}
public synchronized void sendSMS() {
System.out.println("------sendSMS");
}
public void hello() {
System.out.println("------hello");
}
public static void main(String[] args) {
Phone phone = new Phone();
new Thread(() -> {
phone.sendEmail();
}, "a").start();
try {
TimeUnit.MILLISECONDS.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(() -> {
phone.sendSMS();
}, "b").start();
}
}
现象描述:a、b两个线程访问
1、两个都是同步方法,先打印邮件还是短信?-------------先邮件再短信,共用一个对象锁。
2、sendEmail()休眠3秒,先打印邮件还是短信?----------先邮件再短信,共用一个对象锁。
3、添加一个普通的hello方法,先打印普通方法还是邮件?------先hello,再邮件。
4、两部手机,一个发短信,一个发邮件,先打印邮件还是短信?----先短信后邮件 资源没有争抢,不是同一个对象锁。
5、两个静态同步方法,一部手机,先打印邮件还是短信?-----先邮件再短信,共用一个类锁。
6、两个静态同步,两部手机,一个发短信,一个发邮件,先打印邮件还是短信?-----先邮件后短信,共用一个类锁。
7、邮件静态同步,短信普通同步,先打印邮件还是短信?---先短信再邮件,一个类锁一个对象锁。
8、邮件静态同步,短信普通同步,两部手机,先打印邮件还是短信?------先短信后邮件,一个类锁一个对象锁。
14.3 synchronized 的特性与实现原理
synchronized
属于独占锁、悲观锁、可重入锁和非公平锁:
- 独占性/互斥性:同一时刻仅允许一个线程持有锁;
- 可重入性:已持有锁的线程可再次获取同一把锁,避免递归调用时的死锁;
- 非公平性:线程获取锁的顺序不遵循 “先到先得” 原则。
其底层依赖对象监视器(monitor
)机制:每个对象都关联一个monitor
,线程通过竞争monitor
实现同步。具体而言,代码块加锁通过在前后插入monitorenter
和monitorexit
指令实现,方法加锁则依赖方法的访问标志位判断。当多个线程访问同步区域时,monitor
会将线程存储在不同队列(如等待队列、同步队列)中,协调线程的执行顺序。
14.4 synchronized 的锁升级过程
在 Java 中,synchronized
的锁状态会依据竞争状况逐步升级,存在无锁、偏向锁、轻量级锁、重量级锁这四种状态,且锁只能升级不能降级,以此提升锁的获取和释放效率。下面是各状态的特点与转换过程:
无锁到偏向锁:当第一个线程首次访问对象的同步块时,JVM 会在对象头中设置该线程的 Thread ID,并将对象头的状态位设置为 “偏向锁”,这一过程称为 “偏向”,意味着对象当前偏向于首个访问它的线程。
偏向锁:偏向锁适用于单线程频繁访问的场景,支持锁重入。在这种状态下,锁对象的对象头会记录获取锁的线程 ID。当该线程再次访问时,可直接获取锁,无需额外的同步操作。不过,一旦有其他线程尝试访问该对象,即出现竞争,偏向锁就会升级为轻量级锁。
轻量级锁:当两个或以上线程交替获取锁,但未并发竞争时,偏向锁会升级为轻量级锁。此时,当一个线程访问对象,JVM 会把对象头中的 Mark Word 复制一份到线程栈中,并在对象头中存储线程栈中的指针。若另一个线程想要访问该对象,会发现对象已处于轻量级锁状态,于是开始尝试使用 CAS 操作将对象头中的指针替换成自己的指针。线程会通过 CAS 自旋方式尝试获取锁,避免线程阻塞带来的用户态与内核态切换开销。不过,自旋是有次数限制的,在 JDK 1.8 中最多自旋 15 次。若替换成功,则该线程获取锁成功;反之,锁会升级为重量级锁。这里的自旋操作实际上就是轻量级锁获取过程中的自旋锁机制,它能让线程通过 CAS 操作尝试获取锁,避免进入阻塞状态,减少操作系统内核态与用户态切换的性能损耗。
重量级锁:当多个线程并发竞争同一对象的锁时,为避免自旋过度消耗 CPU,轻量级锁会升级为重量级锁。此时,JVM 会将该对象的锁转变为一个重量级锁,并在对象头中记录指向等待队列的指针。若一个线程想要获取锁,需要先进入等待队列,等待锁被释放。当锁被释放时,JVM 会从等待队列中选择一个线程唤醒,并将该线程设置为 “就绪” 状态,线程将被阻塞,直至获取到锁。
14.5 synchronized 的锁升级原理
- 无锁状态:JVM 启动后,共享资源对象在未被访问时处于无锁状态,对象头的 Mark Word 中偏向锁标识位为 0,锁标识位为 01。
- 偏向锁:当共享资源首次被某线程访问时,锁从无锁升级为偏向锁。此时,Mark Word 中记录该线程的操作系统线程 ID,偏向锁标识位变为 1,锁标识位保持 01。后续该线程再次访问时,只需比较线程 ID 即可获取锁,提升单线程场景下的访问效率。不过,由于硬件性能提升,JDK 15 后默认关闭偏向锁。若偏向锁未开启或在 JVM 偏向锁延迟时间内有线程访问,则直接升级为轻量级锁。
- 轻量级锁:当第二个线程尝试获取偏向锁失败时,偏向锁升级为轻量级锁。JVM 通过 CAS 自旋操作尝试获取锁:
-
- 成功则进入临界区;
- 失败时,若仅有一个等待线程,可继续自旋尝试;若自旋次数超限,或出现三个及以上线程竞争,轻量级锁将升级为重量级锁。
- 重量级锁:轻量级锁获取失败时,升级为重量级锁。此时,JVM 借助操作系统的 mutex lock 实现线程阻塞,每个对象关联一个 monitor 对象。通过
monitorenter
和monitorexit
指令(分别插入同步代码块开始、结束及异常处)控制锁的获取与释放,涉及内核态与用户态切换,性能开销较大,因此被称为 “重量级锁” 。
14.6 Monitor 监视器锁
在 HotSpot 虚拟机中,monitor
由 ObjectMonitor
实现,其核心数据结构包含 waitSet
、owner
、EntryList
三个重要参数:
_WaitSet
和_EntryList
:用于存储ObjectWaiter
对象列表,每个等待锁的线程都会封装为ObjectWaiter
。_owner
:指向持有ObjectMonitor
的线程。
当多个线程访问同步代码时:
- 线程首先进入
_EntryList
集合等待。 - 获取到对象的
monitor
后,进入_Owner
区域,owner
变量设为当前线程,count
计数器加 1。 - 若线程调用
wait()
方法,会释放monitor
,owner
变量复位为null
,count
减 1,线程进入_WaitSet
集合等待唤醒。 - 线程执行完毕后,释放
monitor
并复位变量,以便其他线程竞争获取。
wait-set
队列的工作机制如下:拥有 monitor
的线程在满足特定条件(如资源不足)时,调用 Object
的 wait
方法,释放 monitor
并进入 wait-set
队列。当对象调用 notify
或 notifyAll
方法后,wait-set
中的线程被唤醒,与 entry-set
队列中的线程共同竞争 monitor
,仅有一个线程能成功获取并继续执行。
14.7、Lock 和 synchronized 的区别
在 Java 多线程编程中,Lock
和 synchronized
是两种常用的同步机制,它们的区别可以类比为自动挡和手动挡汽车驾驶方式的差异,下面为你详细分析:
1. 基本概念
- Lock:是 Java 中的一个接口,常见的实现类有
ReentrantLock
、ReentrantReadWriteLock
等。它代表可重入锁、悲观锁、独占锁、互斥锁、同步锁,能提供更灵活的锁控制。 - synchronized:是 Java 语言内置的关键字,用于实现线程同步。
2. 锁的获取与释放方式
- Lock:需要手动获取锁和释放锁。这就如同手动挡汽车,驾驶员要手动操作换挡杆来控制挡位变化,使用者需要明确调用
lock()
方法获取锁,使用完后调用unlock()
方法释放锁。例如:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockExample {
private Lock lock = new ReentrantLock();
public void doSomething() {
lock.lock();
try {
// 执行需要同步的代码
} finally {
lock.unlock();
}
}
}
- synchronized:由 JVM 自动完成锁的获取和释放,就像自动挡汽车,驾驶员只需控制油门和刹车,无需手动换挡。当线程进入
synchronized
修饰的方法或代码块时,自动获取锁;退出时,自动释放锁。
3. 异常处理时锁的释放
- Lock:在发生异常时,如果没有在
finally
块中主动通过unlock()
方法释放锁,很可能造成死锁现象。因为异常可能导致unlock()
方法无法正常执行,使得其他线程一直等待该锁。 - synchronized:在发生异常时,会自动释放线程占有的锁,不会导致死锁现象发生。JVM 会确保即使出现异常,锁也能被正确释放。
4. 线程中断响应
- Lock:可以让等待锁的线程响应中断。通过
lockInterruptibly()
方法获取锁时,若线程在等待过程中被中断,会抛出InterruptedException
异常,线程可以进行相应处理。例如:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockInterruptExample {
private Lock lock = new ReentrantLock();
public void doWork() throws InterruptedException {
lock.lockInterruptibly();
try {
// 执行同步代码
} finally {
lock.unlock();
}
}
}
- synchronized:使用
synchronized
时,等待的线程会一直等待下去,不能响应中断。一旦线程进入等待状态,只能等持有锁的线程释放锁后才能继续执行。
5. 锁获取状态判断
- Lock:可以通过
tryLock()
方法知道有没有成功获取锁。该方法会立即返回一个布尔值,表示是否成功获取到锁,方便根据不同情况进行处理。例如:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class TryLockExample {
private Lock lock = new ReentrantLock();
public void tryToDoSomething() {
if (lock.tryLock()) {
try {
// 成功获取锁,执行代码
} finally {
lock.unlock();
}
} else {
// 未获取到锁,进行其他处理
}
}
}
- synchronized:无法直接得知是否成功获取锁,线程会一直尝试获取锁,直到成功或进入阻塞状态。
6. 读写操作效率
- Lock:可以通过实现读写锁(如
ReentrantReadWriteLock
)提高多个线程进行读操作的效率。读写锁允许多个线程同时进行读操作,但写操作是独占的,适用于读多写少的场景。例如:
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReadWriteLockExample {
private ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock();
private ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock();
public void readData() {
readLock.lock();
try {
// 执行读操作
} finally {
readLock.unlock();
}
}
public void writeData() {
writeLock.lock();
try {
// 执行写操作
} finally {
writeLock.unlock();
}
}
}
- synchronized:没有专门针对读写操作的优化,无论读操作还是写操作,同一时刻都只允许一个线程访问,在读写并发场景下效率相对较低。
7. 使用场景建议
- synchronized:足够清晰简单,当只需要基础的同步功能时,使用
synchronized
是不错的选择。JVM 能确保即使出现异常,锁也能被自动释放,降低了死锁风险。 - Lock:当需要更灵活的锁控制,如响应中断、判断锁获取状态、实现读写锁等功能时,应该选择
Lock
。但使用Lock
时,要确保在finally
块中释放锁,并且 Java 虚拟机很难得知哪些锁对象是由特定线程持有的。
14.8、ReentrantLock 和 synchronized 的区别
在 Java 多线程编程中,ReentrantLock
和 synchronized
都是用于实现线程同步的机制,它们在保证线程安全方面有着重要作用,但也存在一些差异。
基本概念
- ReentrantLock:它是 Java 中的一个类,实现了
Lock
接口,具备可重入锁、悲观锁、独占锁、互斥锁、同步锁的特性。 - synchronized:是 Java 语言的关键字,用于修饰方法或代码块,以实现线程同步。
相同点
- 解决的核心问题相同:二者主要都是为了解决多个线程对共享变量的安全访问问题,防止出现数据不一致等并发问题。
- 可重入性:它们都是可重入锁(也称为递归锁),这意味着同一线程可以多次获得同一个锁。例如,一个线程在持有锁的情况下调用一个同样被该锁保护的方法时,无需重新获取锁,可以直接进入方法执行,避免了死锁的发生。
- 保证线程安全特性:都能保证线程安全的两大特性,即可见性和原子性。可见性确保一个线程修改了共享变量后,其他线程能够立即看到这个修改;原子性保证一个操作或者一系列操作要么全部执行,要么都不执行,中间不会被其他线程干扰。
不同点
- 锁的获取与释放方式:
-
- ReentrantLock:如同手动挡汽车,需要显式地调用
lock()
方法来获取锁,使用完后调用unlock()
方法释放锁,并且通常将unlock()
方法放在finally
块中,以确保锁一定会被释放。示例代码如下:
- ReentrantLock:如同手动挡汽车,需要显式地调用
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockExample {
private ReentrantLock lock = new ReentrantLock();
public void doSomething() {
lock.lock();
try {
// 执行需要同步的代码
} finally {
lock.unlock();
}
}
}
- synchronized:类似于自动挡汽车,是隐式地获得和释放锁。当线程进入
synchronized
修饰的方法或代码块时,自动获取锁;退出时,自动释放锁。 - 线程中断响应:
-
- ReentrantLock:可以响应中断。通过
lockInterruptibly()
方法获取锁时,如果线程在等待锁的过程中被中断,会抛出InterruptedException
异常,线程可以进行相应的处理,为处理锁的不可用性提供了更高的灵活性。示例代码如下:
- ReentrantLock:可以响应中断。通过
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockInterruptExample {
private ReentrantLock lock = new ReentrantLock();
public void doWork() throws InterruptedException {
lock.lockInterruptibly();
try {
// 执行同步代码
} finally {
lock.unlock();
}
}
}
- synchronized:不可以响应中断。一旦线程进入等待锁的状态,只能等待持有锁的线程释放锁后才能继续执行,无法被中断。
- 实现级别:
-
- ReentrantLock:是 API 级别的实现,基于 Java 代码实现锁的逻辑。
- synchronized:是 JVM 级别的实现,由 Java 虚拟机负责管理锁的获取和释放。
- 公平性:
-
- ReentrantLock:可以实现公平锁和非公平锁。通过构造函数
ReentrantLock(true)
可以创建公平锁,公平锁会按照线程请求锁的顺序依次获取锁;默认情况下,ReentrantLock
是非公平锁,即线程获取锁的顺序不一定按照请求的顺序。 - synchronized:是非公平锁,且不可更改。线程获取锁的顺序是不确定的,可能会出现某些线程长时间等待的情况。
- ReentrantLock:可以实现公平锁和非公平锁。通过构造函数
- 条件绑定:
-
- ReentrantLock:通过
Condition
对象可以绑定多个条件。Condition
可以实现线程的等待和唤醒机制,并且可以针对不同的条件进行不同的操作,这在一些复杂的同步场景中非常有用。示例代码如下:
- ReentrantLock:通过
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockConditionExample {
private ReentrantLock lock = new ReentrantLock();
private Condition condition = lock.newCondition();
public void await() throws InterruptedException {
lock.lock();
try {
condition.await();
} finally {
lock.unlock();
}
}
public void signal() {
lock.lock();
try {
condition.signal();
} finally {
lock.unlock();
}
}
}
- synchronized:只能使用
Object
类的wait()
、notify()
和notifyAll()
方法来实现线程的等待和唤醒,且只能有一个等待队列,灵活性相对较差。
15、ReentrantLock
15.1 ReentrantLock 介绍
ReentrantLock
实现了 Lock
接口,是一种可重入的独占式锁。其功能与 synchronized
类似,但更为灵活强大,支持轮询、超时、中断等特性,还可选择使用公平锁或非公平锁。
内部实现
ReentrantLock
内部基于 AbstractQueuedSynchronizer
(简称 AQS)实现。AQS 内部维护了一个双向队列,用于管理等待获取锁的线程。同时,还有一个由 volatile
修饰的 int
类型的 state
变量,state = 0
表示当前锁未被占有,state > 0
表示已有线程持有锁。后续会对 AQS 进行详细介绍。
ReentrantLock
内部类 Sync
继承自 AQS
(抽象队列同步器),负责实现加锁和释放锁的核心逻辑。Sync
有两个子类:FairSync
(公平锁)和 NonfairSync
(非公平锁)。默认情况下,ReentrantLock
使用非公平锁,也可通过构造函数显式指定为公平锁。
15.2、主要方法
void lock()
:用于获取锁。若锁已被其他线程占用,当前线程会进入等待状态,直至锁被释放并成功获取到锁。boolean tryLock()
:尝试获取锁,该方法不会阻塞线程。若成功获取锁,返回true
;若锁已被占用,返回false
。void unlock()
:用于释放锁。在使用lock()
方法获取锁后,必须在合适的时机调用该方法释放锁,通常会将其放在finally
块中,以确保锁一定被释放。void lockInterruptibly()
:该方法可中断地获取锁。在获取锁的过程中,如果当前线程被中断,会抛出InterruptedException
异常。boolean tryLock(long time, TimeUnit unit)
:当前线程会在以下三种情况下返回结果:
-
- 当前线程在指定的超时时间内成功获得了锁,返回
true
。 - 当前线程在超时时间内被中断,抛出
InterruptedException
异常。 - 超时时间结束仍未获得锁,返回
false
。
- 当前线程在指定的超时时间内成功获得了锁,返回
Condition newCondition()
:获取一个与当前锁绑定的等待通知组件Condition
。通过Condition
可以实现线程的等待和唤醒机制,且可以针对不同的条件进行不同的操作,这在一些复杂的同步场景中非常有用。
public static void main(String[] args) {
Lock lock = new ReentrantLock();
//获取锁
lock.lock();
try {
//代码逻辑
} finally {
//释放锁
lock.unlock();
}
}
15.3、synchronized 和 ReentrantLock 的区别
synchronized
和 ReentrantLock
都用于线程同步控制,且都是可重入锁,但它们在多个方面存在不同,以下是详细对比:
实现机制
- synchronized:它是 Java 的内置特性,依赖 JVM 底层实现。在使用时,当线程进入
synchronized
修饰的方法或代码块,JVM 会自动完成锁的获取;退出时,也会自动释放锁。 - ReentrantLock:基于 Java API 实现,是通过 Java 代码来完成锁的相关逻辑。需要手动调用
lock()
方法获取锁,使用完后调用unlock()
方法释放锁,通常将unlock()
放在finally
块中确保锁一定被释放。
可中断性
- synchronized:无法中断正在等待锁的线程。一旦线程进入等待锁的状态,只能等待持有锁的线程释放锁后才能继续执行。
- ReentrantLock:支持通过
lockInterruptibly()
方法中断等待锁的线程。在等待锁的过程中,如果线程被中断,会抛出InterruptedException
异常,线程可以进行相应处理,允许线程主动放弃等待,去处理其他任务。
公平性
- synchronized:是非公平锁,无法保证线程获取锁的顺序,即不能保证先请求锁的线程先获得锁。
- ReentrantLock:可以显式设置为公平锁或非公平锁。通过构造函数
ReentrantLock(true)
可以创建公平锁,公平锁会按照线程请求锁的顺序依次获取锁;默认情况下是非公平锁。
锁状态判断
- synchronized:无法直接判断锁的状态,线程会一直尝试获取锁,直到成功或进入阻塞状态。
- ReentrantLock:可以使用
tryLock()
方法判断是否成功获取锁。该方法会立即返回一个布尔值,表示是否成功获取到锁,方便根据不同情况进行处理。
条件通知
- synchronized:仅支持
wait()
、notify()
和notifyAll()
等通用通知机制。notify()
随机唤醒一个等待的线程,notifyAll()
唤醒所有等待的线程,无法进行选择性唤醒。 - ReentrantLock:通过
Condition
接口实现选择性通知。可以创建多个Condition
对象,每个对象可以绑定不同的条件,通过await()
使线程等待,通过signal()
或signalAll()
唤醒指定条件的线程。
超时获取
- synchronized:没有超时获取锁的功能,线程可能会无限期地等待锁。
- ReentrantLock:提供
tryLock(long timeout, TimeUnit unit)
方法,允许设置获取锁的超时时间。在指定的超时时间内,如果成功获取到锁则返回true
;如果超时仍未获取到锁则返回false
;如果在等待过程中线程被中断,会抛出InterruptedException
异常。
15.4、公平锁和非公平锁
syncchronized关键字只有非公平锁,而ReentrantLock可实现非公平锁和公平锁。
//默认非公平锁
public ReentrantLock() {
sync = new NonfairSync();
}
//只需要在new的时候指定其构造函数为true,就是公平锁
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
非公平锁:优点是减少了cpu唤醒线程的开销,整体的吞吐量会高一点。但它可能会导致队列中排队的线程一直获取不到锁或长时间获取不到,活活饿死。
公平锁:优点是所有的线程都能得到资源,不会饿死在队列中。但它的吞吐量相较非公平锁而言,就下降了很多,队列里面除了第一个线程,其它线程都会阻塞,cpu唤醒阻塞线程的开销是很大的缺点。
源码分析(JDK1.8):
- 非公平锁的lock的核心逻辑在NonFairSync中,如下:
static final class NonfairSync extends Sync {
private static final long serialVersionUID = 7316153563782823691L;
/**
* Performs lock. Try immediate barge, backing up to normal
* acquire on failure.
*/
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}
//这是AQS的方法,这里面的tryAcquire()会调用它子类重写的方法
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
//....
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
从代码可以看到,lock方法执行的时候会先用cas来判断当前锁是否有线程占有,如果cas成功,就将state设置为1,如果不成功,则去排队。
- 公平锁的lock核心逻辑在FairSync中,如下:
static final class FairSync extends Sync {
private static final long serialVersionUID = -3000897897090466540L;
final void lock() {
acquire(1);
}
/**
* Fair version of tryAcquire. Don't grant access unless
* recursive call or no waiters or is first.
*/
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
}
公平锁的lock方法在cas时多了一个hasQueuedPredecessors()的判断,解释一下,就是如果当前线程前面还有线程,那就继续排队,如果没有或者队列为空就申请锁。
15.5、可重入性解释及应用
所谓重入锁,是指一个线程拿到锁后,还可以多次获取同一把锁,而不会因为该锁已经被持有(尽管是自己持有的)而陷入等待状态。之前说的sychronized也是可重入锁。
ReentrantLock加锁的时候,看下当前持有锁的线程和当前请求的线程是否同一个,一样就可重入了。只需要简单的讲state加1,记录当前重入的次数即可。同时,在锁释放的时候,需要确保state=0的时候才执行释放的动作,简单的说就是重入多少次就得释放多少次。
举个两个人拿筷子吃饭的例子:
public class Chopsticks {
boolean getOne=false;
boolean getAnother=false;
//拿筷子,获取锁,该锁是当前Chopsticks对象
public synchronized void getOne() {
getOne=true;
System.out.println(Thread.currentThread().getName()+"拿到了一根筷子。");
//if语句块调用了另外的同步方法,需要再次获取锁,而该锁也是当前Chopsticks对象
if(getAnother) {
//有两根筷子,吃饭
canEat();
//吃完放下两根筷子
getOne=false;
getAnother=false;
}else {
//只有一根筷子,去拿另一根,然后吃饭
getAnother();
}
}
public synchronized void getAnother() {
getAnother=true;
System.out.println(Thread.currentThread().getName()+"拿到了一根筷子。");
if(getOne) {
//有两根筷子,吃饭
canEat();
//吃完放下两根筷子
getOne=false;
getAnother=false;
}else {
//只有一根筷子,去拿另一根,然后吃饭
getOne();
}
}
public synchronized void canEat() {
System.out.println(Thread.currentThread().getName()+"拿到了两根筷子,开恰!");
}
}
在这个筷子类中,拿第一根筷子的时候获取了一把锁,锁对象是this,也就是当前Chopsticks对象;拿第二根筷子的时候又获取了一次锁,锁对象是this,也是当前Chopsticks对象。测试类如下,说明在后面:
public class testChopstick {
public static void main(String[] args) {
Chopsticks chopsticks=new Chopsticks();
//线程A,模拟人A
Thread A=new Thread(new Runnable() {
@Override
public void run() {
chopsticks.getOne();
}
});
//线程B,模拟人B
Thread B=new Thread(new Runnable() {
@Override
public void run() {
chopsticks.getAnother();
}
});
A.start();
B.start();
}
}
两个线程都执行的是同一个对象chopsticks中的方法,这两个同步方法在执行时将会获取同样的锁;当线程1抢到CPU进入getOne时获取锁然后执行代码,如果线程1还未执行完毕就被线程2抢占了CPU,当线程2进入getAnother时发现锁在线程1那里,于是线程2等待;线程1重新拿到CPU继续执行代码,进入getAnother方法获取锁,发现锁就在自己这里,于是继续执行,这就是可重入锁。可重入锁避免了死锁的发生,避免线程因获取不了锁而进入永久等待状态。
16、Condition接口
可以结合ReentrantLock选择性地唤醒线程,从而实现更复杂的线程同步操作。
16.1 什么是Condition接口
同样,Conditon接口也来自java.util.concurrent.locks包下,任意一个Java对象,都拥有一组监视器方法(Object),主要包括wait、wait(long timeout)、notify()和notifyAll()方法,这些方法与sychronized关键字配合,可以实现等待/通知模式。Condition接口也提供了类似Object的监视器方法,与Lock配合可以实现等待/通知模式,但是这两者在使用方式上还是有差异的。
16.2 与Object监视器方法对比
对比项 | Object | Condition |
前置条件 | 获取对象的锁 | 调用 Lock.lock () 获取锁,调用 Lock.newCondition () 获取 Condition 对象 |
调用方式 | 直接调用,如:object.wait () | 直接调用,如 condition.await () |
等待队列个数 | 一个 | 多个 |
当前线程释放锁并进入等待状态 | 支持 | 支持 |
当前线程释放锁并进入等待状态,在等待状态中不响应中断 | 不支持 | 支持 |
当前线程释放锁并进入超时等待状态 | 支持 | 支持 |
当前线程释放锁并进入等待状态到将来某个时间 | 不支持 | 支持 |
唤醒等待队列中的一个线程 | 支持 | 支持 |
唤醒等待队列中的全部线程 | 支持 | 支持 |
16.3 Condition接口常用方法
void await() throws InterruptedException:当前线程释放锁,并进入Condition的等待队列中等待,直到被其它线程调用signal()唤醒它、或调用signalAll()、或被线程中断。
void awaitUniterruptibly():与await()方法类似,但它不会响应中断。也就是即使其它线程调用了当前线程的中断方法,当前线程也会继续等待,直到被唤醒。
boolean await(long time,TimeUnit unit):
void signal():唤醒一个等待在Conditon上的线程,该线程从等待方法返回前必须获得与Condition相关联的锁。
void signalAll():唤醒所有等待在Condition上的线程,能够从等待方法返回的线程必须获得与Conditon相关联的锁。
16.4、与ReentrantLock实现三个线程交替输出“ABC”
public class ABC {
//1表示A 2表示B 3表示C
private int num=1;
//创建Lock
private Lock lock=new ReentrantLock();
//创建Condition
Condition conditionA=lock.newCondition();
Condition conditionB=lock.newCondition();
Condition conditionC=lock.newCondition();
//打印A
public void printA() {
//上锁
lock.lock();
try {
if (num!=1){
//如果不是A
try {
//当前线程进入队列等待,并释放锁(也就是不往下走了,直到被唤醒)
conditionA.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//打印A
System.out.println("A");
//将标记该为“B”
num=2;
//唤醒B
conditionB.signal();
}finally {
lock.unlock();
}
}
//B和C的打印就不一一介绍了
public void printB() {
lock.lock();
try {
if (num!=2){
try {
conditionB.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("B");
num=3;
conditionC.signal();
}finally {
lock.unlock();
}
}
//打印C
public void printC(){
lock.lock();
try {
if (num!=3){
try {
conditionC.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("C");
num=1;
conditionA.signal();
}finally {
lock.unlock();
}
}
}
public static void main(String[] args) {
ABC abc=new ABC();
new Thread(()->{
for (int i = 0; i < 20; i++) {
abc.printA();
}
}).start();
new Thread(()->{
for (int i = 0; i < 20; i++) {
abc.printB();
}
}).start();
new Thread(()->{
for (int i = 0; i < 20; i++) {
abc.printC();
}
}).start();
}
/* 输出
A
B
C
A
B
C
...(共 20 组)
*/
17、ReentrantReadWriteLock
无论是synchronized还是ReentrantLock,同一时刻只能有一个线程访问临界资源,但是我们知道,读线程并不会导致并发问题,那么在读多写少的场景下,这两种锁就不太适合了,所以针对这个“痛点”,JUC中提供了另一种锁的实现——ReentrantReadWriteLock。
ReentrantReadWriteLock
是 ReadWriteLock
接口的实现类,其核心设计是将锁分为 读锁 和 写锁:
- 读锁:支持多个线程同时持有(只要没有线程持有写锁),适用于并发读场景。
- 写锁:独占式,同一时刻仅允许一个线程持有,确保写操作的原子性和数据一致性。
通过分离读写操作的锁机制,ReentrantReadWriteLock
在 “多读少写” 的场景下,能显著提升并发性能。
public class ReentrantReadWriteLock
implements ReadWriteLock, java.io.Serializable{
}
public interface ReadWriteLock {
Lock readLock();
Lock writeLock();
}
17.1 读写锁简介与特性
写锁在同一时刻可以允许多个读线程访问,但是写线程访问时,所有的读线程和其它线程均被阻塞。内部维护了一对锁,一个读锁和一个写锁,通过读写锁分离,相比其他一般的排他锁,性能有了很大的提升。
特性:
1、公平性选择:支持非公平和公平锁的获取。
2、重入性:读线程在获取读锁之后,能够再次获取读锁。而写线程在获取了写锁之后能够再次获取写锁,同时也可以获取读锁
3、锁降级:遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级为读锁。(指把当前拥有的写锁,再获取读锁,随后释放先前拥有的写锁的过程)
17.2 读写锁的互斥规则
- 写——写:互斥,一个线程在写的同时,其它线程会被阻塞。
- 读——写:互斥,读的时候不能写,写的时候不能读。
- 读——读:不互斥,不阻塞。
17.3 接口与示例
接口ReadWriteLock仅定义了获取读锁和写锁的两个方法——readLock()和writeLock(),而其实现类ReentrantReadWriteLock,还提供了一些便于监控其内部工作状态的方法,如下:
int getReadLock():返回当前读锁被获取的次数。
int getReadHoldCount():返回当前线程获取的次数。
boolean isWriteLocked():判断写锁是否被获取。
int getWriteHoldCount():返回当前写锁被获取的次数。
public class ReadWriteDemo {
//创建读写锁
ReentrantReadWriteLock readWriteLock=new ReentrantReadWriteLock();
private int value;
//设置
public void setValue(int value){
//上锁
readWriteLock.writeLock().lock();
try {
Thread.sleep(1000);
this.value=value;
System.out.println(Thread.currentThread().getId()+"修改了value:"+this.value);
}catch (Exception e){
e.printStackTrace();
}
finally {
readWriteLock.writeLock().unlock();
}
}
public int getvalue(){
readWriteLock.readLock().lock();
try {
Thread.sleep(1000);
System.out.println(Thread.currentThread().getId()+"读取了value:"+this.value);
return value;
} catch (InterruptedException e) {
e.printStackTrace();
}
finally {
readWriteLock.readLock().unlock();
}
return -1;
}
//测试代码
public static void main(String[] args) {
ReadWriteDemo readWriteDemo=new ReadWriteDemo();
//修改
for (int i = 0; i < 2; i++) {
new Thread(()->{
readWriteDemo.setValue(new Random().nextInt(100));
}).start();
}
//读取
for (int i = 0; i < 8; i++) {
new Thread(()->{
readWriteDemo.getvalue();
}).start();
}
}
}
/* 输出示例
12修改了value:34
11修改了value:87
13读取了value:87
14读取了value:87
15读取了value:87
16读取了value:87
17读取了value:87
18读取了value:87
19读取了value:87
20读取了value:87
*/
通过sleep可以观察到,只有在读写交替和两个写操作的时候程序是互斥执行,而读操作使线程之间使并发执行
18、StampedLock(Java 8特性之一)
ReentrantReadWriteLock(读写锁),它可以保证最多同时有一个线程写数据,或者同时有多个线程读数据,但是读写线程之间互斥,不能同时进行。
假设有一个日志场景,有一个线程在写日志,但在写日志的时候你可能需要把日志集中转移到日志服务,但是此时读线程不能读数据(写线程阻塞了)。
很显然,ReentrantReadWriteLock在这个场景下并不完美,但在Java 8时新增的StampedLock类,可以更好的应对这种场景
18.1 什么是StampedLock(邮戳锁)
StampedLock是Java8提供的一种乐观读写锁。相比于ReentrantReadWriteLock,StampedLock引入了乐观读的概念,就是在已经有写线程加锁的同时,仍然允许读线程进行读操作,这相对于对读模式进行了优化,但是可能会导致数据不一致的问题,所以当使用乐观读时,必须对获取结果进行校验。
18.2 StampedLock的常用方法
long readLock()
-
- 获取读锁并返回一个表示锁状态的标记(stamp)。
- 如果写锁被持有,则方法将阻塞当前线程,直到可以获取读锁。
long tryReadLock()
-
- 尝试非阻塞地获取读锁并返回一个表示锁状态的标记(stamp)。
- 如果有写锁被持有,则方法立即返回 0,而不会阻塞线程。
long writeLock()
-
- 获取写锁并返回一个表示锁状态的标记(stamp)。
- 如果当前没有线程持有读锁或写锁,则立即返回,反之,阻塞当前线程。
void unlockRead(long stamp)
-
- 释放读锁。
- 使用获取读锁时返回的标记(Stamp)进行匹配,如果匹配,释放读锁,反之则抛异常。
void unlockWrite(long stamp)
-
- 释放写锁。
- 使用获取写锁时返回的标记(Stamp)进行匹配,如果匹配,释放写锁,反之则抛异常。
long tryConvertToReadLock(long stamp)
-
- 尝试将写锁转换为读锁。
- 如果转换成功,则新标记表示读锁;否则,返回 0 表示失败。
boolean tryConvertToWriteLock(long stamp)
-
- 尝试将读锁转换为写锁。
- 如果当前只有一个读锁被持有,并且当前标记与获取读锁时一致,则将读锁转换为写锁,并返回 true,反之返回 false。
boolean tryUnlockRead()
-
- 尝试非阻塞释放读锁。
- 如果当前没有线程持有读锁,并且释放成功,则返回 true,否则返回 false 表示失败。
long tryOptimisticRead()
-
- 尝试使用乐观读,并返回一个 stamp。
- 方法立即返回,不阻塞,可以和写操作同时进行。
boolean validate(long stamp)
-
- 检查乐观读的有效性。
- 如果在乐观读取后,没有其它线程成功获取写锁,则返回 true,否则返回 false。
18.3 StampedLock的三种模式
读模式:在读模式下,多个线程可以同时获取读锁,不互相阻塞。但当写线程请求获取写锁时,读线程会被阻塞。与ReentrantReadWriteLock类似。
写模式:写模式时独占的,当一个写线程获取写锁时,其它线程无法同时持有写或读锁。写锁请求会阻塞其它线程的读锁。与ReentrantReadWriteLock类似。
乐观读模式:注意,上述两个模式均加了锁,所以它们之间读写互斥,乐观读模式是不加锁的读。这样就有两个好处,一是不加锁意味着性能会更高一点,二是写线程在写的同时,读线程仍然可以进行读操作。(如果对数据的一致性要求,那么在使用乐观读的时候需要进行validate()校验)
乐观读示例:
public class StampedLockDemo {
//假设这是需要操作的数据
Map<String,String> map=new HashMap<>();
//邮戳锁
StampedLock lock=new StampedLock();
public String optimisticRead(String key){
//乐观读
long stamp = lock.tryOptimisticRead();
//读数据
String value = map.get(key);
//校验数据是否是最新版本
if (!lock.validate(stamp)) {
//校验失败(出现了数据不一致的情况),获取读锁(锁升级)
stamp = lock.readLock();
try {
//重新读取数据并返回
return map.get(key);
}finally {
lock.unlock(stamp);
}
}
//没有出现数据不一致的情况,直接返回
return value;
}
}
18.4 StampedLock VS ReentrantReadWriteLock
ReentrantReadWriteLock以下简称RRW。
一、锁的基本类型
- RRW(传统读写锁):提供读锁和写锁两种类型。读锁允许多个线程同时读取共享资源,写锁则独占共享资源。遵循读 - 读不互斥,读 - 写和写 - 写互斥的规则。
- StampedLock:RRW 的升级版,具备读锁、写锁和乐观读三种类型的锁。其中读锁和写锁的功能与 RRW 类似,乐观读是对读操作的一种优化。
二、互斥规则
- RRW:读 - 读不互斥,读 - 写和写 - 写互斥。
- StampedLock:读锁和写锁之间互斥,但乐观读和写锁之间不互斥。
三、并发性能
- RRW:只有读 - 读操作时才能并发执行,其他情况均为互斥,性能表现良好。
- StampedLock:由于存在乐观读机制,读 - 写操作也可以并发进行,性能较为优秀。
四、可重入性
- RRW:具有可重入性。当线程持有读锁后,能够再次获取同一把读锁;持有写锁的线程获取锁后,不仅可以再次获取写锁,还能获取读锁。
- StampedLock:不具备可重入性,无论是读锁还是写锁,线程都不能再次获取同一把锁。
五、锁升级
- RRW:支持锁降级,但需要按照先获取写锁,再获取读锁,最后释放写锁的顺序,写锁才能降级为读锁。
- StampedLock:读锁和写锁之间可以通过
tryConvertToReadLock(long stamp)
和tryConvertToWriteLock(long stamp)
方法相互转换。
六、锁实现
- RRW:基于 AQS(AbstractQueuedSynchronizer)实现。
- StampedLock:基于 CLH 锁实现,CLH 锁是一种自旋锁,能够保证没有饥饿现象且遵循 FIFO(先进先出)原则。
ps:CLH锁维护着一个等待线程队列,所有申请锁且失败的线程都记录在队列里。一个节点代表一个线程,保存着一个标记为locked,用来判断当前线程是否已经释放锁。当一个线程试图获取锁时,从队列尾节点作为前序节点,循环判断所有的前序节点是否已经成功获取锁。
18.5 StampedLock停车场示例
public class StampedLockDemo {
// 用于存储停车位信息的Map
private Map<String, String> parkingSlots;
// 邮戳锁
private final StampedLock lock;
public StampedLockDemo() {
parkingSlots = new HashMap<>();
lock = new StampedLock();
}
//停车
public void parkCar(String slot, String carNumber) {
// 获取写锁
long stamp = lock.writeLock();
try {
// 停车
parkingSlots.put(slot, carNumber);
} finally {
// 释放写锁
lock.unlockWrite(stamp);
}
}
//找车
public String findCar(String slot) {
// 尝试获取乐观读锁
long stamp = lock.tryOptimisticRead();
String carNumber = parkingSlots.get(slot);
// 校验乐观读锁的有效性
if (lock.validate(stamp)) {
return carNumber;
} else {
// 获取悲观读锁
stamp = lock.readLock();
try {
carNumber = parkingSlots.get(slot);
return carNumber;
} finally {
// 释放悲观读锁
lock.unlockRead(stamp);
}
}
}
//移移除停车车辆
public void removeCar(String slot) {
// 获取写锁
long stamp = lock.writeLock();
try {
// 移除停车车辆
parkingSlots.remove(slot);
} finally {
// 释放写锁
lock.unlockWrite(stamp);
}
}
}
19、CAS(compare and swap)
19.1、什么是CAS
CAS 的全称是 Compare And Swap(比较与交换),其主要作用是保证在多线程环境下对共享变量修改的原子性,解决了多线程条件下使用锁造成性能损耗的问题。它的思想简单直接,即使用一个预期值和要更新的变量值进行比较,只有两值相等时才会进行更新操作。
CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。它涉及到三个操作数:
- V(Var):要更新的变量值。
- E(Expected):预期值。
- N(New):拟写入的新值。
其执行逻辑如下:
如果内存中的值和预期原始值相等,就将修改后的新值保存到内存中;如果内存中的值和预期原始值不相等,说明共享数据已经被修改,此时会放弃已经所做的操作,然后重新执行刚才的操作,直到重试成功。
当多个线程同时使用 CAS 操作一个变量时,只有一个线程会胜出并成功更新,其余线程均会失败。但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。
Java 语言并没有直接实现 CAS,CAS 相关的实现是通过 C++ 内联汇编的形式实现的(JNI 调用),因此,CAS 的具体实现和操作系统以及 CPU 都有关系。sun.misc
包下的 Unsafe
类提供了 compareAndSwapObject
、compareAndSwapInt
、compareAndSwapLong
方法来实现对 Object
、int
、long
类型的 CAS 操作。
/**
* CAS
* @param o 包含要修改field的对象
* @param offset 对象中某field的偏移量
* @param expected 期望值
* @param update 更新值
* @return true | false
*/
public final native boolean compareAndSwapObject(Object o, long offset, Object expected, Object update);
public final native boolean compareAndSwapInt(Object o, long offset, int expected,int update);
public final native boolean compareAndSwapLong(Object o, long offset, long expected, long update);
19.2、CAS 的应用场景与实现原理
CAS(Compare And Swap,比较与交换)算法在 Java 并发编程中具有广泛应用,主要用于实现乐观锁和锁自旋,解决多线程环境下共享变量的原子性更新问题,避免传统锁机制带来的性能损耗。
1. Atomic 原子类的应用
java.util.concurrent.atomic
包下的原子类(如AtomicInteger
)是 CAS 的典型应用。这些类摒弃了synchronized
锁,转而通过volatile
和 CAS 机制保障资源的线程安全:
volatile
:确保变量的可见性和有序性,保证所有线程读取到的变量值是最新的,且禁止指令重排序。CAS
:通过无锁操作实现原子性更新,提升并发效率。以AtomicInteger
为例:
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
static {
try {
valueOffset = unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
private volatile int value;
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
Unsafe
类提供了底层内存操作能力,getAndAddInt
方法通过循环实现 CAS 操作:先获取变量当前值v
,再尝试将内存地址处的值与v
比较,若相等则更新为v + delta
,否则重试,直至更新成功。这种机制确保了操作的原子性,且无需阻塞线程。
2. JUC 框架中的应用
java.util.concurrent
(JUC)包中的大部分类直接或间接依赖 CAS 实现高效并发控制:
AQS(AbstractQueuedSynchronizer)
:作为 JUC 同步器的基础框架,AQS 通过 CAS 操作管理同步状态(state
),实现锁、信号量等同步工具。ConcurrentHashMap
:在 JDK 1.8 后采用 CAS + 分段锁(synchronized
)的方式,在插入、更新数据时利用 CAS 避免锁竞争,提升并发性能。ConcurrentLinkedQueue
:基于 CAS 实现无锁队列,线程在入队、出队操作时通过 CAS 直接修改节点引用,避免锁带来的阻塞开销。
3. CAS 的核心特点与优势
CAS 操作包含三个操作数:内存位置(V)、预期值(A)和新值(B)。在并发修改时,先比较内存中的值(V)与预期值(A)是否相等,若相等则更新为新值(B),否则不做任何操作。这种机制使得 CAS 成为非阻塞算法的典型实现,相比synchronized
等阻塞型同步方式,它具有以下优势:
- 高性能:避免线程阻塞和上下文切换,适用于读多写少的高并发场景。
- 轻量级:无需获取重量级锁,减少了锁竞争带来的性能损耗。
当多个线程同时使用 CAS 更新同一变量时,仅一个线程能成功,其余线程失败后不会被挂起,而是可以立即重试,进一步提升了系统的响应性和吞吐量。
19.3 CAS在操作系统层面的原子性
CAS操作的原理是基于硬件提供的原子操作指令——cmpxchg指令实现:
cmpxchg指令是一条原子指令。在cpu执行cmpxchg指令时,处理器会自动锁定总线,防止其它cpu访问共享变量,然后执行比较和交换操作,最后释放总线。
cmpxchg指令在执行期间,cpu会自动禁止中断。这样可以确保CAS操作的原子性,避免中断或其它干扰对操作的影响。
cmpxchg指令是硬件实现的,可以保证其原子性和正确性。cpu中的硬件电路确保了cmpxchg指令的正确执行,以及对共享变量的访问原子性。
ps:同样是因为cmpxchg指令,这个指令基于cpu缓存一致性协议实现的。在多个cpu中,所有核心的缓存都是一致的。当一个cpu核心执行cmpxchg指令时,其它cpu核心的缓存会自动更新,以确保对共享变量的访问是一致的。
19.4 CAS存在的问题
19.4.1 ABA问题
CAS算法实现一个重要前提是需要取出内存中某时刻的数据,而在下个时刻进行比较和交换,那么这个时间差会导致数据的变化。
比如,当线程1要修改A时,会先读取A的值,如果此时有一个线程2,经过一系列操作,将A修改为B,再由B修改为A,然后线程1在比较A时,发现A的值没有改变,于是就修改了。但此时A的版本已经不是最先读取的版本了,这就时ABA问题。
如何解决?通过引入版本号或时间戳来追踪数据的变更历史。在每次修改数据时,同时递增版本号或更新时间戳,使得每次变更都具有唯一标识。在执行 CAS 操作时,不仅需要比较数据值,还需对比版本号或时间戳是否一致。只有当两者均匹配时,才执行更新并更新版本号。
在 Java 中,JDK 1.5 之后提供的AtomicStampedReference
类专门用于解决 ABA 问题。该类的compareAndSet()
方法要求同时检查当前引用与预期引用是否相等,以及当前标志(版本号或时间戳)与预期标志是否一致。只有这两个条件都满足时,才会以原子方式更新引用和标志的值,从而确保数据的变更过程可追溯,避免 ABA 问题带来的隐患。
public boolean compareAndSet(V expectedReference,
V newReference,
int expectedStamp,
int newStamp) {
Pair<V> current = pair;
return
expectedReference == current.reference &&
expectedStamp == current.stamp &&
((newReference == current.reference &&
newStamp == current.stamp) ||
casPair(current, Pair.of(newReference, newStamp)));
}
19.4.2 忙等待问题
CAS(Compare And Swap)算法通常依赖自旋操作实现重试逻辑,即操作失败时通过循环不断尝试,直至成功。然而,在高并发场景下,若冲突频繁发生,CAS 操作可能会长时间无法成功,导致线程陷入忙等待状态。忙等待是指进程在执行循环程序时,因判断条件始终无法满足,致使处理器持续空转,虽然处于繁忙状态却无法推进有效工作,这将对 CPU 造成极大的性能开销。
ps:忙等待是一种进程的执行状态,进程执行一段循环程序的时候,由于循环判断条件不能满足而导致处理器反复循环,处于繁忙状态,该进程虽然繁忙但无法前进。
解决方案
- 使用
LongAdder
类:Java 8 引入的LongAdder
类专门针对高并发场景下 CAS 性能不足的问题。它采用分段 + CAS 的策略,内部维护一个cell[]
数组和一个base
变量。当 CAS 操作失败时,将计数暂存到cell[]
数组中,实现计数分散,避免竞争;最终计算结果为base
与cell
数组中各值的总和。不过,由于异步累加的特性,LongAdder
的计算结果在某些极端情况下可能存在短暂的不精确性,但适用于对最终一致性要求较高、对实时准确性要求较低的统计场景。 - 利用
pause
指令优化自旋:若 JVM 支持处理器提供的pause
指令,可显著提升 CAS 自旋效率。pause
指令具备双重作用:一方面,它能延迟流水线执行指令,控制 CPU 资源消耗,尽管不同处理器版本下延迟时间存在差异,部分版本甚至延迟为零;另一方面,该指令可规避循环退出时因内存顺序冲突导致的 CPU 流水线清空问题,从而减少无效计算,提高整体执行效率。
19.5 只能保证一个共享变量的原子操作
CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5 开始,提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用AtomicReference类把多个共享变量合并成一个共享变量来操作。
20、AQS(不是锁)
AQS 是什么?
AQS 的全称为 AbstractQueuedSynchronizer
,翻译过来的意思就是抽象队列同步器。这个类在 java.util.concurrent.locks
包下面。
AQS是一个用来构建锁和同步器的框架,使用AQS能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的ReentrantLock,Semaphore,其他的诸如ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基于AQS的。
AQS 的原理是什么?
AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁 实现的,即将暂时获取不到锁的线程加入到队列中。CLH(Craig,Landin,and Hagersten) 队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配。在 CLH 同步队列中,一个节点表示一个线程,它保存着线程的引用(thread)、 当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next)。
CLH 队列结构如下图所示:
AQS(AbstractQueuedSynchronizer)的核心原理图如下:
AQS 提供了一个由volatile
修饰,并且采用CAS方式修改的int
类型的成员变量state
表示同步状态,通过内置的 线程等待队列 来完成获取资源线程的排队工作。
// 共享变量,使用volatile修饰保证线程可见性
private volatile int state;
另外,状态信息 state
可以通过 protected
类型的getState()
、setState()
和compareAndSetState()
进行操作。并且,这几个方法都是 final
修饰的,在子类中无法被重写。
//返回同步状态的当前值
protected final int getState() {
return state;
}
// 设置同步状态的值
protected final void setState(int newState) {
state = newState;
}
//原子地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值)
protected final boolean compareAndSetState(int expect, int update) {
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
以可重入的互斥锁 ReentrantLock 为例,它的内部维护了一个 state 变量,用来表示锁的占用状态。state 的初始值为 0,表示锁处于未锁定状态。当线程 A 调用 lock() 方法时,会尝试通过 tryAcquire() 方法独占该锁,并让 state 的值加 1。如果成功了,那么线程 A 就获取到了锁。如果失败了,那么线程 A 就会被加入到一个等待队列(CLH 队列)中,直到其他线程释放该锁。假设线程 A 获取锁成功了,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加)。这就是可重入性的体现:一个线程可以多次获取同一个锁而不会被阻塞。但是,这也意味着,一个线程必须释放与获取的次数相同的锁,才能让 state 的值回到 0,也就是让锁恢复到未锁定状态。只有这样,其他等待的线程才能有机会获取该锁。
AQS 资源共享方式
AQS 定义两种资源共享方式:Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。
一般来说,自定义同步器的共享方式要么是独占,要么是共享,他们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。但 AQS 也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。
总结
在AQS中维护者一个FIFO的同步队列,当线程获取同步状态失败后,则会加入到这个CLH同步队列的队尾并一直保持着自旋.在CLH同步队列中的线程在自旋时会判断其前驱节点是否为首节点,如果为首节点则不断尝试获取同步获取状态(CAS机制),获取成功后则退出CLH同步队列.当线程执行完逻辑后,释放同步状态,并唤醒其后继节点