JUC显式锁的原理与实战
显式锁
使用Java内置锁时,不需要通过Java代码显式地对同步对象的监视器进行抢占和释放,这些工作由JVM底层完成,而且任何一个Java对象都能作为一个内置锁使用,所以Java的对象锁使用起来非常方便。
但是,Java内置锁的功能相对单一,不具备一些比较高级的锁功能,比如:
- 限时抢锁:在抢锁时设置超时时长,如果超时还未获得锁就放弃,不至于无限等下去。
- 可中断抢锁:在抢锁时,外部线程给抢锁线程发一个中断信号,就能唤起等待锁的线程,并终止抢占过程。
- 多个等待队列:为锁维持多个等待队列,以便提高锁的效率。比如在生产者-消费者模式实现中,生产者和消费者共用一把锁,
该锁上维持两个等待队列,即一个生产者队列和一个消费者队列。
除了以上功能问题之外,Java对象锁还存在性能问题。在竞争稍微激烈的情况下,Java对象锁会膨胀为重量级锁(基于操作系统的
Mutex Lock实现),而重量级锁的线程阻塞和唤醒操作需要进程在内核态和用户态之间来回切换,导致其性能非常低。所以,迫切需要提供一种新的锁来提升争用激烈场景下锁的性能。
Java显式锁就是为了解决这些Java对象锁的功能问题、性能问题而生的。JDK 5版本引入了Lock接口,Lock是Java代码级别的锁。为了与Java对象锁相区分,Lock接口叫作显式锁接口,其对象实例叫作显式锁对象。
显式锁Lock接口
JDK 5版本引入了java.util.concurrent并发包,简称为JUC包,里面提供了各种高并发工具类,通过此JUC工具包可以在Java代码中实现功能非常强大的多线程并发操作。所以,Java显式锁也叫JUC显式锁。
JUC出自并发大师Doug Lea之手,Doug Lea对Java并发性能的提升做出了巨大的贡献。除了实现JUC包外,Doug Lea还提供了高并发IO模式——Reactor模式多个版本的参考实现。
Lock接口位于java.util.concurrent.locks
包中,是JUC显式锁的一个抽象,Lock接口的主要抽象方法如表所示。
方法签名 | 功能描述 |
---|---|
void lock() |
获取锁。若锁不可用,调用线程会阻塞,直至获取到锁 |
void lockInterruptibly() throws InterruptedException |
获取锁,该方法能响应中断,等待锁时若被中断则抛出InterruptedException ,同时清除线程中断状态 |
boolean tryLock() |
尝试获取锁,锁可用则获取并返回true ;若不可用,立即返回false ,线程不阻塞 |
boolean tryLock(long time, TimeUnit unit) throws InterruptedException |
在指定时长内尝试获取锁,若在时长内获取到锁返回true ,超时未获取则返回false ,期间可响应中断 |
void unlock() |
释放锁,通常需搭配lock 系列方法在finally 块中调用,确保锁能正常释放 |
Condition newCondition() |
创建一个与该锁绑定的条件对象,用于线程间复杂的同步控制,像实现等待 / 通知机制 |
JUC包中提供了一系列的显式锁实现类(如ReentrantLock),当然也允许应用程序提供自定义的锁实现类。与synchronized关键字不同,显式锁不再作为Java内置特性来实现,而是作为Java语言可编程特性来实现。这就为多种不同功能的锁实现留下了空间,各种锁实现可能有不同的调度算法、性能特性或者锁定语义。
从Lock提供的接口方法可以看出,显式锁至少比Java内置锁多了以下优势:
- 可中断获取锁
使用synchronized关键字获取锁的时候,如果线程没有获取到被阻塞,阻塞期间该线程是不响应中断信号(interrupt)的;而调用Lock.lockInterruptibly()
方法获取锁时,如果线程被中断,线程将抛出中断异常。 - 可非阻塞获取锁
使用synchronized关键字获取锁时,如果没有成功获取,线程只有被阻塞;而调用Lock.tryLock()
方法获取锁时,如果没有获取成功,线程也不会被阻塞,而是直接返回false。 - 可限时抢锁
调用Lock.tryLock(long time,TimeUnit unit)
方法,显式锁可以设置限定抢占锁的超时时间。而在使用synchronized关键字获取锁时,如果不能抢到锁,线程只能无限制阻塞。
除了以上能通过Lock接口直接观察出来的三点优势之外,显式锁还有不少其他的优势,稍后在介绍显式锁种类繁多的实现类时,大家就能感觉到。
可重入锁ReentrantLock
ReentrantLock是JUC包提供的显式锁的一个基础实现类,ReentrantLock类实现了Lock接口,它拥有与synchronized相同的并发性和内存语义,但是拥有了限时抢占、可中断抢占等一些高级锁特性。此外,ReentrantLock基于内置的抽象队列同步器(Abstract Queued Synchronized,AQS)实现,在争用激烈的场景下,能表现出表内置锁更佳的性能。
ReentrantLock是一个可重入的独占(或互斥)锁,其中两个修饰词的含义为:
可重入的含义:表示该锁能够支持一个线程对资源的重复加锁,也就是说,一个线程可以多次进入同一个锁所同步的临界区代码
块。比如,同一线程在外层函数获得锁后,在内层函数能再次获取该锁,甚至多次抢占到同一把锁。
下面是一段对可重入锁进行两次抢占和释放的伪代码,具体如下:1
2
3
4
5
6
7
8lock.lock(); // 第一次获取锁
lock.lock(); // 第二次获取锁,重新进入
try {
// 临界区代码块
} finally {
lock.unlock(); // 释放锁
lock.unlock(); // 第二次释放锁
}独占的含义:在同一时刻只能有一个线程获取到锁,而其他获取锁的线程只能等待,只有拥有锁的线程释放了锁后,其他的线程
才能够获取锁。一个简单地使用ReentrantLock进行同步累加的演示案例如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41public class LockTest {
private static int count = 0;
private static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
});
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
});
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("最终累加结果: " + count); // 最终累加结果: 2000
}
}
使用显式锁的模板代码
因为JUC中的显式锁都实现了Lock接口,所以不同类型的显式锁对象的使用方法都是模板化的、套路化的.
使用lock()方法抢锁的模板代码
通常情况下,大家会调用lock()方法进行阻塞式的锁抢占,其模板代码如下:
1 | //创建锁对象,SomeLock为Lock的某个实现类,如ReentrantLock |
以上抢锁模板代码有以下几个需要注意的要点:
- 释放锁操作
lock.unlock()
必须在try-catch结构的finally块中执行,否则,如果临界区代码抛出异常,锁就有可能永远得不到释放。 - 抢占锁操作
lock.lock()
必须在try语句块之外,而不是放在try语句块之内。为什么呢?- 原因之一是lock()方法没有申明抛出异常,所以可以不包含到try块中;
- 原因之二是lock()方法并不一定能够抢占锁成功,如果没有抢占成功,当然也就不需要释放锁,而且在没有占有锁的情况下去释放锁,可能会导致运行时异常。
- 在抢占锁操作
lock.lock()
和try语句之间不要插入任何代码,避免抛出异常而导致释放锁操作lock.unlock()
执行不到,导致锁无法被释放。
调用tryLock()方法非阻塞抢锁的模板代码
lock()是阻塞式抢占,在没有抢到锁的情况下,当前线程会阻塞。如果不希望线程阻塞,可以调用tryLock()方法抢占锁。tryLock()是非阻塞抢占,在没有抢到锁的情况下,当前线程会立即返回,不会被阻塞。
调用tryLock()方法非阻塞抢占锁,大致的模板代码如下:
1 | //创建锁对象,SomeLock为Lock的某个实现类,如ReentrantLock |
调用tryLock()
方法时,线程拿不到锁就立即返回,这种处理方式在实际开发中使用不多,但是其重载版本tryLock(long time,TimeUnit unit)
方法在限时阻塞抢锁的场景中非常有用。
调用tryLock(long time,TimeUnit unit)方法
tryLock(long time,TimeUnit unit)方法用于限时抢锁,该方法在抢锁时会进行一段时间的阻塞等待,其中的time参数代表最大的阻塞时长,unit参数为时长的单位(如秒)。
调用tryLock(long time,TimeUnit unit)方法限时抢锁,其大致的代码模板如下:
1 | // 创建锁对象,SomeLock为Lock的某个实现类,如ReentrantLock |
显式锁进行“等待-通知”
在前面介绍Java的线程间通信机制时,基于Java内置锁实现一种简单的“等待-通知”方式的线程间通信:通过Object对象的wait
、notify
两类方法作为开关信号,用来完成通知方线程和等待方线程之间的通信。
“等待-通知”方式的线程间通信机制,具体来说是指一个线程A调用了同步对象的wait()方法进入等待状态,而另一线程B调用了同步
对象的notify()
或者notifyAll()
方法去唤醒等待线程,当线程A收到线程B的唤醒通知后,就可以重新开始执行了。
需要特别注意的是,在通信过程中,线程需要拥有同步对象的监视器,在执行Object对象的wait、notify方法之前,线程必须先通过抢占到内置锁而成为其监视器的Owner。
与Object对象的wait、notify两类方法相类似,基于Lock显式锁,JUC也为大家提供了一个用于线程间进行“等待-通知”方式通信的接口——java.util.concurrent.locks.Condition
。
Condition接口的主要方法
- Condition类的await方法和Object类的wait方法等效。
- Condition类的signal方法和Object类的notify方法等效。
- Condition类的signalAll方法和Object类的notifyAll方法等效。
方法名 | 描述 |
---|---|
await() | 使当前线程等待,直到被通知(signal)或中断。它会释放当前持有的锁,进入等待状态。 |
awaitUninterruptibly() | 和 await () 类似,不过这个方法不会响应中断,线程会一直处于等待状态直到被通知。 |
signal() | 唤醒一个等待在这个 Condition 上的线程。 |
signalAll() | 唤醒所有等待在这个 Condition 上的线程。 |
Condition对象的signal(通知)方法和同一个对象的await(等待)方法是一一配对使用的,也就是说,一个Condition对象的signal(或signalAll)方法不能去唤醒其他Condition对象上的await线程。
Condition对象是基于显式锁的,所以不能独立创建一个Condition对象,而是需要借助于显式锁实例去获取其绑定的Condition对象。不过,每一个Lock显式锁实例都可以有任意数量的Condition对象。具体来说,可以通过lock.newCondition()
方法去获取一个与当前显式锁绑定的Condition实例,然后通过该Condition实例进行“等待-通知”方式的线程间通信。
显式锁Condition演示案例
基于Condition的“等待-通知”通信机制实现一个更高性能的生产者-消费者程序。
1 | class Buffer { |
1 | public class ConditionDemo { |
LockSupport
LockSupport是JUC提供的一个线程阻塞与唤醒的工具类,该工具类可以让线程在任意位置阻塞和唤醒,其所有的方法都是静态方法。
常用方法
LockSupport的方法主要有两类:park
和unpark
。park的英文意思为停车,如果把Thread看成一辆车的话,park()
方法就是让车停下,其作用是将调用park()
方法的当前线程阻塞;而unpark()方法
是让车启动,然后跑起来,其作用是将指定线程Thread唤醒。
方法名 | 描述 | 参数说明 |
---|---|---|
park() |
无限期阻塞当前线程,直到被中断或者被unpark 方法唤醒。 |
无参数 |
unpark(Thread thread) |
唤醒指定的被阻塞线程,使其有机会继续执行。 | thread :要唤醒的目标线程对象 |
parkNanos(long nanos) |
阻塞当前线程,不过存在超时时间限制,超时后线程会自动恢复执行,也可提前被中断或unpark 唤醒。 |
nanos :阻塞的最长纳秒时间 |
parkUntil(long deadline) |
阻塞当前线程,直至达到指定时间,也可提前被中断或unpark 唤醒。 |
deadline :表示阻塞截止的绝对时间(通常基于System.currentTimeMillis() 等计算得出) |
park(Object blocker) |
无限期阻塞当前线程,同时关联blocker 对象,方便诊断工具确定线程阻塞原因。 |
blocker :用于标识阻塞原因的对象 |
parkNanos(Object blocker, long nanos) |
限时阻塞当前线程,并关联blocker 对象,用于辅助分析阻塞相关情况。 |
blocker :用于标识阻塞原因的对象 nanos :阻塞的最长纳秒时间 |
getBlocker(Thread t) |
获取指定被阻塞线程关联的blocker 对象,便于分析线程阻塞原因。 |
LockSupport的演示实例
1 | import java.util.concurrent.locks.LockSupport; |
LockSupport.park()和Thread.sleep()的区别
从功能上说,LockSupport.park()与Thread.sleep()方法类似,都是让线程阻塞,二者的区别如下:
对比项 | LockSupport.park() | Thread.sleep() |
---|---|---|
唤醒方式 | 可通过LockSupport.unpark()从外部唤醒 | 只能等待设定时间结束自行唤醒 |
异常处理 | 不需要捕获中断异常 | 声明了InterruptedException中断异常,调用者需捕获或再抛出 |
中断响应 | 不会抛出InterruptedException异常,仅设置线程中断标志,需检查Thread.interrupted() | 会抛出InterruptedException异常 |
阻塞灵活性 | 能更精准、灵活地阻塞、唤醒指定线程 | 相对不够灵活,按指定时间阻塞线程 |
方法性质 | 调用Unsafe类的Native方法实现,本身非Native方法 | 本身就是Native方法 |
附加功能 | 允许设置Blocker对象,供监视诊断工具确定阻塞原因 | 无 |
使用场景 | 适用于需要灵活控制线程阻塞和唤醒的场景,如线程间的复杂协作、实现自定义的锁机制、线程池中的线程管理等 | 常用于需要让线程暂停一段时间的场景,如定时任务的间隔控制、模拟网络延迟、控制并发访问的频率等 |
LockSupport.park()与Object.wait()的区别
从功能上说,LockSupport.park()与Object.wait()方法也类似,都是让线程阻塞,二者的区别如下:
对比项 | LockSupport.park() | Object.wait() |
---|---|---|
阻塞和唤醒机制 | 通过LockSupport.unpark(Thread thread) 唤醒,可先唤醒再阻塞,唤醒关系灵活 |
需搭配Object.notify() 或Object.notifyAll() ,在同步代码块或方法中,先阻塞后才能被唤醒 |
所属类与依赖条件 | java.util.concurrent.locks.LockSupport 类的方法,不依赖锁机制或同步代码块 |
java.lang.Object 类的方法,必须在synchronized 代码块或同步方法中调用 |
异常处理 | 本身不抛出受检查异常,中断时需通过其他方式处理,如检查Thread.interrupted() |
抛出InterruptedException 异常,需显式处理 |
线程状态与可见性 | 线程状态通常为WAITING 或TIMED_WAITING ,阻塞状态对其他线程可见性更直接 |
线程状态变为WAITING 或TIMED_WAITING ,需通过获取同一对象监视器锁来感知状态变化 |
使用场景 | 适用于灵活控制线程阻塞和唤醒,如线程间复杂协作、自定义锁机制、线程池管理等 | 适用于基于对象监视器锁的简单线程协作,如传统生产者 - 消费者模型 |
提前唤醒情况 | 在LockSupport.park() 执行之前去执行LockSupport.unpark() ,不会抛出任何异常,是被允许的 |
如果在Object.wait() 执行之前去执行Object.notify() 唤醒,会抛出IllegalMonitorStateException 异常,是不被允许的 |
1 | package pers.fulsun._4; |
通过结果可以看出,前两次LockSupport.unpark(t1)唤醒操作没有发生任何作用,因为线程t1还没有被LockSupport.park()阻塞。只有在被LockSupport.park()阻塞之后,LockSupport.unpark(t1)唤醒操作才能将线程t1唤醒。
显式锁的分类
显式锁有多种分类方式,以下是从不同角度进行的分类及相关介绍:
可重入锁和不可重入锁
- 可重入锁(递归锁):
- 定义:一个线程可以多次抢占同一个锁。例如线程A进入外层函数抢占Lock显式锁后,进入内层函数遇到抢占同一Lock显式锁的代码时,依然能抢到该锁。
- 实现类:JUC的ReentrantLock类是可重入锁的标准实现类。
- 不可重入锁:
- 定义:一个线程只能抢占一次同一个锁。线程A进入外层函数抢占锁后,进入内层函数再遇抢占同一锁的代码时,不能抢到该锁,除非提前释放锁,才能第二次抢占。
悲观锁和乐观锁
- 悲观锁:
- 思想及操作:秉持悲观思想,每次进入临界区操作数据时,都认为别的线程会修改,所以读写数据时都会上锁锁住同步资源,其他线程读写该数据时会阻塞等待拿锁。
- 适用场景:适用于写多读少的场景,高并发写时性能高。
- 示例:Java的synchronized重量级锁是一种悲观锁。
- 乐观锁:
- 思想及操作:秉持乐观思想,拿数据时认为别的线程不会修改,所以不上锁,但更新时会判断在此期间别人有无更新数据,采取先读出当前版本号,然后加锁操作(比较跟上一次版本号,一样则更新),若失败需重复读-比较-写操作。
- 适用场景:适用于读多写少的场景,高并发写时性能低。
- 实现方式及示例:基本通过CAS自旋操作实现,如Java的synchronized轻量级锁是一种乐观锁,JUC中基于抽象队列同步器(AQS)实现的显式锁(如ReentrantLock)都是乐观锁。不过在争用激烈场景下,CAS自旋会出现大量空自旋,导致性能大大降低,但因AQS通过队列使用减少锁争用、空的CAS自旋,所以基于AQS的JUC乐观锁在争用激烈场景下也能比悲观锁性能更佳。
公平锁和非公平锁
- 公平锁:
- 定义:不同线程抢占锁的机会公平、平等,按抢占时间先后,先抢占锁的线程先被满足,抢锁成功次序体现为FIFO(先进先出)顺序,保障各线程按顺序获取锁。
- 示例:线程A、B、C、D依次获取锁,A先获取锁,处理完释放后唤醒B获取锁,依此类推。
- 非公平锁:
- 定义:不同线程抢占锁的机会非公平、不平等,先抢占锁的线程不一定先被满足,抢锁成功次序不体现FIFO顺序。
- 示例:线程A持有锁时,B、C、D尝试获取锁进入等待队列,A释放锁后唤醒B的过程中,若有线程E尝试请求锁,E可能趁机获取锁(插队),原因是CPU唤醒线程B有上下文切换时间,E可利用此空档期在其他内核上获取锁,目的是提高锁利用效率。
- 设置方式:ReentrantLock实例默认是非公平锁,构造时传入参数true可得到公平锁,ReentrantLock的tryLock()方法是特例,一旦有线程释放锁,正在tryLock的线程能优先取到锁,即便有其他线程在等待队列中。
可中断锁和不可中断锁
- 可中断锁:某线程A占有锁执行临界区代码,线程B阻塞式抢占锁时,若等待时间过长,B不想等待想处理其他事,可中断自己的阻塞等待,这种就是可中断锁。
- 不可中断锁:一旦锁被其他线程占有,自己想抢占只能等待或阻塞,若对方永远不释放锁,自己只能永远等下去,没办法终止等待或阻塞。例如Java的synchronized内置锁是不可中断锁,JUC的显式锁(如ReentrantLock)是可中断锁。
共享锁和独占锁
- 独占锁:
- 定义及特点:每次只有一个线程能持有的锁,是悲观保守加锁策略,限制读/读竞争,若某个只读线程获取锁,其他读线程只能等待,限制读操作并发性,不过读操作不影响数据一致性。
- 实现类:JUC的ReentrantLock类是标准的独占锁实现类。
- 共享锁:
- 定义及特点:允许多个线程同时获取锁,容许线程并发进入临界区,是一种乐观锁,放宽加锁策略,不限制读/读竞争,允许多个读操作线程同时访问共享资源。
- 实现类及使用规则:JUC的ReentrantReadWriteLock(读写锁)类是共享锁实现类,使用时读操作可多线程一起读,但写操作只能一个线程写,且写入时别的线程不能读。用ReentrantLock替代ReentrantReadWriteLock虽能保证线程安全,但会浪费资源,在读的地方用读锁、写的地方用写锁可提高程序执行效率。
悲观锁和乐观锁
独占锁其实就是一种悲观锁,Java的synchronized是悲观锁。悲观锁可以确保无论哪个线程持有锁,都能独占式访问临界区。虽然悲观锁的逻辑非常简单,但是存在不少问题。
悲观锁存在的问题
悲观锁总是假设会发生最坏的情况,每次线程读取数据时,也会上锁。这样其他线程在读取数据时就会被阻塞,直到它拿到锁。传统的关系型数据库用到了很多悲观锁,比如行锁、表锁、读锁、写锁等。
悲观锁机制存在以下问题:
- 在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题。
- 一个线程持有锁后,会导致其他所有抢占此锁的线程挂起。
- 如果一个优先级高的线程等待一个优先级低的线程释放锁,就会导致线程的优先级倒置,从而引发性能风险。
解决以上悲观锁的这些问题的有效方式是使用乐观锁去替代悲观锁。与之类似,数据库操作中的带版本号数据更新、JUC包的原子类,都使用了乐观锁的方式提升性能。
通过CAS实现乐观锁
乐观锁是一种思想,而CAS是这种思想的一种实现。
乐观锁的操作主要就是两个步骤:
- 第一步:冲突检测。
- 第二步:数据更新。
乐观锁一种比较典型的就是CAS原子操作,JUC强大的高并发性能是建立在CAS原子之上的。CAS操作中包含三个操作数:需要操作的内存位置(V)、进行比较的预期原值(A)和拟写入的新值(B)。如果内存位置V的值与预期原值A相匹配,那么处理器会自动将该位置的值更新为新值B;否则处理器不做任何操作。
CAS操作可以非常清晰地分为两个步骤:
- 检测位置V的值是否为A。
- 如果是,就将位置V更新为B值;否则不要更改该位置。
CAS操作的两个步骤其实与乐观锁操作的两个步骤是一致的,都是在冲突检测后进行数据更新。实际上,如果需要完成数据的最终更新,仅仅进行一次CAS操作是不够的,一般情况下,需要进行自旋操作,即不断地循环重试CAS操作直到成功,这也叫CAS自旋。
通过CAS自旋,在不使用锁的情况下实现多线程之间的变量同步,也就是说,在没有线程被阻塞的情况下实现变量的同步,这叫作“非阻塞同步”(Non-Blocking Synchronization),或者说“无锁同步”。使用基于CAS自旋的乐观锁进行同步控制,属于无锁编程(Lock Free)的一种实践。
自定义不可重入的自旋锁
自旋锁的基本含义为:当一个线程在获取锁的时候,如果锁已经被其他线程获取,调用者就一直在那里循环检查该锁是否已经被释放,一直到获取到锁才会退出循环。
CAS自旋锁的实现原理为:抢锁线程不断进行CAS自旋操作去更新锁的owner(拥有者),如果更新成功,就表明已经抢锁成功,退出抢锁方法。如果锁已经被其他线程获取(也就是owner为其他线程),调用者就一直在那里循环进行owner的CAS更新操作,一直到成功才会退出循环。
作为演示,这里先实现一个简单版本的自旋锁——不可重入的自旋锁,具体的代码如下:
1 | public class NonReentrantSpinLock { |
仔细分析以上代码就可以看出,上述NonReentrantSpinLock是不支持重入的,即当一个线程第一次已经获取到了该锁,在锁没有被释放之前,如果又一次重新获取该锁,第二次将不能成功获取到。
自定义可重入的自旋锁
为了实现可重入锁,主要思路是通过记录当前持有锁的线程以及重入的次数来实现可重入功能:
1 | import java.util.concurrent.atomic.AtomicBoolean; |
CAS可能导致“总线风暴”
“总线风暴” 是指在计算机系统中,多个处理器或设备频繁地对系统总线进行访问和竞争,导致总线上的数据传输量急剧增加,产生大量的冲突和等待,从而使系统性能严重下降的现象。
CAS 操作引发总线风暴的原因:
- 竞争激烈时的高频操作:CAS 操作是一种硬件级别的原子操作,用于在多线程环境下实现对共享变量的无锁并发访问。当多个线程同时竞争同一个基于 CAS 的可重入锁时,它们会频繁地执行 CAS 操作来尝试获取锁。如果竞争非常激烈,大量的 CAS 操作会在极短的时间内被发送到系统总线上,导致总线上的数据流量瞬间增大。
- 缓存一致性协议的开销:现代多核处理器系统中,为了保证各个处理器缓存之间的数据一致性,通常采用缓存一致性协议(如 MESI 协议)。当一个线程通过 CAS 操作修改共享变量时,会导致其他处理器缓存中该变量的副本失效,其他处理器需要从主内存中重新读取数据。在高并发情况下,大量的 CAS 操作会使得缓存失效和数据同步操作频繁发生,这些操作都需要通过系统总线来完成,进一步增加了总线的负载。
- 重试机制的影响:在基于 CAS 的可重入锁实现中,当线程获取锁失败时,通常会通过自旋的方式不断重试。这意味着线程会持续地发起 CAS 操作,而不会主动让出 CPU。如果有大量线程都在自旋等待获取锁,那么它们会不断地向总线发送 CAS 请求,使得总线的负载持续处于高位,无法得到缓解。
总线风暴的影响:
- 系统性能下降:总线风暴会导致系统总线的利用率急剧下降,因为总线上充斥着大量的 CAS 请求和缓存同步等操作,真正用于有效数据传输和其他正常操作的带宽被严重挤压,从而导致整个系统的性能大幅下降。
- 响应时间变长:由于总线被大量无用的 CAS 操作占据,其他重要的操作(如内存读写、I/O 操作等)无法及时得到执行,导致程序的响应时间变长,用户会明显感觉到系统变得卡顿。
- 能耗增加:处理器在不断执行 CAS 操作和处理缓存一致性问题时,需要消耗更多的能量,这不仅会增加硬件的发热量,还会降低系统的能源效率。
前面讲到,在争用激烈的场景下,Java轻量级锁会快速膨胀为重量级锁,其本质上一是为了减少CAS空自旋,二是为了避免同一时间大量CAS操作所导致的总线风暴。
那么,JUC基于CAS实现的轻量级锁如何避免总线风暴呢?答案是:使用队列对抢锁线性进行排队,最大程度上减少了CAS操作数量。
CLH自旋锁
JUC中显式锁基于AQS抽象队列同步器,而AQS是CLH锁的一个变种,为了方便大家理解AQS的原理(此为Java工程师的必备知识),这里详细介绍一下CLH锁的实现和核心原理。
CLH锁其实就是一种基于队列(具体为单向链表)排队的自旋锁,由于是Craig、Landin和Hagersten三人一起发明的,因此被命名为CLH锁,也叫CLH队列锁。
简单的CLH锁可以基于单向链表实现,申请加锁的线程首先会通过CAS操作在单向链表的尾部增加一个节点,之后该线程只需要在其前驱节点上进行普通自旋,等待前驱节点释放锁即可。由于CLH锁只有在节点入队时进行一下CAS的操作,在节点加入队列之后,抢锁线程不需要进行CAS自旋,只需普通自旋即可。因此,在争用激烈的场景下,CLH锁能大大减少CAS操作的数量,以避免CPU的总线风暴。
1 | public class CLHSpinLock { |
CLHSpinLock
类结构:- 内部定义了
Node
类,它代表等待锁的线程节点,节点中有一个volatile
修饰的locked
字段,用于表示该节点对应的线程是否获取到锁(初始为true
,即未获取到锁,处于等待状态)。 - 有一个
AtomicReference<Node>
类型的tail
字段,通过原子操作来维护等待锁的队列尾节点,初始值为null
。 - 还有一个
ThreadLocal<Node>
类型的myNode
,用于为每个线程存储其对应的节点,保证每个线程都有自己独立的等待节点信息。
- 内部定义了
lock
方法逻辑:- 首先通过
myNode.get()
获取当前线程对应的节点。 - 然后使用
tail.getAndSet(node)
原子操作将当前节点设置为尾节点,并获取原来的尾节点(也就是当前线程的前驱节点)。 - 如果前驱节点不为
null
,说明有其他线程在等待队列中,当前线程就会通过自旋(while (pred.locked)
循环)等待前驱节点释放锁(即前驱节点的locked
变为false
)。
- 首先通过
unlock
方法逻辑:- 获取当前线程对应的节点后,将该节点的
locked
字段设置为false
,表示释放锁,让后续等待的线程有机会获取锁。并且可以选择调用myNode.remove()
来清除线程本地变量中存储的节点,避免可能的内存泄漏问题(在实际应用中根据具体需求决定是否执行此操作)。
- 获取当前线程对应的节点后,将该节点的
- 在争用激烈的场景下,相比于那种完全依赖持续 CAS 操作来获取锁的普通自旋锁,CLH 锁通过将竞争分散到链表节点上,把大量的 CAS 操作限制在了节点入队这个环节,后续等待过程不再有 CAS 自旋,从而大大减少了整体的 CAS 操作数量,也就能够有效避免因过多 CAS 操作引发的 CPU 总线风暴问题。
优缺点
- CLH锁是一种队列锁,其优点是空间复杂度低。如果有N个线程、L个锁,每个线程每次只获取一个锁,那么需要的存储空间是O(L+N),N个线程有N个Node,L个锁有L个Tail。
- CLH队列锁的一个显著缺点是它在NUMA架构的CPU平台上性能很差。线程在 CLH 锁中需要通过前驱节点来判断是否可以获取锁,这种访问方式在某些情况下可能会导致伪共享问题。因为多个线程可能会频繁访问不同节点的同一缓存行,从而导致缓存行的频繁无效化,影响性能。
- CLH 锁:更适合在单核处理器或者缓存一致性开销较小的环境中使用,能够有效地实现线程同步。
- MCS 锁:在多核处理器、高并发且对缓存性能要求较高的场景中,MCS 锁能够更好地发挥其优势,提供更高效的同步机制,减少线程之间的竞争和缓存冲突,提高系统的整体性能和并发处理能力。
MCS队列锁
高性能、公平的自旋队列锁。MCS在自身节点上自旋,可以理解为当前线程不断访问本地高速缓存中的变量,因此更适合NUMA结构的CPU。
工作原理
- 入队:当一个线程请求锁时,它会创建一个新的 MCS 节点,并将自己相关的信息存入节点中。然后,线程通过原子操作将自己的节点加入到队列尾部。在这个过程中,新节点会与当前的尾节点建立连接,同时更新尾节点的引用,使其指向新加入的节点。
- 出队:当持有锁的线程释放锁时,它会检查自己的节点是否有后继节点。如果有后继节点,就将后继节点中的锁状态标记设置为可以获取锁的状态,通知后继线程可以尝试获取锁,然后将自己的节点从队列中移除;如果没有后继节点,说明队列中没有其他等待锁的线程,就直接将尾节点设置为 null,表示队列为空。
1 | import java.util.concurrent.atomic.AtomicReference; |
MCS和CLH实现细节对比
队列中Node节点组成的链表结构不同。
- CLH使用前驱指针
- MCS使用后继指针。
自旋对象不同。
- CLH是在前驱节点上自旋。【查看前驱节点的Lock状态是否为false】
- MCS是在自身节点上自旋。【查看自身节点的Lock状态是否为false】
解锁逻辑不同
- CLH解锁方法是,设置自己的Lock状态为false,解除后继节点的自旋状态。
- MCS解锁方法是,当前节点通过next指针将后继节点的Lock状态设置为false,解除其自旋状态。
公平锁与非公平锁
synchronized内置锁是一种非公平锁,默认情况下ReentrantLock锁也是非公平锁。
非公平锁实战
非公平锁是指多个线程获取锁的顺序并不一定是其申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁,抢锁成功的次序不一定体现为FIFO(先进先出)顺序。
非公平锁的优点在于吞吐量比公平锁大,它的缺点是有可能会导致线程优先级反转或者线程饥饿现象。
使用ReentrantLock锁作为非公平锁的实战用例,具体代码如下:
1 | public class NonFairLock { |
从输出的结果可以看出,各个线程的抢锁次序为:线程0→线程3→线程2→线程1,但是抢到锁的次序为:线程0→线程2→线程3→线程1。所以说,非公平锁是不公平的。
公平锁实战
公平锁是指多个线程按照申请锁的顺序来获取锁,抢锁成功的次序体现为FIFO(先进先出)顺序。虽然ReentrantLock锁默认是非公平锁,但可以通过构造器指定该锁为公平
锁,具体的代码如下:
1 | //可重入、公平锁对象 |
简单的非公平锁的示例代码
1 | import java.util.concurrent.atomic.AtomicBoolean; |
公平锁的代码示例
1 | import java.util.concurrent.LinkedBlockingQueue; |
可中断锁与不可中断锁
可中断锁是指抢占过程可以被中断的锁,JUC的显式锁(如ReentrantLock)是一个可中断锁。不可中断锁是指抢占过程不可以被中断的锁,如Java的synchronized内置锁就是一个不可中断锁。
锁的可中断抢占
在JUC的显式锁Lock接口中,有以下两个方法可以用于可中断抢占:
lockInterruptibly()
可中断抢占锁抢占过程中会处理Thread.interrupt()中断信号,如果线程被中断,就会终止抢占并抛出InterruptedException异常。
tryLock(long timeout,TimeUnit unit)
阻塞式“限时抢占”(在timeout时间内)锁抢占过程中会处理Thread.interrupt()中断信号,如果线程被中断,就会终止抢占并抛出InterruptedException异常。
下面是调用lockInterruptibly()方法进行可中断抢锁的一个简单案例,具体代码如下:
1 | import java.util.concurrent.locks.ReentrantLock; |
死锁的监测与中断
死锁是指两个或两个以上线程因抢占锁而造成的相互等待的现象。多个线程通过AB-BA模式抢占两个锁是造成多线程死锁比较普遍的原因。
AB-BA模式的死锁具体表现为:线程X按照先后次序去抢占锁A与锁B,线程Y按照先后次序去抢占锁B与锁A,当线程X抢到锁A再去抢占锁B时,发现已经被其他线程拿走,然而线程Y拿到锁B后再去抢占锁A时,发现已经被其他线程拿走,于是线程X等待其他线程释放锁B,线程Y等待其他线程释放锁A,两个线程互相等待从而造成死锁。
JDK 8中包含的ThreadMXBean接口提供了多种监视线程的方法,其中包括两个死锁监测的方法,具体如下:
findDeadlockedThreads
用于检测由于抢占JUC显式锁、Java内置锁引起死锁的线程。findMonitorDeadlockedThreads
仅仅用于检测由于抢占Java内置锁引起死锁的线程。
ThreadMXBean的实例可以通过JVM管理工厂ManagementFactory去获取,具体的获取代码如下:
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
1 | import java.lang.management.ManagementFactory; |
JVM管理工厂ManagementFactory类提供静态方法,返回各种获取JVM信息的Bean实例。我们通过这些Bean实例能获取大量的JVM运行时信息,比如JVM堆的使用情况、GC情况、线程信息等。我们通过JVM运行时信息可以了解正在运行的JVM的情况,以便可以做出相应的参数调整。
如果是可中断抢占锁(如调用lockInterruptibly()方法等),就可以在监测到死锁发生之后,调用Thread.interrupt()去中断死锁线程,不让死锁线程一直等下去。
ManagementFactory位于JDK的核心包
java.lang.management
中,该包提供了一系列的管理接口,用于监视和管理JVM以及运行JVM的底层操作系统,它同时允许从本地和远程对正在运行的JVM进行监视和管理。
共享锁与独占锁
在访问共享资源之前进行加锁操作,在访问完成之后进行解锁操作。按照“是否允许在同一时刻被多个线程持有”来区分,锁可以分为共享锁与独占锁。
独占锁
占锁也叫排他锁、互斥锁、独享锁,是指锁在同一时刻只能被一个线程所持有。一个线程加锁后,任何其他试图再次加锁的线程都会被阻塞,直到持有锁线程解锁。通俗来说,就是共享资源某一时刻只能有一个线程访问,其余线程阻塞等待。
如果是公平地独占锁,在持有锁线程解锁时,如果有一个以上的线程在阻塞等待,那么最先抢锁的线程被唤醒变为就绪状态去执行加锁操作,其他的线程仍然阻塞等待。Java中的Synchronized内置锁和ReentrantLock显式锁都是独占锁。
共享锁Semaphore
Semaphore和ReentrantLock类似,Semaphore发放许可时有两种式:公平模式和非公平模式,默认情况下使用非公平模式。
共享锁就是在同一时刻允许多个线程持有的锁。当然,获得共享锁的线程只能读取临界区的数据,不能修改临界区的数据。
JUC中的共享锁包括Semaphore
(信号量)、ReadLock
(读写锁)中的读锁、CountDownLatch
。
Semaphore可以用来控制在同一时刻访问共享资源的线程数量,通过协调各个线程以保证共享资源的合理使用。Semaphore维护了一组虚拟许可,它的数量可以通过构造器的参数指定。线程在访问共享资源前必须调用Semaphore的acquire()方法获得许可
,如果许可数量为0,该线程就一直阻塞。线程访问完资源后,必须调用Semaphore的release()方法释放许可
。更形象的说法是:Semaphore是一个许可管理器。
Semaphore的主要方法
1 | // 构造一个Semaphore实例,初始化其管理的许可数量为permits参数值。 |
方法分类 | 方法名 | 方法签名 | 描述 |
---|---|---|---|
许可获取 | acquire |
void acquire() throws InterruptedException |
从信号量获取一个许可,如果没有可用许可,则线程会被阻塞,直到有许可可用或线程被中断。 |
许可获取 | acquire(permits) |
void acquire() throws InterruptedException |
当前线程尝试阻塞地获取permits个许可。此过程是阻塞的,线程会一直等待Semaphore发放permits个许可。如果没有足够的许可而当前线程被中断,就会抛出InterruptedException异常并终止阻塞。 |
许可获取 | acquireUninterruptibly |
void acquireUninterruptibly() |
与acquire 方法类似,但该方法不会响应中断,即使线程在等待许可时被中断,也会一直等待直到获取到许可。 |
许可获取 | tryAcquire (无参) |
boolean tryAcquire() |
尝试获取一个许可,若有许可可用,则获取许可并返回true ;否则,立即返回false ,不会阻塞线程。 |
许可获取 | tryAcquire (有参) |
boolean tryAcquire(long timeout, TimeUnit unit) throws InterruptedException |
在指定的时间内尝试获取一个许可。若在超时时间内获取到许可,则返回true ;否则,返回false 。如果线程在等待期间被中断,会抛出InterruptedException 异常。 |
许可释放 | release |
void release() |
释放一个许可,将信号量的许可数量加1。如果有其他线程正在等待获取许可,则会唤醒其中一个线程使其能够获取许可。 |
许可数量查询 | availablePermits |
int availablePermits() |
返回当前信号量中可用的许可数量。 |
许可数量查询 | drainPermits |
int drainPermits() |
获取并返回当前所有可用的许可,并将可用许可数量置为0。 |
线程等待情况查询 | hasQueuedThreads |
boolean hasQueuedThreads() |
查询是否有线程在等待获取该信号量的许可。 |
线程等待情况查询 | getQueueLength |
int getQueueLength() |
返回正在等待获取许可的线程估计数。该值是一个估计值,因为在计算过程中线程的状态可能会发生变化。 |
假设有10个人在银行办理业务,只有两个工作窗口,使用Semaphore模拟银行排队,同时只有两个线程进入临界区。大致的代码如下
1 | import java.util.concurrent.Semaphore; |
CountDownLatch
门闩(Shuān),横插在门后使门推不开的棍子。
CountDownLatch是一个常用的共享锁,其功能相当于一个多线程环境下的倒数门闩。
原理:CountDownLatch 内部也是基于 AQS 实现的,它通过一个计数器来控制线程的等待和放行。计数器初始化为一个给定的值,当线程调用 countDown () 方法时,计数器会减 1,当计数器的值减到 0 时,所有等待在 CountDownLatch 上的线程会被唤醒。
使用场景:适用于一个或多个线程等待其他多个线程完成一组任务后再继续执行的场景,如主线程等待多个子线程完成数据加载后进行数据汇总。
模拟游戏玩家加载的示例代码。在游戏开始前,需要等待多个玩家完成加载操作,当所有玩家都加载完成后,游戏才正式开始具体代码如下:
1 | import java.util.concurrent.CountDownLatch; |
CyclicBarrier
- 原理:CyclicBarrier 内部使用 ReentrantLock 和 Condition 来实现线程的同步。它维护了一个计数器,当线程到达屏障点时,会调用 await () 方法,将自己阻塞,并使计数器减 1。当计数器的值为 0 时,说明所有线程都到达了屏障点,此时会唤醒所有阻塞的线程。
- 使用场景:适用于一组线程相互等待,到达某个公共屏障点后再一起继续执行的场景,常用于多线程并发处理数据,每个线程处理一部分,全部处理完后进行合并等操作。
1 | import java.util.concurrent.BrokenBarrierException; |
在上述代码中:
- 首先创建了一个
CyclicBarrier
对象,构造函数传入两个参数,第一个参数5
表示需要有5
个线程调用await
方法(也就是到达屏障点)后,所有线程才能继续往下执行。第二个参数是一个Runnable
接口实现(使用了 Java 8 的 Lambda 表达式),它定义了当所有线程都到达屏障点后要执行的操作,在这里就是输出表示比赛开始的语句。 - 然后通过循环创建并启动了
5
个线程,每个线程模拟一个运动员做准备活动,然后调用cyclicBarrier.await()
方法,表示自己已经准备好,进入等待状态,直到所有线程(也就是所有运动员)都调用了await
方法,此时满足了屏障条件,之前传入CyclicBarrier
构造函数中的Runnable
会被执行,接着所有被阻塞的线程就会继续往下执行,模拟比赛开始的情况。
读写锁
读写锁的内部包含两把锁:一把是读(操作)锁,是一种共享锁;另一把是写(操作)锁,是一种独占锁。
在没有写锁的时候,读锁可以被多个线程同时持有。写锁是具有排他性的:如果写锁被一个线程持有,其他的线程不能再持有写锁,抢占写锁会阻塞;进一步来说,如果写锁被一个线程持有,其他的线程不能再持有读锁,抢占读锁也会阻塞。
读写锁的读写操作之间的互斥原则具体如下:
- 读操作、读操作能共存,是相容的。
- 读操作、写操作不能共存,是互斥的。
- 写操作、写操作不能共存,是互斥的。
与单一的互斥锁相比,组合起来的读写锁允许对于共享数据进行更大程度的并发操作。虽然每次只能有一个写线程,但是同时可以有多个线程并发地读数据。读写锁适用于读多写少的并发情况。
JUC包中的读写锁接口为ReadWriteLock
,主要有两个方法,具体如下:
1 | public interface ReadWriteLock { |
JUC中ReadWriteLock接口的实现类为ReentrantReadWriteLock
。
ReentrantReadWriteLock
通过ReentrantReadWriteLock类能获取读锁和写锁,它的读锁是可以多线程共享的共享锁,而它的写锁是排他锁,在被占时不允许其他线程再抢占操作。然而其读锁和写锁之间是有关系的:同一时刻不允许读锁和写锁同时被抢占,二者之间是互斥的。
代码演示,读锁是共享锁,写锁是排他锁:
1 | import java.time.LocalTime; |
从输出结果可以看出:
- 读线程0、读线程1、读线程2同时获取了读锁,说明可以同时进行共享数据的读操作。
- 写线程0、写线程1只能依次获取写锁,说明共享数据的写操作不能同时进行。
- 读线程3必须等待写线程1释放写锁后才能获取到读锁,说明读写操作是互斥的。
锁的升级与降级
锁升级是指读锁升级为写锁,锁降级指的是写锁降级为读锁。在ReentrantReadWriteLock读写锁中,只支持写锁降级为读锁,而不支持读锁升级为写锁
。具体的演示代码如下:
1 | import java.util.concurrent.locks.ReentrantReadWriteLock; |
先通过readLock.lock()
获取读锁,然后尝试通过writeLock.lock()
获取写锁,在ReentrantReadWriteLock
中这种情况是不被允许的,会导致当前线程阻塞在获取写锁的操作上,并且如果不做额外处理(比如通过超时等机制打破这种等待),很容易就陷入死锁情况(因为获取写锁需要等待所有读锁都释放,而当前线程又不释放自己持有的读锁)
通过结果可以看出:ReentrantReadWriteLock不支持读锁的升级,主要是避免死锁,例如两个线程A和B都占了读锁并且都需要升级成写锁,A升级要求B释放读锁,B升级要求A释放读锁,二者就会由于相互等待形成死锁。
总结起来,与ReentrantLock相比,ReentrantReadWriteLock
更适合读多写少的场景,可以提高并发读的效率;而ReentrantLock
更适合读写比例相差不大或写比读多的场景。
StampedLock
StampedLock
是 Java 8 中引入的一种锁机制, StampedLock(印戳锁)是对ReentrantReadWriteLock读写锁的一种改进,主要的改进为:在没有写只有读的场景下,StampedLock支持不用加读锁而是直接进行读操作,最大程度提升读的效率,只有在发生过写操作之后,再加读锁才能进行读操作。
锁类型
- 写锁(排他锁):用于对资源进行独占式的写操作。获取写锁后,其他线程无法获取写锁或读锁,直到写锁被释放。通过
writeLock()
方法获取写锁,返回的戳记用于后续释放锁操作,使用unlockWrite(stamp)
方法释放写锁。 - 读锁(共享锁):多个线程可以同时获取读锁,用于对资源的只读操作。获取读锁后,其他线程可以继续获取读锁,但不能获取写锁。通过
readLock()
方法获取读锁,同样返回一个戳记,释放读锁使用unlockRead(stamp)
方法。 - 乐观读锁:一种优化的读锁方式,它假设在读取数据的过程中,数据不会被其他线程修改。线程先尝试获取一个乐观读戳记,然后进行数据读取操作。在读取完成后,需要使用
validate(stamp)
方法验证戳记的有效性,以确保在读取过程中没有其他线程对数据进行了写操作。如果验证失败,说明数据可能已经被修改,需要重新读取或采取其他处理方式。虽然写锁与乐观读锁不会互斥,但是在数据被更新之后,之前通过乐观读锁获得的数据已经变成了脏数据。
悲观读锁的获取与释放
1 | //获取普通读锁(悲观读锁),返回long类型的印戳值 |
写锁的获取与释放
1 | //获取写锁,返回long类型的印戳值 |
乐观读的印戳获取与有效性判断
1 | //获取乐观读,返回long类型的印戳值,返回0表示当前锁处于写锁模式,不能乐观读 |
三种模式锁获取示例:
1 | import java.util.concurrent.locks.StampedLock; |
stamp值
stamp
的不同取值在不同操作和场景下有特定的含义与作用:
写锁相关
写锁获取成功:当通过
writeLock()
等方法成功获取写锁时,返回的stamp
是一个非零的long
型数值。这个数值代表了此次写锁操作的标识,后续可以使用该stamp
来释放写锁或者进行相关的验证操作。写锁获取失败:如果获取写锁失败,通常返回的
stamp
值为0
,这表示此次获取写锁的操作未成功,线程不能进行写操作。
读锁相关
- 悲观读锁获取成功:使用
readLock()
方法获取悲观读锁成功时,会返回一个非零的stamp
,用于标识该读锁操作。与写锁类似,后续可以用这个stamp
来释放读锁。
- 悲观读锁获取成功:使用
乐观读相关:在乐观读场景中,首先通过
tryOptimisticRead()
方法尝试获取乐观读锁,它也会返回一个stamp
。- 乐观读有效:如果返回的
stamp
不为0
,表示当前可能处于一个没有写操作在进行的状态,即乐观读有效。此时可以进行数据读取操作,并且可以通过validate(stamp)
方法来验证在读取数据期间是否有写操作发生。 - 乐观读无效:如果
tryOptimisticRead()
返回的stamp
为0
,则说明当前可能有写操作正在进行,乐观读无效,不建议直接进行数据读取操作。
- 乐观读有效:如果返回的
StampedLock的演示案例
1 | package pers.fulsun._5; |
1 | 读线程 - 00:33:19.080 进入了写锁模式,不能进行乐观读 |
StampedLock与ReentrantReadWriteLock对比
锁特性
**
StampedLock
**:具有写锁、读锁和乐观读锁三种模式。乐观读锁是其特色,提供了一种无锁的读方式,适用于大部分读操作不会与写操作冲突的场景。**
ReentrantReadWriteLock
**:分为读锁和写锁,支持多个线程同时获取读锁,但写锁是排他的,同一时刻只能有一个线程获取写锁。
锁获取与释放
**
StampedLock
**:获取锁时会返回一个戳记(stamp
),用于后续的锁释放和验证操作。释放锁时需要传入对应的戳记。如long stamp = lock.writeLock();
获取写锁,lock.unlockWrite(stamp);
释放写锁。**
ReentrantReadWriteLock
**:通过readLock()
获取读锁,writeLock()
获取写锁,释放锁时直接调用unlock()
方法即可,不需要额外的标识。如ReadLock readLock = readWriteLock.readLock(); readLock.lock();
获取读锁,readLock.unlock();
释放读锁。
可重入性
**
StampedLock
**:支持可重入,但在重入时需要注意戳记的使用和管理。例如,一个线程可以多次获取读锁或写锁,每次获取都会返回一个新的戳记,但释放锁时需要按照获取的顺序和次数进行释放。**
ReentrantReadWriteLock
**:明确支持可重入,重入时锁的获取和释放相对简单,线程可以多次获取同一类型的锁,不会出现死锁等问题。
性能
**
StampedLock
**:在读多写少的场景下,乐观读锁能提高读操作的并发性,性能优势明显。但如果写操作频繁,乐观读锁的验证失败率会增加,可能导致性能下降。**
ReentrantReadWriteLock
**:读锁和写锁的分离能提高一定的并发性能,但读锁之间存在一定的竞争,在高并发读的情况下性能可能不如StampedLock
的乐观读锁。
公平性
**
StampedLock
**:默认是非公平锁,但可以通过构造函数或方法调用设置为公平锁,在公平模式下,线程按照请求锁的顺序获取锁。**
ReentrantReadWriteLock
**:也支持公平和非公平模式,公平模式下,读锁和写锁的获取都遵循先来先服务的原则。
适用场景
**
StampedLock
**:适用于读操作频率远高于写操作,且对数据实时性要求较高的场景,如缓存系统、实时数据分析等。在这些场景中,乐观读锁可以在不影响数据一致性的前提下,提高系统的并发性能。**
ReentrantReadWriteLock
**:适用于读多写少,但写操作对数据一致性要求严格,且读操作之间不需要过于精细的并发控制的场景,如数据库的读写操作、文件的读写等。