显式锁-ReentrantLock
使用Java内置锁的时候,我们不需要通过Java代码显式的对同步对象的监视器进行抢占和释放,这些工作由JVM底层来完成,而且任何一个Java对象都能作为一个内置锁来使用,使用其实是非常方便的,但是Java内置锁功能比较单一而且不具备一些高级功能。例如
- 限时抢锁
- 可中断抢锁
- 多个等待队列
除了上面不具备高级功能,
Java对象锁还存在严重性能问题
,在竞争激烈的情况下,锁还会膨胀从偏向锁到轻量级锁到重量级锁。而重量级锁的性能是非常低的。为了解决Java对象锁的性能问题,JDK5版本后引入了
Lock接口
,Lock是Java代码级别的锁
,Lock接口
也被称为显式锁接口
。其对象
也被称为显式锁对象
1.显式锁Lock接口
显式锁,特别是在Java并发编程中,指的是通过显式地使用锁机制来管理对共享资源的访问,而不是依赖于语言内置的如synchronized
这样的关键字。java.util.concurrent.locks
包下的Lock
接口为此提供了这样的功能,它是一个更灵活且功能丰富的锁机制,允许更细粒度的控制和更高级的并发策略。
在Java 5之前,开发人员主要依赖于synchronized
关键字来实现同步,但这在某些情况下可能过于僵硬编码和不够灵活。随着多核处理器的普及,对高并发性能的需求增加,Java 5引入了java.util.concurrent
包,其中包括Lock
接口,它为开发人员提供了更细粒度的锁控制,如可中断、超时尝试获取锁、条件队列等待等特性。
Lock
接口及其相关实现(如ReentrantLock
, ReadWriteLock
)是由Doug Lea和团队在Java 5中引入的,Doug Lea是Java并发编程领域的知名专家,也是JDK并发包的主要设计者之一。
1.1.主要抽象方法
以下是Lock
接口主要抽象方法的如下:
方法名 | 功能描述 |
---|---|
lock() | 获取锁,如果锁不可用,则当前线程等待直到获取到锁为止 |
lockInterruptibly() | 尝试获取锁,但可响应中断,如果当前线程被中断则抛出InterruptedExceptio |
tryLock() | 尝试非阻塞地获取锁,立即返回是否获取成功(true/false表示失败) |
tryLock(long time, TimeUnit unit) | 尝试在指定时间内获取锁,到期仍未获取则返回false,支持超时获取锁 |
unlock() | 释放当前线程持有的锁,必须在finally块中使用以防异常时锁未释放 |
newCondition() | 创建与Lock绑定的Condition实例,用于线程间协作,如等待/通知 |
1.2.相对于内置锁的优势
Lock
接口相对于Java中的内置锁(即synchronized
关键字)提供了几个显著的优势,这些优势使得在特定场景下使用Lock
成为更优的选择。下面是Lock
接口的一些关键优势:
- 可中断的锁等待:
synchronized
块或方法中的线程在等待锁时是不可中断的,一旦开始等待就会一直阻塞,直到获得锁或者被其他线程唤醒。相比之下,Lock
接口提供了lockInterruptibly()
方法,允许线程在等待锁的过程中响应中断,这样程序可以更优雅地处理中断请求,避免长时间的无响应状态。
- 尝试非阻塞获取锁:
Lock
接口中的tryLock()
方法允许线程尝试获取锁而不被阻塞,立即返回是否获取成功。这对于执行时间很短或需要快速失败的场景非常有用,因为它减少了上下文切换的开销并提高了性能。
- 定时锁等待:
- 通过
tryLock(long time, TimeUnit unit)
方法,线程可以在指定的时间内尝试获取锁,超时后将不会继续等待,而是直接返回,这样可以避免死锁或无限期等待的情况。
- 通过
- 公平性控制:
Lock
接口允许设置锁的公平性。公平锁(如ReentrantLock
构造函数中可指定true
以启用)会按照线程等待的先后顺序分配锁,而非公平锁则允许插队,可能会提高吞吐量但牺牲了一定的公平性。而synchronized
默认是非公平的。
- 条件变量(Condition Objects):
Lock
接口提供了newCondition()
方法,可以创建与锁关联的Condition
对象,使得线程能够在满足特定条件时被挂起和唤醒,相比synchronized
中的wait()
和notify()
/notifyAll()
方法,Condition
提供了更精细的线程协调能力,可以绑定到特定的条件上,提高并发控制的灵活性和效率。
- 锁分离(如读写锁):
- 虽然不是
Lock
接口本身直接提供的,但通过其子类如ReentrantReadWriteLock
,可以实现读写锁分离,即多个读取者可以同时持有锁,而写入者需要独占锁。这种机制大大提高了并发读取的性能,尤其是在读多写少的场景中。
- 虽然不是
- 更灵活的锁获取和释放模式:
- 使用
Lock
可以更灵活地控制锁的获取和释放顺序,以及在不同代码段中对锁的操作,这在复杂同步需求下特别有用,比如需要在不同范围或顺序上锁定多个资源。
- 使用
2.可重入锁ReentrantLock
ReentrantLock是JUC包提供显式锁的一个基础实现类,ReentrantLock实现了Lock接口。他拥有和synchronized相同的并发性和内存语义,但是拥有了限时抢占,可中断抢占等一些高级锁的特性。此外,ReentrantLock基于内置的抽象队列同步器(Abstract Queued Synchronized) AQS实现,在高并发场景下能获得更好的性能。
2.1.可重入的介绍
可重入(Reentrancy)思想在并发编程中是一个关键概念,它允许一个线程在已经持有某个锁的情况下再次获取该锁而不会造成死锁。这种机制对于支持递归调用和复杂同步逻辑特别重要。ReentrantLock
(可重入锁)是Java中java.util.concurrent.locks
包提供的一个实现,它体现了可重入锁的设计理念,并提供了超越内置synchronized
关键字的额外功能和控制。
/**
* 演示可重入锁
*/
public class ReentrantLockDemo1 {
private final ReentrantLock lock = new ReentrantLock();
@Test
@DisplayName("演示可重入锁")
public void test() {
getLock();
}
public void getLock() {
// 获取锁
lock.lock();
// 开始执行临界区代码
try {
System.out.println(Thread.currentThread().getName() + " - 第 1 次获得到了锁!--" + lock.hashCode());
// 调用内置方法 再次获得锁观察是否是同一把锁
getInnerMethodLock();
} finally {
// 释放锁
lock.unlock();
}
}
public void getInnerMethodLock() {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " - 第 2 次获得到了锁!--" + lock.hashCode());
} finally {
lock.unlock();
}
}
}
在这个示例中,getLock
和getInnerMethodLock
都试图获取同一个ReentrantLock
实例。因为是同一个线程在调用,getInnerMethodLock
中的再次获取锁操作是成功的,不会导致死锁,这正是可重入锁的体现。当所有方法执行完毕,通过适当的解锁操作,锁被完全释放。
2.2.独占的介绍
独占的含义就是:在同一时刻只能有一个线程获得到锁,而其他获取锁的线程只能等待,只有拥有锁的线程释放了锁,其他线程才能获得锁。
下面我们通过一个案例来了解一下
这里我们创建了10个线程,每个线程循环100次进行操作,线程是并发执行的。
package com.hrfan.thread.lock;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.locks.ReentrantLock;
/**
* 演示 独占锁
*/
public class ReentrantLockDemo2 {
private final ReentrantLock lock = new ReentrantLock();
private int count = 0;
@Test
@DisplayName("演示可重入锁")
public void test() {
try {
getOwnerLock(10, 100);
} catch (InterruptedException e) {
throw new RuntimeException("执行任务发生异常", e);
}
}
/**
* 测试可重入锁
*
* @param threads 并发执行的线程数量
* @param turns 每个线程循环执行的次数
*/
public void getOwnerLock(int threads, int turns) throws InterruptedException {
// 这里我们开始指定的线程数量 以及每个线程需要执行的一个次数
// 创建固定大小的线程池
ExecutorService pool = Executors.newFixedThreadPool(threads);
// 创建计时器
CountDownLatch latch = new CountDownLatch(threads);
for (int i = 0; i < threads; i++) {
pool.submit(() -> {
// 每个线程添加固定的任务
for (int j = 0; j < turns; j++) {
try {
lock.lock();
count++;
} finally {
lock.unlock();
}
}
System.out.println(Thread.currentThread().getName() + "-执行完毕!");
latch.countDown();
});
}
// 等待全部线程执行完毕
latch.await();
// 打印最终结果
System.out.println("count = " + count);
}
}
3.分离不变设计
分离不变设计是软件设计的基本原则,将临界区使用的锁的代码进行抽象和封装,形成一个可以复用的独立的类
因为JUC的所有显式锁都实现了Lock接口,所以不同的类型的显式锁对象的使用方式都模板化的、套路化的,下面介绍几种使用显式锁代码的模板
3.1.使用lock()方法抢锁的模板代码
通常情况下,会使用lock()方式进行**阻塞式(没有抢到,当前线程阻塞)**的锁抢占,模板代码如下
// 1.抢占锁
lock1.lock();
try {
// 2.抢锁成功 执行临界区代码
System.out.println("lock1 = " + lock1);
}finally {
// 3.释放锁
lock1.unlock();
}
上面抢占锁的代码需要注意几个地方
-
释放锁操作
lock1.unlock()
必须在try-finally结构中的finally中执行
,否则如果临界区的代码抛出异常,锁就永远不会进行释放。 -
抢占锁操作
lock1.lock()
必须在try 语句块之外,而不是放到try
语句块内部,-
因为
lock()方法
并没有声明抛出异常,所以可以不包含在try
语句块之内 -
lock()
方法不一定能够抢占锁成功,如果没有抢占成功
,当前也就不需要释放锁
,而且在没有抢占成功情况下释放锁,可能会导致运行时异常。 -
在抢占锁操作
lock.lock()
和try
语句之前不要插入任何代码
,避免这段代码抛出异常而无法执行释放锁操作lock.unlock()
。-
// 1.抢占锁 lock1.lock(); // 这里如果发生异常 可能就会导致下面锁无法进行释放了 int i = 1/0; try { }finally{ lock.unlock(); }
-
-
3.2.使用tryLock()方法抢锁模板代码
上面提到lock()方法式阻塞式抢锁,如果不希望当前线程阻塞,那么可以使用tryLock()
方式抢占锁,在没有抢到锁
的情况下,当前线程会被立即返回
,而不是阻塞。
ReentrantLock lock = new ReentrantLock();
// 非阻塞式抢占锁
if (lock.tryLock()) {
// 抢到锁
try {
// 执行临界区代码
System.out.println("lock = " + lock);
} finally {
// 释放锁
lock.unlock();
}
} else {
// 抢锁失败 准备后续操作
System.out.println("------------ 当前线程没有抢到锁 ------------");
}
抢不到锁立即返回,这种场景在实际开发中用的不多,用的多的式他的重载版本 限时非阻塞抢锁
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
3.3.调用tryLock(Long time,TimeUnit unit)方法抢锁模板代码
tryLock(long time, TimeUnit unit)用于限时抢锁,该方法在抢锁时会进行一段时间的阻塞等待,其time参数代表最大阻塞时长,其unit参数为时长的单位(例如秒)
ReentrantLock lock = new ReentrantLock();
if (lock.tryLock(1, TimeUnit.MINUTES)) {
try {
// 获得锁执行临界区代码
System.out.println("lock = " + lock);
} finally {
lock.unlock();
}
} else {
System.out.println("获得锁失败,执行后续操作!");
}
- lock()方法:
- 功能:此方法试图获取锁,如果锁不可用,则当前线程将被阻塞,直到获得锁为止。这是一个阻塞方法,意味着调用它的线程会一直等待直到获得锁。
- 返回值:无。
- 异常:不会抛出中断异常,但若在等待过程中线程被中断,中断状态会被清除,线程会在下次有机会时继续等待锁。
- 使用场景:适用于
不需要关心等待时间
,且可以容忍线程被长时间阻塞的情况。
- tryLock()方法:
- 功能:尝试非阻塞地获取锁。如果锁可用,则立即返回true并获取锁;如果锁不可用,则立即返回false,不会导致当前线程被阻塞。
- 返回值:布尔值,表示是否成功获取了锁。
- 异常:不会抛出中断异常。
- 使用场景:适用于
尝试获取锁但不希望因为锁不可用而阻塞线程的场景
,可以配合循环等机制实现“尝试-失败-重试”的逻辑。
- tryLock(long time, TimeUnit unit) 方法:
- 功能:在指定的时间内尝试获取锁,是一个带超时特性的tryLock方法。如果在指定时间内锁变为可用,则返回true并获取锁;如果超时则返回false。
- 参数:
time
指尝试获取锁的最长时间,unit
指定了时间的单位(如秒、毫秒等)。 - 返回值:布尔值,表示是否在指定时间内成功获取了锁。
- 异常:如果当前线程在等待锁的过程中被中断,会抛出
InterruptedException
。 - 使用场景:适用于
需要尝试获取锁,但又不希望无限等待
,希望有超时控制的场景,可以避免死锁和提高系统响应性。
4.Condtition(等待-通知)
在探索Java线程间的通信机制时,我们利用Java内置的锁机制实现了一种经典的“等待-通知”模式。这一模式依托于
Object
类提供的wait()
,notify()
, 和notifyAll()
方法作为协调信号,巧妙地构建了通信桥梁,确保信息在等待线程与通知线程之间高效传递。具体而言,“等待-通知”机制描述了如下交互过程:一方线程A执行同步代码块或方法期间,主动调用同步对象的
wait()
方法,自愿让出执行权,进入休眠状态,等待特定条件满足。与此同时,另一方线程B,在合适的时机执行同一同步对象上的notify()
或notifyAll()
方法,以此为信号唤醒之前处于等待状态的一个或所有线程。接收到唤醒通知的线程A,并不立即恢复执行,而是从wait()
调用处返回,重新竞争锁,一旦获取到锁,将继续执行后续代码,实现了从暂停到恢复运行的平滑过渡。通过这种精细设计,Java的“等待-通知”机制有效地促进了线程间有序且高效的协作,是解决多线程环境下同步问题的重要工具之一。
需要注意的是,在通信过程中,线程需要拥有同步对象的监视器,在执行Object对象的 wait()
、notify()
或notifyAll()
,线程必须先抢占到内置锁的,而成为监视器的Owner
与Object对象的wait()
、notify()
、notifyAll()
类似,JUC也为我们提供了一个用于线程间进行 等待-通知
方式通信的接口, java.util.concurrent.locks.Condition
4.1.Condition接口的主要方法
public interface Condition {
/**
* 方法1:等待。
* 功能上等同于Object.wait(),使当前线程进入await()等待队列并释放关联锁。
* 一旦其他线程调用signal()或signalAll(),等待队列中的某个线程将被唤醒,进而重新尝试获取锁并继续执行。
*
* @throws InterruptedException 如果当前线程在等待时被中断。
*/
void await() throws InterruptedException;
/**
* 方法2:无中断等待。
* 与await()相似,但在此方法调用中即使线程被中断,也不会抛出InterruptedException,
* 而是默默地清除中断状态,线程将继续等待。
*/
void awaitUninterruptibly();
/**
* 方法3:带超时的等待。
* 允许线程在指定的纳秒时间内等待,超时后自动返回,无需中断。
*
* @param nanosTimeout 等待的最长时间,单位为纳秒。
* @return true如果等待期间被唤醒或成功获取了锁;false如果等待超时。
* @throws InterruptedException 如果等待过程中被中断。
*/
long awaitNanos(long nanosTimeout) throws InterruptedException;
/**
* 方法4:带时间限制的等待。
* 线程等待至指定的时间点,如果在此之前被唤醒或获取到锁则返回true,否则返回false。
*
* @param time 等待的时间长度。
* @param unit 时间单位。
* @return true如果在指定时间内被唤醒或获取了锁;false如果等待超时。
* @throws InterruptedException 如果等待过程中被中断。
*/
boolean await(long time, TimeUnit unit) throws InterruptedException;
/**
* 方法5:等待至特定日期时间。
* 线程等待直到给定的截止日期时间,或者被唤醒、中断。
*
* @param deadline 等待的终止日期时间。
* @return true如果在截止时间前被唤醒或获取了锁;false如果等待直至截止时间到达仍未获取到锁。
* @throws InterruptedException 如果等待过程中被中断。
*/
boolean awaitUntil(Date deadline) throws InterruptedException;
/**
* 方法6:唤醒单个等待线程。
* 唤醒等待队列中的一个线程,使之有机会争夺锁并继续执行。
*/
void signal();
/**
* 方法7:唤醒所有等待线程。
* 唤醒等待队列中的所有线程,它们将竞争锁以恢复执行。
*/
void signalAll();
}
Condition
对象的signal()
方法与同一个Condition
对象上的await()
调用形成了精细的配对关系。这意味着,通过signal()
唤醒的总是那些在相同Condition
实例上因await()
而等待的线程。值得注意的是,一个Condition
实例的signal()
或signalAll()
操作无法激活其他Condition
实例上等待的线程,确保了信号传递的精确性和针对性。
Condition
的设计是与显式锁(如ReentrantLock
)紧密结合的,因此它不能被独立创建。要获取一个Condition
实例,必须通过相应的显式锁实例方法,如lock.newCondition()
来完成。这样,每个Condition
实例都与一个特定的锁紧密绑定,深化了同步控制的灵活性和颗粒度。
更重要的是,一个显式锁实体能够支持绑定多个不同的Condition
对象。这一特性极大地丰富了并发编程中的协调策略,使得同一资源可以在多个条件约束下被不同线程群体以不同的逻辑等待和唤醒,从而实现了高度复杂的线程间协作模式。
4.2.显式锁Condition演示案例
下面我们通过显式锁的Condition来实现一个简单的 等待-通知 的案例。
/**
* 介绍Condition用法
*/
public class ReentrantLockDemo4 {
// 创建一个显式锁
public static final ReentrantLock lock = new ReentrantLock();
// 创建一个显式锁的Condition对象
public static final Condition condition = lock.newCondition();
/**
* 等待线程
*/
static class WaitSetTask implements Runnable {
@Override
public void run() {
// 获取锁
lock.lock();
try {
System.out.println(Thread.currentThread().getName()+"-"+"================= 等待线程启动 =================");
// 开始等待唤醒 并释放锁
condition.await();
// 收到通知继续执行
System.out.println(Thread.currentThread().getName()+"-"+"等待线程继续执行任务");
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 释放锁
lock.unlock();
}
}
}
/**
* 通知线程
*/
static class NotifyTask implements Runnable {
@Override
public void run() {
lock.lock();
try {
System.out.println(Thread.currentThread().getName()+"-"+"================= 通知线程启动 =================");
// 唤醒等待线程
condition.signal();
System.out.println(Thread.currentThread().getName()+"-"+"发出了通知,但是线程没有立即释放锁!");
} finally {
// 释放锁
lock.unlock();
}
}
}
@Test
@DisplayName("测试等待通知线程")
public void test() {
new Thread(new WaitSetTask(),"wait").start();
// 休眠1s 启动通知线程
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
new Thread(new NotifyTask(),"notify").start();
}
}
在上面代码中,使用ReentrantLock(重入锁)
作为显式锁的实现类,然后通过该显式锁去获取一个Condition实例
。
在调用await()
方法前,等待线程必须获得显式锁,在调用await()
方法后,等待线程会释放当前持有的锁,并进入该Condition
相关的等待集中
。此时,线程将不再占用锁资源,进入休眠状态
,直至以下情况之一发生:
- 被其他线程唤醒:如果有另一个已经持有相关
ReentrantLock
的线程调用了condition.signal()
或condition.signalAll()
方法,那么等待集中的一条或多条线程(具体取决于调用的是signal()
还是signalAll()
)会被随机选中,从等待状态中唤醒。唤醒的线程不会立刻开始执行,它们需要重新竞争锁。只有当成功获取到锁之后,这些线程才会从await()
调用返回,继续执行后续的代码。 - 被中断:如果等待线程在等待期间被其他线程中断(通过
Thread.interrupt()
方法),那么await()
方法会抛出InterruptedException
异常,并且线程的中断状态会被清除。随后,线程也需要重新获取锁。
-
通知线程执行流程:
- 通知线程(
NotifyTask
实现)启动后,它首先尝试获取ReentrantLock
锁。由于等待线程此时已释放了锁(在调用await()
后),通知线程能够成功获取到锁。 - 获取锁后,通知线程调用
condition.signal()
方法。这一操作会选择一个(或全部,如果是signalAll()
)在该Condition
上等待的线程,将其移出等待队列并置于可运行状态。但请注意,这并不意味着被唤醒的线程会立即执行——它们还需等待获取锁的机会。 - 打印消息确认通知已发送,然后释放锁,允许被唤醒的线程有机会去竞争和获取锁。
- 通知线程(
-
等待线程继续执行:
- 当等待线程(
WaitSetTask
实现)被condition.signal()
唤醒后,它试图重新获取锁。一旦成功获取到锁,它将从await()
调用处继续执行。 - 线程打印消息,表明其已收到通知并将继续执行任务。
- 执行完剩余任务后,释放锁,允许其他线程(包括可能存在的其他等待线程)进行操作。
- 当等待线程(